Merge pull request #295 from Tencent-Hunyuan/fix/memhub-model-center-main
fix(memhub): land Model Center on main
This commit is contained in:
45 files changed
+7059
-827
No files matched your search
@@ -68,6 +68,10 @@ vi.mock("./api", () => ({
|
||||
startEvolve: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
getSources: vi.fn().mockResolvedValue(sources),
|
||||
getCurrentEmbeddingSwitch: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("unsupported")),
|
||||
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
|
||||
...mutations,
|
||||
}));
|
||||
vi.mock("./use-import-sources", () => ({
|
||||
@@ -149,7 +153,9 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
|
||||
).toBeNull();
|
||||
noModelWrites();
|
||||
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "记忆模式与模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
|
||||
noModelWrites();
|
||||
@@ -212,13 +218,14 @@ it("keeps an open model draft while switching settings groups", async () => {
|
||||
window.history.replaceState({}, "", "/?view=settings");
|
||||
render(<App />);
|
||||
await waitFor(() => expect(modelProps).toHaveBeenCalled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await userEvent.type(
|
||||
screen.getByLabelText("保留的模型草稿"),
|
||||
"unsaved draft",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "记忆模式与模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
expect(window.location.search).toBe("?view=settings");
|
||||
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
|
||||
"value",
|
||||
|
||||
@@ -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";
|
||||
@@ -34,6 +35,7 @@ import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import { R6Button, R6LocalStorage } from "./R6Controls";
|
||||
import { R6Settings } from "./R6Settings";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
import { EmbeddingSwitchProvider } from "./use-embedding-switch";
|
||||
import "./styles.css";
|
||||
import "./core-visual.css";
|
||||
import "./r6-shell.css";
|
||||
@@ -47,10 +49,12 @@ const rendererStartedAt = Date.now();
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DesktopFrame>
|
||||
<AppContent />
|
||||
<DebugLogPanel />
|
||||
</DesktopFrame>
|
||||
<EmbeddingSwitchProvider>
|
||||
<DesktopFrame>
|
||||
<AppContent />
|
||||
<DebugLogPanel />
|
||||
</DesktopFrame>
|
||||
</EmbeddingSwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +313,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,289 @@
|
||||
// @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";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
|
||||
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;
|
||||
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("switches Chinese, Spanish, and English without leaking Chinese", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
const view = render(
|
||||
<LanguageProvider>
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={{ ...plan, users: 2 }}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "¿Actualizar el índice de búsqueda de memorias?",
|
||||
}),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
|
||||
expect(view.container.textContent).toContain("8421");
|
||||
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Update the memory search index?",
|
||||
}),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
|
||||
expect(view.container.textContent).toContain("8,421");
|
||||
});
|
||||
|
||||
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,480 @@
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
|
||||
import { ChevronDown, CircleAlert, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteRetiredEmbeddingSpace } from "./api";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import "./embedding-switch.css";
|
||||
|
||||
type Translator = (
|
||||
key: MessageKey,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
function count(locale: GuiLocale, value: number | null | undefined) {
|
||||
return new Intl.NumberFormat(locale).format(value ?? 0);
|
||||
}
|
||||
|
||||
function duration(locale: GuiLocale, t: Translator, seconds: number | null) {
|
||||
if (seconds === null) return t("embeddingSwitch.duration.unknown");
|
||||
const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60));
|
||||
return t(
|
||||
seconds < 60
|
||||
? "embeddingSwitch.duration.seconds"
|
||||
: "embeddingSwitch.duration.minutes",
|
||||
{ count: count(locale, value) },
|
||||
);
|
||||
}
|
||||
|
||||
function size(locale: GuiLocale, bytes: number) {
|
||||
const megabytes = bytes >= 1024 * 1024;
|
||||
const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1));
|
||||
return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`;
|
||||
}
|
||||
|
||||
const blockerKeys: Record<string, MessageKey> = {
|
||||
embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required",
|
||||
embedding_dimensions_unknown:
|
||||
"embeddingSwitch.blocker.embedding_dimensions_unknown",
|
||||
embedding_switch_requires_migration:
|
||||
"embeddingSwitch.blocker.embedding_switch_requires_migration",
|
||||
sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable",
|
||||
insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space",
|
||||
embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running",
|
||||
memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy",
|
||||
};
|
||||
|
||||
const failureKeys: Record<string, MessageKey> = {
|
||||
writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain",
|
||||
embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed",
|
||||
embedding_dimensions_mismatch:
|
||||
"embeddingSwitch.failure.embedding_dimensions_mismatch",
|
||||
embedding_verification_failed:
|
||||
"embeddingSwitch.failure.embedding_verification_failed",
|
||||
target_profile_changed: "embeddingSwitch.failure.target_profile_changed",
|
||||
embedding_switch_cancelled:
|
||||
"embeddingSwitch.failure.embedding_switch_cancelled",
|
||||
model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable",
|
||||
secure_storage_unavailable:
|
||||
"embeddingSwitch.failure.secure_storage_unavailable",
|
||||
embedding_switch_clients_busy:
|
||||
"embeddingSwitch.failure.embedding_switch_clients_busy",
|
||||
};
|
||||
|
||||
export function EmbeddingSwitchConfirmDialog({
|
||||
plan,
|
||||
busy,
|
||||
errorCode,
|
||||
onCancel,
|
||||
onStart,
|
||||
}: {
|
||||
plan: EmbeddingSwitchPlan;
|
||||
busy: boolean;
|
||||
errorCode: string | null;
|
||||
onCancel(): void;
|
||||
onStart(): void;
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const blockers = plan.blockers.map((code) =>
|
||||
t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const opener =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
panel.current
|
||||
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
|
||||
?.focus();
|
||||
return () => {
|
||||
if (opener?.isConnected) opener.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="r6-settings-dialog embedding-switch-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="embedding-switch-title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !busy) onCancel();
|
||||
if (event.key !== "Tab" || !panel.current) return;
|
||||
const controls = [
|
||||
...panel.current.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
|
||||
),
|
||||
];
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="embedding-switch-title">
|
||||
{t("embeddingSwitch.confirm.title")}
|
||||
</h2>
|
||||
<p>{t("embeddingSwitch.confirm.description")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="embedding-switch-impact">
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.memories")}</dt>
|
||||
<dd>
|
||||
{t("embeddingSwitch.impact.memoryCount", {
|
||||
count: count(locale, plan.total_vectors),
|
||||
})}
|
||||
{plan.users && plan.users > 1
|
||||
? t("embeddingSwitch.impact.userCount", {
|
||||
count: count(locale, plan.users),
|
||||
})
|
||||
: ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.duration")}</dt>
|
||||
<dd>{duration(locale, t, plan.estimate_s)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.requests")}</dt>
|
||||
<dd>
|
||||
{plan.target_local
|
||||
? t("embeddingSwitch.requests.none")
|
||||
: plan.requests === null
|
||||
? t("embeddingSwitch.requests.billed")
|
||||
: t("embeddingSwitch.requests.approx", {
|
||||
count: count(locale, plan.requests),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="embedding-switch-safety">
|
||||
<strong>{t("embeddingSwitch.safety.searchTitle")}</strong>
|
||||
<p>{t("embeddingSwitch.safety.searchBody")}</p>
|
||||
<strong>{t("embeddingSwitch.safety.dataTitle")}</strong>
|
||||
<p>{t("embeddingSwitch.safety.dataBody")}</p>
|
||||
</div>
|
||||
|
||||
<details className="embedding-switch-details">
|
||||
<summary>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{t("embeddingSwitch.details.title")}
|
||||
</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.currentModel")}</dt>
|
||||
<dd>
|
||||
{plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.newModel")}</dt>
|
||||
<dd>
|
||||
{plan.target.model ?? t("embeddingSwitch.details.pending")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.dimensions")}</dt>
|
||||
<dd>
|
||||
{plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "}
|
||||
{plan.target.dims ?? t("embeddingSwitch.details.unknown")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.disk")}</dt>
|
||||
<dd>{size(locale, plan.disk_bytes_needed)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.retention")}</dt>
|
||||
<dd>
|
||||
{t("embeddingSwitch.details.days", {
|
||||
count: count(locale, 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"
|
||||
? t("embeddingSwitch.error.planStale")
|
||||
: t("embeddingSwitch.error.startFailed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={onCancel}>
|
||||
{t("embeddingSwitch.action.notNow")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
disabled={blockers.length > 0}
|
||||
onClick={onStart}
|
||||
>
|
||||
{t("embeddingSwitch.action.start")}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(t: Translator, job: EmbeddingSwitchJob) {
|
||||
if (job.kind === "merge") return t("embeddingSwitch.job.merge");
|
||||
if (job.state === "preparing") return t("embeddingSwitch.job.preparing");
|
||||
if (job.state === "reembedding") return t("embeddingSwitch.job.reembedding");
|
||||
if (job.state === "verifying") return t("embeddingSwitch.job.verifying");
|
||||
if (job.state === "committing") return t("embeddingSwitch.job.committing");
|
||||
if (job.state === "failed") return t("embeddingSwitch.job.failed");
|
||||
return t("embeddingSwitch.job.default");
|
||||
}
|
||||
|
||||
function canCancel(job: EmbeddingSwitchJob) {
|
||||
return ["preparing", "reembedding", "verifying"].includes(job.state);
|
||||
}
|
||||
|
||||
function ActiveSwitchBanner({
|
||||
current,
|
||||
onCancel,
|
||||
cancelling,
|
||||
}: {
|
||||
current: EmbeddingSwitchCurrent;
|
||||
onCancel(): void;
|
||||
cancelling: boolean;
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
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(t, job) : t("embeddingSwitch.job.scanning")}
|
||||
</strong>
|
||||
<span>
|
||||
{job?.state === "failed"
|
||||
? t("embeddingSwitch.job.failedSafety")
|
||||
: job
|
||||
? `${new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(percent / 100)}${
|
||||
job.progress.eta_s === null
|
||||
? ""
|
||||
: ` · ${duration(locale, t, job.progress.eta_s)}`
|
||||
}`
|
||||
: t("embeddingSwitch.job.scanPaused")}
|
||||
</span>
|
||||
</div>
|
||||
{job ? (
|
||||
<div
|
||||
className="embedding-switch-progress"
|
||||
role="progressbar"
|
||||
aria-label={t("embeddingSwitch.progress.aria")}
|
||||
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}>
|
||||
{t("embeddingSwitch.action.cancel")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddingSwitchGlobalStatus({
|
||||
onConfigureSearch,
|
||||
onDiagnostics,
|
||||
}: {
|
||||
onConfigureSearch(): void;
|
||||
onDiagnostics(): void;
|
||||
}) {
|
||||
const { t } = useGuiLanguage();
|
||||
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>{t("embeddingSwitch.refresh.title")}</strong>
|
||||
<span>{t("embeddingSwitch.refresh.body")}</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
|
||||
{t("embeddingSwitch.action.refresh")}
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{controller.current &&
|
||||
["failed", "unavailable"].includes(controller.current.scan_state) ? (
|
||||
<aside className="embedding-switch-outcome is-error" role="alert">
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.scan.title")}</strong>
|
||||
<span>
|
||||
{controller.current.writes_paused
|
||||
? t("embeddingSwitch.scan.paused")
|
||||
: t("embeddingSwitch.scan.recovered")}
|
||||
</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
{t("embeddingSwitch.action.diagnostics")}
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{recent?.state === "done" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.success.title")}</strong>
|
||||
<span>{t("embeddingSwitch.success.body")}</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);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("embeddingSwitch.action.cleanup")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={t("embeddingSwitch.action.closeSuccess")}
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{cleanupError ? (
|
||||
<p className="embedding-switch-cleanup-error" role="alert">
|
||||
{t("embeddingSwitch.cleanup.failed")}
|
||||
</p>
|
||||
) : null}
|
||||
{recent?.state === "rolled_back" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome is-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.rollback.title")}</strong>
|
||||
<span>
|
||||
{failureKeys[recent.error_code ?? ""]
|
||||
? t("embeddingSwitch.rollback.reason", {
|
||||
reason: t(failureKeys[recent.error_code ?? ""]),
|
||||
safety: t("embeddingSwitch.rollback.safety"),
|
||||
})
|
||||
: t("embeddingSwitch.rollback.safety")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="embedding-switch-outcome-actions">
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
{t("embeddingSwitch.action.diagnostics")}
|
||||
</R6Button>
|
||||
<R6Button variant="quiet" onClick={onConfigureSearch}>
|
||||
{t("embeddingSwitch.action.checkConnection")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={t("embeddingSwitch.action.closeFailure")}
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiErrorCode,
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
@@ -59,15 +66,15 @@ async function chooseMode(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
await user.click(picker);
|
||||
await user.click(screen.getByRole("option", { name }));
|
||||
return picker;
|
||||
const option = await screen.findByRole("radio", { name });
|
||||
await user.click(option);
|
||||
return option;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(apiErrorCode).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
@@ -81,29 +88,43 @@ it("confirms the data effects before switching to lite", async () => {
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
expect(screen.getByText(/高级记忆会保留/)).toBeTruthy();
|
||||
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
|
||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
||||
prepare("pro");
|
||||
const outside = document.createElement("button");
|
||||
document.body.append(outside);
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const lite = await chooseMode(user, /基础记忆(lite)/);
|
||||
|
||||
const cancel = screen.getByRole("button", { name: "取消" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
||||
expect(outside.inert).toBe(true);
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "切换模式" }),
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(outside.inert).toBeFalsy();
|
||||
expect(document.activeElement).toBe(lite);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("keeps focus inside the modal while the mode change is pending", async () => {
|
||||
@@ -112,7 +133,9 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
|
||||
const modal = screen.getByRole("dialog");
|
||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||
@@ -129,10 +152,60 @@ it("routes to model setup instead of switching pro without a model", async () =>
|
||||
);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
expect(configure).toHaveBeenCalledExactlyOnceWith(
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||
);
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains model cost, search scope and the next step before switching to ultra", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
|
||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(screen.getByText("切换后")).toBeTruthy();
|
||||
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
||||
prepare("ultra");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
|
||||
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
|
||||
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(setMemoryMode).mockResolvedValue({
|
||||
...state("ultra"),
|
||||
previous_mode: "pro",
|
||||
effects: ["existing_memories_added_to_graph"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
screen.getByRole("heading", { name: "记忆整合(Evolve)" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -145,37 +218,14 @@ it("shows the default nightly evolve controls in ultra", async () => {
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(screen.getByLabelText("自动整合时间") as HTMLInputElement).value,
|
||||
).toBe("03:00");
|
||||
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
|
||||
"03:00",
|
||||
);
|
||||
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores an older refresh that finishes after a newer mode read", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
|
||||
let resolveOld!: (value: ReturnType<typeof state>) => void;
|
||||
vi.mocked(getMemoryMode)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(state("lite"));
|
||||
|
||||
const refresh = screen.getByRole("button", { name: "刷新记忆模式" });
|
||||
await user.click(refresh);
|
||||
await user.click(refresh);
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
resolveOld(state("pro"));
|
||||
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
});
|
||||
|
||||
it("does not let an older refresh undo a saved schedule change", async () => {
|
||||
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
enabled: false,
|
||||
@@ -183,26 +233,28 @@ it("does not let an older refresh undo a saved schedule change", async () => {
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
|
||||
const automatic = await screen.findByRole("switch", {
|
||||
name: "每天自动整合",
|
||||
});
|
||||
await user.click(automatic);
|
||||
|
||||
let resolveOld!: (value: { enabled: true; time: string }) => void;
|
||||
vi.mocked(getEvolveSchedule).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "刷新记忆模式" }));
|
||||
await user.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }),
|
||||
);
|
||||
resolveOld({ enabled: true, time: "03:00" });
|
||||
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
||||
expect(screen.queryByLabelText("执行时间")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false"),
|
||||
);
|
||||
it("updates mode state automatically without showing a manual refresh control", async () => {
|
||||
prepare("pro");
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
|
||||
expect((pro as HTMLInputElement).checked).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
||||
expect(getMemoryMode).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(getEvolveSchedule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
@@ -211,15 +263,55 @@ it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("combobox", {
|
||||
name: "当前模式",
|
||||
})) as HTMLButtonElement,
|
||||
(await screen.findByRole("radio", {
|
||||
name: /智能记忆(pro)/,
|
||||
})) as HTMLInputElement,
|
||||
).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("offers a text recovery action when the mode read fails", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(getMemoryMode).mockRejectedValueOnce(new Error("offline"));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", false),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a failed switch explanation inside the open confirmation dialog", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(apiErrorCode).mockReturnValue("graph_store_unavailable");
|
||||
vi.mocked(setMemoryMode).mockRejectedValue(new Error("unavailable"));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps lite available while model status is unknown", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={false}
|
||||
@@ -228,14 +320,57 @@ it("keeps lite available while model status is unknown", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("combobox", { name: "当前模式" }));
|
||||
expect(
|
||||
screen.getByRole("option", { name: /基础记忆(lite)/ }),
|
||||
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", false);
|
||||
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /深度记忆(ultra)/ }),
|
||||
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(getMemoryProcessing).mockResolvedValue({
|
||||
embedding_switch: { phase: "running" },
|
||||
} as never);
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks lifecycle controls while the knowledge graph is being built", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(getMemoryMode).mockResolvedValue({
|
||||
...state("ultra"),
|
||||
graph_build: {
|
||||
state: "running",
|
||||
started_at: "2026-11-03T09:00:00Z",
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("正在把已有高级记忆加入知识图谱…"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
MemoryModeState,
|
||||
MemoryPipelineMode,
|
||||
} from "@one-memory/contracts";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
@@ -18,9 +17,9 @@ import {
|
||||
setMemoryMode,
|
||||
startEvolve,
|
||||
} from "./api";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
|
||||
const MODE_COPY: Record<
|
||||
MemoryPipelineMode,
|
||||
@@ -28,42 +27,104 @@ const MODE_COPY: Record<
|
||||
> = {
|
||||
lite: {
|
||||
title: "基础记忆(lite)",
|
||||
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
|
||||
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
||||
},
|
||||
pro: {
|
||||
title: "智能记忆(pro)",
|
||||
description: "使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
|
||||
},
|
||||
ultra: {
|
||||
title: "深度记忆(ultra)",
|
||||
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
|
||||
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
|
||||
},
|
||||
};
|
||||
|
||||
const EFFECT_COPY: Record<string, string> = {
|
||||
earlier_raw_not_extracted:
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。",
|
||||
lite_search_stops: "原始对话检索会停止,改为使用高级记忆。",
|
||||
higher_mode_memories_hidden:
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。",
|
||||
evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||
existing_memories_added_to_graph:
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。",
|
||||
graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。",
|
||||
};
|
||||
|
||||
function plannedEffects(
|
||||
function confirmationRows(
|
||||
current: MemoryPipelineMode,
|
||||
target: MemoryPipelineMode,
|
||||
): string[] {
|
||||
if (current === target) return [];
|
||||
const effects: string[] = [];
|
||||
if (current === "lite" && target !== "lite")
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
if (target === "lite") effects.push("higher_mode_memories_hidden");
|
||||
if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped");
|
||||
if (target === "ultra") effects.push("existing_memories_added_to_graph");
|
||||
return effects;
|
||||
): Array<{ label: string; content: string }> {
|
||||
if (target === "lite") {
|
||||
return [
|
||||
{
|
||||
label: "新对话",
|
||||
content: "之后只保存原始对话,不再生成新记忆。",
|
||||
},
|
||||
{
|
||||
label: "模型调用与费用",
|
||||
content:
|
||||
current === "ultra"
|
||||
? "停止调用生成模型;每天自动整合也会停止。"
|
||||
: "停止调用生成模型,不再产生这部分费用。",
|
||||
},
|
||||
{
|
||||
label: "搜索范围",
|
||||
content: "立即改为搜索原始对话。",
|
||||
},
|
||||
{
|
||||
label: "已有数据",
|
||||
content:
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (target === "pro") {
|
||||
return [
|
||||
{
|
||||
label: "新对话",
|
||||
content:
|
||||
current === "lite"
|
||||
? "排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。"
|
||||
: "仍会由模型整理成记忆;新记忆不再写入知识图谱。",
|
||||
},
|
||||
{
|
||||
label: "模型调用与费用",
|
||||
content:
|
||||
current === "ultra"
|
||||
? "提取仍会调用生成模型;每天自动整合会停止。"
|
||||
: "开始调用当前生成模型,可能产生服务商费用。",
|
||||
},
|
||||
{
|
||||
label: "搜索范围",
|
||||
content:
|
||||
current === "ultra"
|
||||
? "改为搜索智能记忆,不再使用知识图谱补充关联。"
|
||||
: "立即改为搜索沉淀后的记忆。",
|
||||
},
|
||||
{
|
||||
label: "已有数据",
|
||||
content:
|
||||
current === "ultra"
|
||||
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
||||
: "原始对话全部保留,不会删除。",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: "新对话",
|
||||
content:
|
||||
current === "lite"
|
||||
? "排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。"
|
||||
: "提取方式不变,新记忆会写入知识图谱。",
|
||||
},
|
||||
{
|
||||
label: "模型调用与费用",
|
||||
content:
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。",
|
||||
},
|
||||
{
|
||||
label: "搜索范围",
|
||||
content: "搜索沉淀后的记忆,并用知识图谱补充关联。",
|
||||
},
|
||||
{
|
||||
label: "已有数据",
|
||||
content: "已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。",
|
||||
},
|
||||
{
|
||||
label: "切换后",
|
||||
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function MemoryModeSettings({
|
||||
@@ -74,10 +135,10 @@ export function MemoryModeSettings({
|
||||
}: {
|
||||
hasActiveModel: boolean;
|
||||
modelStateKnown?: boolean;
|
||||
onConfigureModel(): void;
|
||||
onConfigureModel(message?: string): void;
|
||||
onModeChange?(mode: MemoryPipelineMode): void;
|
||||
}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const { text, locale } = useOnboardingTranslation();
|
||||
const id = useId();
|
||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
|
||||
@@ -88,16 +149,30 @@ export function MemoryModeSettings({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [readFailed, setReadFailed] = useState(false);
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const refreshVersion = useRef(0);
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||
const focusEvolveWhenReady = useRef(false);
|
||||
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
busyRef.current = busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMode || !dialog.current) return;
|
||||
const panel = dialog.current;
|
||||
const inertSiblings = new Map<HTMLElement, boolean>();
|
||||
let branch: HTMLElement = panel.parentElement ?? panel;
|
||||
while (branch.parentElement) {
|
||||
for (const sibling of branch.parentElement.children) {
|
||||
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
|
||||
inertSiblings.set(sibling, sibling.inert);
|
||||
sibling.inert = true;
|
||||
}
|
||||
branch = branch.parentElement;
|
||||
if (branch === document.body) break;
|
||||
}
|
||||
const controls = () =>
|
||||
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
|
||||
controls()[0]?.focus();
|
||||
@@ -127,6 +202,7 @@ export function MemoryModeSettings({
|
||||
panel.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
panel.removeEventListener("keydown", handleKey);
|
||||
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
|
||||
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
|
||||
};
|
||||
}, [pendingMode]);
|
||||
@@ -135,6 +211,24 @@ export function MemoryModeSettings({
|
||||
if (pendingMode && busy) dialog.current?.focus();
|
||||
}, [busy, pendingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
||||
return;
|
||||
focusEvolveWhenReady.current = false;
|
||||
document.getElementById(`${id}-evolve`)?.focus();
|
||||
}, [id, pendingMode, state?.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = focusModeWhenReady.current;
|
||||
if (!target || busy || pendingMode || state?.mode !== target) return;
|
||||
focusModeWhenReady.current = null;
|
||||
document
|
||||
.querySelector<HTMLInputElement>(
|
||||
`input[name="${id}-mode"][value="${target}"]`,
|
||||
)
|
||||
?.focus();
|
||||
}, [busy, id, pendingMode, state?.mode]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
||||
@@ -149,8 +243,10 @@ export function MemoryModeSettings({
|
||||
setState(modeResult.value);
|
||||
onModeChange?.(modeResult.value.mode);
|
||||
setError("");
|
||||
setReadFailed(false);
|
||||
} else {
|
||||
setError("记忆模式状态暂时无法读取,请重试。");
|
||||
setReadFailed(true);
|
||||
}
|
||||
if (scheduleResult.status === "fulfilled")
|
||||
setScheduleState(scheduleResult.value);
|
||||
@@ -181,25 +277,29 @@ export function MemoryModeSettings({
|
||||
run?.state === "running" ||
|
||||
state?.graph_build.state === "running";
|
||||
|
||||
function choose(mode: MemoryPipelineMode) {
|
||||
function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
|
||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||
if (mode !== "lite" && !hasActiveModel) {
|
||||
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
|
||||
onConfigureModel();
|
||||
const message = "使用 pro 或 ultra 前需要先配置并启用生成模型。";
|
||||
setError("");
|
||||
onConfigureModel(message);
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||
dialogOpener.current = opener;
|
||||
setPendingMode(mode);
|
||||
}
|
||||
|
||||
async function commitMode() {
|
||||
if (!pendingMode) return;
|
||||
const targetMode = pendingMode;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
|
||||
focusEvolveWhenReady.current = targetMode === "ultra";
|
||||
focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode;
|
||||
setState(changed);
|
||||
setPendingMode(null);
|
||||
await refresh();
|
||||
@@ -261,41 +361,53 @@ export function MemoryModeSettings({
|
||||
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={text("刷新记忆模式")}
|
||||
title={text("刷新记忆模式")}
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
<div
|
||||
className="r6-language-setting-row r6-mode-setting-row"
|
||||
className="r6-mode-choice-list"
|
||||
role="radiogroup"
|
||||
aria-label={text("当前模式")}
|
||||
aria-busy={state === null}
|
||||
>
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`}>
|
||||
{text("当前模式")}
|
||||
</label>
|
||||
<OptionMenu
|
||||
id={`${id}-select`}
|
||||
labelledBy={`${id}-label`}
|
||||
value={state?.mode ?? ""}
|
||||
disabled={!state || busy || lifecycleBusy}
|
||||
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
value: mode,
|
||||
label: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
disabled: mode !== "lite" && !modelStateKnown,
|
||||
}))}
|
||||
className="r6-language-option-menu r6-mode-option-menu"
|
||||
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
|
||||
onChange={(value) => {
|
||||
if (value === "lite" || value === "pro" || value === "ultra")
|
||||
choose(value);
|
||||
}}
|
||||
/>
|
||||
{(["lite", "pro", "ultra"] as const).map((mode) => {
|
||||
const checked = state?.mode === mode;
|
||||
return (
|
||||
<label
|
||||
key={mode}
|
||||
className="r6-mode-choice"
|
||||
data-current={checked || undefined}
|
||||
data-disabled={
|
||||
!state ||
|
||||
busy ||
|
||||
lifecycleBusy ||
|
||||
(state?.forced_by_env && mode !== state.mode) ||
|
||||
(mode !== "lite" && !modelStateKnown) ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${id}-mode`}
|
||||
value={mode}
|
||||
checked={checked}
|
||||
disabled={
|
||||
!state ||
|
||||
busy ||
|
||||
lifecycleBusy ||
|
||||
(state?.forced_by_env && mode !== state.mode) ||
|
||||
(mode !== "lite" && !modelStateKnown)
|
||||
}
|
||||
onChange={(event) => choose(mode, event.currentTarget)}
|
||||
/>
|
||||
<span className="r6-mode-choice-copy">
|
||||
<strong>{text(MODE_COPY[mode].title)}</strong>
|
||||
<small>{text(MODE_COPY[mode].description)}</small>
|
||||
</span>
|
||||
{checked ? (
|
||||
<span className="r6-mode-current">{text("当前模式")}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
@@ -312,20 +424,38 @@ export function MemoryModeSettings({
|
||||
{text("正在把已有高级记忆加入知识图谱…")}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
{error && !pendingMode ? (
|
||||
readFailed ? (
|
||||
<R6StateRecovery
|
||||
message={text(error)}
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{state?.mode === "ultra" && schedule ? (
|
||||
<div className="r6-evolve-settings">
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<section
|
||||
id={`${id}-evolve`}
|
||||
className="r6-evolve-settings"
|
||||
aria-labelledby={`${id}-evolve-title`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="r6-evolve-heading">
|
||||
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
|
||||
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
||||
</div>
|
||||
<div className="r6-evolve-setting">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("每天自动整合")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
schedule.enabled
|
||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
).replace("{time}", schedule.time)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -335,40 +465,45 @@ export function MemoryModeSettings({
|
||||
disabled={busy || maintenance}
|
||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
||||
/>
|
||||
{schedule.enabled ? (
|
||||
<div className="r6-evolve-time">
|
||||
<div>
|
||||
<label htmlFor={`${id}-evolve-time`}>
|
||||
{text("执行时间")}
|
||||
</label>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
id={`${id}-evolve-time`}
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("自动整合时间")}</h3>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
aria-label={text("自动整合时间")}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("立即整合记忆")}</h3>
|
||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("立即整合一次")}</h3>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(),
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
@@ -376,30 +511,40 @@ export function MemoryModeSettings({
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{pendingMode ? (
|
||||
<div className="r6-settings-dialog-layer" role="presentation">
|
||||
<section
|
||||
ref={dialog}
|
||||
className="r6-settings-dialog"
|
||||
className="r6-settings-dialog r6-mode-confirm-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="memory-mode-confirm-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<h2 id="memory-mode-confirm-title">
|
||||
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
|
||||
{text("切换到 {mode}?").replace(
|
||||
"{mode}",
|
||||
text(MODE_COPY[pendingMode].title),
|
||||
)}
|
||||
</h2>
|
||||
<ul>
|
||||
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
|
||||
(effect) => (
|
||||
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
|
||||
<dl className="r6-mode-confirm-effects">
|
||||
{confirmationRows(state?.mode ?? "pro", pendingMode).map(
|
||||
(row) => (
|
||||
<div key={row.label}>
|
||||
<dt>{text(row.label)}</dt>
|
||||
<dd>{text(row.content)}</dd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<li>{text("原始对话在所有模式下都会保留。")}</li>
|
||||
</ul>
|
||||
</dl>
|
||||
{error ? (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
) : null}
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
|
||||
{text("取消")}
|
||||
@@ -409,7 +554,10 @@ export function MemoryModeSettings({
|
||||
pending={busy}
|
||||
onClick={() => void commitMode()}
|
||||
>
|
||||
{text("切换模式")}
|
||||
{text("切换到 {mode}").replace(
|
||||
"{mode}",
|
||||
text(MODE_COPY[pendingMode].title),
|
||||
)}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -1494,7 +1494,7 @@ function message(reason: unknown, t: Translate): string {
|
||||
reason.message === "memory_answer_context_changed"
|
||||
? reason.message
|
||||
: apiErrorCode(reason);
|
||||
const labels: Record<string, Parameters<Translate>[0]> = {
|
||||
const labels: Record<string, string> = {
|
||||
memory_answer_context_changed:
|
||||
"memory:记忆内容或使用权限已变化,回答已停止。请重新提问。",
|
||||
memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。",
|
||||
@@ -1507,8 +1507,14 @@ function message(reason: unknown, t: Translate): string {
|
||||
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
|
||||
request_validation_failed: "memory:输入内容不符合要求。",
|
||||
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
|
||||
embedding_unavailable:
|
||||
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
|
||||
embedding_switch_in_progress:
|
||||
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
|
||||
embedding_space_profile_missing:
|
||||
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
|
||||
};
|
||||
if (code && labels[code]) return t(labels[code]);
|
||||
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
|
||||
// No code at all means the request never reached the core — usually because
|
||||
// it is still starting. Saying only "failed" left the one case that resolves
|
||||
// itself looking like the ones that do not.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
ProbeCode,
|
||||
ProviderKind,
|
||||
} from "@one-memory/contracts";
|
||||
import { Check, Pencil, Plus, RefreshCw } from "lucide-react";
|
||||
import { Check, Pencil, Plus } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type Ref,
|
||||
@@ -16,9 +16,12 @@ 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 { R6StateRecovery } from "./R6StateRecovery";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import { useModelProfiles } from "./use-model-profiles";
|
||||
import "./model-setup-next.css";
|
||||
|
||||
@@ -216,6 +219,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 +230,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 +271,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 +285,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 +498,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 +570,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 +658,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 +707,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 +726,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 +790,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>
|
||||
@@ -797,18 +900,12 @@ export function ModelSettings({
|
||||
</p>
|
||||
)}
|
||||
{models.refreshError && (
|
||||
<div className="model-refresh-notice" role="status">
|
||||
<p>{text(models.refreshError)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={locked}
|
||||
onClick={() => void models.refreshProfiles()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{text("刷新配置列表")}
|
||||
</button>
|
||||
</div>
|
||||
<R6StateRecovery
|
||||
message={text(models.refreshError)}
|
||||
pending={models.refreshing}
|
||||
role="status"
|
||||
onRetry={() => void models.refreshProfiles()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The paste-one-key path goes first when it exists. Behind it sits the
|
||||
@@ -956,326 +1053,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 +1527,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 +1551,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,22 @@ const models = {
|
||||
activate: vi.fn(),
|
||||
};
|
||||
vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models }));
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
useEmbeddingSwitch: () => ({
|
||||
current: null,
|
||||
plan: null,
|
||||
busy: null,
|
||||
errorCode: null,
|
||||
refreshFailed: false,
|
||||
supported: false,
|
||||
planActivation: vi.fn(),
|
||||
startPlannedSwitch: vi.fn(),
|
||||
cancelCurrentSwitch: vi.fn(),
|
||||
shouldUseLegacyActivation: () => true,
|
||||
clearPlan: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
const source: SourceResponse = {
|
||||
source_id: "cursor",
|
||||
display_name: "Cursor",
|
||||
|
||||
@@ -41,6 +41,21 @@ const { models } = vi.hoisted(() => ({
|
||||
vi.mock("./use-model-profiles", () => ({
|
||||
useModelProfiles: () => models,
|
||||
}));
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
useEmbeddingSwitch: () => ({
|
||||
current: null,
|
||||
plan: null,
|
||||
busy: null,
|
||||
errorCode: null,
|
||||
refreshFailed: false,
|
||||
supported: false,
|
||||
planActivation: vi.fn(),
|
||||
startPlannedSwitch: vi.fn(),
|
||||
shouldUseLegacyActivation: () => true,
|
||||
clearPlan: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("./OnboardingModelStep", () => ({
|
||||
OnboardingModelStep: (props: {
|
||||
probe?: { message: string } | null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "@one-memory/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getModelProfiles } from "./api";
|
||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||
import {
|
||||
type OnboardingModelDraft,
|
||||
OnboardingModelStep,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import { useModelProfiles } from "./use-model-profiles";
|
||||
|
||||
const defaultPreset = PROVIDER_PRESETS.find(
|
||||
@@ -68,6 +70,7 @@ export function OnboardingModelController({
|
||||
}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const models = useModelProfiles();
|
||||
const embeddingSwitch = useEmbeddingSwitch();
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [saved, setSaved] = useState<ModelProfile | null>(null);
|
||||
@@ -82,6 +85,9 @@ export function OnboardingModelController({
|
||||
state: "passed" | "failed" | "stale";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const flight = useRef(false);
|
||||
const draftTouched = useRef(false);
|
||||
|
||||
@@ -115,6 +121,27 @@ export function OnboardingModelController({
|
||||
});
|
||||
}, [models.profiles, saved]);
|
||||
|
||||
useEffect(() => {
|
||||
const finished = embeddingSwitch.current?.last_finished;
|
||||
if (
|
||||
!switchPendingJobId ||
|
||||
!finished ||
|
||||
finished.job_id !== switchPendingJobId
|
||||
)
|
||||
return;
|
||||
if (finished.state === "done") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
onEnabled?.();
|
||||
} else if (finished.state === "rolled_back") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
|
||||
}
|
||||
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
|
||||
|
||||
function edit(patch: Partial<OnboardingModelDraft>) {
|
||||
if (flight.current) return;
|
||||
draftTouched.current = true;
|
||||
@@ -273,6 +300,18 @@ export function OnboardingModelController({
|
||||
setError(null);
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
try {
|
||||
const plan = embeddingSwitch.shouldUseLegacyActivation()
|
||||
? null
|
||||
: await embeddingSwitch.planActivation(
|
||||
saved.profile_id,
|
||||
saved.revision,
|
||||
);
|
||||
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
|
||||
throw new Error("embedding_switch_plan_unconfirmed");
|
||||
if (plan?.needed) {
|
||||
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
|
||||
return;
|
||||
}
|
||||
const active = await models.activate(saved.profile_id);
|
||||
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
|
||||
trace.done("succeeded");
|
||||
@@ -287,77 +326,102 @@ export function OnboardingModelController({
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingModelStep
|
||||
draft={draft}
|
||||
providers={[
|
||||
...PROVIDER_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
keyRequired: preset.provider !== "ollama",
|
||||
supported: true,
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "自定义兼容服务",
|
||||
keyRequired: true,
|
||||
supported: true,
|
||||
},
|
||||
]}
|
||||
advancedOpen={advanced}
|
||||
onAdvancedChange={setAdvanced}
|
||||
onDraftChange={edit}
|
||||
phase={phase}
|
||||
capability={{ saveAndEnable: true }}
|
||||
canSaveAndEnable={probe?.state === "passed" && !!saved}
|
||||
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
|
||||
canTest={!models.pendingProbe && !models.probeStorageFailed}
|
||||
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
|
||||
inputsLocked={flight.current}
|
||||
fieldErrors={Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [
|
||||
key,
|
||||
value === "onboardingIntegerRange"
|
||||
? t(
|
||||
"onboardingIntegerRange",
|
||||
key === "timeout_seconds"
|
||||
? { min: 1, max: 600 }
|
||||
: key === "max_concurrency"
|
||||
? { min: 1, max: 32 }
|
||||
: { min: 1024, max: 10_000_000 },
|
||||
)
|
||||
: text(value),
|
||||
]),
|
||||
)}
|
||||
error={error ?? (models.error ? text(models.error) : null)}
|
||||
probe={probe}
|
||||
profile={
|
||||
saved
|
||||
? {
|
||||
profile_id: saved.profile_id,
|
||||
revision: saved.revision,
|
||||
has_configured_api_key:
|
||||
saved.has_configured_api_key ?? saved.has_api_key,
|
||||
credential_state: saved.credential_state,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sourceNames={sourceNames}
|
||||
importedConversationCount={importedConversationCount}
|
||||
connectionSummary={
|
||||
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
|
||||
}
|
||||
generationRecipient={draft.base_url || "尚未填写接收方"}
|
||||
embeddingRecipient={
|
||||
draft.embedding_mode === "local"
|
||||
? "本机内置向量模型"
|
||||
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
|
||||
}
|
||||
onSaveDraft={() => save(false)}
|
||||
onTest={() => save(true)}
|
||||
onSaveAndEnable={enable}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
onActionError={() => setError("操作未确认,草稿已保留。")}
|
||||
/>
|
||||
<>
|
||||
<OnboardingModelStep
|
||||
draft={draft}
|
||||
providers={[
|
||||
...PROVIDER_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
keyRequired: preset.provider !== "ollama",
|
||||
supported: true,
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "自定义兼容服务",
|
||||
keyRequired: true,
|
||||
supported: true,
|
||||
},
|
||||
]}
|
||||
advancedOpen={advanced}
|
||||
onAdvancedChange={setAdvanced}
|
||||
onDraftChange={edit}
|
||||
phase={phase}
|
||||
capability={{ saveAndEnable: true }}
|
||||
canSaveAndEnable={probe?.state === "passed" && !!saved}
|
||||
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
|
||||
canTest={!models.pendingProbe && !models.probeStorageFailed}
|
||||
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
|
||||
inputsLocked={flight.current}
|
||||
fieldErrors={Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [
|
||||
key,
|
||||
value === "onboardingIntegerRange"
|
||||
? t(
|
||||
"onboardingIntegerRange",
|
||||
key === "timeout_seconds"
|
||||
? { min: 1, max: 600 }
|
||||
: key === "max_concurrency"
|
||||
? { min: 1, max: 32 }
|
||||
: { min: 1024, max: 10_000_000 },
|
||||
)
|
||||
: text(value),
|
||||
]),
|
||||
)}
|
||||
error={error ?? (models.error ? text(models.error) : null)}
|
||||
probe={probe}
|
||||
profile={
|
||||
saved
|
||||
? {
|
||||
profile_id: saved.profile_id,
|
||||
revision: saved.revision,
|
||||
has_configured_api_key:
|
||||
saved.has_configured_api_key ?? saved.has_api_key,
|
||||
credential_state: saved.credential_state,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sourceNames={sourceNames}
|
||||
importedConversationCount={importedConversationCount}
|
||||
connectionSummary={
|
||||
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
|
||||
}
|
||||
generationRecipient={draft.base_url || "尚未填写接收方"}
|
||||
embeddingRecipient={
|
||||
draft.embedding_mode === "local"
|
||||
? "本机内置向量模型"
|
||||
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
|
||||
}
|
||||
onSaveDraft={() => save(false)}
|
||||
onTest={() => save(true)}
|
||||
onSaveAndEnable={enable}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
onActionError={() => setError("操作未确认,草稿已保留。")}
|
||||
/>
|
||||
{embeddingSwitch.plan?.needed && saved ? (
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={embeddingSwitch.plan}
|
||||
busy={embeddingSwitch.busy === "start"}
|
||||
errorCode={embeddingSwitch.errorCode}
|
||||
onCancel={() => {
|
||||
embeddingSwitch.clearPlan();
|
||||
embeddingSwitch.clearError();
|
||||
}}
|
||||
onStart={() => {
|
||||
flight.current = true;
|
||||
setPhase("enabling");
|
||||
void embeddingSwitch.startPlannedSwitch().then((job) => {
|
||||
if (job) {
|
||||
setSwitchPendingJobId(job.job_id);
|
||||
return;
|
||||
}
|
||||
flight.current = false;
|
||||
setPhase("idle");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Ref } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
|
||||
import {
|
||||
getMemoryMode,
|
||||
getModelProfiles,
|
||||
getSedimentation,
|
||||
setSedimentation,
|
||||
} from "./api";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
import { R6Settings } from "./R6Settings";
|
||||
@@ -19,7 +30,7 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Modelo de memorias" }),
|
||||
await screen.findByRole("heading", { name: "Modo de memoria" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("switch", {
|
||||
@@ -27,9 +38,13 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
}),
|
||||
).toBeNull();
|
||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Idioma de la interfaz" }),
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
expect(await screen.findByRole("heading", { name: "模型配置" })).toBeTruthy();
|
||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||
});
|
||||
vi.mock("./api", () => ({
|
||||
@@ -85,14 +100,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" }, "核对测试结果"),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -127,6 +153,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
} as NonNullable<typeof window.oneMemory>;
|
||||
const user = userEvent.setup();
|
||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "界面语言" }));
|
||||
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox", { name: "语言" }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(12);
|
||||
@@ -145,6 +172,79 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses one sidebar hierarchy and explains when a saved model is inactive in lite", async () => {
|
||||
vi.mocked(getMemoryMode).mockResolvedValueOnce({
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "界面语言" }));
|
||||
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "刷新模型状态" })).toBeNull();
|
||||
});
|
||||
|
||||
it("carries a missing-model explanation into model configuration and moves focus", async () => {
|
||||
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
|
||||
vi.mocked(getMemoryMode).mockResolvedValueOnce({
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
).toBeTruthy();
|
||||
const heading = screen.getByRole("heading", { name: "模型配置" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(heading));
|
||||
});
|
||||
|
||||
it("offers a text recovery action when the model overview cannot be read", async () => {
|
||||
vi.mocked(getModelProfiles).mockRejectedValueOnce(new Error("offline"));
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
expect(await screen.findByText("模型状态未能读取,请重试。")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
|
||||
expect(await screen.findByText("当前模型")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stops model sedimentation on one switch flip without touching the model config", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(setSedimentation).mockResolvedValue({
|
||||
@@ -153,6 +253,7 @@ it("stops model sedimentation on one switch flip without touching the model conf
|
||||
imports_unaffected: true,
|
||||
});
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
@@ -171,6 +272,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(setSedimentation).mockRejectedValue(new Error("offline"));
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" });
|
||||
await user.click(toggle);
|
||||
@@ -181,14 +283,21 @@ 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(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
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");
|
||||
@@ -202,15 +311,57 @@ it("reaches the same recovery entry when no model has been configured yet", asyn
|
||||
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
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(
|
||||
await within(generationLane).findByRole("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()} />);
|
||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
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 () => {
|
||||
vi.mocked(getSedimentation).mockRejectedValueOnce(new Error("offline"));
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
|
||||
const toggle = (await screen.findByRole("switch", {
|
||||
name: "用模型沉淀记忆",
|
||||
|
||||
+212
-103
@@ -1,5 +1,5 @@
|
||||
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -13,9 +13,14 @@ 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 { R6StateRecovery } from "./R6StateRecovery";
|
||||
import {
|
||||
refreshLoginPreference,
|
||||
setLoginPreference,
|
||||
@@ -28,14 +33,9 @@ import {
|
||||
} from "./sedimentation-state";
|
||||
import "./r6-settings.css";
|
||||
|
||||
type Group = "model" | "connections" | "help";
|
||||
type Group = "language" | "mode" | "models" | "connections" | "help";
|
||||
|
||||
const labels: Record<Group, string> = {
|
||||
model: "记忆模式与模型",
|
||||
connections: "连接其他应用",
|
||||
help: "帮助与诊断",
|
||||
};
|
||||
const primaryGroups: Group[] = ["model", "connections"];
|
||||
const primaryGroups: Group[] = ["language", "mode", "models", "connections"];
|
||||
|
||||
function safeEndpoint(value: string | null) {
|
||||
if (!value) return "本机 / 受管端点";
|
||||
@@ -62,12 +62,22 @@ export function R6Settings({
|
||||
const groups = showDiagnostics
|
||||
? [...primaryGroups, "help" as const]
|
||||
: primaryGroups;
|
||||
const [group, setGroup] = useState<Group>("model");
|
||||
const groupLabel = (key: Group) => {
|
||||
if (key === "language") return t("languageTitle");
|
||||
if (key === "mode") return text("记忆模式");
|
||||
if (key === "models") return text("模型配置");
|
||||
if (key === "connections") return text("连接其他应用");
|
||||
return text("帮助与诊断");
|
||||
};
|
||||
const [group, setGroup] = useState<Group>("mode");
|
||||
const [profiles, setProfiles] = useState<ModelProfile[] | null>(null);
|
||||
const [modelError, setModelError] = useState(false);
|
||||
const [configurationRefusal, setConfigurationRefusal] = useState(false);
|
||||
const [modeHandoffMessage, setModeHandoffMessage] = useState("");
|
||||
const [modelVersion, setModelVersion] = useState(0);
|
||||
const [configurationOpen, setConfigurationOpen] = useState(false);
|
||||
const [configurationFocus, setConfigurationFocus] =
|
||||
useState<ModelSettingsFocusSection>("generation");
|
||||
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
@@ -126,9 +136,8 @@ export function R6Settings({
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: modelVersion is the explicit refresh trigger.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (group !== "model") return;
|
||||
void readModels();
|
||||
}, [active, group, modelVersion, readModels]);
|
||||
}, [active, modelVersion, readModels]);
|
||||
|
||||
const readSedimentation = useCallback(async () => {
|
||||
try {
|
||||
@@ -143,7 +152,7 @@ export function R6Settings({
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (group !== "model") return;
|
||||
if (group !== "models") return;
|
||||
void readSedimentation();
|
||||
}, [active, group, readSedimentation]);
|
||||
|
||||
@@ -212,8 +221,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();
|
||||
@@ -272,7 +284,6 @@ export function R6Settings({
|
||||
return (
|
||||
<section className="r6-settings" aria-label={t("settings")}>
|
||||
<h1>{t("settings")}</h1>
|
||||
<LanguageSelector />
|
||||
<div className="r6-settings-layout">
|
||||
<nav aria-label={text("设置分组")}>
|
||||
{groups.map((key) => (
|
||||
@@ -283,114 +294,211 @@ export function R6Settings({
|
||||
aria-controls={`${id}-panel-${key}`}
|
||||
aria-current={group === key ? "page" : undefined}
|
||||
onKeyDown={moveGroupFocus}
|
||||
onClick={() => setGroup(key)}
|
||||
onClick={() => {
|
||||
setModeHandoffMessage("");
|
||||
setGroup(key);
|
||||
}}
|
||||
>
|
||||
{text(labels[key])}
|
||||
{groupLabel(key)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="r6-settings-content">
|
||||
<section
|
||||
id={`${id}-panel-model`}
|
||||
aria-labelledby={`${id}-nav-model`}
|
||||
hidden={group !== "model"}
|
||||
className="r6-settings-model"
|
||||
id={`${id}-panel-language`}
|
||||
aria-labelledby={`${id}-nav-language`}
|
||||
hidden={group !== "language"}
|
||||
className="r6-settings-language"
|
||||
>
|
||||
<LanguageSelector />
|
||||
</section>
|
||||
<section
|
||||
id={`${id}-panel-mode`}
|
||||
aria-labelledby={`${id}-nav-mode`}
|
||||
hidden={group !== "mode"}
|
||||
className="r6-settings-mode"
|
||||
>
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={currentModel !== null}
|
||||
modelStateKnown={profiles !== null && !modelError}
|
||||
onConfigureModel={openConfiguration}
|
||||
onConfigureModel={(message) => {
|
||||
setModeHandoffMessage(message ?? "");
|
||||
setGroup("models");
|
||||
openConfiguration("generation");
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(`${id}-model-title`)?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
onModeChange={setMemoryModeState}
|
||||
/>
|
||||
<h2>{text("记忆模型")}</h2>
|
||||
<div className="r6-model-overview">
|
||||
<p className="r6-model-caption">{text("当前模型")}</p>
|
||||
<h3 className="r6-current-model">
|
||||
{currentModel?.name ??
|
||||
text(
|
||||
profiles === null
|
||||
? "正在读取模型…"
|
||||
: modelError
|
||||
? "模型状态暂不可读"
|
||||
: "尚未配置模型",
|
||||
)}
|
||||
</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}
|
||||
</section>
|
||||
<section
|
||||
id={`${id}-panel-models`}
|
||||
aria-labelledby={`${id}-nav-models`}
|
||||
hidden={group !== "models"}
|
||||
className="r6-settings-model"
|
||||
>
|
||||
<div className="r6-model-heading">
|
||||
<div>
|
||||
<h2
|
||||
id={`${id}-model-title`}
|
||||
className="r6-model-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{text(currentModel ? "修改模型" : "配置模型")}
|
||||
</R6Button>
|
||||
{text("模型配置")}
|
||||
</h2>
|
||||
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{modeHandoffMessage ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text(modeHandoffMessage)}
|
||||
</p>
|
||||
) : null}
|
||||
{currentModel && (
|
||||
<div className="r6-model-mode-status" role="status">
|
||||
<strong>
|
||||
{text(
|
||||
memoryMode === "lite"
|
||||
? "当前是基础记忆(lite)"
|
||||
: memoryMode === "ultra"
|
||||
? "当前是深度记忆(ultra)"
|
||||
: memoryMode === "pro"
|
||||
? "当前是智能记忆(pro)"
|
||||
: "正在读取记忆模式…",
|
||||
)}
|
||||
</strong>
|
||||
<p>
|
||||
{text(
|
||||
memoryMode === "lite"
|
||||
? "模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。"
|
||||
: memoryMode === "pro" || memoryMode === "ultra"
|
||||
? sedimentation === null
|
||||
? "正在确认模型沉淀状态,模型配置和已有记忆不会受影响。"
|
||||
: sedimentation === false
|
||||
? "当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。"
|
||||
: "模型已在当前模式下生效,会用于整理新对话并沉淀记忆。"
|
||||
: "模型配置已保留,确认记忆模式后会显示是否生效。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="r6-model-overview">
|
||||
<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>
|
||||
)}
|
||||
</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 && (
|
||||
<R6StateRecovery
|
||||
message={text(
|
||||
profiles === null
|
||||
? "模型状态未能读取,请重试。"
|
||||
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
|
||||
)}
|
||||
onRetry={() => setModelVersion((value) => value + 1)}
|
||||
/>
|
||||
)}
|
||||
{configurationRefusal && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{currentModel && memoryMode !== "lite" && (
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("用模型沉淀记忆")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("用模型沉淀记忆")}
|
||||
checked={sedimentation === true}
|
||||
disabled={
|
||||
sedimentation === null ||
|
||||
sedimentationPending ||
|
||||
sedimentationUnavailable
|
||||
}
|
||||
onCheckedChange={(enabled) =>
|
||||
void changeSedimentation(enabled)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sedimentationError && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(sedimentationError)}
|
||||
</p>
|
||||
)}
|
||||
{sedimentationUnavailable && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<section
|
||||
className="r6-model-owner"
|
||||
hidden={!configurationOpen}
|
||||
@@ -398,6 +506,7 @@ export function R6Settings({
|
||||
>
|
||||
<ModelSettings
|
||||
compactSingle
|
||||
focusSection={configurationFocus}
|
||||
controlsRef={modelActions}
|
||||
onProfilesChanged={() => setModelVersion((value) => value + 1)}
|
||||
onSetupComplete={() => setConfigurationOpen(false)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
|
||||
export function R6StateRecovery({
|
||||
message,
|
||||
onRetry,
|
||||
pending = false,
|
||||
role = "alert",
|
||||
}: {
|
||||
message: string;
|
||||
onRetry(): void;
|
||||
pending?: boolean;
|
||||
role?: "alert" | "status";
|
||||
}) {
|
||||
const { t } = useOnboardingTranslation();
|
||||
return (
|
||||
<div className="r6-state-recovery">
|
||||
<p className="r6-settings-error" role={role}>
|
||||
{message}
|
||||
</p>
|
||||
<R6Button pending={pending} onClick={onRetry}>
|
||||
{t("startupRetry")}
|
||||
</R6Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1213,7 +1362,10 @@ export function isHealthResponse(value: unknown): value is HealthResponse {
|
||||
(candidate.memory_backfill === undefined ||
|
||||
candidate.memory_backfill === "running" ||
|
||||
candidate.memory_backfill === "done" ||
|
||||
candidate.memory_backfill === "failed")
|
||||
candidate.memory_backfill === "failed") &&
|
||||
(candidate.embedding_switch === undefined ||
|
||||
candidate.embedding_switch === null ||
|
||||
parseEmbeddingSwitchSummary(candidate.embedding_switch) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
.r6-settings-dialog-layer.embedding-switch-dialog-layer {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(600px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
color: var(--r6-text);
|
||||
}
|
||||
|
||||
.embedding-switch-dialog > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.embedding-switch-safety p,
|
||||
.embedding-switch-dialog header p {
|
||||
margin: 0;
|
||||
color: var(--r6-muted);
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 22px 0;
|
||||
border-block: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div {
|
||||
padding: 14px 16px 14px 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div + div {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-impact dt,
|
||||
.embedding-switch-details dt {
|
||||
color: var(--meta-reading);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact dd,
|
||||
.embedding-switch-details dd {
|
||||
margin: 3px 0 0;
|
||||
color: var(--r6-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-safety {
|
||||
display: grid;
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 10px 18px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-safety strong {
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-details {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.embedding-switch-details summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
color: var(--r6-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.embedding-switch-details summary:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-alert svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-details[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.embedding-switch-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 24px;
|
||||
margin: 12px 0 0;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--bd);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.embedding-switch-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dg-bd);
|
||||
background: var(--dg-bg);
|
||||
color: var(--dg);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip,
|
||||
.embedding-switch-outcome {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 9px 18px;
|
||||
border-bottom: 0.5px solid var(--bd);
|
||||
background: var(--panel);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.embedding-switch-strip-copy,
|
||||
.embedding-switch-outcome > div:first-child {
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip strong,
|
||||
.embedding-switch-outcome strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip span,
|
||||
.embedding-switch-outcome span {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-progress {
|
||||
height: 5px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--ac);
|
||||
transition: width var(--motion-state) var(--motion-enter);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome {
|
||||
justify-content: space-between;
|
||||
background: var(--ok-bg);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome.is-error {
|
||||
background: var(--dg-bg);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedding-switch-cleanup-error {
|
||||
margin: 0;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 0.5px solid var(--dg-bd);
|
||||
background: var(--dg-bg);
|
||||
color: var(--dg);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome-actions > .r6-button:last-child {
|
||||
width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.embedding-switch-dialog-layer {
|
||||
align-items: end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embedding-switch-dialog {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
padding: 22px 18px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div + div {
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--bd);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.embedding-switch-safety,
|
||||
.embedding-switch-details dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.embedding-switch-strip,
|
||||
.embedding-switch-outcome {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.embedding-switch-progress {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.embedding-switch-progress > span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-native-reduce-motion="true"] .embedding-switch-progress > span {
|
||||
transition: none;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type PollKey =
|
||||
| "applications"
|
||||
| "calls"
|
||||
| "model-profiles"
|
||||
| "embedding-switch"
|
||||
| "memory-add-processing"
|
||||
| "sdk-raw-history"
|
||||
| "health";
|
||||
|
||||
@@ -11,6 +11,41 @@
|
||||
--r6-fast: 140ms;
|
||||
}
|
||||
|
||||
.r6-settings-dialog-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(46 66 82 / 18%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.r6-settings-dialog {
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 28px;
|
||||
border: 1px solid white;
|
||||
border-radius: 16px;
|
||||
background: #f3f7f9;
|
||||
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
|
||||
}
|
||||
|
||||
.r6-settings-dialog h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.r6-settings-dialog p {
|
||||
margin: 0 0 24px;
|
||||
color: #63707b;
|
||||
}
|
||||
|
||||
.r6-settings-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
button.r6-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -318,6 +353,18 @@ button.r6-switch:focus-visible,
|
||||
[data-native-reduce-motion="true"] .r6-button-spinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.r6-settings-dialog-layer {
|
||||
background: rgb(46 66 82 / 28%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-native-reduce-transparency="true"] .r6-settings-dialog-layer {
|
||||
background: rgb(46 66 82 / 28%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
[data-native-reduce-motion="true"]
|
||||
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
|
||||
transition: none;
|
||||
|
||||
@@ -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;
|
||||
@@ -151,9 +151,9 @@
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
text-wrap: pretty;
|
||||
white-space: normal;
|
||||
transition:
|
||||
color 140ms ease-out,
|
||||
background-color 140ms ease-out;
|
||||
@@ -194,6 +194,17 @@
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.r6-settings .r6-settings-language .r6-language-setting {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.r6-settings .r6-settings-language .r6-language-setting-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.r6-memory-mode-settings {
|
||||
margin: 0 0 32px;
|
||||
padding: 0 0 32px;
|
||||
@@ -217,53 +228,70 @@
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-mode-setting-row {
|
||||
max-width: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.r6-settings .r6-mode-option-menu {
|
||||
width: 100%;
|
||||
.r6-mode-choice-list {
|
||||
max-width: 760px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d8e2ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button {
|
||||
height: auto;
|
||||
min-height: 68px;
|
||||
padding: 10px 44px 10px 14px;
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button::after {
|
||||
top: 27px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy {
|
||||
.r6-mode-choice {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > span,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > span {
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 74px;
|
||||
padding: 12px 16px;
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.r6-mode-choice + .r6-mode-choice {
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-mode-choice[data-current="true"] {
|
||||
background: #edf3f6;
|
||||
}
|
||||
.r6-mode-choice[data-disabled="true"] {
|
||||
color: #71808b;
|
||||
cursor: default;
|
||||
}
|
||||
.r6-mode-choice:focus-within {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.r6-mode-choice input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--r6-action);
|
||||
}
|
||||
.r6-mode-choice input:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
.r6-mode-choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.r6-mode-choice-copy strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > small,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > small {
|
||||
.r6-mode-choice-copy small {
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 19px;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 20px;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||
[role="option"] {
|
||||
min-height: 62px;
|
||||
padding: 9px 10px;
|
||||
white-space: normal;
|
||||
.r6-mode-current {
|
||||
align-self: start;
|
||||
color: #405e75;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r6-settings-note {
|
||||
margin: 14px 0 0;
|
||||
@@ -275,17 +303,131 @@
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-evolve-settings {
|
||||
margin-top: 20px;
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-evolve-settings:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.r6-evolve-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.r6-evolve-heading h3,
|
||||
.r6-evolve-setting h3 {
|
||||
margin: 0;
|
||||
color: #304757;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
word-break: normal;
|
||||
}
|
||||
.r6-evolve-heading p,
|
||||
.r6-evolve-setting p {
|
||||
margin: 3px 0 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.r6-evolve-setting {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px 24px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-evolve-setting-copy {
|
||||
min-width: 280px;
|
||||
}
|
||||
.r6-evolve-setting > .r6-switch,
|
||||
.r6-evolve-setting > .r6-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.r6-evolve-time {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 180px;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 2px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
}
|
||||
.r6-settings .r6-evolve-time label {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-evolve-time p {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
min-height: 40px;
|
||||
box-sizing: border-box;
|
||||
width: 180px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"]:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-state-recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.r6-state-recovery .r6-settings-error {
|
||||
margin: 0;
|
||||
}
|
||||
.r6-mode-confirm-effects {
|
||||
margin: 18px 0 22px;
|
||||
}
|
||||
.r6-mode-confirm-dialog {
|
||||
width: min(560px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
}
|
||||
.r6-mode-confirm-dialog footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.r6-mode-confirm-effects > div {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-mode-confirm-effects > div:last-child {
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-mode-confirm-effects dt {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-mode-confirm-effects dd {
|
||||
margin: 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-settings-mode .r6-memory-mode-settings {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.r6-settings-dialog ul {
|
||||
margin: 14px 0 20px;
|
||||
padding-left: 20px;
|
||||
@@ -473,28 +615,94 @@
|
||||
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-mode-status {
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--r6-field-fill);
|
||||
}
|
||||
.r6-settings .r6-model-mode-status strong {
|
||||
display: block;
|
||||
color: var(--r6-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-model-mode-status p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--r6-muted);
|
||||
font-size: 13px;
|
||||
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: var(--r6-muted);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-settings .r6-model-lane > .r6-button {
|
||||
min-width: 72px;
|
||||
}
|
||||
.r6-settings .r6-model-recipient {
|
||||
font-size: 14px;
|
||||
@@ -535,36 +743,6 @@
|
||||
background: rgb(158 178 194 / 22%);
|
||||
color: #0f6e56;
|
||||
}
|
||||
.r6-settings-dialog-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(46 66 82 / 18%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.r6-settings-dialog {
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 28px;
|
||||
border: 1px solid white;
|
||||
border-radius: 16px;
|
||||
background: #f3f7f9;
|
||||
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
|
||||
}
|
||||
.r6-settings-dialog h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.r6-settings-dialog p {
|
||||
margin: 0 0 24px;
|
||||
color: #63707b;
|
||||
}
|
||||
.r6-settings-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.r6-settings .r6-model-maintenance {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -713,17 +891,61 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 54px;
|
||||
border: 1px solid #c0ccd5;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--r6-control-edge);
|
||||
border-radius: 8px;
|
||||
background: #f0f5f6;
|
||||
color: #262a2e;
|
||||
font-size: 15px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
color: var(--r6-text);
|
||||
font-size: 14px;
|
||||
padding: 10px 13px;
|
||||
}
|
||||
.r6-settings .model-form .om-option-menu-button {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 42px 0 13px;
|
||||
border-color: var(--r6-control-edge);
|
||||
background: #fff;
|
||||
color: var(--r6-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.r6-settings .model-form .om-option-menu-button::after {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-settings .model-form input:not([type="checkbox"]):hover,
|
||||
.r6-settings .model-form select:hover,
|
||||
.r6-settings .model-form .om-option-menu-button:hover:not(:disabled) {
|
||||
border-color: #aabcc8;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .model-form input:not([type="checkbox"]):focus-visible,
|
||||
.r6-settings .model-form select:focus-visible,
|
||||
.r6-settings .model-form .om-option-menu-button:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-settings .model-advanced {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.r6-settings .model-search-intro {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-settings .model-search-intro strong {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.r6-settings .model-search-intro p {
|
||||
margin: 4px 0 0;
|
||||
color: #63707b;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-settings .model-advanced label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -828,10 +1050,49 @@
|
||||
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;
|
||||
}
|
||||
.r6-mode-choice {
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
}
|
||||
.r6-mode-current {
|
||||
grid-column: 2;
|
||||
}
|
||||
.r6-evolve-setting,
|
||||
.r6-evolve-time,
|
||||
.r6-mode-confirm-effects > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.r6-evolve-setting-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-evolve-setting > .r6-switch,
|
||||
.r6-evolve-setting > .r6-button {
|
||||
justify-self: start;
|
||||
}
|
||||
.r6-evolve-time {
|
||||
gap: 8px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
.r6-state-recovery {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.r6-mode-confirm-dialog footer .r6-button {
|
||||
width: 100%;
|
||||
}
|
||||
.r6-language-setting-description {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@@ -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 { useEmbeddingSwitchController as 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,299 @@
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
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 useEmbeddingSwitchController() {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export type EmbeddingSwitchController = ReturnType<
|
||||
typeof useEmbeddingSwitchController
|
||||
>;
|
||||
|
||||
const EmbeddingSwitchContext = createContext<EmbeddingSwitchController | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function EmbeddingSwitchProvider({ children }: { children: ReactNode }) {
|
||||
const controller = useEmbeddingSwitchController();
|
||||
return createElement(
|
||||
EmbeddingSwitchContext.Provider,
|
||||
{ value: controller },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
export function useEmbeddingSwitch(): EmbeddingSwitchController {
|
||||
const controller = useContext(EmbeddingSwitchContext);
|
||||
if (!controller) throw new Error("embedding_switch_provider_missing");
|
||||
return controller;
|
||||
}
|
||||
@@ -605,6 +605,7 @@ it("prevents an older refresh from overwriting discard's profile snapshot", asyn
|
||||
refreshing = hook.result.current.refreshProfiles();
|
||||
});
|
||||
await waitFor(() => expect(api.getModelProfiles).toHaveBeenCalledTimes(2));
|
||||
expect(hook.result.current.refreshing).toBe(true);
|
||||
await act(async () => {
|
||||
expect(await hook.result.current.discardMissingCreation()).toBeUndefined();
|
||||
});
|
||||
@@ -614,6 +615,7 @@ it("prevents an older refresh from overwriting discard's profile snapshot", asyn
|
||||
await refreshing;
|
||||
});
|
||||
expect(hook.result.current.profiles).toEqual([profile]);
|
||||
expect(hook.result.current.refreshing).toBe(false);
|
||||
expect(readPendingModelCreation()).toBe(ID);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export function useModelProfiles() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [pendingCreation, setPendingCreation] = useState<string | null>(null);
|
||||
const [creationMissing, setCreationMissingState] = useState<string | null>(
|
||||
null,
|
||||
@@ -107,6 +108,7 @@ export function useModelProfiles() {
|
||||
const alive = useRef(false);
|
||||
const session = useRef(0);
|
||||
const readVersion = useRef(0);
|
||||
const refreshingReads = useRef(0);
|
||||
const flight = useRef<{ key: string; promise: Promise<unknown> } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -119,6 +121,8 @@ export function useModelProfiles() {
|
||||
async (afterOperation = false) => {
|
||||
const version = ++readVersion.current;
|
||||
const owner = session.current;
|
||||
refreshingReads.current++;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const next = await getModelProfiles();
|
||||
if (
|
||||
@@ -141,6 +145,10 @@ export function useModelProfiles() {
|
||||
? "操作已完成,但配置列表暂未刷新。已保留操作回执,请刷新核对,不要重复保存。"
|
||||
: "暂时无法刷新模型配置,已确认的配置仍保留。请重试刷新。",
|
||||
);
|
||||
} finally {
|
||||
refreshingReads.current = Math.max(0, refreshingReads.current - 1);
|
||||
if (alive.current && refreshingReads.current === 0)
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[setProfiles],
|
||||
@@ -165,6 +173,7 @@ export function useModelProfiles() {
|
||||
alive.current = false;
|
||||
session.current++;
|
||||
readVersion.current++;
|
||||
refreshingReads.current = 0;
|
||||
flight.current = null;
|
||||
creationMissingRef.current = null;
|
||||
};
|
||||
@@ -336,6 +345,7 @@ export function useModelProfiles() {
|
||||
busy,
|
||||
error,
|
||||
refreshError,
|
||||
refreshing,
|
||||
pendingCreation,
|
||||
creationMissing,
|
||||
pendingProbe,
|
||||
|
||||
@@ -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,985 @@
|
||||
export const embeddingSwitchChineseMessages = {
|
||||
"embeddingSwitch.confirm.title": "更新记忆搜索索引?",
|
||||
"embeddingSwitch.confirm.description":
|
||||
"不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。",
|
||||
"embeddingSwitch.impact.memories": "已有记忆",
|
||||
"embeddingSwitch.impact.memoryCount": "{count} 条",
|
||||
"embeddingSwitch.impact.userCount": " · {count} 位用户",
|
||||
"embeddingSwitch.impact.duration": "预计耗时",
|
||||
"embeddingSwitch.duration.unknown": "暂时无法估算",
|
||||
"embeddingSwitch.duration.seconds": "约 {count} 秒",
|
||||
"embeddingSwitch.duration.minutes": "约 {count} 分钟",
|
||||
"embeddingSwitch.impact.requests": "远程请求",
|
||||
"embeddingSwitch.requests.none": "不会产生",
|
||||
"embeddingSwitch.requests.billed": "按服务商计费",
|
||||
"embeddingSwitch.requests.approx": "约 {count} 次",
|
||||
"embeddingSwitch.safety.searchTitle": "更新期间仍可搜索记忆",
|
||||
"embeddingSwitch.safety.searchBody":
|
||||
"MemHub 会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。",
|
||||
"embeddingSwitch.safety.dataTitle": "失败不会影响已有数据",
|
||||
"embeddingSwitch.safety.dataBody":
|
||||
"只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。",
|
||||
"embeddingSwitch.details.title": "查看技术详情",
|
||||
"embeddingSwitch.details.currentModel": "当前搜索模型",
|
||||
"embeddingSwitch.details.legacyIndex": "旧版本索引",
|
||||
"embeddingSwitch.details.newModel": "新的搜索模型",
|
||||
"embeddingSwitch.details.pending": "待确认",
|
||||
"embeddingSwitch.details.dimensions": "索引维度",
|
||||
"embeddingSwitch.details.unknown": "未知",
|
||||
"embeddingSwitch.details.disk": "额外磁盘空间",
|
||||
"embeddingSwitch.details.retention": "旧索引保留",
|
||||
"embeddingSwitch.details.days": "{count} 天",
|
||||
"embeddingSwitch.blocker.embedding_probe_required":
|
||||
"请先测试新的搜索模型连接。",
|
||||
"embeddingSwitch.blocker.embedding_dimensions_unknown":
|
||||
"还无法确认新搜索模型的索引规格,请重新测试连接。",
|
||||
"embeddingSwitch.blocker.embedding_switch_requires_migration":
|
||||
"旧版本数据仍在升级,请等待升级完成后再更换。",
|
||||
"embeddingSwitch.blocker.sdk_reembed_unavailable":
|
||||
"当前安装版本还不能安全更新记忆索引。",
|
||||
"embeddingSwitch.blocker.insufficient_disk_space":
|
||||
"本机剩余空间不足,无法安全建立新索引。",
|
||||
"embeddingSwitch.blocker.embedding_switch_running":
|
||||
"另一次索引更新正在进行。",
|
||||
"embeddingSwitch.blocker.memory_lifecycle_busy":
|
||||
"记忆正在整合,请等待完成后再更换搜索模型。",
|
||||
"embeddingSwitch.blocker.default": "当前状态暂时不允许更换搜索模型。",
|
||||
"embeddingSwitch.error.planStale": "记忆状态发生了变化,请重新检查更换影响。",
|
||||
"embeddingSwitch.error.startFailed": "索引更新暂时无法开始,请稍后重试。",
|
||||
"embeddingSwitch.action.notNow": "暂不更换",
|
||||
"embeddingSwitch.action.start": "开始更新索引",
|
||||
"embeddingSwitch.job.merge": "正在整理旧版本留下的搜索索引",
|
||||
"embeddingSwitch.job.preparing": "正在准备更新记忆索引",
|
||||
"embeddingSwitch.job.reembedding": "正在更新记忆索引",
|
||||
"embeddingSwitch.job.verifying": "正在校验新的记忆索引",
|
||||
"embeddingSwitch.job.committing": "正在启用新的搜索模型",
|
||||
"embeddingSwitch.job.failed": "更新没有完成,正在恢复原设置",
|
||||
"embeddingSwitch.job.default": "记忆索引更新",
|
||||
"embeddingSwitch.job.scanning": "正在检查本机的记忆搜索索引",
|
||||
"embeddingSwitch.job.failedSafety": "原来的搜索模型仍在使用,记忆没有丢失。",
|
||||
"embeddingSwitch.job.scanPaused": "完成前暂不整理新记忆,搜索仍可使用。",
|
||||
"embeddingSwitch.progress.aria": "记忆索引更新进度",
|
||||
"embeddingSwitch.action.cancel": "取消更新",
|
||||
"embeddingSwitch.refresh.title": "索引状态暂时无法读取",
|
||||
"embeddingSwitch.refresh.body":
|
||||
"当前显示可能不是最新状态,可以继续使用已有记忆。",
|
||||
"embeddingSwitch.action.refresh": "重新读取",
|
||||
"embeddingSwitch.scan.title": "旧版本搜索索引检查未完成",
|
||||
"embeddingSwitch.scan.paused": "新的记忆整理暂时暂停,已有记忆仍可搜索。",
|
||||
"embeddingSwitch.scan.recovered":
|
||||
"记忆整理已经恢复;可以继续使用,诊断中保留了失败原因。",
|
||||
"embeddingSwitch.action.diagnostics": "查看诊断",
|
||||
"embeddingSwitch.success.title": "搜索模型已更换",
|
||||
"embeddingSwitch.success.body": "暂停期间保存的新对话正在整理成记忆。",
|
||||
"embeddingSwitch.action.cleanup": "清理旧索引",
|
||||
"embeddingSwitch.action.closeSuccess": "关闭完成提示",
|
||||
"embeddingSwitch.cleanup.failed":
|
||||
"旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。",
|
||||
"embeddingSwitch.rollback.title": "索引更新没有完成",
|
||||
"embeddingSwitch.rollback.safety": "仍在使用原来的搜索模型,记忆没有丢失。",
|
||||
"embeddingSwitch.failure.writes_did_not_drain":
|
||||
"等待正在进行的记忆整理结束超时。",
|
||||
"embeddingSwitch.failure.embedding_service_failed":
|
||||
"搜索模型服务连续请求失败。",
|
||||
"embeddingSwitch.failure.embedding_dimensions_mismatch":
|
||||
"搜索服务返回的索引规格与测试结果不一致。",
|
||||
"embeddingSwitch.failure.embedding_verification_failed":
|
||||
"新的记忆索引没有通过完整性校验。",
|
||||
"embeddingSwitch.failure.target_profile_changed":
|
||||
"更新期间搜索模型配置发生了变化。",
|
||||
"embeddingSwitch.failure.embedding_switch_cancelled": "索引更新已取消。",
|
||||
"embeddingSwitch.failure.model_secret_unavailable":
|
||||
"无法读取搜索服务的 API Key。",
|
||||
"embeddingSwitch.failure.secure_storage_unavailable":
|
||||
"系统安全存储暂时不可用。",
|
||||
"embeddingSwitch.failure.embedding_switch_clients_busy":
|
||||
"记忆服务一直被占用,暂时无法完成切换。",
|
||||
"embeddingSwitch.rollback.reason": "{reason} {safety}",
|
||||
"embeddingSwitch.action.checkConnection": "检查连接",
|
||||
"embeddingSwitch.action.closeFailure": "关闭失败提示",
|
||||
} as const;
|
||||
|
||||
type EmbeddingSwitchMessages = Record<
|
||||
keyof typeof embeddingSwitchChineseMessages,
|
||||
string
|
||||
>;
|
||||
|
||||
export const embeddingSwitchEnglishMessages: EmbeddingSwitchMessages = {
|
||||
"embeddingSwitch.confirm.title": "Update the memory search index?",
|
||||
"embeddingSwitch.confirm.description":
|
||||
"Indexes created by different search models cannot be mixed. Rebuild the index to keep finding existing memories.",
|
||||
"embeddingSwitch.impact.memories": "Existing memories",
|
||||
"embeddingSwitch.impact.memoryCount": "{count}",
|
||||
"embeddingSwitch.impact.userCount": " · Users: {count}",
|
||||
"embeddingSwitch.impact.duration": "Estimated time",
|
||||
"embeddingSwitch.duration.unknown": "Cannot estimate yet",
|
||||
"embeddingSwitch.duration.seconds": "About {count} sec",
|
||||
"embeddingSwitch.duration.minutes": "About {count} min",
|
||||
"embeddingSwitch.impact.requests": "Remote requests",
|
||||
"embeddingSwitch.requests.none": "None",
|
||||
"embeddingSwitch.requests.billed": "Billed by provider",
|
||||
"embeddingSwitch.requests.approx": "About {count}",
|
||||
"embeddingSwitch.safety.searchTitle":
|
||||
"Memories stay searchable during the update",
|
||||
"embeddingSwitch.safety.searchBody":
|
||||
"MemHub keeps using the current search model. New conversations are saved and turned into memories after the update.",
|
||||
"embeddingSwitch.safety.dataTitle": "A failure will not affect existing data",
|
||||
"embeddingSwitch.safety.dataBody":
|
||||
"The model changes only after the update and verification finish. The current model stays active if the update fails or is cancelled.",
|
||||
"embeddingSwitch.details.title": "View technical details",
|
||||
"embeddingSwitch.details.currentModel": "Current search model",
|
||||
"embeddingSwitch.details.legacyIndex": "Legacy index",
|
||||
"embeddingSwitch.details.newModel": "New search model",
|
||||
"embeddingSwitch.details.pending": "Pending",
|
||||
"embeddingSwitch.details.dimensions": "Index dimensions",
|
||||
"embeddingSwitch.details.unknown": "Unknown",
|
||||
"embeddingSwitch.details.disk": "Additional disk space",
|
||||
"embeddingSwitch.details.retention": "Keep old index",
|
||||
"embeddingSwitch.details.days": "{count} días",
|
||||
"embeddingSwitch.blocker.embedding_probe_required":
|
||||
"Test the new search model connection first.",
|
||||
"embeddingSwitch.blocker.embedding_dimensions_unknown":
|
||||
"The new search model's index dimensions are not confirmed. Test the connection again.",
|
||||
"embeddingSwitch.blocker.embedding_switch_requires_migration":
|
||||
"Legacy data is still being upgraded. Wait for the upgrade to finish.",
|
||||
"embeddingSwitch.blocker.sdk_reembed_unavailable":
|
||||
"This installation cannot safely update the memory index.",
|
||||
"embeddingSwitch.blocker.insufficient_disk_space":
|
||||
"There is not enough free disk space to build the new index safely.",
|
||||
"embeddingSwitch.blocker.embedding_switch_running":
|
||||
"Another index update is already running.",
|
||||
"embeddingSwitch.blocker.memory_lifecycle_busy":
|
||||
"Memories are being consolidated. Wait for it to finish before changing the search model.",
|
||||
"embeddingSwitch.blocker.default":
|
||||
"The search model cannot be changed in the current state.",
|
||||
"embeddingSwitch.error.planStale":
|
||||
"The memory state changed. Review the impact again.",
|
||||
"embeddingSwitch.error.startFailed":
|
||||
"The index update cannot start right now. Try again later.",
|
||||
"embeddingSwitch.action.notNow": "Not now",
|
||||
"embeddingSwitch.action.start": "Start index update",
|
||||
"embeddingSwitch.job.merge": "Consolidating a legacy search index",
|
||||
"embeddingSwitch.job.preparing": "Preparing the memory index update",
|
||||
"embeddingSwitch.job.reembedding": "Updating the memory index",
|
||||
"embeddingSwitch.job.verifying": "Verifying the new memory index",
|
||||
"embeddingSwitch.job.committing": "Enabling the new search model",
|
||||
"embeddingSwitch.job.failed":
|
||||
"Restoring previous settings after an incomplete update",
|
||||
"embeddingSwitch.job.default": "Memory index update",
|
||||
"embeddingSwitch.job.scanning": "Checking local memory search indexes",
|
||||
"embeddingSwitch.job.failedSafety":
|
||||
"The previous search model is still active. No memories were lost.",
|
||||
"embeddingSwitch.job.scanPaused":
|
||||
"New memories will not be consolidated until this finishes. Search is still available.",
|
||||
"embeddingSwitch.progress.aria": "Memory index update progress",
|
||||
"embeddingSwitch.action.cancel": "Cancel update",
|
||||
"embeddingSwitch.refresh.title": "Index status is temporarily unavailable",
|
||||
"embeddingSwitch.refresh.body":
|
||||
"This status may be out of date. Existing memories remain available.",
|
||||
"embeddingSwitch.action.refresh": "Reload",
|
||||
"embeddingSwitch.scan.title": "Legacy search index check did not finish",
|
||||
"embeddingSwitch.scan.paused":
|
||||
"New memory consolidation is paused. Existing memories remain searchable.",
|
||||
"embeddingSwitch.scan.recovered":
|
||||
"Memory consolidation has resumed. You can keep using MemHub; diagnostics retain the failure reason.",
|
||||
"embeddingSwitch.action.diagnostics": "View diagnostics",
|
||||
"embeddingSwitch.success.title": "Search model changed",
|
||||
"embeddingSwitch.success.body":
|
||||
"Conversations saved during the pause are being turned into memories.",
|
||||
"embeddingSwitch.action.cleanup": "Clean up old index",
|
||||
"embeddingSwitch.action.closeSuccess": "Dismiss completion message",
|
||||
"embeddingSwitch.cleanup.failed":
|
||||
"The old index cannot be removed right now. MemHub will retry after the retention period.",
|
||||
"embeddingSwitch.rollback.title": "Index update did not finish",
|
||||
"embeddingSwitch.rollback.safety":
|
||||
"The previous search model is still active. No memories were lost.",
|
||||
"embeddingSwitch.failure.writes_did_not_drain":
|
||||
"Timed out while waiting for memory consolidation to finish.",
|
||||
"embeddingSwitch.failure.embedding_service_failed":
|
||||
"The search model service failed repeatedly.",
|
||||
"embeddingSwitch.failure.embedding_dimensions_mismatch":
|
||||
"The search service returned index dimensions that differ from the test result.",
|
||||
"embeddingSwitch.failure.embedding_verification_failed":
|
||||
"The new memory index failed integrity verification.",
|
||||
"embeddingSwitch.failure.target_profile_changed":
|
||||
"The search model configuration changed during the update.",
|
||||
"embeddingSwitch.failure.embedding_switch_cancelled":
|
||||
"The index update was cancelled.",
|
||||
"embeddingSwitch.failure.model_secret_unavailable":
|
||||
"The search service API key could not be read.",
|
||||
"embeddingSwitch.failure.secure_storage_unavailable":
|
||||
"Secure system storage is temporarily unavailable.",
|
||||
"embeddingSwitch.failure.embedding_switch_clients_busy":
|
||||
"The memory service remained busy, so the change could not finish.",
|
||||
"embeddingSwitch.rollback.reason": "{reason} {safety}",
|
||||
"embeddingSwitch.action.checkConnection": "Check connection",
|
||||
"embeddingSwitch.action.closeFailure": "Dismiss failure message",
|
||||
};
|
||||
|
||||
export const embeddingSwitchSpanishMessages: EmbeddingSwitchMessages = {
|
||||
"embeddingSwitch.confirm.title":
|
||||
"¿Actualizar el índice de búsqueda de memorias?",
|
||||
"embeddingSwitch.confirm.description":
|
||||
"Los índices creados por modelos de búsqueda distintos no se pueden combinar. Hay que reconstruir el índice para seguir encontrando las memorias existentes.",
|
||||
"embeddingSwitch.impact.memories": "Memorias existentes",
|
||||
"embeddingSwitch.impact.memoryCount": "{count}",
|
||||
"embeddingSwitch.impact.userCount": " · Usuarios: {count}",
|
||||
"embeddingSwitch.impact.duration": "Tiempo estimado",
|
||||
"embeddingSwitch.duration.unknown": "Aún no se puede estimar",
|
||||
"embeddingSwitch.duration.seconds": "Aprox. {count} s",
|
||||
"embeddingSwitch.duration.minutes": "Aprox. {count} min",
|
||||
"embeddingSwitch.impact.requests": "Solicitudes remotas",
|
||||
"embeddingSwitch.requests.none": "Ninguna",
|
||||
"embeddingSwitch.requests.billed": "Facturadas por el proveedor",
|
||||
"embeddingSwitch.requests.approx": "Aprox. {count}",
|
||||
"embeddingSwitch.safety.searchTitle":
|
||||
"Las memorias seguirán disponibles durante la actualización",
|
||||
"embeddingSwitch.safety.searchBody":
|
||||
"MemHub seguirá usando el modelo de búsqueda actual. Las conversaciones nuevas se guardarán y se convertirán en memorias después de la actualización.",
|
||||
"embeddingSwitch.safety.dataTitle":
|
||||
"Un fallo no afectará a los datos existentes",
|
||||
"embeddingSwitch.safety.dataBody":
|
||||
"El modelo solo cambiará cuando terminen la actualización y la verificación. Si falla o se cancela, se seguirá usando el modelo actual.",
|
||||
"embeddingSwitch.details.title": "Ver detalles técnicos",
|
||||
"embeddingSwitch.details.currentModel": "Modelo de búsqueda actual",
|
||||
"embeddingSwitch.details.legacyIndex": "Índice anterior",
|
||||
"embeddingSwitch.details.newModel": "Nuevo modelo de búsqueda",
|
||||
"embeddingSwitch.details.pending": "Pendiente",
|
||||
"embeddingSwitch.details.dimensions": "Dimensiones del índice",
|
||||
"embeddingSwitch.details.unknown": "Desconocido",
|
||||
"embeddingSwitch.details.disk": "Espacio adicional en disco",
|
||||
"embeddingSwitch.details.retention": "Conservar índice anterior",
|
||||
"embeddingSwitch.details.days": "{count} d",
|
||||
"embeddingSwitch.blocker.embedding_probe_required":
|
||||
"Prueba primero la conexión del nuevo modelo de búsqueda.",
|
||||
"embeddingSwitch.blocker.embedding_dimensions_unknown":
|
||||
"No se han confirmado las dimensiones del índice del nuevo modelo. Prueba otra vez la conexión.",
|
||||
"embeddingSwitch.blocker.embedding_switch_requires_migration":
|
||||
"Los datos anteriores aún se están actualizando. Espera a que termine.",
|
||||
"embeddingSwitch.blocker.sdk_reembed_unavailable":
|
||||
"Esta instalación no puede actualizar el índice de memorias de forma segura.",
|
||||
"embeddingSwitch.blocker.insufficient_disk_space":
|
||||
"No hay suficiente espacio libre para crear el índice nuevo de forma segura.",
|
||||
"embeddingSwitch.blocker.embedding_switch_running":
|
||||
"Ya hay otra actualización del índice en curso.",
|
||||
"embeddingSwitch.blocker.memory_lifecycle_busy":
|
||||
"Las memorias se están consolidando. Espera a que termine antes de cambiar el modelo.",
|
||||
"embeddingSwitch.blocker.default":
|
||||
"No se puede cambiar el modelo de búsqueda en el estado actual.",
|
||||
"embeddingSwitch.error.planStale":
|
||||
"El estado de las memorias ha cambiado. Revisa de nuevo el impacto.",
|
||||
"embeddingSwitch.error.startFailed":
|
||||
"No se puede iniciar ahora la actualización del índice. Inténtalo más tarde.",
|
||||
"embeddingSwitch.action.notNow": "Ahora no",
|
||||
"embeddingSwitch.action.start": "Iniciar actualización",
|
||||
"embeddingSwitch.job.merge": "Consolidando un índice de búsqueda anterior",
|
||||
"embeddingSwitch.job.preparing": "Preparando la actualización del índice",
|
||||
"embeddingSwitch.job.reembedding": "Actualizando el índice de memorias",
|
||||
"embeddingSwitch.job.verifying": "Verificando el nuevo índice",
|
||||
"embeddingSwitch.job.committing": "Activando el nuevo modelo de búsqueda",
|
||||
"embeddingSwitch.job.failed": "Restaurando la configuración anterior",
|
||||
"embeddingSwitch.job.default": "Actualización del índice de memorias",
|
||||
"embeddingSwitch.job.scanning": "Comprobando los índices locales de memorias",
|
||||
"embeddingSwitch.job.failedSafety":
|
||||
"El modelo anterior sigue activo. No se ha perdido ninguna memoria.",
|
||||
"embeddingSwitch.job.scanPaused":
|
||||
"No se consolidarán memorias nuevas hasta que termine. La búsqueda sigue disponible.",
|
||||
"embeddingSwitch.progress.aria": "Progreso de actualización del índice",
|
||||
"embeddingSwitch.action.cancel": "Cancelar actualización",
|
||||
"embeddingSwitch.refresh.title": "El estado del índice no está disponible",
|
||||
"embeddingSwitch.refresh.body":
|
||||
"Este estado puede no estar actualizado. Las memorias existentes siguen disponibles.",
|
||||
"embeddingSwitch.action.refresh": "Volver a cargar",
|
||||
"embeddingSwitch.scan.title":
|
||||
"No terminó la comprobación del índice anterior",
|
||||
"embeddingSwitch.scan.paused":
|
||||
"La consolidación de memorias nuevas está pausada. Las existentes siguen disponibles.",
|
||||
"embeddingSwitch.scan.recovered":
|
||||
"La consolidación se ha reanudado. Puedes seguir usando MemHub; el motivo del fallo se conserva en el diagnóstico.",
|
||||
"embeddingSwitch.action.diagnostics": "Ver diagnóstico",
|
||||
"embeddingSwitch.success.title": "Modelo de búsqueda cambiado",
|
||||
"embeddingSwitch.success.body":
|
||||
"Las conversaciones guardadas durante la pausa se están convirtiendo en memorias.",
|
||||
"embeddingSwitch.action.cleanup": "Eliminar índice anterior",
|
||||
"embeddingSwitch.action.closeSuccess": "Cerrar aviso de finalización",
|
||||
"embeddingSwitch.cleanup.failed":
|
||||
"No se puede eliminar ahora el índice anterior. MemHub lo reintentará al terminar el periodo de conservación.",
|
||||
"embeddingSwitch.rollback.title": "La actualización no terminó",
|
||||
"embeddingSwitch.rollback.safety":
|
||||
"El modelo anterior sigue activo. No se ha perdido ninguna memoria.",
|
||||
"embeddingSwitch.failure.writes_did_not_drain":
|
||||
"Se agotó el tiempo de espera para terminar la consolidación.",
|
||||
"embeddingSwitch.failure.embedding_service_failed":
|
||||
"El servicio del modelo de búsqueda falló repetidamente.",
|
||||
"embeddingSwitch.failure.embedding_dimensions_mismatch":
|
||||
"El servicio devolvió dimensiones distintas del resultado de la prueba.",
|
||||
"embeddingSwitch.failure.embedding_verification_failed":
|
||||
"El nuevo índice no superó la verificación de integridad.",
|
||||
"embeddingSwitch.failure.target_profile_changed":
|
||||
"La configuración del modelo cambió durante la actualización.",
|
||||
"embeddingSwitch.failure.embedding_switch_cancelled":
|
||||
"Se canceló la actualización del índice.",
|
||||
"embeddingSwitch.failure.model_secret_unavailable":
|
||||
"No se pudo leer la clave API del servicio de búsqueda.",
|
||||
"embeddingSwitch.failure.secure_storage_unavailable":
|
||||
"El almacenamiento seguro del sistema no está disponible.",
|
||||
"embeddingSwitch.failure.embedding_switch_clients_busy":
|
||||
"El servicio de memorias siguió ocupado y no se pudo completar el cambio.",
|
||||
"embeddingSwitch.rollback.reason": "{reason} {safety}",
|
||||
"embeddingSwitch.action.checkConnection": "Comprobar conexión",
|
||||
"embeddingSwitch.action.closeFailure": "Cerrar aviso de error",
|
||||
};
|
||||
|
||||
const embeddingSwitchKeys = Object.keys(
|
||||
embeddingSwitchEnglishMessages,
|
||||
) as (keyof EmbeddingSwitchMessages)[];
|
||||
|
||||
const localized = (values: readonly string[]): EmbeddingSwitchMessages => {
|
||||
if (values.length !== embeddingSwitchKeys.length)
|
||||
throw new Error(
|
||||
`Expected ${embeddingSwitchKeys.length} embedding switch messages, got ${values.length}`,
|
||||
);
|
||||
return Object.fromEntries(
|
||||
embeddingSwitchKeys.map((key, index) => [key, values[index]]),
|
||||
) as EmbeddingSwitchMessages;
|
||||
};
|
||||
|
||||
export const embeddingSwitchTraditionalChineseMessages = localized([
|
||||
"更新記憶搜尋索引?",
|
||||
"不同搜尋模型建立的索引不能混用。若要繼續找到現有記憶,必須重新建立索引。",
|
||||
"現有記憶",
|
||||
"{count} 條",
|
||||
" · {count} 位使用者",
|
||||
"預計耗時",
|
||||
"暫時無法估算",
|
||||
"約 {count} 秒",
|
||||
"約 {count} 分鐘",
|
||||
"遠端請求",
|
||||
"不會產生",
|
||||
"由服務供應商計費",
|
||||
"約 {count} 次",
|
||||
"更新期間仍可搜尋記憶",
|
||||
"MemHub 會繼續使用目前的搜尋模型。新對話仍會儲存,並在更新後轉換為記憶。",
|
||||
"更新失敗不會影響現有資料",
|
||||
"只有完成更新及驗證後才會切換模型。若更新失敗或取消,仍會使用目前的模型。",
|
||||
"檢視技術詳細資料",
|
||||
"目前的搜尋模型",
|
||||
"舊版索引",
|
||||
"新的搜尋模型",
|
||||
"等待處理",
|
||||
"索引維度",
|
||||
"未知",
|
||||
"額外磁碟空間",
|
||||
"保留舊索引",
|
||||
"{count} 天",
|
||||
"請先測試新搜尋模型的連線。",
|
||||
"尚未確認新搜尋模型的索引維度,請重新測試連線。",
|
||||
"舊版資料仍在升級,請等待升級完成。",
|
||||
"目前的安裝版本無法安全更新記憶索引。",
|
||||
"可用磁碟空間不足,無法安全建立新索引。",
|
||||
"另一項索引更新已在進行中。",
|
||||
"系統正在整合記憶,請等候完成後再更換搜尋模型。",
|
||||
"目前狀態不允許更換搜尋模型。",
|
||||
"記憶狀態已變更,請重新檢查影響。",
|
||||
"目前無法開始更新索引,請稍後再試。",
|
||||
"暫時不要",
|
||||
"開始更新索引",
|
||||
"正在整合舊版搜尋索引",
|
||||
"正在準備更新記憶索引",
|
||||
"正在更新記憶索引",
|
||||
"正在驗證新的記憶索引",
|
||||
"正在啟用新的搜尋模型",
|
||||
"更新未完成,正在還原先前設定",
|
||||
"記憶索引更新",
|
||||
"正在檢查本機記憶搜尋索引",
|
||||
"先前的搜尋模型仍在使用,沒有遺失任何記憶。",
|
||||
"完成前不會整合新記憶,但搜尋功能仍可使用。",
|
||||
"記憶索引更新進度",
|
||||
"取消更新",
|
||||
"暫時無法取得索引狀態",
|
||||
"此狀態可能不是最新資訊,現有記憶仍可使用。",
|
||||
"重新載入",
|
||||
"舊版搜尋索引檢查未完成",
|
||||
"新記憶的整合作業已暫停,現有記憶仍可搜尋。",
|
||||
"記憶整合作業已恢復。你可以繼續使用 MemHub;診斷資訊會保留失敗原因。",
|
||||
"檢視診斷資訊",
|
||||
"搜尋模型已更換",
|
||||
"暫停期間儲存的對話正在轉換為記憶。",
|
||||
"清理舊索引",
|
||||
"關閉完成訊息",
|
||||
"目前無法移除舊索引。MemHub 會在保留期限結束後重試。",
|
||||
"索引更新未完成",
|
||||
"先前的搜尋模型仍在使用,沒有遺失任何記憶。",
|
||||
"等待記憶整合作業完成時逾時。",
|
||||
"搜尋模型服務連續發生錯誤。",
|
||||
"搜尋服務傳回的索引維度與測試結果不符。",
|
||||
"新的記憶索引未通過完整性驗證。",
|
||||
"更新期間搜尋模型設定已變更。",
|
||||
"索引更新已取消。",
|
||||
"無法讀取搜尋服務的 API Key。",
|
||||
"系統的安全儲存空間暫時無法使用。",
|
||||
"記憶服務持續忙碌,因此無法完成更換。",
|
||||
"{reason} {safety}",
|
||||
"檢查連線",
|
||||
"關閉失敗訊息",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchJapaneseMessages = localized([
|
||||
"メモリ検索インデックスを更新しますか?",
|
||||
"異なる検索モデルで作成したインデックスは併用できません。既存のメモリを引き続き検索できるよう、インデックスを再構築します。",
|
||||
"既存のメモリ",
|
||||
"{count}",
|
||||
" · ユーザー数: {count}",
|
||||
"推定所要時間",
|
||||
"まだ推定できません",
|
||||
"約 {count} 秒",
|
||||
"約 {count} 分",
|
||||
"リモートリクエスト",
|
||||
"発生しません",
|
||||
"プロバイダーによる課金",
|
||||
"約 {count} 回",
|
||||
"更新中もメモリを検索できます",
|
||||
"MemHub は現在の検索モデルを引き続き使用します。新しい会話は保存され、更新後にメモリへ変換されます。",
|
||||
"失敗しても既存のデータには影響しません",
|
||||
"更新と検証が完了してからモデルを切り替えます。失敗またはキャンセルした場合は、現在のモデルを使い続けます。",
|
||||
"技術情報を表示",
|
||||
"現在の検索モデル",
|
||||
"旧形式のインデックス",
|
||||
"新しい検索モデル",
|
||||
"保留中",
|
||||
"インデックスの次元数",
|
||||
"不明",
|
||||
"追加のディスク容量",
|
||||
"旧インデックスの保持期間",
|
||||
"{count} 日",
|
||||
"先に新しい検索モデルへの接続をテストしてください。",
|
||||
"新しい検索モデルのインデックス次元数を確認できません。もう一度接続をテストしてください。",
|
||||
"旧形式のデータをアップグレード中です。完了するまでお待ちください。",
|
||||
"このインストール環境では、メモリインデックスを安全に更新できません。",
|
||||
"新しいインデックスを安全に作成するための空き容量が不足しています。",
|
||||
"別のインデックス更新がすでに実行中です。",
|
||||
"メモリを統合中です。完了してから検索モデルを変更してください。",
|
||||
"現在の状態では検索モデルを変更できません。",
|
||||
"メモリの状態が変わりました。影響をもう一度確認してください。",
|
||||
"現在はインデックス更新を開始できません。しばらくしてから再試行してください。",
|
||||
"今はしない",
|
||||
"インデックス更新を開始",
|
||||
"旧形式の検索インデックスを統合中",
|
||||
"メモリインデックスの更新を準備中",
|
||||
"メモリインデックスを更新中",
|
||||
"新しいメモリインデックスを検証中",
|
||||
"新しい検索モデルを有効化中",
|
||||
"未完了の更新から以前の設定を復元中",
|
||||
"メモリインデックスの更新",
|
||||
"ローカルのメモリ検索インデックスを確認中",
|
||||
"以前の検索モデルが引き続き有効です。メモリは失われていません。",
|
||||
"完了するまで新しいメモリは統合されません。検索は引き続き利用できます。",
|
||||
"メモリインデックスの更新状況",
|
||||
"更新をキャンセル",
|
||||
"インデックスの状態を一時的に取得できません",
|
||||
"表示されている状態は最新でない可能性があります。既存のメモリは引き続き利用できます。",
|
||||
"再読み込み",
|
||||
"旧形式の検索インデックスの確認が完了しませんでした",
|
||||
"新しいメモリの統合は一時停止中です。既存のメモリは引き続き検索できます。",
|
||||
"メモリの統合を再開しました。MemHub はそのまま利用できます。失敗理由は診断情報に保持されます。",
|
||||
"診断情報を表示",
|
||||
"検索モデルを変更しました",
|
||||
"一時停止中に保存された会話をメモリへ変換しています。",
|
||||
"旧インデックスを削除",
|
||||
"完了メッセージを閉じる",
|
||||
"現在は旧インデックスを削除できません。保持期間の終了後に MemHub が再試行します。",
|
||||
"インデックスの更新が完了しませんでした",
|
||||
"以前の検索モデルが引き続き有効です。メモリは失われていません。",
|
||||
"メモリの統合完了を待機中にタイムアウトしました。",
|
||||
"検索モデルのサービスでエラーが繰り返し発生しました。",
|
||||
"検索サービスから返されたインデックス次元数がテスト結果と一致しません。",
|
||||
"新しいメモリインデックスは整合性検証に合格しませんでした。",
|
||||
"更新中に検索モデルの設定が変更されました。",
|
||||
"インデックスの更新をキャンセルしました。",
|
||||
"検索サービスの API Key を読み取れませんでした。",
|
||||
"システムの安全なストレージを一時的に利用できません。",
|
||||
"メモリサービスが使用中のままだったため、変更を完了できませんでした。",
|
||||
"{reason} {safety}",
|
||||
"接続を確認",
|
||||
"失敗メッセージを閉じる",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchGermanMessages = localized([
|
||||
"Speichersuchindex aktualisieren?",
|
||||
"Indizes verschiedener Suchmodelle lassen sich nicht kombinieren. Der Index wird neu erstellt, damit vorhandene Erinnerungen weiterhin auffindbar sind.",
|
||||
"Vorhandene Erinnerungen",
|
||||
"{count}",
|
||||
" · Benutzer: {count}",
|
||||
"Geschätzte Dauer",
|
||||
"Noch nicht abschätzbar",
|
||||
"Etwa {count} Sek.",
|
||||
"Etwa {count} Min.",
|
||||
"Remote-Anfragen",
|
||||
"Keine",
|
||||
"Abrechnung durch den Anbieter",
|
||||
"Etwa {count}",
|
||||
"Erinnerungen bleiben während der Aktualisierung durchsuchbar",
|
||||
"MemHub verwendet weiterhin das aktuelle Suchmodell. Neue Unterhaltungen werden gespeichert und nach der Aktualisierung in Erinnerungen umgewandelt.",
|
||||
"Ein Fehler beeinträchtigt vorhandene Daten nicht",
|
||||
"Das Modell wird erst nach erfolgreicher Aktualisierung und Prüfung gewechselt. Bei einem Fehler oder Abbruch bleibt das aktuelle Modell aktiv.",
|
||||
"Technische Details anzeigen",
|
||||
"Aktuelles Suchmodell",
|
||||
"Alter Index",
|
||||
"Neues Suchmodell",
|
||||
"Ausstehend",
|
||||
"Indexdimensionen",
|
||||
"Unbekannt",
|
||||
"Zusätzlicher Speicherplatz",
|
||||
"Alten Index aufbewahren",
|
||||
"{count} Tg.",
|
||||
"Testen Sie zuerst die Verbindung zum neuen Suchmodell.",
|
||||
"Die Indexdimensionen des neuen Suchmodells sind nicht bestätigt. Testen Sie die Verbindung erneut.",
|
||||
"Altdaten werden noch aktualisiert. Warten Sie, bis der Vorgang abgeschlossen ist.",
|
||||
"Diese Installation kann den Speicherindex nicht sicher aktualisieren.",
|
||||
"Zum sicheren Erstellen des neuen Index ist nicht genügend Speicherplatz verfügbar.",
|
||||
"Eine weitere Indexaktualisierung wird bereits ausgeführt.",
|
||||
"Erinnerungen werden gerade zusammengeführt. Warten Sie vor dem Modellwechsel, bis der Vorgang beendet ist.",
|
||||
"Im aktuellen Zustand kann das Suchmodell nicht geändert werden.",
|
||||
"Der Speicherstatus hat sich geändert. Prüfen Sie die Auswirkungen erneut.",
|
||||
"Die Indexaktualisierung kann derzeit nicht gestartet werden. Versuchen Sie es später erneut.",
|
||||
"Nicht jetzt",
|
||||
"Index aktualisieren",
|
||||
"Alter Suchindex wird zusammengeführt",
|
||||
"Aktualisierung des Speicherindex wird vorbereitet",
|
||||
"Speicherindex wird aktualisiert",
|
||||
"Neuer Speicherindex wird geprüft",
|
||||
"Neues Suchmodell wird aktiviert",
|
||||
"Vorherige Einstellungen werden nach unvollständiger Aktualisierung wiederhergestellt",
|
||||
"Speicherindex-Aktualisierung",
|
||||
"Lokale Speichersuchindizes werden geprüft",
|
||||
"Das vorherige Suchmodell ist weiterhin aktiv. Es gingen keine Erinnerungen verloren.",
|
||||
"Neue Erinnerungen werden erst danach zusammengeführt. Die Suche bleibt verfügbar.",
|
||||
"Fortschritt der Speicherindex-Aktualisierung",
|
||||
"Aktualisierung abbrechen",
|
||||
"Indexstatus ist vorübergehend nicht verfügbar",
|
||||
"Dieser Status ist möglicherweise veraltet. Vorhandene Erinnerungen bleiben verfügbar.",
|
||||
"Neu laden",
|
||||
"Prüfung des alten Suchindex wurde nicht abgeschlossen",
|
||||
"Das Zusammenführen neuer Erinnerungen ist pausiert. Vorhandene Erinnerungen bleiben durchsuchbar.",
|
||||
"Das Zusammenführen wurde fortgesetzt. Sie können MemHub weiterverwenden; der Fehlergrund bleibt in der Diagnose erhalten.",
|
||||
"Diagnose anzeigen",
|
||||
"Suchmodell wurde geändert",
|
||||
"Während der Pause gespeicherte Unterhaltungen werden in Erinnerungen umgewandelt.",
|
||||
"Alten Index bereinigen",
|
||||
"Erfolgsmeldung schließen",
|
||||
"Der alte Index kann derzeit nicht entfernt werden. MemHub versucht es nach Ablauf der Aufbewahrungsfrist erneut.",
|
||||
"Indexaktualisierung wurde nicht abgeschlossen",
|
||||
"Das vorherige Suchmodell ist weiterhin aktiv. Es gingen keine Erinnerungen verloren.",
|
||||
"Zeitüberschreitung beim Warten auf den Abschluss der Speicherzusammenführung.",
|
||||
"Der Suchmodelldienst ist wiederholt fehlgeschlagen.",
|
||||
"Der Suchdienst lieferte andere Indexdimensionen als beim Test.",
|
||||
"Der neue Speicherindex hat die Integritätsprüfung nicht bestanden.",
|
||||
"Die Konfiguration des Suchmodells wurde während der Aktualisierung geändert.",
|
||||
"Die Indexaktualisierung wurde abgebrochen.",
|
||||
"Der API Key des Suchdienstes konnte nicht gelesen werden.",
|
||||
"Der sichere Systemspeicher ist vorübergehend nicht verfügbar.",
|
||||
"Der Speicherdienst blieb belegt, daher konnte die Änderung nicht abgeschlossen werden.",
|
||||
"{reason} {safety}",
|
||||
"Verbindung prüfen",
|
||||
"Fehlermeldung schließen",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchPortugueseMessages = localized([
|
||||
"Atualizar o índice de pesquisa de memórias?",
|
||||
"Os índices criados por modelos de pesquisa diferentes não podem ser combinados. O índice será reconstruído para que as memórias existentes continuem acessíveis.",
|
||||
"Memórias existentes",
|
||||
"{count}",
|
||||
" · Utilizadores: {count}",
|
||||
"Tempo estimado",
|
||||
"Ainda não é possível estimar",
|
||||
"Cerca de {count} s",
|
||||
"Cerca de {count} min",
|
||||
"Pedidos remotos",
|
||||
"Nenhum",
|
||||
"Faturados pelo fornecedor",
|
||||
"Cerca de {count}",
|
||||
"As memórias continuam pesquisáveis durante a atualização",
|
||||
"O MemHub continuará a usar o modelo de pesquisa atual. As novas conversas serão guardadas e convertidas em memórias após a atualização.",
|
||||
"Uma falha não afeta os dados existentes",
|
||||
"O modelo só muda depois de concluídas a atualização e a verificação. Se o processo falhar ou for cancelado, o modelo atual permanece ativo.",
|
||||
"Ver detalhes técnicos",
|
||||
"Modelo de pesquisa atual",
|
||||
"Índice antigo",
|
||||
"Novo modelo de pesquisa",
|
||||
"Pendente",
|
||||
"Dimensões do índice",
|
||||
"Desconhecidas",
|
||||
"Espaço adicional em disco",
|
||||
"Manter o índice antigo",
|
||||
"{count} dias",
|
||||
"Teste primeiro a ligação ao novo modelo de pesquisa.",
|
||||
"As dimensões do índice do novo modelo ainda não foram confirmadas. Teste novamente a ligação.",
|
||||
"Os dados antigos ainda estão a ser atualizados. Aguarde até o processo terminar.",
|
||||
"Esta instalação não consegue atualizar o índice de memórias em segurança.",
|
||||
"Não há espaço livre suficiente para criar o novo índice em segurança.",
|
||||
"Já está em curso outra atualização do índice.",
|
||||
"As memórias estão a ser consolidadas. Aguarde até terminar antes de mudar o modelo de pesquisa.",
|
||||
"Não é possível mudar o modelo de pesquisa no estado atual.",
|
||||
"O estado das memórias mudou. Reveja novamente o impacto.",
|
||||
"Não é possível iniciar agora a atualização do índice. Tente novamente mais tarde.",
|
||||
"Agora não",
|
||||
"Iniciar atualização do índice",
|
||||
"A consolidar um índice de pesquisa antigo",
|
||||
"A preparar a atualização do índice de memórias",
|
||||
"A atualizar o índice de memórias",
|
||||
"A verificar o novo índice de memórias",
|
||||
"A ativar o novo modelo de pesquisa",
|
||||
"A restaurar as definições anteriores após uma atualização incompleta",
|
||||
"Atualização do índice de memórias",
|
||||
"A verificar os índices locais de pesquisa de memórias",
|
||||
"O modelo de pesquisa anterior continua ativo. Nenhuma memória foi perdida.",
|
||||
"As novas memórias só serão consolidadas após a conclusão. A pesquisa continua disponível.",
|
||||
"Progresso da atualização do índice de memórias",
|
||||
"Cancelar atualização",
|
||||
"O estado do índice está temporariamente indisponível",
|
||||
"Este estado pode estar desatualizado. As memórias existentes continuam disponíveis.",
|
||||
"Recarregar",
|
||||
"A verificação do índice de pesquisa antigo não terminou",
|
||||
"A consolidação de novas memórias está em pausa. As memórias existentes continuam pesquisáveis.",
|
||||
"A consolidação de memórias foi retomada. Pode continuar a usar o MemHub; o motivo da falha fica guardado no diagnóstico.",
|
||||
"Ver diagnóstico",
|
||||
"Modelo de pesquisa alterado",
|
||||
"As conversas guardadas durante a pausa estão a ser convertidas em memórias.",
|
||||
"Limpar índice antigo",
|
||||
"Fechar mensagem de conclusão",
|
||||
"Não é possível remover agora o índice antigo. O MemHub tentará novamente após o período de retenção.",
|
||||
"A atualização do índice não terminou",
|
||||
"O modelo de pesquisa anterior continua ativo. Nenhuma memória foi perdida.",
|
||||
"O tempo de espera terminou enquanto se aguardava a conclusão da consolidação de memórias.",
|
||||
"O serviço do modelo de pesquisa falhou repetidamente.",
|
||||
"O serviço de pesquisa devolveu dimensões de índice diferentes do resultado do teste.",
|
||||
"O novo índice de memórias não passou na verificação de integridade.",
|
||||
"A configuração do modelo de pesquisa mudou durante a atualização.",
|
||||
"A atualização do índice foi cancelada.",
|
||||
"Não foi possível ler a API Key do serviço de pesquisa.",
|
||||
"O armazenamento seguro do sistema está temporariamente indisponível.",
|
||||
"O serviço de memórias permaneceu ocupado, por isso não foi possível concluir a alteração.",
|
||||
"{reason} {safety}",
|
||||
"Verificar ligação",
|
||||
"Fechar mensagem de falha",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchFrenchMessages = localized([
|
||||
"Mettre à jour l’index de recherche des mémoires ?",
|
||||
"Les index créés par des modèles de recherche différents ne peuvent pas être combinés. L’index sera reconstruit pour continuer à retrouver les mémoires existantes.",
|
||||
"Mémoires existantes",
|
||||
"{count}",
|
||||
" · Utilisateurs : {count}",
|
||||
"Durée estimée",
|
||||
"Estimation pas encore disponible",
|
||||
"Environ {count} s",
|
||||
"Environ {count} min",
|
||||
"Requêtes distantes",
|
||||
"Aucune",
|
||||
"Facturées par le fournisseur",
|
||||
"Environ {count}",
|
||||
"Les mémoires restent accessibles pendant la mise à jour",
|
||||
"MemHub continue d’utiliser le modèle de recherche actuel. Les nouvelles conversations sont enregistrées, puis transformées en mémoires après la mise à jour.",
|
||||
"Un échec n’affectera pas les données existantes",
|
||||
"Le modèle change uniquement après la mise à jour et sa vérification. En cas d’échec ou d’annulation, le modèle actuel reste actif.",
|
||||
"Afficher les détails techniques",
|
||||
"Modèle de recherche actuel",
|
||||
"Ancien index",
|
||||
"Nouveau modèle de recherche",
|
||||
"En attente",
|
||||
"Dimensions de l’index",
|
||||
"Inconnues",
|
||||
"Espace disque supplémentaire",
|
||||
"Conserver l’ancien index",
|
||||
"{count} j",
|
||||
"Testez d’abord la connexion au nouveau modèle de recherche.",
|
||||
"Les dimensions de l’index du nouveau modèle ne sont pas confirmées. Testez à nouveau la connexion.",
|
||||
"Les anciennes données sont encore en cours de mise à niveau. Attendez la fin de l’opération.",
|
||||
"Cette installation ne peut pas mettre à jour l’index des mémoires en toute sécurité.",
|
||||
"L’espace disque disponible est insuffisant pour créer le nouvel index en toute sécurité.",
|
||||
"Une autre mise à jour de l’index est déjà en cours.",
|
||||
"Les mémoires sont en cours de consolidation. Attendez la fin avant de changer de modèle de recherche.",
|
||||
"Le modèle de recherche ne peut pas être modifié dans l’état actuel.",
|
||||
"L’état des mémoires a changé. Vérifiez à nouveau les conséquences.",
|
||||
"Impossible de lancer la mise à jour de l’index pour le moment. Réessayez plus tard.",
|
||||
"Pas maintenant",
|
||||
"Lancer la mise à jour de l’index",
|
||||
"Consolidation d’un ancien index de recherche",
|
||||
"Préparation de la mise à jour de l’index des mémoires",
|
||||
"Mise à jour de l’index des mémoires",
|
||||
"Vérification du nouvel index des mémoires",
|
||||
"Activation du nouveau modèle de recherche",
|
||||
"Restauration des réglages précédents après une mise à jour inachevée",
|
||||
"Mise à jour de l’index des mémoires",
|
||||
"Vérification des index locaux de recherche des mémoires",
|
||||
"Le modèle de recherche précédent est toujours actif. Aucune mémoire n’a été perdue.",
|
||||
"Les nouvelles mémoires ne seront pas consolidées avant la fin. La recherche reste disponible.",
|
||||
"Progression de la mise à jour de l’index des mémoires",
|
||||
"Annuler la mise à jour",
|
||||
"L’état de l’index est temporairement indisponible",
|
||||
"Cet état n’est peut-être pas à jour. Les mémoires existantes restent disponibles.",
|
||||
"Recharger",
|
||||
"La vérification de l’ancien index de recherche ne s’est pas terminée",
|
||||
"La consolidation des nouvelles mémoires est en pause. Les mémoires existantes restent accessibles.",
|
||||
"La consolidation des mémoires a repris. Vous pouvez continuer à utiliser MemHub ; le motif de l’échec reste disponible dans le diagnostic.",
|
||||
"Afficher le diagnostic",
|
||||
"Modèle de recherche modifié",
|
||||
"Les conversations enregistrées pendant la pause sont transformées en mémoires.",
|
||||
"Nettoyer l’ancien index",
|
||||
"Fermer le message de réussite",
|
||||
"Impossible de supprimer l’ancien index pour le moment. MemHub réessaiera après la période de conservation.",
|
||||
"La mise à jour de l’index ne s’est pas terminée",
|
||||
"Le modèle de recherche précédent est toujours actif. Aucune mémoire n’a été perdue.",
|
||||
"Le délai d’attente de la consolidation des mémoires a expiré.",
|
||||
"Le service du modèle de recherche a échoué à plusieurs reprises.",
|
||||
"Le service de recherche a renvoyé des dimensions d’index différentes du résultat du test.",
|
||||
"Le nouvel index des mémoires n’a pas réussi la vérification d’intégrité.",
|
||||
"La configuration du modèle de recherche a changé pendant la mise à jour.",
|
||||
"La mise à jour de l’index a été annulée.",
|
||||
"Impossible de lire l’API Key du service de recherche.",
|
||||
"Le stockage sécurisé du système est temporairement indisponible.",
|
||||
"Le service de mémoires est resté occupé, empêchant la modification de se terminer.",
|
||||
"{reason} {safety}",
|
||||
"Vérifier la connexion",
|
||||
"Fermer le message d’échec",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchKoreanMessages = localized([
|
||||
"메모리 검색 인덱스를 업데이트할까요?",
|
||||
"서로 다른 검색 모델로 만든 인덱스는 함께 사용할 수 없습니다. 기존 메모리를 계속 찾을 수 있도록 인덱스를 다시 만듭니다.",
|
||||
"기존 메모리",
|
||||
"{count}개",
|
||||
" · 사용자 {count}명",
|
||||
"예상 소요 시간",
|
||||
"아직 예상할 수 없음",
|
||||
"약 {count}초",
|
||||
"약 {count}분",
|
||||
"원격 요청",
|
||||
"없음",
|
||||
"제공업체에서 요금 부과",
|
||||
"약 {count}회",
|
||||
"업데이트 중에도 메모리를 검색할 수 있습니다",
|
||||
"MemHub는 현재 검색 모델을 계속 사용합니다. 새 대화는 저장되며 업데이트 후 메모리로 변환됩니다.",
|
||||
"실패해도 기존 데이터에는 영향이 없습니다",
|
||||
"업데이트와 검증이 모두 끝난 후에만 모델이 바뀝니다. 실패하거나 취소하면 현재 모델이 계속 활성화됩니다.",
|
||||
"기술 세부 정보 보기",
|
||||
"현재 검색 모델",
|
||||
"이전 인덱스",
|
||||
"새 검색 모델",
|
||||
"대기 중",
|
||||
"인덱스 차원",
|
||||
"알 수 없음",
|
||||
"추가 디스크 공간",
|
||||
"이전 인덱스 보관",
|
||||
"{count}일",
|
||||
"먼저 새 검색 모델의 연결을 테스트하세요.",
|
||||
"새 검색 모델의 인덱스 차원이 확인되지 않았습니다. 연결을 다시 테스트하세요.",
|
||||
"이전 데이터가 아직 업그레이드 중입니다. 완료될 때까지 기다려 주세요.",
|
||||
"현재 설치 버전에서는 메모리 인덱스를 안전하게 업데이트할 수 없습니다.",
|
||||
"새 인덱스를 안전하게 만들 디스크 여유 공간이 부족합니다.",
|
||||
"다른 인덱스 업데이트가 이미 실행 중입니다.",
|
||||
"메모리를 통합하고 있습니다. 완료된 후 검색 모델을 변경하세요.",
|
||||
"현재 상태에서는 검색 모델을 변경할 수 없습니다.",
|
||||
"메모리 상태가 변경되었습니다. 영향을 다시 확인하세요.",
|
||||
"지금은 인덱스 업데이트를 시작할 수 없습니다. 나중에 다시 시도하세요.",
|
||||
"나중에",
|
||||
"인덱스 업데이트 시작",
|
||||
"이전 검색 인덱스 통합 중",
|
||||
"메모리 인덱스 업데이트 준비 중",
|
||||
"메모리 인덱스 업데이트 중",
|
||||
"새 메모리 인덱스 검증 중",
|
||||
"새 검색 모델 활성화 중",
|
||||
"완료되지 않은 업데이트에서 이전 설정 복원 중",
|
||||
"메모리 인덱스 업데이트",
|
||||
"로컬 메모리 검색 인덱스 확인 중",
|
||||
"이전 검색 모델이 계속 활성화되어 있습니다. 손실된 메모리는 없습니다.",
|
||||
"완료될 때까지 새 메모리를 통합하지 않습니다. 검색은 계속 사용할 수 있습니다.",
|
||||
"메모리 인덱스 업데이트 진행률",
|
||||
"업데이트 취소",
|
||||
"인덱스 상태를 일시적으로 확인할 수 없음",
|
||||
"표시된 상태가 최신이 아닐 수 있습니다. 기존 메모리는 계속 사용할 수 있습니다.",
|
||||
"새로고침",
|
||||
"이전 검색 인덱스 확인이 완료되지 않음",
|
||||
"새 메모리 통합이 일시 중지되었습니다. 기존 메모리는 계속 검색할 수 있습니다.",
|
||||
"메모리 통합이 재개되었습니다. MemHub를 계속 사용할 수 있으며 실패 원인은 진단 정보에 보관됩니다.",
|
||||
"진단 정보 보기",
|
||||
"검색 모델 변경 완료",
|
||||
"일시 중지 중 저장된 대화를 메모리로 변환하고 있습니다.",
|
||||
"이전 인덱스 정리",
|
||||
"완료 메시지 닫기",
|
||||
"지금은 이전 인덱스를 삭제할 수 없습니다. 보관 기간이 끝나면 MemHub가 다시 시도합니다.",
|
||||
"인덱스 업데이트가 완료되지 않음",
|
||||
"이전 검색 모델이 계속 활성화되어 있습니다. 손실된 메모리는 없습니다.",
|
||||
"메모리 통합이 끝나기를 기다리는 동안 시간이 초과되었습니다.",
|
||||
"검색 모델 서비스에서 오류가 반복적으로 발생했습니다.",
|
||||
"검색 서비스에서 테스트 결과와 다른 인덱스 차원을 반환했습니다.",
|
||||
"새 메모리 인덱스가 무결성 검증을 통과하지 못했습니다.",
|
||||
"업데이트 중 검색 모델 설정이 변경되었습니다.",
|
||||
"인덱스 업데이트가 취소되었습니다.",
|
||||
"검색 서비스의 API Key를 읽을 수 없습니다.",
|
||||
"시스템 보안 저장소를 일시적으로 사용할 수 없습니다.",
|
||||
"메모리 서비스가 계속 사용 중이어서 변경을 완료할 수 없었습니다.",
|
||||
"{reason} {safety}",
|
||||
"연결 확인",
|
||||
"실패 메시지 닫기",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchRussianMessages = localized([
|
||||
"Обновить индекс поиска воспоминаний?",
|
||||
"Индексы, созданные разными моделями поиска, нельзя использовать вместе. Индекс будет перестроен, чтобы существующие воспоминания оставались доступными.",
|
||||
"Существующие воспоминания",
|
||||
"{count}",
|
||||
" · Пользователей: {count}",
|
||||
"Расчётное время",
|
||||
"Пока невозможно оценить",
|
||||
"Около {count} с",
|
||||
"Около {count} мин",
|
||||
"Удалённые запросы",
|
||||
"Не требуются",
|
||||
"Оплачиваются по тарифу провайдера",
|
||||
"Около {count}",
|
||||
"Во время обновления воспоминания остаются доступными для поиска",
|
||||
"MemHub продолжит использовать текущую модель поиска. Новые диалоги будут сохранены и преобразованы в воспоминания после обновления.",
|
||||
"Сбой не затронет существующие данные",
|
||||
"Модель изменится только после завершения обновления и проверки. При сбое или отмене текущая модель останется активной.",
|
||||
"Показать технические сведения",
|
||||
"Текущая модель поиска",
|
||||
"Старый индекс",
|
||||
"Новая модель поиска",
|
||||
"Ожидает",
|
||||
"Размерность индекса",
|
||||
"Неизвестно",
|
||||
"Дополнительное место на диске",
|
||||
"Хранить старый индекс",
|
||||
"{count} дн.",
|
||||
"Сначала проверьте подключение к новой модели поиска.",
|
||||
"Размерность индекса новой модели поиска не подтверждена. Проверьте подключение ещё раз.",
|
||||
"Старые данные всё ещё обновляются. Дождитесь завершения.",
|
||||
"Эта установка не может безопасно обновить индекс воспоминаний.",
|
||||
"Недостаточно свободного места для безопасного создания нового индекса.",
|
||||
"Другое обновление индекса уже выполняется.",
|
||||
"Воспоминания объединяются. Дождитесь завершения, прежде чем менять модель поиска.",
|
||||
"В текущем состоянии изменить модель поиска нельзя.",
|
||||
"Состояние воспоминаний изменилось. Проверьте последствия ещё раз.",
|
||||
"Сейчас невозможно запустить обновление индекса. Повторите попытку позже.",
|
||||
"Не сейчас",
|
||||
"Начать обновление индекса",
|
||||
"Объединение старого поискового индекса",
|
||||
"Подготовка обновления индекса воспоминаний",
|
||||
"Обновление индекса воспоминаний",
|
||||
"Проверка нового индекса воспоминаний",
|
||||
"Включение новой модели поиска",
|
||||
"Восстановление прежних настроек после незавершённого обновления",
|
||||
"Обновление индекса воспоминаний",
|
||||
"Проверка локальных поисковых индексов воспоминаний",
|
||||
"Предыдущая модель поиска по-прежнему активна. Воспоминания не потеряны.",
|
||||
"Новые воспоминания не будут объединяться до завершения. Поиск остаётся доступным.",
|
||||
"Ход обновления индекса воспоминаний",
|
||||
"Отменить обновление",
|
||||
"Состояние индекса временно недоступно",
|
||||
"Эти сведения могут быть устаревшими. Существующие воспоминания остаются доступными.",
|
||||
"Обновить",
|
||||
"Проверка старого поискового индекса не завершилась",
|
||||
"Объединение новых воспоминаний приостановлено. Существующие воспоминания остаются доступными для поиска.",
|
||||
"Объединение воспоминаний возобновлено. Можно продолжать пользоваться MemHub; причина сбоя сохранена в диагностике.",
|
||||
"Показать диагностику",
|
||||
"Модель поиска изменена",
|
||||
"Диалоги, сохранённые во время паузы, преобразуются в воспоминания.",
|
||||
"Удалить старый индекс",
|
||||
"Закрыть сообщение об успехе",
|
||||
"Сейчас удалить старый индекс невозможно. MemHub повторит попытку после окончания срока хранения.",
|
||||
"Обновление индекса не завершилось",
|
||||
"Предыдущая модель поиска по-прежнему активна. Воспоминания не потеряны.",
|
||||
"Истекло время ожидания завершения объединения воспоминаний.",
|
||||
"Сервис модели поиска несколько раз завершился с ошибкой.",
|
||||
"Сервис поиска вернул размерность индекса, отличающуюся от результата проверки.",
|
||||
"Новый индекс воспоминаний не прошёл проверку целостности.",
|
||||
"Во время обновления изменилась конфигурация модели поиска.",
|
||||
"Обновление индекса отменено.",
|
||||
"Не удалось прочитать API Key сервиса поиска.",
|
||||
"Защищённое системное хранилище временно недоступно.",
|
||||
"Сервис воспоминаний оставался занят, поэтому изменение не удалось завершить.",
|
||||
"{reason} {safety}",
|
||||
"Проверить подключение",
|
||||
"Закрыть сообщение об ошибке",
|
||||
]);
|
||||
|
||||
export const embeddingSwitchIndonesianMessages = localized([
|
||||
"Perbarui indeks pencarian memori?",
|
||||
"Indeks yang dibuat oleh model pencarian berbeda tidak dapat digunakan bersama. Indeks akan dibuat ulang agar memori yang ada tetap dapat ditemukan.",
|
||||
"Memori yang ada",
|
||||
"{count}",
|
||||
" · Pengguna: {count}",
|
||||
"Perkiraan waktu",
|
||||
"Belum dapat diperkirakan",
|
||||
"Sekitar {count} detik",
|
||||
"Sekitar {count} menit",
|
||||
"Permintaan jarak jauh",
|
||||
"Tidak ada",
|
||||
"Ditagihkan oleh penyedia",
|
||||
"Sekitar {count}",
|
||||
"Memori tetap dapat dicari selama pembaruan",
|
||||
"MemHub tetap menggunakan model pencarian saat ini. Percakapan baru akan disimpan dan diubah menjadi memori setelah pembaruan.",
|
||||
"Kegagalan tidak akan memengaruhi data yang ada",
|
||||
"Model hanya diganti setelah pembaruan dan verifikasi selesai. Jika gagal atau dibatalkan, model saat ini tetap aktif.",
|
||||
"Lihat detail teknis",
|
||||
"Model pencarian saat ini",
|
||||
"Indeks lama",
|
||||
"Model pencarian baru",
|
||||
"Tertunda",
|
||||
"Dimensi indeks",
|
||||
"Tidak diketahui",
|
||||
"Ruang disk tambahan",
|
||||
"Simpan indeks lama",
|
||||
"{count} hari",
|
||||
"Uji koneksi model pencarian baru terlebih dahulu.",
|
||||
"Dimensi indeks model pencarian baru belum dikonfirmasi. Uji kembali koneksinya.",
|
||||
"Data lama masih ditingkatkan. Tunggu hingga proses selesai.",
|
||||
"Instalasi ini tidak dapat memperbarui indeks memori dengan aman.",
|
||||
"Ruang disk kosong tidak cukup untuk membuat indeks baru dengan aman.",
|
||||
"Pembaruan indeks lain sedang berjalan.",
|
||||
"Memori sedang digabungkan. Tunggu hingga selesai sebelum mengganti model pencarian.",
|
||||
"Model pencarian tidak dapat diganti dalam keadaan saat ini.",
|
||||
"Status memori telah berubah. Tinjau kembali dampaknya.",
|
||||
"Pembaruan indeks tidak dapat dimulai sekarang. Coba lagi nanti.",
|
||||
"Jangan sekarang",
|
||||
"Mulai pembaruan indeks",
|
||||
"Menggabungkan indeks pencarian lama",
|
||||
"Menyiapkan pembaruan indeks memori",
|
||||
"Memperbarui indeks memori",
|
||||
"Memverifikasi indeks memori baru",
|
||||
"Mengaktifkan model pencarian baru",
|
||||
"Memulihkan pengaturan sebelumnya setelah pembaruan tidak selesai",
|
||||
"Pembaruan indeks memori",
|
||||
"Memeriksa indeks pencarian memori lokal",
|
||||
"Model pencarian sebelumnya masih aktif. Tidak ada memori yang hilang.",
|
||||
"Memori baru tidak akan digabungkan hingga proses selesai. Pencarian tetap tersedia.",
|
||||
"Progres pembaruan indeks memori",
|
||||
"Batalkan pembaruan",
|
||||
"Status indeks untuk sementara tidak tersedia",
|
||||
"Status ini mungkin sudah tidak terbaru. Memori yang ada tetap tersedia.",
|
||||
"Muat ulang",
|
||||
"Pemeriksaan indeks pencarian lama tidak selesai",
|
||||
"Penggabungan memori baru dijeda. Memori yang ada tetap dapat dicari.",
|
||||
"Penggabungan memori telah dilanjutkan. Anda dapat terus menggunakan MemHub; alasan kegagalan tetap tersimpan dalam diagnostik.",
|
||||
"Lihat diagnostik",
|
||||
"Model pencarian telah diganti",
|
||||
"Percakapan yang disimpan selama jeda sedang diubah menjadi memori.",
|
||||
"Bersihkan indeks lama",
|
||||
"Tutup pesan penyelesaian",
|
||||
"Indeks lama tidak dapat dihapus sekarang. MemHub akan mencoba lagi setelah masa penyimpanan berakhir.",
|
||||
"Pembaruan indeks tidak selesai",
|
||||
"Model pencarian sebelumnya masih aktif. Tidak ada memori yang hilang.",
|
||||
"Waktu tunggu habis saat menunggu penggabungan memori selesai.",
|
||||
"Layanan model pencarian gagal berulang kali.",
|
||||
"Layanan pencarian mengembalikan dimensi indeks yang berbeda dari hasil pengujian.",
|
||||
"Indeks memori baru tidak lolos verifikasi integritas.",
|
||||
"Konfigurasi model pencarian berubah selama pembaruan.",
|
||||
"Pembaruan indeks dibatalkan.",
|
||||
"API Key layanan pencarian tidak dapat dibaca.",
|
||||
"Penyimpanan aman sistem untuk sementara tidak tersedia.",
|
||||
"Layanan memori tetap sibuk sehingga perubahan tidak dapat diselesaikan.",
|
||||
"{reason} {safety}",
|
||||
"Periksa koneksi",
|
||||
"Tutup pesan kegagalan",
|
||||
]);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentEnglishMessages } from "./gui-i18n-current.js";
|
||||
import { embeddingSwitchEnglishMessages } from "./gui-i18n-embedding-switch.js";
|
||||
import { modeEnglishMessages } from "./gui-i18n-mode.js";
|
||||
|
||||
// English translations integrated from PR #114.
|
||||
@@ -800,6 +801,19 @@ const baseEnglishMessages = {
|
||||
设置分组: "Settings sections",
|
||||
记忆模型: "Memory model",
|
||||
当前模型: "Current model",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Manage the models used for memory processing and search separately.",
|
||||
记忆整理: "Memory processing",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Turn conversations into memories you can use over the long term.",
|
||||
记忆搜索: "Memory search",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Find the memories most relevant to your question when you need them.",
|
||||
"本地内置(推荐)": "Built-in locally (recommended)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Customizable after memory processing is configured",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Processed locally, no extra setup required",
|
||||
"正在读取模型…": "Loading model…",
|
||||
模型状态暂不可读: "Model status is unavailable",
|
||||
尚未配置模型: "No model configured",
|
||||
@@ -2007,5 +2021,6 @@ const baseEnglishMessages = {
|
||||
export const englishMessages = {
|
||||
...baseEnglishMessages,
|
||||
...currentEnglishMessages,
|
||||
...embeddingSwitchEnglishMessages,
|
||||
...modeEnglishMessages,
|
||||
} as const;
|
||||
@@ -16,6 +16,12 @@ const INTENTIONALLY_EMPTY: Partial<Record<string, readonly string[]>> = {
|
||||
es: ["apps.unit"],
|
||||
};
|
||||
|
||||
/** Locale-independent interpolation-only values that cannot be translated. */
|
||||
const ENGLISH_EQUIVALENT_ALLOWED = new Set<MessageKey>([
|
||||
"embeddingSwitch.impact.memoryCount",
|
||||
"embeddingSwitch.rollback.reason",
|
||||
]);
|
||||
|
||||
const keys = Object.keys(chineseMessages) as MessageKey[];
|
||||
|
||||
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
|
||||
@@ -61,6 +67,41 @@ describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedding switch catalog", () => {
|
||||
const embeddingKeys = keys.filter((key) =>
|
||||
key.startsWith("embeddingSwitch."),
|
||||
);
|
||||
|
||||
it("is complete in all 11 locales", () => {
|
||||
expect(embeddingKeys.length).toBeGreaterThan(0);
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
for (const key of embeddingKeys)
|
||||
expect(
|
||||
guiMessages[locale][key]?.trim(),
|
||||
`${locale} ${key}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "es"] as const)("%s has no Chinese leakage", (locale) => {
|
||||
for (const key of embeddingKeys)
|
||||
expect(guiMessages[locale][key], key).not.toMatch(/[一-鿿]/);
|
||||
});
|
||||
|
||||
it("does not silently retain English fallback copy", () => {
|
||||
const untranslated: string[] = [];
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (locale === "en") continue;
|
||||
for (const key of embeddingKeys) {
|
||||
if (ENGLISH_EQUIVALENT_ALLOWED.has(key)) continue;
|
||||
if (guiMessages[locale][key] === guiMessages.en[key])
|
||||
untranslated.push(`${locale} ${key}`);
|
||||
}
|
||||
}
|
||||
expect(untranslated).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback", () => {
|
||||
it("falls back to English when a locale lacks a key", () => {
|
||||
const key = "languageTitle";
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
currentChineseMessages,
|
||||
currentSpanishMessages,
|
||||
} from "./gui-i18n-current.js";
|
||||
import {
|
||||
embeddingSwitchChineseMessages,
|
||||
embeddingSwitchSpanishMessages,
|
||||
} from "./gui-i18n-embedding-switch.js";
|
||||
import {
|
||||
guideChineseMessages,
|
||||
guideSpanishMessages,
|
||||
@@ -95,6 +99,11 @@ export const guiCatalogs: readonly Catalog[] = [
|
||||
chinese: currentChineseMessages,
|
||||
spanish: currentSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "embedding-switch",
|
||||
chinese: embeddingSwitchChineseMessages,
|
||||
spanish: embeddingSwitchSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "workbench",
|
||||
chinese: workbenchChineseMessages,
|
||||
@@ -187,6 +196,7 @@ const merged = mergeCatalogs(guiCatalogs);
|
||||
|
||||
/** The complete Chinese GUI catalog defines the supported message keys. */
|
||||
export const chineseMessages = merged.chinese as typeof guideChineseMessages &
|
||||
typeof embeddingSwitchChineseMessages &
|
||||
typeof healthChineseMessages &
|
||||
typeof recoveryChineseMessages &
|
||||
typeof settingsChineseMessages &
|
||||
|
||||
@@ -69,9 +69,80 @@ const modeChineseMessages = {
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切换到 {mode}?",
|
||||
"切换到 {mode}?": "切换到{mode}?",
|
||||
"切换到 {mode}": "切换到{mode}",
|
||||
"原始对话在所有模式下都会保留。": "原始对话在所有模式下都会保留。",
|
||||
切换模式: "切换模式",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
|
||||
新对话: "新对话",
|
||||
模型调用与费用: "模型调用与费用",
|
||||
搜索范围: "搜索范围",
|
||||
已有数据: "已有数据",
|
||||
切换后: "切换后",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之后只保存原始对话,不再生成新记忆。",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"停止调用生成模型;每天自动整合也会停止。",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"停止调用生成模型,不再产生这部分费用。",
|
||||
"立即改为搜索原始对话。": "立即改为搜索原始对话。",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"提取仍会调用生成模型;每天自动整合会停止。",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"开始调用当前生成模型,可能产生服务商费用。",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。",
|
||||
"立即改为搜索沉淀后的记忆。": "立即改为搜索沉淀后的记忆。",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
|
||||
"原始对话全部保留,不会删除。": "原始对话全部保留,不会删除。",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"提取方式不变,新记忆会写入知识图谱。",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。",
|
||||
"记忆整合(Evolve)": "记忆整合(Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
执行时间: "执行时间",
|
||||
立即整合一次: "立即整合一次",
|
||||
"只运行这一次,不改变每天的计划。": "只运行这一次,不改变每天的计划。",
|
||||
"当前是基础记忆(lite)": "当前是基础记忆(lite)",
|
||||
"当前是智能记忆(pro)": "当前是智能记忆(pro)",
|
||||
"当前是深度记忆(ultra)": "当前是深度记忆(ultra)",
|
||||
"正在读取记忆模式…": "正在读取记忆模式…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"模型配置已保留,目前不会用于整理新对话。切换到 pro 或 ultra 后,是否开始整理取决于单独的“用模型沉淀记忆”(沉淀)开关。",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。",
|
||||
} as const;
|
||||
|
||||
export { modeChineseMessages };
|
||||
@@ -158,9 +229,84 @@ export const modeEnglishMessages: ModeMessages = {
|
||||
尚未整合: "Not yet consolidated",
|
||||
立即整合: "Consolidate now",
|
||||
"切换到 {mode}?": "Switch to {mode}?",
|
||||
"切换到 {mode}": "Switch to {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Original conversations are kept in every mode.",
|
||||
切换模式: "Switch mode",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Save original conversations · No generation model calls · Search original conversations",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Create advanced memories · Calls a generation model · Search created memories",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Smart memory + knowledge graph · Optional automatic Evolve · Related-context recall",
|
||||
新对话: "New conversations",
|
||||
模型调用与费用: "Model calls and cost",
|
||||
搜索范围: "Search scope",
|
||||
已有数据: "Existing data",
|
||||
切换后: "After switching",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"New conversations are saved as original text only; no new memories are created.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Generation model calls stop, and daily consolidation stops too.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Generation model calls stop, so this part no longer incurs provider charges.",
|
||||
"立即改为搜索原始对话。":
|
||||
"Search immediately switches to original conversations.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Existing memories are kept, but Basic memory does not show or recall them. Original conversations are not deleted.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Queued and future conversations become memories. Conversations already completed are not processed again.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"The model still creates memories, but new memories are no longer written to the knowledge graph.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"Extraction still calls the generation model; daily consolidation stops.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"The current generation model starts being called and may incur provider charges.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"Search uses Smart memories without knowledge-graph relationships.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"Search immediately switches to created memories.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Regular memories remain available. Higher-level memories and graph data added by Deep memory are kept but are not shown or recalled in Smart memory. Original conversations are not deleted.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"All original conversations are kept and are not deleted.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Queued and future conversations become memories and are written to the knowledge graph. Conversations already completed are not processed again.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"Memory extraction stays the same, and new memories are written to the knowledge graph.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"Extraction calls the generation model. If daily consolidation is enabled, it makes additional calls at the saved time. Switching does not run consolidation immediately.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"Search uses created memories and supplements them with knowledge-graph relationships.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Existing memories are added to the knowledge graph in the background and remain usable. Original conversations are not deleted.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Set a daily consolidation time or run one consolidation at any time.",
|
||||
"记忆整合(Evolve)": "Memory consolidation (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Periodically reorganize existing memories to strengthen relationships and conclusions.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Consolidates daily around {time} in this device's local time. A missed run catches up after wake.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Off. When re-enabled, it still runs at {time}. Existing results are kept.",
|
||||
执行时间: "Run time",
|
||||
立即整合一次: "Consolidate once now",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Runs once without changing the daily schedule.",
|
||||
"当前是基础记忆(lite)": "Current mode: Basic memory (lite)",
|
||||
"当前是智能记忆(pro)": "Current mode: Smart memory (pro)",
|
||||
"当前是深度记忆(ultra)": "Current mode: Deep memory (ultra)",
|
||||
"正在读取记忆模式…": "Reading memory mode…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"The model configuration is retained and is not currently used to organize new conversations. After switching to pro or ultra, whether it starts organizing them depends on the separate “Create memories with a model” switch.",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Checking memory creation status. The model configuration and existing memories are unaffected.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"This mode supports memory creation, but model processing is paused. Re-enable it to continue retained tasks.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"The model is active in this mode and creates memories from new conversations.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"The model configuration is kept. Its active state appears after the memory mode is confirmed.",
|
||||
};
|
||||
|
||||
export const modeSpanishMessages: ModeMessages = {
|
||||
@@ -243,9 +389,84 @@ export const modeSpanishMessages: ModeMessages = {
|
||||
尚未整合: "Aún sin consolidar",
|
||||
立即整合: "Consolidar ahora",
|
||||
"切换到 {mode}?": "¿Cambiar a {mode}?",
|
||||
"切换到 {mode}": "Cambiar a {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Las conversaciones originales se conservan en todos los modos.",
|
||||
切换模式: "Cambiar modo",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Guarda conversaciones originales · Sin llamadas al modelo generativo · Busca conversaciones originales",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Crea memorias avanzadas · Usa un modelo generativo · Busca memorias creadas",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Memoria inteligente + grafo de conocimiento · Evolve automático opcional · Recuperación relacionada",
|
||||
新对话: "Conversaciones nuevas",
|
||||
模型调用与费用: "Llamadas al modelo y coste",
|
||||
搜索范围: "Ámbito de búsqueda",
|
||||
已有数据: "Datos existentes",
|
||||
切换后: "Después del cambio",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Se detienen las llamadas al modelo generativo y la consolidación diaria.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Se detienen las llamadas al modelo generativo, por lo que esta parte deja de generar costes.",
|
||||
"立即改为搜索原始对话。":
|
||||
"La búsqueda cambia de inmediato a las conversaciones originales.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Las memorias existentes se conservan, pero la memoria básica no las muestra ni recupera. Las conversaciones originales no se eliminan.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Las conversaciones en cola y futuras se convierten en memorias. Las ya procesadas no se vuelven a procesar.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"El modelo sigue creando memorias, pero las nuevas ya no se escriben en el grafo de conocimiento.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"La extracción sigue usando el modelo generativo; la consolidación diaria se detiene.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"Empieza a usarse el modelo generativo actual y puede generar costes del proveedor.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"La búsqueda usa memorias inteligentes sin relaciones del grafo de conocimiento.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"La búsqueda cambia de inmediato a las memorias creadas.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Las memorias normales siguen disponibles. Las memorias de nivel superior y los datos del grafo añadidos por la memoria profunda se conservan, pero no se muestran ni recuperan en la memoria inteligente. Las conversaciones originales no se eliminan.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Todas las conversaciones originales se conservan y no se eliminan.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Las conversaciones en cola y futuras se convierten en memorias y se escriben en el grafo. Las ya procesadas no se vuelven a procesar.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"La extracción no cambia y las memorias nuevas se escriben en el grafo de conocimiento.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"La extracción usa el modelo generativo. Si la consolidación diaria está activa, hará llamadas adicionales a la hora guardada. El cambio no consolida de inmediato.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"La búsqueda usa memorias creadas y añade relaciones del grafo de conocimiento.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Las memorias existentes se añaden al grafo en segundo plano y siguen disponibles. Las conversaciones originales no se eliminan.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Configura una hora diaria o ejecuta una consolidación cuando quieras.",
|
||||
"记忆整合(Evolve)": "Consolidación de memoria (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Reorganiza periódicamente las memorias para reforzar relaciones y conclusiones.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Consolida cada día alrededor de las {time}, según la hora local del dispositivo. Si se omite por suspensión, se ejecuta al reactivarse.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Desactivado. Al reactivarlo, seguirá ejecutándose a las {time}. Los resultados existentes se conservan.",
|
||||
执行时间: "Hora de ejecución",
|
||||
立即整合一次: "Consolidar una vez ahora",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Se ejecuta una vez sin cambiar la programación diaria.",
|
||||
"当前是基础记忆(lite)": "Modo actual: memoria básica (lite)",
|
||||
"当前是智能记忆(pro)": "Modo actual: memoria inteligente (pro)",
|
||||
"当前是深度记忆(ultra)": "Modo actual: memoria profunda (ultra)",
|
||||
"正在读取记忆模式…": "Leyendo el modo de memoria…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"La configuración del modelo se conserva y actualmente no se usa para organizar conversaciones nuevas. Tras cambiar a pro o ultra, que empiece a organizarlas dependerá del interruptor independiente «Generar memorias con el modelo».",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Comprobando la creación de memorias. La configuración y las memorias existentes no se ven afectadas.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Este modo admite la creación de memorias, pero el procesamiento está pausado. Reactívalo para continuar las tareas conservadas.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"El modelo está activo en este modo y crea memorias a partir de conversaciones nuevas.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"La configuración se conserva. Su estado aparecerá cuando se confirme el modo de memoria.",
|
||||
};
|
||||
|
||||
export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
@@ -322,9 +543,80 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切換到 {mode}?",
|
||||
"切换到 {mode}?": "切換到{mode}?",
|
||||
"切换到 {mode}": "切換到{mode}",
|
||||
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
||||
切换模式: "切換模式",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"儲存原始對話 · 不呼叫生成模型 · 搜尋原始對話",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"建立進階記憶 · 呼叫生成模型 · 搜尋已建立的記憶",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"智慧記憶 + 知識圖譜 · 可自動 Evolve · 強化關聯召回",
|
||||
新对话: "新對話",
|
||||
模型调用与费用: "模型呼叫與費用",
|
||||
搜索范围: "搜尋範圍",
|
||||
已有数据: "現有資料",
|
||||
切换后: "切換後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之後只儲存原始對話,不再產生新記憶。",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"停止呼叫生成模型;每日自動整合也會停止。",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"停止呼叫生成模型,不再產生這部分費用。",
|
||||
"立即改为搜索原始对话。": "立即改為搜尋原始對話。",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"現有記憶會繼續保留,但在基礎記憶下不會顯示或參與召回;原始對話不會刪除。",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"佇列中及之後的新對話會由模型整理成記憶;已處理完成的舊對話不會補做。",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"仍會由模型整理成記憶;新記憶不再寫入知識圖譜。",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"擷取仍會呼叫生成模型;每日自動整合會停止。",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"開始呼叫目前的生成模型,可能產生服務供應商費用。",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"改為搜尋智慧記憶,不再使用知識圖譜補充關聯。",
|
||||
"立即改为搜索沉淀后的记忆。": "立即改為搜尋已建立的記憶。",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"一般記憶仍可使用;深度記憶新增的高階記憶與圖譜資料會繼續保留,但在智慧記憶下不會顯示或參與召回。原始對話不會刪除。",
|
||||
"原始对话全部保留,不会删除。": "所有原始對話都會保留,不會刪除。",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"佇列中及之後的新對話會擷取記憶並寫入知識圖譜;已處理完成的舊對話不會補做。",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"擷取方式不變,新記憶會寫入知識圖譜。",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"擷取會呼叫生成模型;若已開啟每日自動整合,到了設定時間還會產生額外呼叫。切換本身不會立即整合。",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"搜尋已建立的記憶,並用知識圖譜補充關聯。",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"現有記憶會在背景加入知識圖譜,期間仍可使用;原始對話不會刪除。",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"可設定每日自動整合時間,也可以隨時立即整合一次。",
|
||||
"记忆整合(Evolve)": "記憶整合(Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"定期重新整理現有記憶,補充更穩定的關聯與結論。",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"每天約 {time} 自動整合,使用這台裝置的本地時間;休眠錯過後會補跑。",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"已關閉。重新開啟後仍會在 {time} 執行;已產生的結果會保留。",
|
||||
执行时间: "執行時間",
|
||||
立即整合一次: "立即整合一次",
|
||||
"只运行这一次,不改变每天的计划。": "只執行這一次,不變更每日排程。",
|
||||
"当前是基础记忆(lite)": "目前是基礎記憶(lite)",
|
||||
"当前是智能记忆(pro)": "目前是智慧記憶(pro)",
|
||||
"当前是深度记忆(ultra)": "目前是深度記憶(ultra)",
|
||||
"正在读取记忆模式…": "正在讀取記憶模式…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"模型設定已保留,目前不會用於整理新對話。切換到 pro 或 ultra 後,是否開始整理取決於獨立的「用模型建立記憶」(沉澱)開關。",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"正在確認模型建立記憶的狀態,模型設定與現有記憶不會受到影響。",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"目前模式支援模型建立記憶,但模型整理已暫停;重新開啟後會繼續處理保留的工作。",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"模型已在目前模式下生效,會用於整理新對話並建立記憶。",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"模型設定已保留,確認記憶模式後會顯示是否生效。",
|
||||
};
|
||||
|
||||
export const modeJapaneseMessages: ModeMessages = {
|
||||
@@ -365,8 +657,81 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
尚未整合: "まだ統合されていません",
|
||||
立即整合: "今すぐ統合",
|
||||
"切换到 {mode}?": "{mode} に切り替えますか?",
|
||||
"切换到 {mode}": "{mode} に切り替える",
|
||||
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
||||
切换模式: "モードを切り替える",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"元の会話を保存 · 生成モデルを呼び出さない · 元の会話を検索",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"高度な記憶を作成 · 生成モデルを呼び出す · 作成した記憶を検索",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"スマート記憶 + 知識グラフ · 自動 Evolve に対応 · 関連情報の想起を強化",
|
||||
新对话: "新しい会話",
|
||||
模型调用与费用: "モデル呼び出しと料金",
|
||||
搜索范围: "検索範囲",
|
||||
已有数据: "既存データ",
|
||||
切换后: "切り替え後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"生成モデルの呼び出しと毎日の自動統合を停止します。",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"生成モデルの呼び出しを停止するため、この料金は発生しなくなります。",
|
||||
"立即改为搜索原始对话。": "検索対象がすぐに元の会話へ切り替わります。",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"既存の記憶は保持されますが、基本記憶では表示も想起もされません。元の会話は削除されません。",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"待機中および今後の会話はモデルによって記憶に整理されます。処理済みの会話は再処理されません。",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"引き続きモデルが記憶を作成しますが、新しい記憶は知識グラフに書き込まれません。",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"抽出では引き続き生成モデルを呼び出しますが、毎日の自動統合は停止します。",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"現在の生成モデルの呼び出しが始まり、プロバイダー料金が発生する場合があります。",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"スマート記憶を検索し、知識グラフによる関連情報の補完は行いません。",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"検索対象がすぐに作成済みの記憶へ切り替わります。",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"通常の記憶は引き続き利用できます。高度な記憶で追加された上位の記憶とグラフデータは保持されますが、スマート記憶では表示も想起もされません。元の会話は削除されません。",
|
||||
"原始对话全部保留,不会删除。": "元の会話はすべて保持され、削除されません。",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"待機中および今後の会話から記憶を抽出し、知識グラフに書き込みます。処理済みの会話は再処理されません。",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"抽出方法は変わらず、新しい記憶が知識グラフに書き込まれます。",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"抽出では生成モデルを呼び出します。毎日の自動統合が有効な場合、設定時刻に追加の呼び出しが発生します。切り替えただけではすぐに統合されません。",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"作成済みの記憶を検索し、知識グラフで関連情報を補完します。",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"既存の記憶はバックグラウンドで知識グラフに追加され、その間も利用できます。元の会話は削除されません。",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"毎日の自動統合時刻を設定するか、いつでも一度だけ統合できます。",
|
||||
"记忆整合(Evolve)": "記憶の統合(Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"既存の記憶を定期的に再整理し、より安定した関連性と結論を補います。",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"このデバイスの現地時刻で毎日 {time} ごろに自動統合します。スリープ中に実行できなかった場合は復帰後に実行します。",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"オフです。再度有効にすると {time} に実行されます。生成済みの結果は保持されます。",
|
||||
执行时间: "実行時刻",
|
||||
立即整合一次: "今すぐ一度統合",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"今回だけ実行し、毎日のスケジュールは変更しません。",
|
||||
"当前是基础记忆(lite)": "現在は基本記憶(lite)です",
|
||||
"当前是智能记忆(pro)": "現在はスマート記憶(pro)です",
|
||||
"当前是深度记忆(ultra)": "現在は高度な記憶(ultra)です",
|
||||
"正在读取记忆模式…": "記憶モードを読み込み中…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"モデル設定は保持されていますが、現在は新しい会話の整理には使用されません。pro または ultra に切り替えた後、整理を開始するかどうかは、独立した「モデルで記憶を作成」スイッチによって決まります。",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"モデルによる記憶作成の状態を確認中です。モデル設定と既存の記憶には影響しません。",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"現在のモードはモデルによる記憶作成に対応していますが、処理は一時停止中です。再度有効にすると保留中のタスクを再開します。",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"このモードではモデルが有効で、新しい会話を整理して記憶を作成します。",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"モデル設定は保持されています。記憶モードの確認後に有効状態が表示されます。",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"生成モデルを使わず、元の会話を保存・検索します。高度な記憶は生成しません。",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -450,9 +815,84 @@ export const modeGermanMessages: ModeMessages = {
|
||||
尚未整合: "Noch nicht konsolidiert",
|
||||
立即整合: "Jetzt konsolidieren",
|
||||
"切换到 {mode}?": "Zu {mode} wechseln?",
|
||||
"切换到 {mode}": "Zu {mode} wechseln",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
||||
切换模式: "Modus wechseln",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Originalunterhaltungen speichern · Keine Aufrufe des generativen Modells · Originalunterhaltungen durchsuchen",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Erweiterte Erinnerungen erstellen · Generatives Modell aufrufen · Erstellte Erinnerungen durchsuchen",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Intelligente Erinnerung + Wissensgraph · Optionales automatisches Evolve · Verbesserter Abruf von Zusammenhängen",
|
||||
新对话: "Neue Unterhaltungen",
|
||||
模型调用与费用: "Modellaufrufe und Kosten",
|
||||
搜索范围: "Suchbereich",
|
||||
已有数据: "Vorhandene Daten",
|
||||
切换后: "Nach dem Wechsel",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Aufrufe des generativen Modells und die tägliche Konsolidierung werden beendet.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Aufrufe des generativen Modells werden beendet, sodass dafür keine Anbieterkosten mehr anfallen.",
|
||||
"立即改为搜索原始对话。":
|
||||
"Die Suche wechselt sofort zu den Originalunterhaltungen.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Vorhandene Erinnerungen bleiben erhalten, werden in der Basiserinnerung aber weder angezeigt noch abgerufen. Originalunterhaltungen werden nicht gelöscht.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Unterhaltungen in der Warteschlange und künftige Unterhaltungen werden zu Erinnerungen verarbeitet. Bereits abgeschlossene Unterhaltungen werden nicht erneut verarbeitet.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"Das Modell erstellt weiterhin Erinnerungen, neue Erinnerungen werden aber nicht mehr in den Wissensgraphen geschrieben.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"Die Extraktion ruft weiterhin das generative Modell auf; die tägliche Konsolidierung wird beendet.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"Das aktuelle generative Modell wird aufgerufen, wodurch Anbieterkosten entstehen können.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"Die Suche verwendet intelligente Erinnerungen ohne Ergänzungen aus dem Wissensgraphen.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"Die Suche wechselt sofort zu den erstellten Erinnerungen.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Normale Erinnerungen bleiben verfügbar. Höherstufige Erinnerungen und Graphdaten aus der tiefen Erinnerung bleiben erhalten, werden in der intelligenten Erinnerung aber weder angezeigt noch abgerufen. Originalunterhaltungen werden nicht gelöscht.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Alle Originalunterhaltungen bleiben erhalten und werden nicht gelöscht.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Aus Unterhaltungen in der Warteschlange und künftigen Unterhaltungen werden Erinnerungen extrahiert und in den Wissensgraphen geschrieben. Bereits abgeschlossene Unterhaltungen werden nicht erneut verarbeitet.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"Die Extraktion bleibt unverändert; neue Erinnerungen werden in den Wissensgraphen geschrieben.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"Die Extraktion ruft das generative Modell auf. Bei aktivierter täglicher Konsolidierung erfolgen zur festgelegten Zeit zusätzliche Aufrufe. Der Wechsel löst keine sofortige Konsolidierung aus.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"Die Suche verwendet erstellte Erinnerungen und ergänzt Zusammenhänge aus dem Wissensgraphen.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Vorhandene Erinnerungen werden im Hintergrund dem Wissensgraphen hinzugefügt und bleiben dabei nutzbar. Originalunterhaltungen werden nicht gelöscht.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Legen Sie eine tägliche Konsolidierungszeit fest oder führen Sie jederzeit eine Konsolidierung aus.",
|
||||
"记忆整合(Evolve)": "Erinnerungskonsolidierung (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Vorhandene Erinnerungen regelmäßig neu ordnen, um Zusammenhänge und Schlussfolgerungen zu festigen.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Konsolidiert täglich gegen {time} nach der lokalen Zeit dieses Geräts. Ein verpasster Lauf wird nach dem Aufwachen nachgeholt.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Aus. Nach dem erneuten Aktivieren wird weiterhin um {time} ausgeführt. Vorhandene Ergebnisse bleiben erhalten.",
|
||||
执行时间: "Ausführungszeit",
|
||||
立即整合一次: "Jetzt einmal konsolidieren",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Wird einmal ausgeführt, ohne den täglichen Zeitplan zu ändern.",
|
||||
"当前是基础记忆(lite)": "Aktueller Modus: Basiserinnerung (lite)",
|
||||
"当前是智能记忆(pro)": "Aktueller Modus: Intelligente Erinnerung (pro)",
|
||||
"当前是深度记忆(ultra)": "Aktueller Modus: Tiefe Erinnerung (ultra)",
|
||||
"正在读取记忆模式…": "Erinnerungsmodus wird gelesen…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"Die Modellkonfiguration bleibt erhalten und wird derzeit nicht zum Ordnen neuer Unterhaltungen verwendet. Ob sie nach dem Wechsel zu pro oder ultra damit beginnt, hängt vom separaten Schalter „Erinnerungen mit Modell erstellen“ ab.",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Der Status der Erinnerungserstellung wird geprüft. Modellkonfiguration und vorhandene Erinnerungen bleiben unverändert.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Dieser Modus unterstützt die Erinnerungserstellung, die Modellverarbeitung ist jedoch pausiert. Nach dem erneuten Aktivieren werden zurückgehaltene Aufgaben fortgesetzt.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"Das Modell ist in diesem Modus aktiv und erstellt Erinnerungen aus neuen Unterhaltungen.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"Die Modellkonfiguration bleibt erhalten. Nach Bestätigung des Erinnerungsmodus wird ihr Aktivierungsstatus angezeigt.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Originalunterhaltungen ohne generatives Modell speichern und durchsuchen. Es werden keine erweiterten Erinnerungen erstellt.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -536,9 +976,84 @@ export const modePortugueseMessages: ModeMessages = {
|
||||
尚未整合: "Ainda não consolidado",
|
||||
立即整合: "Consolidar agora",
|
||||
"切换到 {mode}?": "Mudar para {mode}?",
|
||||
"切换到 {mode}": "Mudar para {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"As conversas originais são mantidas em todos os modos.",
|
||||
切换模式: "Mudar de modo",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Guardar conversas originais · Sem chamadas ao modelo generativo · Pesquisar conversas originais",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Criar memórias avançadas · Chamar o modelo generativo · Pesquisar memórias criadas",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Memória inteligente + grafo de conhecimento · Evolve automático opcional · Recuperação de relações melhorada",
|
||||
新对话: "Novas conversas",
|
||||
模型调用与费用: "Chamadas ao modelo e custos",
|
||||
搜索范围: "Âmbito da pesquisa",
|
||||
已有数据: "Dados existentes",
|
||||
切换后: "Após a mudança",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"As chamadas ao modelo generativo e a consolidação diária são interrompidas.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"As chamadas ao modelo generativo são interrompidas, deixando de gerar estes custos.",
|
||||
"立即改为搜索原始对话。":
|
||||
"A pesquisa muda imediatamente para as conversas originais.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"As memórias existentes são mantidas, mas a memória básica não as mostra nem recupera. As conversas originais não são eliminadas.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"As conversas em fila e futuras são convertidas em memórias. As conversas já concluídas não são processadas novamente.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"O modelo continua a criar memórias, mas as novas memórias deixam de ser escritas no grafo de conhecimento.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"A extração continua a chamar o modelo generativo; a consolidação diária é interrompida.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"O modelo generativo atual começa a ser chamado e pode gerar custos do fornecedor.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"A pesquisa passa a usar memórias inteligentes sem relações do grafo de conhecimento.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"A pesquisa muda imediatamente para as memórias criadas.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"As memórias normais continuam disponíveis. As memórias de nível superior e os dados do grafo acrescentados pela memória profunda são mantidos, mas não são mostrados nem recuperados na memória inteligente. As conversas originais não são eliminadas.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Todas as conversas originais são mantidas e não são eliminadas.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"As conversas em fila e futuras geram memórias que são escritas no grafo de conhecimento. As conversas já concluídas não são processadas novamente.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"A extração mantém-se igual e as novas memórias são escritas no grafo de conhecimento.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"A extração chama o modelo generativo. Se a consolidação diária estiver ativa, haverá chamadas adicionais à hora definida. A mudança não executa uma consolidação imediata.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"A pesquisa usa as memórias criadas e complementa-as com relações do grafo de conhecimento.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"As memórias existentes são adicionadas ao grafo em segundo plano e continuam disponíveis. As conversas originais não são eliminadas.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Defina uma hora de consolidação diária ou execute uma consolidação a qualquer momento.",
|
||||
"记忆整合(Evolve)": "Consolidação de memórias (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Reorganiza periodicamente as memórias existentes para reforçar relações e conclusões.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Consolida diariamente por volta das {time}, segundo a hora local deste dispositivo. Uma execução perdida é retomada ao reativar.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Desativado. Quando reativado, continua a executar às {time}. Os resultados existentes são mantidos.",
|
||||
执行时间: "Hora de execução",
|
||||
立即整合一次: "Consolidar uma vez agora",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Executa uma vez sem alterar o horário diário.",
|
||||
"当前是基础记忆(lite)": "Modo atual: memória básica (lite)",
|
||||
"当前是智能记忆(pro)": "Modo atual: memória inteligente (pro)",
|
||||
"当前是深度记忆(ultra)": "Modo atual: memória profunda (ultra)",
|
||||
"正在读取记忆模式…": "A ler o modo de memória…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"A configuração do modelo é mantida e não é atualmente usada para organizar novas conversas. Após mudar para pro ou ultra, o início dessa organização depende do interruptor separado «Criar memórias com um modelo».",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"A verificar o estado da criação de memórias. A configuração do modelo e as memórias existentes não são afetadas.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Este modo permite criar memórias, mas o processamento está em pausa. Reative-o para continuar as tarefas mantidas.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"O modelo está ativo neste modo e cria memórias a partir de novas conversas.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"A configuração do modelo é mantida. O estado de ativação aparece após a confirmação do modo de memória.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Guarda e pesquisa conversas originais sem usar um modelo generativo nem criar memórias avançadas.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -622,9 +1137,84 @@ export const modeFrenchMessages: ModeMessages = {
|
||||
尚未整合: "Pas encore consolidé",
|
||||
立即整合: "Consolider maintenant",
|
||||
"切换到 {mode}?": "Passer à {mode} ?",
|
||||
"切换到 {mode}": "Passer à {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Les conversations originales sont conservées dans tous les modes.",
|
||||
切换模式: "Changer de mode",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Enregistrer les conversations originales · Aucun appel au modèle génératif · Rechercher les conversations originales",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Créer des mémoires avancées · Appeler le modèle génératif · Rechercher les mémoires créées",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Mémoire intelligente + graphe de connaissances · Evolve automatique en option · Rappel contextuel enrichi",
|
||||
新对话: "Nouvelles conversations",
|
||||
模型调用与费用: "Appels au modèle et coût",
|
||||
搜索范围: "Périmètre de recherche",
|
||||
已有数据: "Données existantes",
|
||||
切换后: "Après le changement",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Les appels au modèle génératif et la consolidation quotidienne s’arrêtent.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Les appels au modèle génératif s’arrêtent, ce qui supprime les frais correspondants.",
|
||||
"立即改为搜索原始对话。":
|
||||
"La recherche bascule immédiatement vers les conversations originales.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Les mémoires existantes sont conservées, mais la mémoire de base ne les affiche ni ne les rappelle. Les conversations originales ne sont pas supprimées.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Les conversations en attente et futures deviennent des mémoires. Les conversations déjà traitées ne le sont pas de nouveau.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"Le modèle continue à créer des mémoires, mais les nouvelles mémoires ne sont plus ajoutées au graphe de connaissances.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"L’extraction continue à appeler le modèle génératif ; la consolidation quotidienne s’arrête.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"Le modèle génératif actuel commence à être appelé et peut entraîner des frais du fournisseur.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"La recherche utilise les mémoires intelligentes sans les relations du graphe de connaissances.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"La recherche bascule immédiatement vers les mémoires créées.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Les mémoires ordinaires restent disponibles. Les mémoires de niveau supérieur et les données de graphe ajoutées par la mémoire approfondie sont conservées, mais ne sont ni affichées ni rappelées dans la mémoire intelligente. Les conversations originales ne sont pas supprimées.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Toutes les conversations originales sont conservées et ne sont pas supprimées.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Les conversations en attente et futures produisent des mémoires qui sont ajoutées au graphe de connaissances. Les conversations déjà traitées ne le sont pas de nouveau.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"L’extraction reste inchangée et les nouvelles mémoires sont ajoutées au graphe de connaissances.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"L’extraction appelle le modèle génératif. Si la consolidation quotidienne est activée, des appels supplémentaires ont lieu à l’heure définie. Le changement ne lance pas immédiatement de consolidation.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"La recherche utilise les mémoires créées et les complète avec les relations du graphe de connaissances.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Les mémoires existantes sont ajoutées au graphe en arrière-plan et restent utilisables. Les conversations originales ne sont pas supprimées.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Définissez une heure de consolidation quotidienne ou lancez-en une à tout moment.",
|
||||
"记忆整合(Evolve)": "Consolidation des mémoires (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Réorganise régulièrement les mémoires existantes pour renforcer les relations et les conclusions.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Consolide chaque jour vers {time}, selon l’heure locale de cet appareil. Une exécution manquée est rattrapée au réveil.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Désactivé. Après réactivation, l’exécution aura toujours lieu à {time}. Les résultats existants sont conservés.",
|
||||
执行时间: "Heure d’exécution",
|
||||
立即整合一次: "Consolider une fois maintenant",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"S’exécute une fois sans modifier la programmation quotidienne.",
|
||||
"当前是基础记忆(lite)": "Mode actuel : mémoire de base (lite)",
|
||||
"当前是智能记忆(pro)": "Mode actuel : mémoire intelligente (pro)",
|
||||
"当前是深度记忆(ultra)": "Mode actuel : mémoire approfondie (ultra)",
|
||||
"正在读取记忆模式…": "Lecture du mode de mémoire…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"La configuration du modèle est conservée et n’est actuellement pas utilisée pour organiser les nouvelles conversations. Après le passage à pro ou ultra, leur organisation dépend de l’interrupteur distinct « Créer des mémoires avec un modèle ».",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Vérification de l’état de création des mémoires. La configuration du modèle et les mémoires existantes ne sont pas affectées.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Ce mode prend en charge la création de mémoires, mais le traitement est en pause. Réactivez-le pour reprendre les tâches conservées.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"Le modèle est actif dans ce mode et crée des mémoires à partir des nouvelles conversations.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"La configuration du modèle est conservée. Son état d’activation s’affichera après confirmation du mode de mémoire.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Enregistre et recherche les conversations originales sans modèle génératif ni création de mémoires avancées.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -707,8 +1297,81 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
尚未整合: "아직 통합되지 않음",
|
||||
立即整合: "지금 통합",
|
||||
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
|
||||
"切换到 {mode}": "{mode}(으)로 전환",
|
||||
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
||||
切换模式: "모드 전환",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"원본 대화 저장 · 생성 모델 호출 없음 · 원본 대화 검색",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"고급 메모리 생성 · 생성 모델 호출 · 생성된 메모리 검색",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"스마트 메모리 + 지식 그래프 · 자동 Evolve 선택 가능 · 연관 정보 검색 강화",
|
||||
新对话: "새 대화",
|
||||
模型调用与费用: "모델 호출 및 비용",
|
||||
搜索范围: "검색 범위",
|
||||
已有数据: "기존 데이터",
|
||||
切换后: "전환 후",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"생성 모델 호출과 매일 통합이 중지됩니다.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"생성 모델 호출이 중지되어 관련 제공업체 비용이 더 이상 발생하지 않습니다.",
|
||||
"立即改为搜索原始对话。": "검색 대상이 즉시 원본 대화로 전환됩니다.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"기존 메모리는 유지되지만 기본 메모리에서는 표시되거나 검색되지 않습니다. 원본 대화는 삭제되지 않습니다.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"대기 중인 대화와 이후의 새 대화는 메모리로 정리됩니다. 이미 처리가 끝난 대화는 다시 처리되지 않습니다.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"모델은 계속 메모리를 생성하지만 새 메모리는 지식 그래프에 기록되지 않습니다.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"추출에는 계속 생성 모델이 호출되지만 매일 통합은 중지됩니다.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"현재 생성 모델 호출이 시작되며 제공업체 비용이 발생할 수 있습니다.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"지식 그래프 관계를 사용하지 않고 스마트 메모리를 검색합니다.",
|
||||
"立即改为搜索沉淀后的记忆。": "검색 대상이 즉시 생성된 메모리로 전환됩니다.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"일반 메모리는 계속 사용할 수 있습니다. 심층 메모리가 추가한 상위 메모리와 그래프 데이터는 유지되지만 스마트 메모리에서는 표시되거나 검색되지 않습니다. 원본 대화는 삭제되지 않습니다.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"모든 원본 대화는 유지되며 삭제되지 않습니다.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"대기 중인 대화와 이후의 새 대화에서 메모리를 추출해 지식 그래프에 기록합니다. 이미 처리가 끝난 대화는 다시 처리되지 않습니다.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"추출 방식은 그대로 유지되며 새 메모리는 지식 그래프에 기록됩니다.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"추출에는 생성 모델이 호출됩니다. 매일 통합이 켜져 있으면 설정된 시간에 추가 호출이 발생합니다. 모드 전환만으로 즉시 통합되지는 않습니다.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"생성된 메모리를 검색하고 지식 그래프 관계로 보완합니다.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"기존 메모리가 백그라운드에서 지식 그래프에 추가되며 그동안 계속 사용할 수 있습니다. 원본 대화는 삭제되지 않습니다.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"매일 통합 시간을 설정하거나 언제든 한 번 통합할 수 있습니다.",
|
||||
"记忆整合(Evolve)": "메모리 통합(Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"기존 메모리를 주기적으로 재구성해 관계와 결론을 더 안정적으로 보강합니다.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"이 기기의 현지 시간으로 매일 {time}경 자동 통합합니다. 절전 중 놓친 실행은 기기가 깨어난 뒤 수행됩니다.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"꺼짐. 다시 켜면 {time}에 실행됩니다. 기존 결과는 유지됩니다.",
|
||||
执行时间: "실행 시간",
|
||||
立即整合一次: "지금 한 번 통합",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"한 번만 실행하며 매일 일정을 변경하지 않습니다.",
|
||||
"当前是基础记忆(lite)": "현재 모드: 기본 메모리(lite)",
|
||||
"当前是智能记忆(pro)": "현재 모드: 스마트 메모리(pro)",
|
||||
"当前是深度记忆(ultra)": "현재 모드: 심층 메모리(ultra)",
|
||||
"正在读取记忆模式…": "메모리 모드 읽는 중…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"모델 구성은 유지되며 현재 새 대화를 정리하는 데 사용되지 않습니다. pro 또는 ultra로 전환한 후 정리를 시작할지는 별도의 ‘모델로 기억 생성’ 스위치에 따라 결정됩니다.",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"모델의 메모리 생성 상태를 확인 중입니다. 모델 구성과 기존 메모리는 영향을 받지 않습니다.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"현재 모드는 메모리 생성을 지원하지만 모델 처리가 일시 중지되었습니다. 다시 켜면 보류된 작업을 계속 처리합니다.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"이 모드에서 모델이 활성화되어 새 대화를 정리하고 메모리를 생성합니다.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"모델 구성은 유지됩니다. 메모리 모드가 확인되면 활성화 상태가 표시됩니다.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"생성 모델을 사용하지 않고 원본 대화를 저장하고 검색합니다. 고급 메모리는 만들지 않습니다.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -792,9 +1455,83 @@ export const modeRussianMessages: ModeMessages = {
|
||||
尚未整合: "Ещё не объединялись",
|
||||
立即整合: "Объединить сейчас",
|
||||
"切换到 {mode}?": "Переключиться на {mode}?",
|
||||
"切换到 {mode}": "Переключиться на {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Исходные диалоги сохраняются во всех режимах.",
|
||||
切换模式: "Переключить режим",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Сохранять исходные диалоги · Не вызывать генеративную модель · Искать в исходных диалогах",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Создавать расширенные воспоминания · Вызывать генеративную модель · Искать в созданных воспоминаниях",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Интеллектуальная память + граф знаний · Автоматический Evolve по желанию · Расширенный поиск связей",
|
||||
新对话: "Новые диалоги",
|
||||
模型调用与费用: "Вызовы модели и расходы",
|
||||
搜索范围: "Область поиска",
|
||||
已有数据: "Существующие данные",
|
||||
切换后: "После переключения",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Вызовы генеративной модели и ежедневное объединение прекратятся.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Вызовы генеративной модели прекратятся, поэтому связанные с ними расходы исчезнут.",
|
||||
"立即改为搜索原始对话。": "Поиск сразу переключится на исходные диалоги.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Существующие воспоминания сохранятся, но базовая память не будет показывать или находить их. Исходные диалоги не удаляются.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Диалоги в очереди и будущие диалоги станут воспоминаниями. Уже обработанные диалоги не будут обрабатываться повторно.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"Модель продолжит создавать воспоминания, но новые воспоминания больше не будут записываться в граф знаний.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"Для извлечения по-прежнему вызывается генеративная модель; ежедневное объединение прекратится.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"Начнутся вызовы текущей генеративной модели, за которые поставщик может взимать плату.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"Поиск будет использовать интеллектуальную память без связей из графа знаний.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"Поиск сразу переключится на созданные воспоминания.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Обычные воспоминания останутся доступны. Воспоминания более высокого уровня и данные графа, добавленные глубокой памятью, сохранятся, но не будут показываться или находиться в интеллектуальной памяти. Исходные диалоги не удаляются.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Все исходные диалоги сохраняются и не удаляются.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Из диалогов в очереди и будущих диалогов будут извлекаться воспоминания и записываться в граф знаний. Уже обработанные диалоги не будут обрабатываться повторно.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"Способ извлечения не изменится, а новые воспоминания будут записываться в граф знаний.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"Для извлечения вызывается генеративная модель. Если включено ежедневное объединение, в заданное время будут выполняться дополнительные вызовы. Само переключение не запускает объединение сразу.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"Поиск использует созданные воспоминания и дополняет их связями из графа знаний.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Существующие воспоминания добавляются в граф знаний в фоновом режиме и остаются доступны. Исходные диалоги не удаляются.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Задайте время ежедневного объединения или запустите его один раз в любой момент.",
|
||||
"记忆整合(Evolve)": "Объединение воспоминаний (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Периодически реорганизует существующие воспоминания, укрепляя связи и выводы.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Ежедневно объединяет воспоминания примерно в {time} по местному времени устройства. Пропущенный запуск выполняется после выхода из сна.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Выключено. После повторного включения запуск по-прежнему будет выполняться в {time}. Существующие результаты сохранятся.",
|
||||
执行时间: "Время запуска",
|
||||
立即整合一次: "Объединить один раз сейчас",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Выполняется один раз без изменения ежедневного расписания.",
|
||||
"当前是基础记忆(lite)": "Текущий режим: базовая память (lite)",
|
||||
"当前是智能记忆(pro)": "Текущий режим: интеллектуальная память (pro)",
|
||||
"当前是深度记忆(ultra)": "Текущий режим: глубокая память (ultra)",
|
||||
"正在读取记忆模式…": "Чтение режима памяти…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"Конфигурация модели сохранена и сейчас не используется для упорядочивания новых диалогов. После переключения на pro или ultra начало обработки зависит от отдельного переключателя «Создавать записи памяти с помощью модели».",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Проверяется состояние создания воспоминаний. Конфигурация модели и существующие воспоминания не изменятся.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Этот режим поддерживает создание воспоминаний, но обработка моделью приостановлена. После повторного включения сохранённые задачи продолжатся.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"Модель активна в этом режиме и создаёт воспоминания из новых диалогов.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"Конфигурация модели сохранена. Её состояние появится после подтверждения режима памяти.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Сохраняет и ищет исходные диалоги без генеративной модели. Расширенные воспоминания не создаются.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
@@ -878,8 +1615,82 @@ export const modeIndonesianMessages: ModeMessages = {
|
||||
尚未整合: "Belum dikonsolidasikan",
|
||||
立即整合: "Konsolidasikan sekarang",
|
||||
"切换到 {mode}?": "Beralih ke {mode}?",
|
||||
"切换到 {mode}": "Beralih ke {mode}",
|
||||
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
||||
切换模式: "Beralih mode",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Simpan percakapan asli · Tanpa panggilan model generatif · Cari percakapan asli",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
"Buat memori lanjutan · Panggil model generatif · Cari memori yang dibuat",
|
||||
"智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回":
|
||||
"Memori cerdas + knowledge graph · Evolve otomatis opsional · Pencarian konteks terkait yang lebih baik",
|
||||
新对话: "Percakapan baru",
|
||||
模型调用与费用: "Panggilan model dan biaya",
|
||||
搜索范围: "Cakupan pencarian",
|
||||
已有数据: "Data yang ada",
|
||||
切换后: "Setelah beralih",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"Panggilan model generatif dan konsolidasi harian dihentikan.",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
"Panggilan model generatif dihentikan sehingga biaya penyedia terkait tidak lagi timbul.",
|
||||
"立即改为搜索原始对话。": "Pencarian langsung beralih ke percakapan asli.",
|
||||
"已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。":
|
||||
"Memori yang ada tetap disimpan, tetapi memori dasar tidak menampilkan atau mengambilnya. Percakapan asli tidak dihapus.",
|
||||
"排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。":
|
||||
"Percakapan dalam antrean dan percakapan mendatang akan menjadi memori. Percakapan yang sudah selesai tidak diproses ulang.",
|
||||
"仍会由模型整理成记忆;新记忆不再写入知识图谱。":
|
||||
"Model tetap membuat memori, tetapi memori baru tidak lagi ditulis ke knowledge graph.",
|
||||
"提取仍会调用生成模型;每天自动整合会停止。":
|
||||
"Ekstraksi tetap memanggil model generatif; konsolidasi harian dihentikan.",
|
||||
"开始调用当前生成模型,可能产生服务商费用。":
|
||||
"Model generatif saat ini mulai dipanggil dan dapat menimbulkan biaya penyedia.",
|
||||
"改为搜索智能记忆,不再使用知识图谱补充关联。":
|
||||
"Pencarian menggunakan memori cerdas tanpa relasi dari knowledge graph.",
|
||||
"立即改为搜索沉淀后的记忆。":
|
||||
"Pencarian langsung beralih ke memori yang dibuat.",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。":
|
||||
"Memori biasa tetap tersedia. Memori tingkat tinggi dan data graf yang ditambahkan oleh memori mendalam tetap disimpan, tetapi tidak ditampilkan atau diambil dalam memori cerdas. Percakapan asli tidak dihapus.",
|
||||
"原始对话全部保留,不会删除。":
|
||||
"Semua percakapan asli tetap disimpan dan tidak dihapus.",
|
||||
"排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。":
|
||||
"Percakapan dalam antrean dan percakapan mendatang menghasilkan memori yang ditulis ke knowledge graph. Percakapan yang sudah selesai tidak diproses ulang.",
|
||||
"提取方式不变,新记忆会写入知识图谱。":
|
||||
"Ekstraksi tetap sama dan memori baru ditulis ke knowledge graph.",
|
||||
"提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。":
|
||||
"Ekstraksi memanggil model generatif. Jika konsolidasi harian aktif, panggilan tambahan dilakukan pada waktu yang ditetapkan. Peralihan tidak langsung menjalankan konsolidasi.",
|
||||
"搜索沉淀后的记忆,并用知识图谱补充关联。":
|
||||
"Pencarian menggunakan memori yang dibuat dan melengkapinya dengan relasi dari knowledge graph.",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。":
|
||||
"Memori yang ada ditambahkan ke knowledge graph di latar belakang dan tetap dapat digunakan. Percakapan asli tidak dihapus.",
|
||||
"可设置每天自动整合时间,也可以随时立即整合一次。":
|
||||
"Tetapkan waktu konsolidasi harian atau jalankan satu konsolidasi kapan saja.",
|
||||
"记忆整合(Evolve)": "Konsolidasi memori (Evolve)",
|
||||
"定期重新整理已有记忆,补充更稳定的关联和结论。":
|
||||
"Susun ulang memori yang ada secara berkala untuk memperkuat relasi dan kesimpulan.",
|
||||
"每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。":
|
||||
"Konsolidasi otomatis setiap hari sekitar {time} menurut waktu lokal perangkat ini. Proses yang terlewat dijalankan setelah perangkat aktif kembali.",
|
||||
"已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。":
|
||||
"Nonaktif. Setelah diaktifkan kembali, proses tetap berjalan pukul {time}. Hasil yang ada tetap disimpan.",
|
||||
执行时间: "Waktu pelaksanaan",
|
||||
立即整合一次: "Konsolidasikan sekali sekarang",
|
||||
"只运行这一次,不改变每天的计划。":
|
||||
"Berjalan sekali tanpa mengubah jadwal harian.",
|
||||
"当前是基础记忆(lite)": "Mode saat ini: memori dasar (lite)",
|
||||
"当前是智能记忆(pro)": "Mode saat ini: memori cerdas (pro)",
|
||||
"当前是深度记忆(ultra)": "Mode saat ini: memori mendalam (ultra)",
|
||||
"正在读取记忆模式…": "Membaca mode memori…",
|
||||
"模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。":
|
||||
"Konfigurasi model tetap disimpan dan saat ini tidak digunakan untuk menata percakapan baru. Setelah beralih ke pro atau ultra, apakah penataan dimulai bergantung pada sakelar terpisah “Buat memori dengan model”.",
|
||||
"正在确认模型沉淀状态,模型配置和已有记忆不会受影响。":
|
||||
"Memeriksa status pembuatan memori. Konfigurasi model dan memori yang ada tidak terpengaruh.",
|
||||
"当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。":
|
||||
"Mode ini mendukung pembuatan memori, tetapi pemrosesan model dijeda. Aktifkan kembali untuk melanjutkan tugas yang disimpan.",
|
||||
"模型已在当前模式下生效,会用于整理新对话并沉淀记忆。":
|
||||
"Model aktif dalam mode ini dan membuat memori dari percakapan baru.",
|
||||
"模型配置已保留,确认记忆模式后会显示是否生效。":
|
||||
"Konfigurasi model tetap disimpan. Status aktifnya muncul setelah mode memori dikonfirmasi.",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Simpan dan cari percakapan asli tanpa model generatif. Memori lanjutan tidak dibuat.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
|
||||
@@ -174,6 +174,19 @@ export const settingsSpanishMessages = {
|
||||
设置分组: "Secciones de configuración",
|
||||
记忆模型: "Modelo de memorias",
|
||||
当前模型: "Modelo actual",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Gestiona por separado los modelos usados para organizar y buscar recuerdos.",
|
||||
记忆整理: "Organización de recuerdos",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Convierte las conversaciones en recuerdos útiles a largo plazo.",
|
||||
记忆搜索: "Búsqueda de recuerdos",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Encuentra los recuerdos más relevantes para tu pregunta cuando los necesitas.",
|
||||
"本地内置(推荐)": "Integrado localmente (recomendado)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Personalizable tras configurar la organización de recuerdos",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Procesamiento local, sin configuración adicional",
|
||||
"正在读取模型…": "Leyendo el modelo…",
|
||||
模型状态暂不可读: "Estado del modelo no disponible",
|
||||
尚未配置模型: "No hay un modelo configurado",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchGermanMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeGermanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** German (de, formal Sie) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const germanMessages: Record<MessageKey, string> = {
|
||||
...modeGermanMessages,
|
||||
...embeddingSwitchGermanMessages,
|
||||
"connectionFallback.title": "Andere Tools verbinden",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Lokale Kopplung ist nur in der Desktop-App verfügbar.",
|
||||
@@ -986,6 +988,19 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
设置分组: "Einstellungsbereiche",
|
||||
记忆模型: "Erinnerungsmodell",
|
||||
当前模型: "Aktuelles Modell",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Verwalten Sie die Modelle für die Aufbereitung und Suche von Erinnerungen getrennt.",
|
||||
记忆整理: "Erinnerungen aufbereiten",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Verarbeitet Unterhaltungen zu langfristig nutzbaren Erinnerungen.",
|
||||
记忆搜索: "Erinnerungen durchsuchen",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Findet bei Bedarf die Erinnerungen, die für Ihre Frage am relevantesten sind.",
|
||||
"本地内置(推荐)": "Lokal integriert (empfohlen)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Nach Einrichtung der Erinnerungsaufbereitung anpassbar",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Lokale Verarbeitung, keine zusätzliche Einrichtung erforderlich",
|
||||
"正在读取模型…": "Modell wird geladen…",
|
||||
模型状态暂不可读: "Modellstatus nicht verfügbar",
|
||||
尚未配置模型: "Kein Modell eingerichtet",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchFrenchMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeFrenchMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** French (fr, vous) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const frenchMessages: Record<MessageKey, string> = {
|
||||
...modeFrenchMessages,
|
||||
...embeddingSwitchFrenchMessages,
|
||||
"connectionFallback.title": "Connecter d’autres outils",
|
||||
"connectionFallback.desktopOnly":
|
||||
"L’association locale n’est disponible que dans l’application de bureau.",
|
||||
@@ -998,6 +1000,19 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
设置分组: "Sections des paramètres",
|
||||
记忆模型: "Modèle de mémoire",
|
||||
当前模型: "Modèle actuel",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Gérez séparément les modèles utilisés pour organiser et rechercher les mémoires.",
|
||||
记忆整理: "Organisation des mémoires",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Transforme les conversations en mémoires utiles à long terme.",
|
||||
记忆搜索: "Recherche de mémoires",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Trouve, au besoin, les mémoires les plus pertinentes pour votre question.",
|
||||
"本地内置(推荐)": "Intégré en local (recommandé)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Personnalisable après la configuration de l’organisation des mémoires",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Traitement local, sans configuration supplémentaire",
|
||||
"正在读取模型…": "Chargement du modèle…",
|
||||
模型状态暂不可读: "L’état du modèle est indisponible",
|
||||
尚未配置模型: "Aucun modèle configuré",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchIndonesianMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeIndonesianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Indonesian (id, Anda) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const indonesianMessages: Record<MessageKey, string> = {
|
||||
...modeIndonesianMessages,
|
||||
...embeddingSwitchIndonesianMessages,
|
||||
"connectionFallback.title": "Hubungkan alat lain",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Pemasangan lokal hanya tersedia di aplikasi desktop.",
|
||||
@@ -969,6 +971,19 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
设置分组: "Bagian pengaturan",
|
||||
记忆模型: "Model memori",
|
||||
当前模型: "Model saat ini",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Kelola secara terpisah model yang digunakan untuk mengolah dan mencari memori.",
|
||||
记忆整理: "Pengolahan memori",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Mengubah percakapan menjadi memori yang dapat digunakan dalam jangka panjang.",
|
||||
记忆搜索: "Pencarian memori",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Menemukan memori yang paling relevan dengan pertanyaan Anda saat diperlukan.",
|
||||
"本地内置(推荐)": "Bawaan lokal (disarankan)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Dapat disesuaikan setelah pengolahan memori dikonfigurasi",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Diproses secara lokal, tanpa konfigurasi tambahan",
|
||||
"正在读取模型…": "Memuat model…",
|
||||
模型状态暂不可读: "Status model tidak tersedia",
|
||||
尚未配置模型: "Belum ada model yang dikonfigurasi",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchJapaneseMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeJapaneseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Japanese (ja) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const japaneseMessages: Record<MessageKey, string> = {
|
||||
...modeJapaneseMessages,
|
||||
...embeddingSwitchJapaneseMessages,
|
||||
"connectionFallback.title": "ほかのツールを接続",
|
||||
"connectionFallback.desktopOnly":
|
||||
"ローカルのペアリングはデスクトップアプリでのみ利用できます。",
|
||||
@@ -966,6 +968,19 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
设置分组: "設定のセクション",
|
||||
记忆模型: "記憶モデル",
|
||||
当前模型: "現在のモデル",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"記憶の整理と検索に使うモデルを個別に管理します。",
|
||||
记忆整理: "記憶の整理",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"会話を理解し、長期的に活用できる記憶として整理します。",
|
||||
记忆搜索: "記憶の検索",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"必要なときに、質問に最も関連する記憶を見つけます。",
|
||||
"本地内置(推荐)": "ローカル内蔵(推奨)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · 記憶の整理を設定後にカスタマイズ可能",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · このデバイスで処理、追加設定不要",
|
||||
"正在读取模型…": "モデルを読み込み中…",
|
||||
模型状态暂不可读: "モデルの状態を取得できません",
|
||||
尚未配置模型: "モデル未設定",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchKoreanMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeKoreanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Korean (ko) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const koreanMessages: Record<MessageKey, string> = {
|
||||
...modeKoreanMessages,
|
||||
...embeddingSwitchKoreanMessages,
|
||||
"connectionFallback.title": "다른 도구 연결",
|
||||
"connectionFallback.desktopOnly":
|
||||
"로컬 페어링은 데스크톱 앱에서만 사용할 수 있습니다.",
|
||||
@@ -944,6 +946,19 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
设置分组: "설정 섹션",
|
||||
记忆模型: "기억 모델",
|
||||
当前模型: "현재 모델",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"기억 정리와 검색에 사용할 모델을 각각 관리합니다.",
|
||||
记忆整理: "기억 정리",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"대화를 이해하고 장기간 활용할 수 있는 기억으로 정리합니다.",
|
||||
记忆搜索: "기억 검색",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"필요할 때 질문과 가장 관련성 높은 기억을 찾습니다.",
|
||||
"本地内置(推荐)": "로컬 내장(권장)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · 기억 정리 구성 후 사용자 지정 가능",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · 로컬 처리, 추가 구성 필요 없음",
|
||||
"正在读取模型…": "모델을 불러오는 중…",
|
||||
模型状态暂不可读: "모델 상태를 확인할 수 없습니다",
|
||||
尚未配置模型: "구성된 모델이 없습니다",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchPortugueseMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modePortugueseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Portuguese (pt, Brazilian usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const portugueseMessages: Record<MessageKey, string> = {
|
||||
...modePortugueseMessages,
|
||||
...embeddingSwitchPortugueseMessages,
|
||||
"connectionFallback.title": "Conectar outras ferramentas",
|
||||
"connectionFallback.desktopOnly":
|
||||
"O pareamento local só está disponível no app para desktop.",
|
||||
@@ -990,6 +992,19 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
设置分组: "Seções das configurações",
|
||||
记忆模型: "Modelo de memória",
|
||||
当前模型: "Modelo atual",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Gerencie separadamente os modelos usados para organizar e pesquisar memórias.",
|
||||
记忆整理: "Organização de memórias",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Transforma conversas em memórias úteis a longo prazo.",
|
||||
记忆搜索: "Pesquisa de memórias",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"Encontra, quando necessário, as memórias mais relevantes para sua pergunta.",
|
||||
"本地内置(推荐)": "Integrado localmente (recomendado)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Personalizável após configurar a organização de memórias",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Processamento local, sem configuração adicional",
|
||||
"正在读取模型…": "Carregando modelo…",
|
||||
模型状态暂不可读: "O status do modelo não está disponível",
|
||||
尚未配置模型: "Nenhum modelo configurado",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchRussianMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeRussianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Russian (ru) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const russianMessages: Record<MessageKey, string> = {
|
||||
...modeRussianMessages,
|
||||
...embeddingSwitchRussianMessages,
|
||||
"connectionFallback.title": "Подключить другие инструменты",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Локальное сопряжение доступно только в настольном приложении.",
|
||||
@@ -974,6 +976,19 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
设置分组: "Разделы настроек",
|
||||
记忆模型: "Модель памяти",
|
||||
当前模型: "Текущая модель",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"Отдельно управляйте моделями для обработки и поиска записей памяти.",
|
||||
记忆整理: "Обработка памяти",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"Преобразует диалоги в записи памяти для долгосрочного использования.",
|
||||
记忆搜索: "Поиск по памяти",
|
||||
"在需要时找到与你的问题最相关的记忆。":
|
||||
"При необходимости находит записи памяти, наиболее релевантные вашему вопросу.",
|
||||
"本地内置(推荐)": "Встроенная локальная модель (рекомендуется)",
|
||||
"Embedding · 配置记忆整理后可自定义":
|
||||
"Embedding · Настраивается после конфигурации обработки памяти",
|
||||
"Embedding · 本机处理,无需额外配置":
|
||||
"Embedding · Локальная обработка, дополнительная настройка не требуется",
|
||||
"正在读取模型…": "Загрузка модели…",
|
||||
模型状态暂不可读: "Статус модели недоступен",
|
||||
尚未配置模型: "Модель не настроена",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { embeddingSwitchTraditionalChineseMessages } from "../gui-i18n-embedding-switch.js";
|
||||
import { modeTraditionalChineseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Traditional Chinese (zh-Hant, Taiwan usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
...modeTraditionalChineseMessages,
|
||||
...embeddingSwitchTraditionalChineseMessages,
|
||||
"connectionFallback.title": "連線其他工具",
|
||||
"connectionFallback.desktopOnly": "本機配對僅在桌面 App 中可用。",
|
||||
"connectionFallback.description":
|
||||
@@ -877,6 +879,16 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
设置分组: "設定分組",
|
||||
记忆模型: "記憶模型",
|
||||
当前模型: "目前模型",
|
||||
"分别管理记忆整理与搜索所使用的模型。":
|
||||
"分別管理記憶整理與搜尋所使用的模型。",
|
||||
记忆整理: "記憶整理",
|
||||
"把会话理解并整理成可长期使用的记忆。":
|
||||
"將對話理解並整理成可長期使用的記憶。",
|
||||
记忆搜索: "記憶搜尋",
|
||||
"在需要时找到与你的问题最相关的记忆。": "需要時找出與你的問題最相關的記憶。",
|
||||
"本地内置(推荐)": "本機內建(建議)",
|
||||
"Embedding · 配置记忆整理后可自定义": "Embedding · 設定記憶整理後可自訂",
|
||||
"Embedding · 本机处理,无需额外配置": "Embedding · 本機處理,無需額外設定",
|
||||
"正在读取模型…": "正在讀取模型…",
|
||||
模型状态暂不可读: "模型狀態暫不可讀",
|
||||
尚未配置模型: "尚未設定模型",
|
||||
|
||||
@@ -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