Compare commits

...
Author SHA1 Message Date
kristinhe 01ace26d08 Merge remote-tracking branch 'origin/main' into feat/memhub-mode-projection
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/core/src/one_memory/api.py
#	memhub/apps/core/src/one_memory/sdk_write_processor.py
#	memhub/apps/core/tests/test_memory_mode_and_evolve.py
#	memhub/apps/web/src/ApplicationsHome.tsx
#	memhub/apps/web/src/MemorySpace.tsx
2026-11-04 03:17:38 +08:00
kristinheandCursor ba2f647a30 feat(memhub): project home by memory mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 02:58:32 +08:00
kristinhe1111 82d9514d62 Merge pull request #297 from Tencent-Hunyuan/fix/memhub-lite-raw-conversations
fix(memhub): preserve raw conversations in lite mode
2026-11-04 01:22:04 +08:00
kristinhe1111 369351719d Merge pull request #295 from Tencent-Hunyuan/fix/memhub-model-center-main
fix(memhub): land Model Center on main
2026-11-04 00:28:38 +08:00
kristinheandCursor 1429ba2d19 fix(memhub): close model center settings gaps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:22:25 +08:00
kristinheandCursor 17441d368c fix(memhub): harden settings state recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:39:47 +08:00
kristinheandCursor 89d29c9c41 feat(memhub): redesign memory mode settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:27:00 +08:00
kristinhe eac795b2d2 Merge remote-tracking branch 'origin/main' into fix/memhub-model-center-main 2026-11-03 20:55:54 +08:00
kristinhe 23b74f91bc Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:41:41 +08:00
kristinhe a8b92d21e1 Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:35:22 +08:00
kristinheandCursor 1663c9424e fix(memhub): align model center with R6 visuals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:31:00 +08:00
kristinhe f754f8bc0f Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center
Co-authored-by: Cursor <cursoragent@cursor.com>

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

No files matched your search

+3
View File
@@ -17,11 +17,14 @@ const READ_ONLY_PATHS = [
"/api/v1/model-profiles",
"/api/v1/source-grants",
"/api/v1/sources",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/user-profile",
"/api/v2/user-profile/versions",
"/api/v1/settings/memory-mode",
"/api/v1/settings/memory-mode/plan",
"/api/v1/settings/memory-features",
"/api/v1/settings/ui-language",
"/api/v2/evolve",
+175
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
ImportIssue,
ImportJob,
LiveSyncHealth,
@@ -26,6 +27,9 @@ export const MEMORY_STATUSES = [
const ID_MAX = 128;
const LABEL_MAX = 256;
const LIST_MAX = 500;
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
const ISO_TIME =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
const LIVE_SYNC_STATES = [
"idle",
"running",
@@ -406,6 +410,153 @@ export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
return summary as unknown as SdkHomeSummary;
}
/** `GET /api/v2/home-projection`: the mode-aware home content plane. */
export function validateHomeProjection(
value: unknown,
): HomeProjectionV1 | null {
const projection = exactRecord(value, [
"projection_version",
"user_id",
"effective_mode",
"capabilities",
"visible_count",
"display_state",
"latest",
"observed_at",
]);
if (!projection) return null;
const mode = projection.effective_mode;
const capabilities = exactRecord(projection.capabilities, [
"content_plane",
"generation_required",
"graph_enabled",
"evolve_available",
]);
if (
projection.projection_version !== 1 ||
typeof projection.user_id !== "string" ||
!USER_ID.test(projection.user_id) ||
!oneOf(mode, ["lite", "pro", "ultra"] as const) ||
!capabilities ||
capabilities.content_plane !== (mode === "lite" ? "raw" : "memory") ||
capabilities.generation_required !== (mode !== "lite") ||
capabilities.graph_enabled !== (mode === "ultra") ||
capabilities.evolve_available !== (mode === "ultra") ||
count(projection.visible_count) === null ||
!oneOf(projection.display_state, [
"empty",
"excerpt",
"no_user_excerpt",
"no_displayable_memory",
] as const) ||
!isoTime(projection.observed_at) ||
!validProjectionLatest(projection.latest, mode)
) {
return null;
}
const visible = projection.visible_count as number;
const latest = projection.latest as Record<string, unknown> | null;
if (
(visible === 0 &&
(latest !== null || projection.display_state !== "empty")) ||
(visible > 0 && projection.display_state === "empty") ||
(latest === null &&
["excerpt", "no_user_excerpt"].includes(
projection.display_state as string,
)) ||
(latest?.type === "raw" &&
projection.display_state !== latest.display_state) ||
(latest?.type === "memory" && projection.display_state !== "excerpt")
) {
return null;
}
return projection as unknown as HomeProjectionV1;
}
function validProjectionLatest(
value: unknown,
mode: "lite" | "pro" | "ultra",
): boolean {
if (value === null) return true;
const base = [
"type",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
];
const candidate = record(value);
if (!candidate) return false;
const keys =
candidate.type === "raw"
? [...base, "raw_id", "session_id", "display_state"]
: [...base, "memory_id", "scene", "kind", "variant"];
const latest = exactRecord(value, keys);
if (
!latest ||
!(latest.time === null || isoTime(latest.time)) ||
![null, "conversation", "import", "write", "update"].includes(
latest.time_basis as null | string,
) ||
text(latest.excerpt, 200) === null ||
typeof latest.truncated !== "boolean" ||
text(latest.source_id, ID_MAX) === null
) {
return false;
}
if (latest.type === "raw") {
return (
mode === "lite" &&
nonemptyText(latest.raw_id, ID_MAX) &&
nonemptyText(latest.session_id, LABEL_MAX) &&
oneOf(latest.display_state, ["excerpt", "no_user_excerpt"] as const) &&
(latest.display_state !== "no_user_excerpt" ||
(latest.excerpt === "" && latest.truncated === false))
);
}
return (
latest.type === "memory" &&
mode !== "lite" &&
nonemptyText(latest.memory_id, ID_MAX) &&
oneOf(latest.scene, ["chat", "working", "coding"] as const) &&
oneOf(latest.kind, ["chat", "experience"] as const) &&
[null, "working", "coding"].includes(latest.variant as null | string) &&
((latest.kind === "chat" &&
latest.scene === "chat" &&
latest.variant === null) ||
(latest.kind === "experience" &&
latest.scene === latest.variant &&
oneOf(latest.scene, ["working", "coding"] as const)))
);
}
/** The bounded `/api/v2/raw?limit=1` envelope used only for its real total. */
export function validateRawTotal(value: unknown): number | null {
const page = exactRecord(value, [
"user_id",
"items",
"total",
"offset",
"limit",
"time_filtered",
]);
if (
!page ||
typeof page.user_id !== "string" ||
!USER_ID.test(page.user_id) ||
!Array.isArray(page.items) ||
page.items.length > 1 ||
count(page.total) === null ||
page.offset !== 0 ||
page.limit !== 1 ||
page.time_filtered !== false
) {
return null;
}
return page.total as number;
}
function identifiers(value: unknown): string[] | null {
return list(value ?? [], (item) => text(item, ID_MAX));
}
@@ -430,6 +581,30 @@ function record(value: unknown): Record<string, unknown> | null {
: null;
}
function exactRecord(
value: unknown,
keys: readonly string[],
): Record<string, unknown> | null {
const item = record(value);
return item &&
Object.keys(item).length === keys.length &&
keys.every((key) => Object.hasOwn(item, key))
? item
: null;
}
function isoTime(value: unknown): value is string {
return (
typeof value === "string" &&
ISO_TIME.test(value) &&
Number.isFinite(Date.parse(value))
);
}
function nonemptyText(value: unknown, max: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
function text(value: unknown, max: number): string | null {
return typeof value === "string" && value.length <= max ? value : null;
}
@@ -250,6 +250,20 @@ describe("models activate with an embedding change", () => {
code: "embedding_switch_required",
});
});
it("does not treat a domain 404 as an old-Core route miss", async () => {
transport.post.mockResolvedValue({
status: 404,
data: { detail: "embedding_switch_not_found" },
});
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
expect(result.code).toBe(1);
expect(
transport.post.mock.calls.some(([url]) =>
String(url).endsWith("/activate"),
),
).toBe(false);
});
});
describe("models switch and spaces", () => {
+6 -1
View File
@@ -9,6 +9,7 @@
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
*/
import { isBareNotFound } from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type {
CoreDeleteOperations,
@@ -387,7 +388,11 @@ async function requestPlan(
}
if (outcome.state === "rejected") {
// A Core before the registry has no plan route: activation as before.
if (outcome.http_status === 404 && outcome.code === "http_404") {
if (
outcome.http_status === 404 &&
outcome.code === "http_404" &&
isBareNotFound(outcome.body)
) {
return { state: "unsupported" };
}
const plan = parsePlan(record(outcome.body)?.plan);
+11 -1
View File
@@ -161,6 +161,7 @@ const CORE = new Map<string, { status: number; data: unknown }>([
blocked_stages: [
{ kind: "extract", reason: "model_missing", count: 2 },
],
pipeline: { mode: "pro", configured_mode: "pro" },
},
},
],
@@ -202,7 +203,9 @@ const READ_ONLY_PATHS = [
"/api/v1/live-sync",
"/api/v1/memory-processing",
"/api/v1/model-profiles",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/memories/",
"/api/v2/user-profile",
@@ -257,6 +260,9 @@ describe("CLI-20 status summary", () => {
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "memory",
visible_count: 42,
effective_mode: "pro",
memory_count: 42,
});
expect(report.summary.state).toBe("ok");
@@ -903,7 +909,10 @@ describe("CLI-21 memory reads", () => {
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-projection")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
expect(isReadOnlyPath("/api/v2/raw")).toBe(true);
expect(isReadOnlyPath("/api/v1/settings/memory-mode/plan")).toBe(true);
// The retired v1 data plane is no longer a CLI read.
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
@@ -1085,7 +1094,8 @@ describe("malformed success responses", () => {
it("refuses a model profile whose string fields are not strings", async () => {
transport.get.mockImplementation(async (url: string) => {
if (url !== "/api/v1/model-profiles") return CORE.get(url);
if (url !== "/api/v1/model-profiles")
return CORE.get(url) ?? { status: 404, data: { detail: "not_found" } };
return {
status: 200,
data: [
+137
View File
@@ -147,3 +147,140 @@ describe("status and doctor on an unfinished upgrade migration", () => {
expect(report.runtime.log_file).toBeUndefined();
});
});
describe("mode-aware status content projection", () => {
const runtime = {
state: "running" as const,
code: "runtime_verified",
host: "127.0.0.1",
port: 30001,
};
const processing = (mode: "lite" | "pro" | "ultra") => ({
pending: 0,
fresh_pending: 0,
backlog_pending: 0,
outside_window_pending: 0,
running: 0,
waiting_for_model: 0,
completed: 0,
failed: 0,
pipeline: { mode, configured_mode: mode },
});
const projection = {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
display_state: "excerpt",
time: null,
time_basis: null,
excerpt: "hello",
truncated: false,
source_id: "cursor",
},
observed_at: "2026-11-04T00:00:00Z",
};
it("uses the projection without reading either legacy count endpoint", async () => {
const calls: string[] = [];
const reader: CoreReader = async <T>(path: string) => {
calls.push(path);
const data =
path === "/api/v2/home-projection"
? projection
: path === "/api/v1/memory-processing"
? processing("lite")
: null;
return { state: "ok", data } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "ok",
content_plane: "raw",
visible_count: 7,
effective_mode: "lite",
raw_count: 7,
});
expect(calls).not.toContain("/api/v2/raw");
expect(calls).not.toContain("/api/v2/home-summary");
expect(renderStatusText(report, "MemHub")).toContain(
"原始对话:原始对话 7",
);
});
it("falls back to the real raw total only for an old Lite Core", async () => {
const reader: CoreReader = async <T>(path: string) => {
if (path === "/api/v2/home-projection")
return { state: "not_found", code: "http_404" } as ReadOutcome<T>;
if (path === "/api/v1/memory-processing")
return {
state: "ok",
data: processing("lite"),
} as ReadOutcome<T>;
if (path === "/api/v2/raw")
return {
state: "ok",
data: {
user_id: "local",
items: [],
total: 9,
offset: 0,
limit: 1,
time_filtered: false,
},
} as ReadOutcome<T>;
return { state: "unavailable", code: "x" } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "raw",
visible_count: 9,
raw_count: 9,
});
expect(report.sections.memories).not.toHaveProperty("memory_count");
});
it("rejects extra projection fields instead of falling back or leaking them", async () => {
const reader: CoreReader = async <T>(path: string) =>
({
state: "ok",
data:
path === "/api/v2/home-projection"
? { ...projection, secret: "must-not-leak" }
: processing("lite"),
}) as ReadOutcome<T>;
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "invalid",
code: "response_invalid",
});
expect(JSON.stringify(report)).not.toContain("must-not-leak");
});
});
+86 -8
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
LiveSyncHealth,
MemoryProcessingSummary,
ModelProfile,
@@ -12,10 +13,12 @@ import { sdkMigrationLogPath } from "@one-memory/runtime-control";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import {
type SdkHomeSummary,
validateHomeProjection,
validateHomeSummary,
validateLiveSync,
validateModelProfiles,
validateProcessing,
validateRawTotal,
validateSources,
} from "./core-responses.js";
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
@@ -211,12 +214,12 @@ export async function composeStatusReport(options: {
for (const name of SECTION_NAMES) sections[name] = { state, code };
} else {
const reader = options.reader;
const [sources, sync, processing, model, memories] = await Promise.all([
const [sources, sync, processing, model, projection] = await Promise.all([
reader<unknown>("/api/v1/sources"),
reader<unknown>("/api/v1/live-sync"),
reader<unknown>("/api/v1/memory-processing"),
reader<unknown>("/api/v1/model-profiles"),
reader<unknown>("/api/v2/home-summary"),
reader<unknown>("/api/v2/home-projection"),
]);
sections.sources = section(sources, validateSources, describeSources);
sections.sync = section(sync, validateLiveSync, describeSync);
@@ -228,11 +231,7 @@ export async function composeStatusReport(options: {
pipeline =
processing.state === "ok" ? describePipeline(processing.data) : null;
sections.model = section(model, validateModelProfiles, describeModel);
sections.memories = section(
memories,
validateHomeSummary,
describeMemories,
);
sections.memories = await memorySection(reader, projection, pipeline);
}
// A bridge we could not inspect is unknown, not broken and not fine.
const bridge: StatusDocument["bridge"] =
@@ -312,7 +311,9 @@ export function renderStatusText(
];
for (const name of SECTION_NAMES) {
lines.push(
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
`${sectionLabel(name, report.sections[name])}:${sectionLine(
report.sections[name],
)}`,
);
}
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
@@ -409,8 +410,17 @@ const SECTION_LINE_OMITTED = new Set([
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
"content_plane",
"visible_count",
"effective_mode",
]);
function sectionLabel(name: string, section: Section | undefined): string {
return name === "memories" && section?.content_plane === "raw"
? "原始对话"
: SECTION_LABELS[name];
}
function sectionLine(section: Section | undefined): string {
if (!section) return "未检查";
if (section.state === "not_checked") return "未检查";
@@ -462,6 +472,10 @@ function fieldLabel(key: string): string {
profiles: "配置",
active: "当前",
memory_count: "记忆",
raw_count: "原始对话",
visible_count: "可见",
content_plane: "内容层",
effective_mode: "模式",
};
return labels[key] ?? key;
}
@@ -595,6 +609,70 @@ function describeMemories(summary: SdkHomeSummary): Record<string, unknown> {
};
}
async function memorySection(
reader: CoreReader,
projection: ReadOutcome<unknown>,
pipeline: PipelineSummary | null,
): Promise<Section> {
if (projection.state === "ok") {
const parsed = validateHomeProjection(projection.data);
return parsed === null
? { state: "invalid", code: "response_invalid" }
: { state: "ok", ...describeProjection(parsed) };
}
if (projection.state !== "not_found") {
return {
state: projection.state === "denied" ? "denied" : "unavailable",
code: projection.code,
};
}
if (pipeline === null) {
return { state: "unavailable", code: "effective_mode_unavailable" };
}
if (pipeline.mode === "lite") {
const raw = await reader<unknown>("/api/v2/raw", {
limit: 1,
offset: 0,
});
if (raw.state !== "ok") {
return {
state: raw.state === "denied" ? "denied" : "unavailable",
code: raw.code,
};
}
const total = validateRawTotal(raw.data);
return total === null
? { state: "invalid", code: "response_invalid" }
: {
state: "ok",
content_plane: "raw",
visible_count: total,
effective_mode: "lite",
raw_count: total,
};
}
const legacy = await reader<unknown>("/api/v2/home-summary");
return section(legacy, validateHomeSummary, (summary) => ({
content_plane: "memory",
visible_count: summary.memory_count,
effective_mode: pipeline.mode,
...describeMemories(summary),
}));
}
function describeProjection(
projection: HomeProjectionV1,
): Record<string, unknown> {
return {
content_plane: projection.capabilities.content_plane,
visible_count: projection.visible_count,
effective_mode: projection.effective_mode,
...(projection.capabilities.content_plane === "raw"
? { raw_count: projection.visible_count }
: { memory_count: projection.visible_count }),
};
}
function sum(
sources: SourceResponse[],
key: "imported_conversations" | "imported_messages",
+128 -29
View File
@@ -323,6 +323,13 @@ from one_memory.ui_language import (
UiLanguage,
sdk_language,
)
from one_memory.ui_projection import (
HomeProjectionCapabilities,
HomeProjectionV1,
home_capabilities,
raw_list_filters,
read_home_projection,
)
from one_memory.user_scope import (
DEFAULT_USER_ID,
MULTI_USER_SOURCES,
@@ -640,11 +647,6 @@ class SdkLayerCounts(BaseModel):
statuses: tuple[str, ...] | None = None
#: Raw lifecycle states that are still a kept conversation. Pro marks a raw
#: SHADOW once extraction succeeds; lite leaves it ACTIVE.
RAW_STATUSES = ("active", "shadow")
class MemoryProcessingBlock(BaseModel):
kind: str
reason: str
@@ -771,6 +773,29 @@ class MemoryModeChange(MemoryModeState):
effects: tuple[MemoryModeEffect, ...] = ()
MemoryModeBlocker = Literal[
"active_model_required",
"embedding_maintenance_in_progress",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
MemoryModeWarning = Literal["forced_by_env", "effective_no_change"]
class MemoryModePlan(BaseModel):
"""Shared validation result used by plan and PUT."""
model_config = ConfigDict(extra="forbid")
current: MemoryMode
target: MemoryMode
effective_target: MemoryMode
capabilities: HomeProjectionCapabilities
effects: tuple[MemoryModeEffect, ...] = ()
warnings: tuple[MemoryModeWarning, ...] = ()
blockers: tuple[MemoryModeBlocker, ...] = ()
class EvolveScheduleUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -3846,6 +3871,30 @@ def create_app(
app.state.memory_task_store.pause_profile_for_model(profile_id)
return Response(status_code=204)
@app.get(
"/api/v2/home-projection",
response_model=HomeProjectionV1,
dependencies=strict,
)
async def home_projection(
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> HomeProjectionV1:
"""The mode-neutral homepage read model owned by Core."""
try:
mode = _memory_mode(app)
handle = _sdk_client_handle(app)
if isinstance(handle, ActiveProfileReadHandle):
client = await handle.client_for_mode(mode)
else:
client = await handle.client()
return await read_home_projection(
client,
user_id=user_id,
mode=mode,
)
except Exception as error:
raise _sdk_http_error(error, "home_projection_read_failed") from None
@app.get(
"/api/v2/home-summary",
response_model=SdkHomeSummary,
@@ -4012,7 +4061,9 @@ def create_app(
client = await _sdk_client_handle(app).client()
def count(layer: str) -> int:
layer_statuses = chosen or (list(RAW_STATUSES) if layer == "l1_raw" else ["active"])
layer_statuses = chosen or (
raw_list_filters()["statuses"] if layer == "l1_raw" else ["active"]
)
raw = client.list_memories(
user_id=user_id,
layers=[layer],
@@ -4397,9 +4448,10 @@ def create_app(
time, else the stored time) have no SDK list filter: they cut this
page in Core and mark it `time_filtered`.
"""
chosen_statuses = _library_values(
statuses or [], MEMORY_STATUSES, "memory_status_unknown"
) or list(RAW_STATUSES)
chosen_statuses = (
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
@@ -4408,8 +4460,7 @@ def create_app(
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
layers=["l1_raw"],
statuses=chosen_statuses,
**raw_list_filters(chosen_statuses),
include_sources=sources or None,
exclude_sources=excluded or None,
session_ids=sessions or None,
@@ -4692,6 +4743,15 @@ def create_app(
"""The SDK mode (lite / pro / ultra): configured, effective, and why."""
return _memory_mode_state(app)
@app.post(
"/api/v1/settings/memory-mode/plan",
response_model=MemoryModePlan,
dependencies=strict,
)
def plan_memory_mode(request: MemoryModeUpdate) -> MemoryModePlan:
"""Validate and explain a mode change without mutating anything."""
return _memory_mode_plan(app, request.mode)
@app.put(
"/api/v1/settings/memory-mode",
response_model=MemoryModeChange,
@@ -4710,17 +4770,12 @@ def create_app(
added to the graph once, in the background (`graph_build`). No raw is
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
"""
_refuse_pipeline_change_during_embedding_maintenance(app)
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
raise HTTPException(status_code=409, detail="graph_store_unavailable")
store = _config_store(app)
async with app.state.memory_mode_lock:
_refuse_pipeline_change_during_embedding_maintenance(app)
previous_effective = _memory_mode(app)
if request.mode != previous_effective and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
plan = _memory_mode_plan(app, request.mode)
if plan.blockers:
raise HTTPException(status_code=409, detail=plan.blockers[0])
previous_effective = plan.current
previous = store.memory_mode()
if previous != request.mode or not store.memory_mode_explicitly_configured():
await asyncio.to_thread(store.set_memory_mode, request.mode)
@@ -5016,16 +5071,16 @@ def create_app(
"""Start or stop model-driven sedimentation, keeping the configuration.
Stopping is the user's cost control: nothing further goes to the model
and already queued work waits rather than failing, so starting again
resumes it. Importing conversations is not affected either way.
and already queued work remains pending rather than failing. Starting
again lets the normal claim tick continue it; this route never broadly
re-admits permission/model parked jobs. Importing conversations is not
affected either way.
"""
store = _config_store(app)
changed = False
async with app.state.sedimentation_lock:
previous = store.sedimentation_enabled()
if previous is not request.enabled:
await asyncio.to_thread(store.set_sedimentation_enabled, request.enabled)
changed = True
diagnostic_event(
level="info",
component="memory.sedimentation",
@@ -5033,11 +5088,6 @@ def create_app(
outcome="succeeded",
details={"enabled": request.enabled},
)
if changed and request.enabled:
# Work parked by the stop is picked up again rather than waiting
# for the next natural dispatch, but not on this request's time:
# see _resume_sedimentation.
_resume_sedimentation(app)
return SedimentationState(enabled=store.sedimentation_enabled())
@app.post(
@@ -6226,6 +6276,55 @@ def _memory_mode_state(app: FastAPI) -> MemoryModeState:
)
def _memory_mode_plan(app: FastAPI, target: MemoryMode) -> MemoryModePlan:
"""One validator for previews and mutations, in stable blocker order."""
current = _memory_mode(app)
effective_target = cast(MemoryMode, effective_memory_mode(target))
blockers: list[MemoryModeBlocker] = []
if not _embedding_writes_open(app):
blockers.append("embedding_maintenance_in_progress")
if (
effective_target != current
and effective_target in {"pro", "ultra"}
and _model_profile_store(app).active_identity() is None
):
blockers.append("active_model_required")
if effective_target != current and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
blockers.append("memory_lifecycle_busy")
if (
effective_target != current
and effective_target == "ultra"
and importlib.util.find_spec("kuzu") is None
):
blockers.append("graph_store_unavailable")
warnings: list[MemoryModeWarning] = []
if graph_store_enabled():
warnings.append("forced_by_env")
if effective_target == current:
warnings.append("effective_no_change")
graph_backfill: bool | None = None
if effective_target == "ultra" and current != "ultra":
graph_backfill = not app.state.memory_graph_backfill.running
effects = memory_mode_effects(
current,
effective_target,
graph_backfill=graph_backfill,
)
return MemoryModePlan(
current=current,
target=target,
effective_target=effective_target,
capabilities=home_capabilities(effective_target),
effects=() if blockers else effects,
warnings=tuple(warnings),
blockers=tuple(blockers),
)
def _evolve_service(app: FastAPI) -> EvolveService:
return cast(EvolveService, app.state.evolve_service)
@@ -75,6 +75,9 @@ class HySdkMemoryRecord(BaseModel):
# Epoch seconds. `updated_at` falls back to `created_at`.
created_at: int | None = None
updated_at: int | None = None
# Actual scene=all ordering timestamp before the public fallback above.
# Internal-only: callers keep the existing SdkMemoryRecord wire shape.
sort_updated_at: int | None = Field(default=None, exclude=True, repr=False)
@classmethod
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
@@ -95,6 +98,10 @@ class HySdkMemoryRecord(BaseModel):
if created_at is None:
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
modified_at = _epoch(raw.get("gmt_modified"))
sort_updated_at = (
modified_at if modified_at is not None else updated_at
)
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -116,6 +123,7 @@ class HySdkMemoryRecord(BaseModel):
gmt_created=gmt_created,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=sort_updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -180,6 +188,7 @@ class HySdkMemoryRecord(BaseModel):
solution=solution,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -10,7 +10,10 @@ from pydantic import JsonValue
from one_memory.input_sanitization import sanitize_model_input
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
_CONTEXT_TAGS = r"user_info|rules|agent_transcripts|system[-_]notification|pipeline_log"
_CONTEXT_TAGS = (
r"user_info|rules|agent_transcripts|attached_files|image_local_path|"
r"system[-_]notification|pipeline_log"
)
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
@@ -94,6 +94,11 @@ class ActiveProfileReadHandle(SdkClientHandle):
handle = await self.current_handle()
return self._guarded(handle, await handle.client())
async def client_for_mode(self, mode: str) -> Any:
"""Build/read with the caller's one mode snapshot, without rereading it."""
handle = await self.current_handle(mode=mode)
return self._guarded(handle, await handle.client())
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
profile = getattr(handle, "profile", None)
@@ -111,15 +116,15 @@ class ActiveProfileReadHandle(SdkClientHandle):
),
)
async def current_handle(self) -> SdkClientHandle:
async def current_handle(self, *, mode: str | None = None) -> SdkClientHandle:
"""The handle of the live space, held for the asking task."""
space, identity = await in_sdk_read(self._resolve)
mode, features = self.memory_mode(), self.current_features()
selected_mode, features = mode or self.memory_mode(), self.current_features()
language = self.current_language()
key = (
space.space_id if space is not None else "",
*identity,
mode,
selected_mode,
features,
language,
)
@@ -128,7 +133,9 @@ class ActiveProfileReadHandle(SdkClientHandle):
if handle is None:
# Only on a change of space or profile: resolving a key can
# reach the system keychain, so it is not done on every read.
handle = await in_sdk_read(self._build, space, identity, mode, features, language)
handle = await in_sdk_read(
self._build, space, identity, selected_mode, features, language
)
self._handles[key] = handle
self._retired.update(value for name, value in self._handles.items() if name != key)
self._handles = {key: handle}
@@ -0,0 +1,283 @@
"""Stable UI projections over the HY SDK's mode-specific storage shapes."""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field
from one_memory.hy_sdk_types import (
HySdkMemoryPage,
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
raw_turn_messages,
)
from one_memory.imports.sources.parsing import unwrap_host_envelope_with_positions
MemoryMode = Literal["lite", "pro", "ultra"]
TimeBasis = Literal["conversation", "import", "write", "update"]
ProjectionDisplayState = Literal["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"]
RAW_STATUSES = ("active", "shadow")
HOME_PROJECTION_CANDIDATES = 10
HOME_EXCERPT_CODEPOINTS = 200
_SPACE = re.compile(r"\s+")
_CONTROL_ONLY = re.compile(
r"^(?:"
r"<<?\s*(?:image|attachment|system[_-]?reminder|no[_ -]?content)\s*>>?"
r"|\[\s*(?:image|attachment|system[_ -]?reminder|no[_ -]?content)\s*\]"
r")$",
re.IGNORECASE,
)
class HomeProjectionCapabilities(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
content_plane: Literal["raw", "memory"]
generation_required: bool
graph_enabled: bool
evolve_available: bool
class _HomeLatest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
time: datetime | None
time_basis: TimeBasis | None
excerpt: str
truncated: bool
source_id: str
class HomeRawLatest(_HomeLatest):
type: Literal["raw"] = "raw"
raw_id: str
session_id: str
display_state: Literal["excerpt", "no_user_excerpt"]
class HomeMemoryLatest(_HomeLatest):
type: Literal["memory"] = "memory"
memory_id: str
scene: Literal["chat", "working", "coding"]
# scene=all lists chat rows and productivity experiences; facts are not
# part of that page or its total.
kind: Literal["chat", "experience"]
variant: Literal["working", "coding"] | None
class HomeProjectionV1(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
projection_version: Literal[1] = 1
user_id: str
effective_mode: MemoryMode
capabilities: HomeProjectionCapabilities
visible_count: int = Field(ge=0)
display_state: ProjectionDisplayState
latest: HomeRawLatest | HomeMemoryLatest | None
observed_at: datetime
def raw_list_filters(statuses: list[str] | tuple[str, ...] | None = None) -> dict[str, list[str]]:
"""The one kept-raw definition shared by `/raw` and the home projection."""
return {
"layers": ["l1_raw"],
"statuses": list(statuses or RAW_STATUSES),
}
def home_capabilities(mode: MemoryMode) -> HomeProjectionCapabilities:
return HomeProjectionCapabilities(
content_plane="raw" if mode == "lite" else "memory",
generation_required=mode != "lite",
graph_enabled=mode == "ultra",
evolve_available=mode == "ultra",
)
def raw_user_excerpt(content: str, *, limit: int = HOME_EXCERPT_CODEPOINTS) -> tuple[str, bool]:
"""Return only displayable user text from one SDK raw turn.
Role selection happens before wrapper removal, so assistant/system content
can never become a homepage excerpt after sanitization.
"""
candidates: list[str] = []
for role, text in raw_turn_messages(content):
if role != "user":
continue
envelope = unwrap_host_envelope_with_positions(text)
if envelope.status == "ambiguous":
# An uncertain host envelope may contain system context. Showing
# nothing is safer than guessing which bytes the person authored.
continue
cleaned = envelope.text or ""
cleaned = _SPACE.sub(" ", cleaned).strip()
if not cleaned or _CONTROL_ONLY.fullmatch(cleaned):
continue
candidates.append(cleaned)
joined = " ".join(candidates)
return joined[:limit], len(joined) > limit
def _epoch(value: int | None) -> datetime | None:
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
def _raw_time(record: HySdkRawRecord) -> tuple[datetime | None, TimeBasis | None]:
if record.memory_at is not None:
return _epoch(record.memory_at), "conversation"
if record.gmt_created is not None:
return _epoch(record.gmt_created), "import"
return None, None
def _memory_time(record: HySdkMemoryRecord) -> tuple[datetime | None, TimeBasis | None]:
# Exactly the scene=all merge key. `memory_at` is event time and does not
# decide which row list_memories returns first.
if record.sort_updated_at is not None:
return _epoch(record.sort_updated_at), "update"
written = record.gmt_created if record.scene == "chat" else record.created_at
if written is not None:
return _epoch(written), "write"
return None, None
def _bounded(text: str) -> tuple[str, bool]:
flattened = _SPACE.sub(" ", text).strip()
return flattened[:HOME_EXCERPT_CODEPOINTS], len(flattened) > HOME_EXCERPT_CODEPOINTS
def project_raw_latest(records: tuple[HySdkRawRecord, ...]) -> HomeRawLatest | None:
if not records:
return None
for record in records:
excerpt, truncated = raw_user_excerpt(record.content)
if excerpt:
at, basis = _raw_time(record)
return HomeRawLatest(
raw_id=record.raw_id,
session_id=record.session_id,
display_state="excerpt",
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source,
)
newest = records[0]
at, basis = _raw_time(newest)
return HomeRawLatest(
raw_id=newest.raw_id,
session_id=newest.session_id,
display_state="no_user_excerpt",
time=at,
time_basis=basis,
excerpt="",
truncated=False,
source_id=newest.source,
)
def project_memory_latest(
records: tuple[HySdkMemoryRecord, ...],
) -> HomeMemoryLatest | None:
record = next(
(
item
for item in records
if item.kind in {"chat", "experience"}
and (
(item.kind == "chat" and item.scene == "chat" and item.variant is None)
or (
item.kind == "experience"
and item.scene in {"working", "coding"}
and item.variant == item.scene
)
)
),
None,
)
if record is None:
return None
excerpt, truncated = _bounded(record.content)
at, basis = _memory_time(record)
return HomeMemoryLatest(
memory_id=record.memory_id,
scene=record.scene,
kind=cast(Literal["chat", "experience"], record.kind),
variant=record.variant,
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source or "",
)
async def read_home_projection(
client: Any,
*,
user_id: str,
mode: MemoryMode,
) -> HomeProjectionV1:
"""Read one authoritative projection; any SDK/shape failure escapes."""
client_mode = getattr(client, "mode", mode)
if client_mode != mode:
raise RuntimeError("home_projection_mode_mismatch")
if mode == "lite":
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
**raw_list_filters(),
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
raw_page = HySdkRawPage.from_memory_list(raw, limit=HOME_PROJECTION_CANDIDATES, offset=0)
visible_count = raw_page.total
raw_latest = project_raw_latest(raw_page.raw)
latest: HomeRawLatest | HomeMemoryLatest | None = raw_latest
display_state: ProjectionDisplayState = (
"empty"
if visible_count == 0
else raw_latest.display_state
if raw_latest is not None
else "no_user_excerpt"
)
else:
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
scene="all",
statuses=["active"],
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
memory_page, _skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=HOME_PROJECTION_CANDIDATES, offset=0
)
visible_count = memory_page.total
latest = project_memory_latest(memory_page.memories)
display_state = (
"empty"
if visible_count == 0
else "excerpt"
if latest is not None
else "no_displayable_memory"
)
return HomeProjectionV1(
user_id=user_id,
effective_mode=mode,
capabilities=home_capabilities(mode),
visible_count=visible_count,
display_state=display_state,
latest=latest,
observed_at=datetime.now(UTC),
)
+2 -1
View File
@@ -111,6 +111,7 @@
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
| `test_home_projection.py` | `contract` | 覆盖 memory projection covers pro and ultra、memory projection preserves productivity namespace、project raw latest uses import time without conversation time 等 7 个场景。 |
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
@@ -320,4 +321,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 315 个测试文件。
共 316 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 315
EXPECTED_TEST_FILES_SHA256 = "681258b2a13ca852e273707071b286afe6b0b2fe537f90a8a2c64d83ec662772"
EXPECTED_TEST_FILE_COUNT = 316
EXPECTED_TEST_FILES_SHA256 = "011482974a26e9d43ba03bb88cbd168e645175beacd96c02ff642bc17dac49da"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -0,0 +1,397 @@
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_types import HySdkReadError
from one_memory.ui_projection import (
HOME_PROJECTION_CANDIDATES,
project_raw_latest,
raw_user_excerpt,
read_home_projection,
)
def raw_row(
raw_id: str,
content: str,
*,
status: str = "active",
memory_at: int | None = 1_700_000_000,
created: int | None = 1_699_000_000,
) -> dict[str, object]:
return {
"memory_id": raw_id,
"content": content,
"layer": "l1_raw",
"status": status,
"user_id": "local",
"agent_id": "all-apps",
"session_id": f"session-{raw_id}",
"source": "cursor",
"memory_at": memory_at,
"gmt_created": created,
"custom": {"private": "not projected"},
}
def chat_row(memory_id: str = "m-1") -> dict[str, object]:
return {
"memory_id": memory_id,
"content": "The user ships on Friday",
"layer": "l2_fact",
"status": "active",
"is_latest": True,
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s-1",
"source": "cursor",
"memory_at": 1_700_000_000,
"gmt_created": 1_699_000_000,
"updated_at": 1_800_000_000,
"scene": "chat",
"kind": "chat",
}
class ProjectionSdk:
def __init__(self, rows: list[dict[str, object]], *, total: int | None = None) -> None:
self.rows = rows
self.total = len(rows) if total is None else total
self.calls: list[dict[str, object]] = []
def list_memories(self, **kwargs):
self.calls.append(kwargs)
if kwargs.get("layers") == ["l1_raw"]:
return {"vdb": {"memories": self.rows, "total": self.total}}
return {
"scene": "all",
"memories": self.rows,
"total": self.total,
"totals": {"chat": len(self.rows), "working": 0, "coding": 0},
"errors": {},
}
def memory_stats(self, **kwargs):
raise AssertionError("home projection must use one scene=all snapshot")
def test_raw_excerpt_keeps_only_user_query_and_truncates_by_codepoint() -> None:
text = (
"[user]: <system_reminder>ignore this</system_reminder>\n"
"<user_info>private host data</user_info>\n"
"<user_query>短句 https://example.com/a /tmp/x " + "🙂" * 220 + "</user_query>\n"
"[assistant]: secret answer"
)
excerpt, truncated = raw_user_excerpt(text)
assert excerpt.startswith("短句 https://example.com/a /tmp/x ")
assert "ignore" not in excerpt and "private" not in excerpt and "secret" not in excerpt
assert len(excerpt) == 200
assert truncated is True
def test_raw_excerpt_fails_closed_on_nested_host_wrappers() -> None:
assert raw_user_excerpt(
"[user]: <system_reminder><user_query>keep me</user_query></system_reminder>"
) == ("", False)
@pytest.mark.parametrize(
"wrapper",
[
"system_reminder",
"system-reminder",
"user_info",
"attached_files",
"image_local_path",
"agent_transcripts",
"rules",
"additional_data",
"working_memory_reminder",
],
)
def test_raw_excerpt_uses_shared_envelope_parser_for_host_context(wrapper) -> None:
excerpt, truncated = raw_user_excerpt(
f"[user]: <{wrapper}>private</{wrapper}><user_query>keep me</user_query>"
)
assert (excerpt, truncated) == ("keep me", False)
@pytest.mark.parametrize(
"text",
[
"[user]: <system_reminder>unclosed <user_query>do not guess</user_query>",
"[user]: <user_query>unclosed",
"[user]: <rules><rules>nested</rules><user_query>ambiguous</user_query></rules>",
],
)
def test_raw_excerpt_fails_closed_on_ambiguous_envelopes(text) -> None:
assert raw_user_excerpt(text) == ("", False)
def test_raw_projection_uses_shadow_rows_and_k10_without_leaking_payload() -> None:
sdk = ProjectionSdk(
[
raw_row("r-1", "[assistant]: no user here", status="shadow"),
raw_row("r-2", "[user]: deploy Friday", status="active"),
],
total=42,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.visible_count == 42
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-2"
assert result.latest.excerpt == "deploy Friday"
assert result.latest.time_basis == "conversation"
assert result.display_state == "excerpt"
assert sdk.calls == [
{
"user_id": "local",
"layers": ["l1_raw"],
"statuses": ["active", "shadow"],
"limit": HOME_PROJECTION_CANDIDATES,
"offset": 0,
"order": "desc",
}
]
assert set(result.latest.model_dump()) == {
"type",
"raw_id",
"session_id",
"display_state",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
}
serialized = result.model_dump(mode="json")
assert serialized["projection_version"] == 1
assert serialized["latest"]["time"].endswith("Z")
assert "content" not in serialized["latest"]
def test_raw_projection_reports_no_user_excerpt_when_k_has_none() -> None:
sdk = ProjectionSdk([raw_row("r-1", "[assistant]: answer")])
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-1"
assert result.latest.display_state == "no_user_excerpt"
assert result.latest.excerpt == ""
@pytest.mark.parametrize(
("mode", "graph", "evolve"),
[("pro", False, False), ("ultra", True, True)],
)
def test_memory_projection_covers_pro_and_ultra(mode, graph, evolve) -> None:
sdk = ProjectionSdk([chat_row()], total=9)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode=mode))
assert result.visible_count == 9
assert result.display_state == "excerpt"
assert result.capabilities.content_plane == "memory"
assert result.capabilities.graph_enabled is graph
assert result.capabilities.evolve_available is evolve
assert result.latest is not None and result.latest.type == "memory"
assert result.latest.memory_id == "m-1"
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2027-01-15T08:00:00+00:00"
def test_chat_projection_prefers_gmt_modified_when_the_sdk_supplies_it() -> None:
row = {**chat_row(), "gmt_modified": 1_810_000_000}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "update"
assert result.latest.time.timestamp() == 1_810_000_000
def test_memory_projection_uses_productivity_updated_at() -> None:
row = {
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "working",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"memory_at": 1_600_000_000,
"created_at": 1_700_000_000,
"updated_at": 1_710_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None and result.latest.type == "memory"
assert (result.latest.scene, result.latest.kind, result.latest.variant) == (
"working",
"experience",
"working",
)
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2024-03-09T16:00:00+00:00"
@pytest.mark.parametrize(
"row",
[
{**chat_row(), "updated_at": None},
{
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
"updated_at": None,
},
],
)
def test_memory_projection_sort_time_falls_back_to_scene_creation(row) -> None:
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "write"
expected = row.get("gmt_created") or row.get("created_at")
assert result.latest.time.timestamp() == expected
def test_fact_is_not_a_home_scene_all_item() -> None:
row = {
"kind": "fact",
"fact_id": "f-1",
"statement": "Uses pytest",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.visible_count == 1
assert result.latest is None
assert result.display_state == "no_displayable_memory"
def test_invalid_latest_uses_next_parseable_candidate_from_same_snapshot() -> None:
sdk = ProjectionSdk(
[
{"memory_id": "broken", "content": "", "scene": "chat", "kind": "chat"},
chat_row("m-2"),
],
total=2,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
assert result.visible_count == 2
assert result.latest is not None and result.latest.memory_id == "m-2"
assert len(sdk.calls) == 1
def test_scene_all_rejects_boolean_total() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.total = True # type: ignore[assignment]
with pytest.raises(HySdkReadError):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_projection_read_failure_is_not_converted_to_an_empty_projection() -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
with pytest.raises(OSError, match="down"):
asyncio.run(read_home_projection(Broken(), user_id="local", mode="lite"))
def test_projection_refuses_a_client_built_for_another_mode() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "lite"
with pytest.raises(RuntimeError, match="home_projection_mode_mismatch"):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_home_projection_route_maps_whole_read_failure_to_503(tmp_path, monkeypatch) -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
class Handle:
async def client(self):
return Broken()
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
response = client.get("/api/v2/home-projection")
assert response.status_code == 503
assert response.json() == {"detail": "home_projection_read_failed"}
def test_home_projection_reads_mode_once_and_matches_the_client(tmp_path, monkeypatch) -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "pro"
class Handle:
async def client(self):
return sdk
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
reads = 0
def mode(_app):
nonlocal reads
reads += 1
return "pro"
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
monkeypatch.setattr("one_memory.api._memory_mode", mode)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
response = client.get("/api/v2/home-projection")
assert response.status_code == 200, response.text
assert response.json()["effective_mode"] == "pro"
assert reads == 1
def test_project_raw_latest_uses_import_time_without_conversation_time() -> None:
from one_memory.hy_sdk_types import HySdkRawRecord
record = HySdkRawRecord.from_sdk(
{
**raw_row("r-1", "[user]: hello", memory_at=None),
"raw_id": "r-1",
}
)
latest = project_raw_latest((record,))
assert latest is not None
assert latest.time_basis == "import"
@@ -524,6 +524,11 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
"episode_recall": True,
}
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
await app.state.memory_graph_backfill.wait()
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
@@ -177,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
asyncio.run(scenario())
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
from one_memory import sdk_read_handle
callback_reads = 0
def current_mode() -> str:
nonlocal callback_reads
callback_reads += 1
return "pro"
monkeypatch.setattr(
sdk_read_handle,
"SdkClientHandle",
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
)
reads = sdk_read_handle.ActiveProfileReadHandle(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=tmp_path,
memory_mode=current_mode,
)
async def scenario():
client = await reads.client_for_mode("lite")
assert client.mode == "lite"
await reads.close()
asyncio.run(scenario())
assert callback_reads == 0
class _FakeHandle:
def __init__(self, mode: str, profile_id: str = "p") -> None:
self.mode = mode
@@ -354,6 +385,37 @@ def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None
assert [name for name, _ in sdk.calls] == ["add"]
def test_a_lite_write_ignores_the_model_sedimentation_switch(monkeypatch) -> None:
sdk = _WritingSdk()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
)
admitted = 0
async def admit(_requires_generation: bool) -> None:
nonlocal admitted
admitted += 1
receipt = asyncio.run(
coordinator.process_sdk_job(
(_record("still stored as raw", extracted=False),),
request_id="r-off-lite",
before_hy_invocation=admit,
)
)
assert receipt.raw_ids == ("raw-1",)
assert admitted == 1
assert [name for name, _ in sdk.calls] == ["add"]
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
"""Owner decision: MemHub keeps every raw; there is no retention window."""
from one_memory import sdk_memory_runtime
@@ -820,18 +882,27 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
app.state.history_import_gate_released = True
assert not _sedimentation_claim_enabled(app, "one-memory-manual")
app.state.config_store.set_sedimentation_enabled(False)
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert lite.status_code == 200, lite.text
body = lite.json()
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
assert body["explicitly_configured"] is True
assert body["effects"] == ["higher_mode_memories_hidden"]
resume = getattr(app.state, "sedimentation_resume", None)
assert resume is not None
await asyncio.wait_for(asyncio.shield(resume), 5)
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
assert _sdk_client_handle(app).memory_mode() == "lite"
# lite claims queued work without a generation model.
assert app.state.model_profile_store.active_identity() is None
assert _sedimentation_claim_enabled(app, "one-memory-manual")
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert ultra.status_code == 200, ultra.text
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
@@ -855,6 +926,92 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_and_put_share_validation_and_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
plan = await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
assert plan.status_code == 200
body = plan.json()
assert body == {
"current": "pro",
"target": "lite",
"effective_target": "lite",
"capabilities": {
"content_plane": "raw",
"generation_required": False,
"graph_enabled": False,
"evolve_available": False,
},
"effects": ["higher_mode_memories_hidden"],
"warnings": [],
"blockers": [],
}
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
assert changed.json()["effects"] == body["effects"]
missing_model = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "pro"},
)
).json()
assert missing_model["blockers"] == ["active_model_required"]
assert missing_model["effects"] == []
refused = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert refused.status_code == 409
assert refused.json()["detail"] == missing_model["blockers"][0]
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
forced = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
).json()
assert forced["effective_target"] == "ultra"
assert forced["warnings"] == ["forced_by_env", "effective_no_change"]
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_reports_all_blockers_without_planned_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
switch = app.state.embedding_switch
switch._scan_hold = True
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
monkeypatch.setattr("one_memory.api.importlib.util.find_spec", lambda _name: None)
try:
plan = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "ultra"},
)
).json()
assert plan["blockers"] == [
"embedding_maintenance_in_progress",
"active_model_required",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
assert plan["effects"] == []
finally:
switch._scan_hold = False
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None:
store = ConfigStore(tmp_path / "data")
@@ -943,7 +1100,13 @@ def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatc
assert refused.status_code == 409
assert refused.json()["detail"] == "memory_mode_not_ultra"
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert changed.status_code == 200, changed.text
await app.state.memory_graph_backfill.wait()
started = await client.post(
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
@@ -832,7 +832,7 @@ class _Recorder:
async def process_sdk_job(self, records, **_kwargs):
self.seen.append(tuple(record.evidence_id for record in records))
if self.invoke_hy:
await _kwargs["before_hy_invocation"]()
await _kwargs["before_hy_invocation"](True)
if self.error is not None:
raise self.error
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -844,10 +844,10 @@ class _TwoInvocationRecorder:
self.invocations = 0
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
self.pause_after_first()
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -916,7 +916,7 @@ def test_execute_job_persists_sdk_ids_returned_after_the_single_add(tmp_path) ->
@classmethod
async def process_sdk_job(cls, _records, **kwargs):
cls.calls += 1
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
return type(
"Result",
(),
@@ -1728,7 +1728,7 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
class _Failing:
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
try:
closed_store(type("Client", (), {"private_store_name": None})())
except AttributeError as error:
@@ -1,13 +1,12 @@
"""The sedimentation API persists decisions and resumes the SDK queue."""
import asyncio
import threading
"""The sedimentation API persists decisions without rewriting parked work."""
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import WAITING_FOR_PERMISSION
def _app(tmp_path):
@@ -25,51 +24,62 @@ def _app(tmp_path):
)
def test_sedimentation_route_persists_and_resumes_without_restart(tmp_path) -> None:
def test_sedimentation_route_persists_without_restart(tmp_path) -> None:
with TestClient(_app(tmp_path), headers={"Authorization": "Bearer test"}) as client:
assert client.get("/api/v1/sedimentation").json()["enabled"] is True
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).json()["enabled"] is False
assert (
client.put("/api/v1/sedimentation", json={"enabled": False}).json()["enabled"] is False
)
assert client.get("/api/v1/sedimentation").json()["enabled"] is False
assert client.put(
"/api/v1/sedimentation", json={"enabled": True}
).json()["enabled"] is True
assert client.put("/api/v1/sedimentation", json={"enabled": True}).json()["enabled"] is True
def test_switch_answers_before_sdk_queue_resume_finishes(tmp_path, monkeypatch) -> None:
def test_switch_does_not_run_the_broad_parked_queue_resume(tmp_path, monkeypatch) -> None:
app = _app(tmp_path)
released = threading.Event()
finished = threading.Event()
called = False
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def slow_resume() -> None:
await asyncio.get_running_loop().run_in_executor(None, released.wait, 10.0)
finished.set()
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", slow_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
async def forbidden_resume() -> None:
nonlocal called
called = True
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", forbidden_resume)
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
assert finished.is_set() is False
released.set()
assert called is False
def test_a_failing_sdk_queue_resume_does_not_fail_the_switch(tmp_path, monkeypatch) -> None:
def test_switch_does_not_rewrite_permission_parked_jobs(tmp_path) -> None:
app = _app(tmp_path)
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def broken_resume() -> None:
raise RuntimeError("sdk_queue_unavailable")
queue = app.state.memory_write_queue
queue.evidence = None
request = WriteRequest(
internal_session_id="session-1",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="session-1",
first_round=1,
last_round=1,
),
evidence_ids=["e-1"],
idempotency_key="permission-parked",
)
record = queue.submit(request)
queue.settle(
record.job_id,
state=WAITING_FOR_PERMISSION,
error_code="model_content_permission_required",
hy_invoked=False,
)
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", broken_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
assert client.put("/api/v1/sedimentation", json={"enabled": True}).status_code == 200
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
parked = queue.get("permission-parked")
assert parked.state == WAITING_FOR_PERMISSION
assert parked.detail == "model_content_permission_required"
+34 -1
View File
@@ -9,6 +9,11 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { App } from "./App";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("localizes shared navigation and footer in Spanish", async () => {
@@ -36,6 +41,7 @@ it("localizes shared navigation and footer in Spanish", async () => {
});
const memoryProps = vi.hoisted(() => vi.fn());
const progressProps = vi.hoisted(() => vi.fn());
vi.mock("./api", () => ({
getOnboarding: vi.fn().mockResolvedValue({ status: "completed" }),
@@ -62,11 +68,21 @@ vi.mock("./MemorySpace", () => ({
vi.mock("./ModelSettings", () => ({
ModelSettings: () => <p>模型设置内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null }));
vi.mock("./R6Settings", () => ({
R6Settings: () => <p>设置视图内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({
ImportProgressStrip: (props: unknown) => {
progressProps(props);
return null;
},
}));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
beforeEach(() => {
resetConfirmedMemoryModeForTests();
progressProps.mockClear();
window.history.replaceState({}, "", "/");
localStorage.clear();
});
@@ -105,6 +121,23 @@ it("uses explicit memory deep links and restores the applications default URL",
).toBe("page");
});
it("passes confirmed mode capabilities to the production progress strip", async () => {
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings"));
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
await userEvent.click(within(nav).getByRole("button", { name: "设置" }));
await waitFor(() =>
expect(progressProps).toHaveBeenCalledWith(
expect.objectContaining({
modeCapabilities: expect.objectContaining({
content_plane: "raw",
generation_required: false,
}),
}),
),
);
});
it("keeps an independent scroll position for each primary page", async () => {
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
+9 -2
View File
@@ -68,6 +68,10 @@ vi.mock("./api", () => ({
startEvolve: vi.fn(),
apiErrorCode: vi.fn().mockReturnValue(null),
getSources: vi.fn().mockResolvedValue(sources),
getCurrentEmbeddingSwitch: vi
.fn()
.mockRejectedValue(new Error("unsupported")),
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
...mutations,
}));
vi.mock("./use-import-sources", () => ({
@@ -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",
+17 -4
View File
@@ -23,8 +23,10 @@ import {
import { getOnboarding } from "./api";
import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { useConfirmedMemoryMode } from "./confirmed-memory-mode";
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 +36,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,16 +50,19 @@ const rendererStartedAt = Date.now();
export function App() {
return (
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
<EmbeddingSwitchProvider>
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
</EmbeddingSwitchProvider>
);
}
function AppContent() {
const { t } = useGuiLanguage();
const { a } = useApplicationsLanguage();
const confirmedMode = useConfirmedMemoryMode();
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
"loading",
@@ -309,6 +315,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. */}
@@ -322,6 +332,9 @@ function AppContent() {
sourceSnapshot={imports.sourceSnapshot}
onRefresh={imports.refresh}
arrival={memoryArrival}
modeCapabilities={
confirmedMode.capabilities ?? memoryArrival?.capabilities
}
onConfigureModel={() => navigate("settings")}
onJobAction={imports.changeJob}
showSourceLink={displayedView !== "settings"}
+290 -20
View File
@@ -15,6 +15,11 @@ import {
CALL_HISTORY_ENTRY_VISIBLE,
} from "./ApplicationsHome";
import * as api from "./api";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { LanguageProvider } from "./gui-language";
@@ -67,21 +72,37 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const { summary, processing, sedimentation, history, liveSync } = vi.hoisted(
() => ({
const { summary, projection, processing, sedimentation, history, liveSync } =
vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
}),
);
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
}));
vi.mock("./api", () => ({
getHomeProjection: projection,
getHomeSummary: summary,
homeProjectionUnsupported: vi.fn().mockReturnValue(true),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getSedimentation: sedimentation,
@@ -106,6 +127,22 @@ const imports = {
sourceSnapshot: { stale: false },
} as unknown as Parameters<typeof ApplicationsHome>[0]["imports"];
beforeEach(() => {
resetConfirmedMemoryModeForTests();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.getSdkRaw).mockReset();
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(true);
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
@@ -122,6 +159,7 @@ beforeEach(() => {
},
observed_at: "2026-09-19T00:00:00Z",
});
projection.mockReset().mockRejectedValue(new Error("legacy core"));
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
sedimentation.mockReset().mockResolvedValue({
enabled: true,
@@ -149,6 +187,218 @@ beforeEach(() => {
]),
} as unknown as NonNullable<typeof window.oneMemory>;
});
it("hides an old projection immediately when confirmed mode changes", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
commitConfirmedMemoryMode("pro", issueConfirmedModeTicket("settings"));
projection
.mockResolvedValueOnce({
projection_version: 1,
user_id: "local",
effective_mode: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "memory",
memory_id: "old",
scene: "chat",
kind: "chat",
variant: null,
source_id: "app-0",
excerpt: "旧模式投影",
truncated: false,
time: null,
time_basis: "update",
},
observed_at: "2026-11-04T00:00:00Z",
})
.mockImplementationOnce(() => new Promise(() => {}));
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
/>,
);
expect(await screen.findByText("旧模式投影")).toBeTruthy();
act(() =>
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings")),
);
expect(screen.queryByText("旧模式投影")).toBeNull();
expect(screen.getAllByText("正在核对本机原始对话…")).toHaveLength(2);
});
it("renders the lite projection as raw data with mode metadata and no navigation tail", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
source_id: "app-0",
excerpt: "用户原始对话摘要",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: "conversation",
},
observed_at: "2026-09-19T00:00:01Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={vi.fn()}
onCalls={() => {}}
/>,
);
expect(await screen.findByText("原始对话")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("用户原始对话摘要")).toBeTruthy();
expect(screen.getByText(/原始对话 · App 0 · 对话时间/)).toBeTruthy();
expect(screen.getByText("lite")).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
});
it.each([
["conversation", "对话时间"],
["import", "导入时间"],
["write", "写入时间"],
["update", "更新时间"],
[null, null],
] as const)(
"renders projection time basis %s truthfully",
async (basis, label) => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-basis",
session_id: "session-basis",
source_id: "app-0",
excerpt: "basis excerpt",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: basis,
},
observed_at: "2026-09-19T00:00:01Z",
});
render(mountHome());
const activity = (await screen.findByText("basis excerpt")).closest(
"section",
);
const meta = activity?.querySelector("small")?.textContent ?? "";
if (label) expect(meta).toContain(label);
else expect(meta).not.toMatch(/对话时间|导入时间|写入时间|更新时间/);
},
);
it("does not publish a stopped legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
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: {},
},
});
let resolveRaw!: (value: {
user_id: string;
items: [];
total: number;
limit: number;
offset: number;
}) => void;
vi.mocked(api.getSdkRaw).mockReturnValue(
new Promise((resolve) => {
resolveRaw = resolve;
}),
);
const view = render(mountHome(true));
await waitFor(() => expect(api.getSdkRaw).toHaveBeenCalledWith({ limit: 1 }));
view.rerender(mountHome(false));
await act(async () =>
resolveRaw({
user_id: "local",
items: [],
total: 73,
limit: 1,
offset: 0,
}),
);
expect(screen.queryByText("73")).toBeNull();
});
it("uses only total from the legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
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: {},
},
});
vi.mocked(api.getSdkRaw).mockResolvedValue({
user_id: "local",
items: [
{
raw_id: "raw-private",
content: "正文不应进入首页",
user_id: "local",
agent_id: "agent",
session_id: "session",
source: "app-0",
memory_at: null,
gmt_created: null,
custom: {},
},
],
total: 9,
limit: 1,
offset: 0,
});
render(mountHome());
expect(await screen.findByText("9")).toBeTruthy();
expect(screen.queryByText("正文不应进入首页")).toBeNull();
});
it.each([
[true, "Organizando", "Organizando nuevas conversaciones"],
[false, "En pausa", "Se dejó de generar memorias nuevas"],
@@ -197,7 +447,7 @@ it("localizes startup recovery and empty states without leaking errors", async (
"El servicio se está iniciando; se actualizará automáticamente",
),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Reintentar lectura" }),
@@ -235,7 +485,7 @@ it("shows latest recorded call separately without exposing recent-update detail
);
await screen.findByText("实际治理记忆");
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
await screen.findByText("Codex 查到 3 条相关内容");
await screen.findByText("Codex 找到 2 条记忆 · 1 条原始会话");
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
expect(screen.queryByText("实际治理记忆")).toBeNull();
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
@@ -292,7 +542,7 @@ it("skips direct diagnostics and shows the latest attributed application call",
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
expect(await screen.findByText("WorkBuddy 找到 5 条记忆")).toBeTruthy();
expect(screen.queryByText(/直接调用/)).toBeNull();
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
const direction = document.querySelector(".r6-home-call-direction");
@@ -330,7 +580,7 @@ it("shows only the host for memory hits whose references have no source", async
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 2 条相关内容");
await screen.findByText("WorkBuddy 找到 2 条记忆");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
@@ -368,7 +618,7 @@ it("keeps source to host direction for raw hits with a source", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 1 条相关内容");
await screen.findByText("WorkBuddy 找到 1 条原始会话");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("svg")).toBeTruthy();
@@ -416,7 +666,9 @@ it("shows unlinked apps and the latest returned call on matching rows", async ()
onCalls={() => {}}
/>,
);
expect(await screen.findByText(/Cursor 查到 3 条相关内容 ·/)).toBeTruthy();
expect(
await screen.findByText(/Cursor 找到 2 条记忆 · 1 条原始会话 ·/),
).toBeTruthy();
expect(screen.getByText("尚未关联")).toBeTruthy();
});
it("keeps the latest call visible when recording is incomplete", async () => {
@@ -444,7 +696,7 @@ it("keeps the latest call visible when recording is incomplete", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("Cursor 查到 1 条相关内容");
await screen.findByText("Cursor 找到 1 条记忆");
expect(screen.getByText(/有几次调用没被记下来/)).toBeTruthy();
expect(screen.queryByText("暂时无法读取调用记录")).toBeNull();
expect(screen.queryByText("暂无已记录调用")).toBeNull();
@@ -496,7 +748,7 @@ it("marks a retained application activity as last-read when call history cannot
/>,
);
await screen.findByText(/App 0 查到 3 条相关内容 ·/);
await screen.findByText(/App 0 找到 2 条记忆 · 1 条原始会话 ·/);
view.rerender(
<ApplicationsHome
active={false}
@@ -520,8 +772,10 @@ it("marks a retained application activity as last-read when call history cannot
await waitFor(() => expect(history).toHaveBeenCalledTimes(2));
expect(await screen.findByText("实际治理记忆")).toBeTruthy();
expect(screen.getByText(/上次读取:App 0 查到 3 条相关内容/)).toBeTruthy();
expect(screen.queryByText(/^App 0 查到/)).toBeNull();
expect(
screen.getByText(/上次读取:App 0 找到 2 条记忆 · 1 条原始会话/),
).toBeTruthy();
expect(screen.queryByText(/^App 0 找到/)).toBeNull();
});
it("does not say an unavailable call returned memories", async () => {
history.mockResolvedValue({
@@ -770,7 +1024,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("keeps a neutral zero while the first home summary read recovers", async () => {
it("keeps an unknown dash while the first home read recovers", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -783,7 +1037,7 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
expect(document.body.textContent).not.toContain("private offline detail");
@@ -945,6 +1199,22 @@ it("shows a paused switch immediately even when task rows still say running", as
});
it("shows lite raw ingestion as running even when model sedimentation is paused", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 0,
display_state: "empty",
latest: null,
observed_at: "2026-11-04T00:00:00Z",
});
processing.mockResolvedValue({
running: 0,
pending: 10,
@@ -965,8 +1235,8 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
/>,
);
expect(await screen.findAllByText("整理中")).toHaveLength(2);
expect(screen.getByText("新的对话正在整理")).toBeTruthy();
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
@@ -1338,7 +1608,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("shows a provisional zero while the first summary read is pending", async () => {
it("shows an unknown dash while the first projection read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1355,7 +1625,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
/>,
);
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+249 -117
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
HostHomeSummary,
LiveSyncHealth,
MemoryProcessingSummary,
@@ -8,7 +9,15 @@ import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { getHomeSummary, getLiveSyncHealth, getMemoryProcessing } from "./api";
import {
getHomeProjection,
getHomeSummary,
getLiveSyncHealth,
getMemoryMode,
getMemoryProcessing,
getSdkRaw,
homeProjectionUnsupported,
} from "./api";
import { AppMark } from "./app-mark";
import {
type CallEvent,
@@ -17,6 +26,13 @@ import {
callRowDetail,
getCallHistory,
} from "./call-history-client";
import {
commitConfirmedMemoryMode,
confirmedMemoryModeSnapshot,
isConfirmedModeTicketCurrent,
issueConfirmedModeTicket,
useConfirmedMemoryMode,
} from "./confirmed-memory-mode";
import { hostMatchesSource, sameConnectionHost } from "./connection-hosts";
import { readConnectionPreferences } from "./connection-preferences";
import {
@@ -27,6 +43,10 @@ import {
} from "./connection-reconcile";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
buildHomeViewModel,
type HomeProjectionReadState,
} from "./home-view-model";
import {
hostBridgeAvailable,
hostConnectionApproved,
@@ -85,6 +105,17 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
}
function projectionTimeBasisLabel(
basis: string | null,
t: Translate,
): string | null {
if (basis === "conversation") return t("apps.conversationTime");
if (basis === "import") return t("apps.importTime");
if (basis === "write") return t("apps.writeTime");
if (basis === "update") return t("apps.updateTime");
return null;
}
type ActivityChoice = "call" | "update";
function sourceMatchesHost(sourceId: string, hostId: string | null) {
@@ -167,7 +198,6 @@ function SourceMark({ source }: { source: SourceResponse }) {
export function ApplicationsHome({
imports,
onManage,
onMemories,
onCalls,
active = true,
}: {
@@ -192,6 +222,11 @@ export function ApplicationsHome({
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
const auto = useAutoConnect();
const sedimentation = useSedimentation();
const confirmedMode = useConfirmedMemoryMode();
const [projection, setProjection] = useState<HomeProjectionV1 | null>(null);
const [projectionState, setProjectionState] =
useState<HomeProjectionReadState>("loading");
const [legacyCore, setLegacyCore] = useState(false);
const [summary, setSummary] = useState<HostHomeSummary | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
@@ -278,6 +313,7 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
const root = useRef<HTMLElement>(null);
@@ -538,6 +574,18 @@ export function ApplicationsHome({
.map((s) => `${s.source_id}:${s.authorized}:${s.grant_revision}`)
.join("|");
const requestKey = `${sourceVersion}:${revision}`;
useEffect(() => {
const previous = confirmedModeSeen.current;
if (previous.revision === confirmedMode.revision) return;
confirmedModeSeen.current = confirmedMode;
// The first confirmed mode accompanies the first self-consistent
// projection. There is no old-mode projection to hide yet.
if (previous.mode === null) return;
setProjection(null);
setSummary(null);
setProjectionState("loading");
if (active) setRevision((value) => value + 1);
}, [active, confirmedMode]);
// requestKey retriggers this read when the source snapshot or a manual retry changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: requestKey is the refresh token
useEffect(() => {
@@ -547,8 +595,9 @@ export function ApplicationsHome({
async function refresh() {
if (busy || document.hidden) return;
busy = true;
const homeTicket = issueConfirmedModeTicket("home");
const [home, state, sync] = await Promise.allSettled([
getHomeSummary(),
getHomeProjection(),
getMemoryProcessing(),
getLiveSyncHealth(),
refreshSedimentation(),
@@ -556,17 +605,99 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
if (
!commitConfirmedMemoryMode(
home.value.effective_mode,
homeTicket,
home.value.capabilities,
)
)
return;
setLegacyCore(false);
setSummaryFailures(0);
setProjection(home.value);
setProjectionState("ready");
} else {
setSummaryFailures((count) => count + 1);
if (homeProjectionUnsupported(home.reason)) {
setLegacyCore(true);
try {
const modeTicket = issueConfirmedModeTicket("home");
const modeResult = await getMemoryMode();
if (stopped) return;
const modeAccepted = commitConfirmedMemoryMode(
modeResult.mode,
modeTicket,
);
const effectiveMode = modeAccepted
? modeResult.mode
: confirmedMemoryModeSnapshot().mode;
if (effectiveMode === "lite") {
const raw = await getSdkRaw({ limit: 1 });
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
setProjection({
projection_version: 1,
user_id: raw.user_id ?? "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: raw.total,
display_state: raw.total > 0 ? "no_user_excerpt" : "empty",
latest: null,
observed_at: new Date().toISOString(),
});
setSummaryFailures(0);
setProjectionState("ready");
return;
}
const legacy = await getHomeSummary();
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
if (!("stale" in legacy) || !legacy.stale) {
setSummary(legacy);
setSummaryFailures(0);
setProjectionState("ready");
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} catch {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -582,10 +713,31 @@ export function ApplicationsHome({
stop();
};
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const modeChangedPending =
confirmedModeSeen.current.mode !== null &&
confirmedModeSeen.current.revision !== confirmedMode.revision;
const effectiveProjection = modeChangedPending ? null : projection;
const visible = modeChangedPending ? null : summary;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const viewModel = buildHomeViewModel({
projection: effectiveProjection,
projectionState: modeChangedPending ? "loading" : projectionState,
confirmedMode: confirmedMode.mode,
legacySummary: legacyCore ? visible : null,
processing,
sedimentation: sedimentation ?? null,
recentCall,
importsRunning: historyImporting,
hostState,
syncEnabledCount: imports.sources.filter(
(source) => source.authorized && source.sync_enabled,
).length,
});
const latest = viewModel.recent;
const summaryUnavailable = viewModel.state === "unavailable";
const summaryStale = viewModel.state === "stale";
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -599,9 +751,6 @@ export function ApplicationsHome({
activityView !== null && availableActivities.includes(activityView)
? activityView
: null;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const recentCallSourceId = (recentCall?.references ?? []).find(
(reference) => reference.source_id,
)?.source_id;
@@ -616,75 +765,46 @@ export function ApplicationsHome({
: null;
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const baseActivity =
viewModel.activity.kind === "update"
? ("memory" as const)
: viewModel.activity.kind;
const activity = summaryUnavailable
? "unavailable"
: visible === null
? "loading"
: pinned === "call"
? "call"
: pinned === "update"
: pinned === "call"
? "call"
: pinned === "update"
? updateKind
: updateKind
? updateKind
: updateKind
? updateKind
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: historyImporting
? "importing"
: callState === "loading"
? recentCall !== null
? "call"
: "loading"
: processing?.running
? "first-running"
: processing?.pending || processing?.waiting_for_model
? "first-queued"
: "empty";
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: callState === "loading" && recentCall !== null
? "call"
: baseActivity;
const apps = imports.sources.filter(
(s) => s.authorized || s.detected === true || s.location_state === "found",
);
const rawOnly = processing?.pipeline?.mode === "lite";
const stateLabel = rawOnly
? t("apps.running")
: sedimentation?.enabled === true
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.checkingStatus");
const stateDetail = rawOnly
? t("apps.organizingNew")
: sedimentation?.enabled === false
? t("apps.stoppedNew")
: sedimentation?.enabled === true
? t("apps.organizingNew")
: t("apps.checkingProcessing");
const latestUpdatedAt =
latest === null || latest === undefined
? null
: "revision" in latest
? latest.updated_at
: latest.gmt_created === null
? null
: new Date(latest.gmt_created * 1000).toISOString();
const stateLabel = t(viewModel.processingLabel as never);
const stateDetail = t(viewModel.processingDetail as never);
const latestUpdatedAt = latest?.time ?? null;
const latestClock = latestUpdatedAt
? clockParts(latestUpdatedAt, locale)
: null;
const latestSourceIds =
latest === null || latest === undefined
? []
: "revision" in latest
? latest.source_ids
: latest.source
? [latest.source]
: [];
const latestSourceIds = latest?.sourceId ? [latest.sourceId] : [];
const latestSourceNames = latestSourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)?.display_name ??
id,
);
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
const checked = effectiveProjection
? clockParts(effectiveProjection.observed_at, locale)
: visible
? clockParts(visible.observed_at, locale)
: null;
// A failed read leaves the last health in place on purpose: clearing it would
// drop the instance it names, and that instance is what keeps memory writes
// and backfill receipts attached to the run that produced them. So the card
@@ -787,7 +907,7 @@ export function ApplicationsHome({
? tn("apps.enabledCount", authorizedSyncSources.length)
: syncApplyFailed
? t("apps.enableFailed")
: wantsSync || !visible
: wantsSync || viewModel.state === "loading"
? t("apps.checkingState")
: t("apps.notEnabled");
function toggleAll(next: boolean) {
@@ -835,29 +955,31 @@ export function ApplicationsHome({
</header>
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
<p>{t(viewModel.countLabel)}</p>
<p className="r6-home-figure">
<strong>
{new Intl.NumberFormat(locale).format(
visible?.memory_count ?? 0,
)}
{viewModel.count === null
? "—"
: new Intl.NumberFormat(locale).format(viewModel.count)}
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
{viewModel.count !== null && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>
{visible
{viewModel.state !== "loading"
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: t(viewModel.countDetail)
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
? t("apps.startingRetry")
: t("apps.readingCount")}
: t(viewModel.countDetail)}
</small>
</div>
<div>
<p>{t("apps.processing")}</p>
<p>{t(viewModel.processingTitle)}</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>{stateDetail}</small>
</div>
@@ -865,16 +987,14 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
(viewModel.recentMetric.valueKey
? t(viewModel.recentMetric.valueKey)
: "")}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t(viewModel.recentMetric.detailKey)}`
: t(viewModel.recentMetric.detailKey)}
</small>
</div>
</div>
@@ -908,7 +1028,7 @@ export function ApplicationsHome({
? t("apps.recentCall")
: activity === "memory"
? t("apps.recentUpdate")
: t("apps.firstBatch")}
: t(viewModel.activity.headingKey)}
</p>
)}
<div>
@@ -938,7 +1058,13 @@ export function ApplicationsHome({
})()
) : null}
<section>
<h3>
<h3
className={
activity === "memory" && latest?.kind === "raw"
? "r6-home-raw-excerpt"
: undefined
}
>
{activity === "unavailable"
? callState === "unavailable"
? t("apps.callsUnavailable")
@@ -950,16 +1076,12 @@ export function ApplicationsHome({
locale,
)
: activity === "memory" && latest
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
: activity === "importing"
? t("apps.importing")
: activity === "first-running"
? t("apps.firstRunning")
: activity === "first-queued"
? t("apps.firstQueued")
: activity === "loading"
? t("apps.readingActivity")
: t("apps.emptyMemories")}
? latest.emptyExcerpt
? t("apps.rawNoExcerpt")
: latest.excerpt
: viewModel.activity.titleKey
? t(viewModel.activity.titleKey)
: ""}
</h3>
<small>
{activity === "unavailable"
@@ -969,14 +1091,24 @@ export function ApplicationsHome({
: activity === "call" && recentCall
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
: activity === "memory" && latest
? [time, latestSourceNames.join(", ")]
? (latest.kind === "raw"
? [
t("apps.rawConversation"),
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
time,
]
: [
time,
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
]
)
.filter(Boolean)
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
: viewModel.activity.detailKey
? t(viewModel.activity.detailKey)
: ""}
</small>
</section>
{(activity === "unavailable" ||
@@ -990,16 +1122,12 @@ export function ApplicationsHome({
setRevision((n) => n + 1);
setCallRevision((n) => n + 1);
}
: activity === "call"
? onCalls
: () => onMemories(latest?.memory_id)
: onCalls
}
>
{activity === "unavailable"
? t("apps.reread")
: activity === "call"
? t("apps.viewCalls")
: t("apps.viewMemory")}
: t("apps.viewCalls")}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
@@ -1052,8 +1180,12 @@ export function ApplicationsHome({
<dd>{syncState}</dd>
</div>
<div>
<dt>{t("apps.organizing")}</dt>
<dd>{stateLabel}</dd>
<dt>{t(viewModel.organizingTitle)}</dt>
<dd>{t(viewModel.organizingLabel as never)}</dd>
</div>
<div>
<dt>{t("apps.currentMode")}</dt>
<dd>{viewModel.currentModeLabel}</dd>
</div>
<div>
<dt>{t("apps.access")}</dt>
+10 -10
View File
@@ -159,7 +159,9 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
expect(
screen.getByRole("heading", { name: "Historial de uso" }),
).toBeTruthy();
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
expect(first.textContent).toBe(
"Cursor encontró 1 memoria · 2 conversaciones originales",
);
fireEvent.click(first);
expect(
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
@@ -364,7 +366,7 @@ it("shows exact identity, timestamp, result counts and metadata-only references"
).toBeTruthy();
expect(
within(detail).getByText((value) =>
value.includes("Cursor 查到 3 条相关内容"),
value.includes("Cursor 找到 1 条记忆 · 2 条原始会话"),
),
).toBeTruthy();
expect(within(detail).getAllByText("source-1").length).toBeGreaterThan(0);
@@ -465,13 +467,9 @@ it("keeps detail bound to event ID across refresh reordering, then removes missi
});
it.each([
["grounded", "查询成功", "直接调用(诊断) 查到 0 条相关内容"],
["grounded", "查询成功", "直接调用(诊断) 调用已完成"],
["empty", "查询成功,没有找到相关记忆", "直接调用(诊断) 没有查到相关记忆"],
[
"partial",
"查询成功,部分内容还在整理中",
"直接调用(诊断) 查到 0 条相关内容",
],
["partial", "查询成功,部分内容还在整理中", "直接调用(诊断) 调用已完成"],
[
"timed_out",
"查询超时,没有提供记忆",
@@ -546,11 +544,13 @@ it.each(["om_recall", "om_conversations", "om_ask", "om_matters"] as const)(
mount();
expect(
await screen.findByRole("button", {
name: "Cursor 查到 3 条相关内容",
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Cursor 查到 3 条相关内容" }),
screen.getByRole("button", {
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
);
expect(
within(screen.getByRole("article")).getByText("查记忆"),
@@ -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}
</>
);
}
@@ -153,6 +153,58 @@ describe("ImportProgressStrip", () => {
expect(container.textContent).toBe("");
});
it("uses raw-saving copy and never offers model setup in lite mode", () => {
render(
<ImportProgressStrip
jobs={[job()]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
onConfigureModel={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: true,
importedMessages: 3,
importedConversations: 1,
phase: "model-required",
}}
/>,
);
expect(screen.getByRole("status").textContent).toContain(
"正在保存原始对话",
);
expect(screen.queryByRole("button", { name: "设置模型" })).toBeNull();
});
it("does not keep a lite progress strip mounted without active work", () => {
const { container } = render(
<ImportProgressStrip
jobs={[]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: false,
importedMessages: 3,
importedConversations: 1,
phase: "stopped",
}}
/>,
);
expect(container.textContent).toBe("");
});
it("shows one plain sentence for first history import without details", () => {
render(
<ImportProgressStrip
+46 -29
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionCapabilities,
ImportIssue,
ImportJob,
SourceResponse,
@@ -68,6 +69,7 @@ export interface ImportProgressStripProps {
showSourceLink?: boolean;
showProgressDetails?: boolean;
userFacing?: boolean;
modeCapabilities?: HomeProjectionCapabilities | null;
}
/** Named sources and confirmed counts, without claiming cached jobs are live. */
@@ -85,6 +87,7 @@ export function ImportProgressStrip({
showSourceLink = true,
showProgressDetails = true,
userFacing = false,
modeCapabilities,
}: ImportProgressStripProps) {
const { t, tn } = useGuiLanguage();
const [refreshing, setRefreshing] = useState(false);
@@ -212,7 +215,13 @@ export function ImportProgressStrip({
),
);
const currentArrival = arrival
? resolveMemoryArrival({ arrival, processing: null, sources, loaded: true })
? resolveMemoryArrival({
arrival,
processing: null,
sources,
loaded: true,
capabilities: modeCapabilities,
})
: null;
const needsAction =
currentArrival &&
@@ -253,11 +262,14 @@ export function ImportProgressStrip({
(sum, source) => sum + source.imported_conversations,
0,
);
const message = firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
const message =
modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
return (
<div
className="import-strip"
@@ -272,16 +284,18 @@ export function ImportProgressStrip({
)}
<strong>{message}</strong>
</div>
{!firstImport && onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
{!firstImport &&
modeCapabilities?.generation_required !== false &&
onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
</div>
</div>
);
@@ -322,19 +336,21 @@ export function ImportProgressStrip({
? updating
? "已保存内容保留,正在核对进度"
: "进度待更新"
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
: modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
return (
<div
@@ -645,6 +661,7 @@ export function ImportProgressStrip({
</button>
)}
{!stale &&
modeCapabilities?.generation_required !== false &&
currentArrival?.phase === "model-required" &&
onConfigureModel && (
<button type="button" className="quiet" onClick={onConfigureModel}>
+3
View File
@@ -1,5 +1,6 @@
import type {
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
MemoryDetail,
MemoryGraphNode,
@@ -92,6 +93,7 @@ type Props = {
hidden?: boolean;
id?: string;
arrival?: MemoryArrival;
modeCapabilities?: HomeProjectionCapabilities | null;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
@@ -268,6 +270,7 @@ export function MemoryMap(props: Props) {
processing: props.processing,
sources,
loaded,
capabilities: props.modeCapabilities,
});
useEffect(() => {
if (!loaded || library) return;
+256 -65
View File
@@ -1,13 +1,22 @@
// @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,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
} from "./api";
@@ -21,6 +30,8 @@ vi.mock("./api", () => ({
getEvolveOverview: vi.fn(),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn(),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
@@ -53,21 +64,47 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
}
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);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("confirms the data effects before switching to lite", async () => {
@@ -81,29 +118,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 +163,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));
@@ -122,6 +175,20 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
it("routes to model setup instead of switching pro without a model", async () => {
prepare("lite");
vi.mocked(planMemoryMode).mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
const configure = vi.fn();
const user = userEvent.setup();
render(
@@ -129,10 +196,72 @@ 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("uses the isolated legacy plan only for a route-level old-Core 404", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
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 +274,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 +289,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 +319,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 +376,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,
);
});
+273 -109
View File
@@ -3,10 +3,11 @@ import type {
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { RefreshCw } from "lucide-react";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
@@ -14,13 +15,19 @@ import {
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
import { OptionMenu } from "./OptionMenu";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
const MODE_COPY: Record<
MemoryPipelineMode,
@@ -28,44 +35,84 @@ 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(
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;
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function MemoryModeSettings({
hasActiveModel,
modelStateKnown = true,
@@ -74,10 +121,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);
@@ -85,25 +132,41 @@ export function MemoryModeSettings({
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
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();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
setPendingPlan(null);
setPendingMode(null);
return;
}
@@ -127,6 +190,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,8 +199,27 @@ 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 modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
@@ -146,11 +229,14 @@ export function MemoryModeSettings({
]);
if (version !== refreshVersion.current) return;
if (modeResult.status === "fulfilled") {
commitConfirmedMemoryMode(modeResult.value.mode, modeTicket);
setState(modeResult.value);
onModeChange?.(modeResult.value.mode);
setError("");
setReadFailed(false);
} else {
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
@@ -181,26 +267,46 @@ export function MemoryModeSettings({
run?.state === "running" ||
state?.graph_build.state === "running";
function choose(mode: MemoryPipelineMode) {
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
if (mode !== "lite" && !hasActiveModel) {
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
onConfigureModel();
setError("");
setBusy(true);
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
setBusy(false);
return;
}
plan = legacyMemoryModePlan(state.mode, mode, { hasActiveModel });
}
setBusy(false);
if (plan.blockers.includes("active_model_required")) {
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
setError("");
dialogOpener.current = document.getElementById(`${id}-select`);
dialogOpener.current = opener;
setPendingPlan(plan);
setPendingMode(mode);
}
async function commitMode() {
if (!pendingMode) return;
const targetMode = pendingMode;
refreshVersion.current++;
const modeTicket = issueConfirmedModeTicket("settings");
setBusy(true);
setError("");
try {
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
commitConfirmedMemoryMode(changed.mode, modeTicket);
focusEvolveWhenReady.current = targetMode === "ultra";
focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode;
setState(changed);
onModeChange?.(changed.mode);
setPendingPlan(null);
setPendingMode(null);
await refresh();
} catch (reason) {
@@ -261,41 +367,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) => void 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 +430,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 +471,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,40 +517,63 @@ 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)}
</h2>
<ul>
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
(effect) => (
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
),
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
<li>{text("原始对话在所有模式下都会保留。")}</li>
</ul>
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换模式")}
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
+19 -2
View File
@@ -1,6 +1,7 @@
import type {
ExtractionWindowPreset,
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
ImportJob,
MemoryDetail,
@@ -216,6 +217,14 @@ export function MemorySpace({
processing?.pipeline?.mode ??
(processingResolved && !processingFailed ? "pro" : null);
const lite = pipelineMode === "lite";
const modeCapabilities: HomeProjectionCapabilities | null = pipelineMode
? {
content_plane: lite ? "raw" : "memory",
generation_required: !lite,
graph_enabled: pipelineMode === "ultra",
evolve_available: pipelineMode === "ultra",
}
: null;
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
@@ -452,6 +461,7 @@ export function MemorySpace({
),
refreshing: checkingSetup,
memoryCount: undefined,
capabilities: modeCapabilities,
blockedStages: processing?.blocked_stages,
backlogPending: processing?.backlog_pending,
phase: sourceSnapshotStale ? "unavailable" : "checking",
@@ -1169,6 +1179,7 @@ export function MemorySpace({
id="memory-mode-panel-map"
simplified={simplified}
contentKind={lite ? "raw" : "memory"}
modeCapabilities={modeCapabilities}
rawRecord={selectedRaw}
libraryView={
simplified
@@ -1529,7 +1540,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:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1542,8 +1553,14 @@ function message(reason: unknown, t: Translate): string {
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
request_validation_failed: "memory:输入内容不符合要求。",
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
embedding_unavailable:
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
embedding_switch_in_progress:
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
embedding_space_profile_missing:
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
};
if (code && labels[code]) return t(labels[code]);
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
// No code at all means the request never reached the core — usually because
// it is still starting. Saying only "failed" left the one case that resolves
// itself looking like the ones that do not.
+187 -1
View File
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
ModelProbeResult,
ModelProfile,
} from "@one-memory/contracts";
import {
act,
cleanup,
@@ -66,6 +72,10 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
let switchCurrent: EmbeddingSwitchCurrent | null = null;
it("explains an insufficient model balance with the required recovery", () => {
expect(probeLabel("insufficient_balance")).toBe(
@@ -114,6 +124,71 @@ const preset: ModelProfile = {
has_api_key: false,
disable_thinking: true,
};
const migrationPlan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "plan-token",
source: {
space_id: "old-space",
model: "old-search",
dims: 512,
collection_base: "memory",
vectors: 12,
},
target: {
space_id: null,
model: "new-search",
dims: 1024,
collection_base: "",
vectors: null,
},
target_profile_id: profile.profile_id,
target_revision: profile.revision,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [],
total_vectors: 12,
users: 1,
graph_vectors: null,
estimate_s: 10,
requests: 1,
disk_bytes_needed: 1024,
disk_bytes_free: 4096,
retention_days: 14,
blockers: [],
};
const oldFinishedJob: EmbeddingSwitchJob = {
job_id: "old-job",
kind: "switch",
state: "done",
source: migrationPlan.source,
target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 },
stray_space_ids: [],
target_profile_id: profile.profile_id,
target_revision: profile.revision,
requested_by: "app",
progress: {
phase: "done",
done: 12,
total: 12,
percent: 100,
embedded: 12,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: 0,
updated_at: "2026-11-03T09:00:00Z",
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: "2026-11-03T09:00:00Z",
updated_at: "2026-11-03T09:00:01Z",
finished_at: "2026-11-03T09:00:01Z",
};
/** Re-renders on demand so a cleared journal is picked up, like a real refresh. */
function ProbeRecoveryHarness({
@@ -151,6 +226,19 @@ vi.mock("./use-model-profiles", () => ({
update,
}),
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: switchCurrent,
plan: switchPlan,
busy: null,
errorCode: null,
planActivation,
startPlannedSwitch,
shouldUseLegacyActivation: () => false,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
describe("ModelSettings", () => {
it("exposes distinct edit, add and test actions from the same owner without activating", async () => {
@@ -187,8 +275,14 @@ describe("ModelSettings", () => {
probes = {};
pendingProbe = null;
profiles = [profile];
switchPlan = null;
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
});
});
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
@@ -399,6 +493,62 @@ describe("ModelSettings", () => {
});
});
it("focuses generation without exposing or patching the search lane", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy();
expect(screen.queryByLabelText("搜索模型")).toBeNull();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
profile.model,
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not repair a hidden legacy search endpoint while editing generation", async () => {
const legacy = {
...profile,
embedding_provider_kind: "openai_compatible" as const,
embedding_model: "remote-search",
embedding_base_url: null,
};
profiles = [legacy];
update.mockResolvedValue(legacy);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.type(screen.getByLabelText("生成模型名"), "-new");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
model: "memory-model-new",
});
});
it("focuses search on the recommended builtin without exposing or patching generation", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="embedding" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy();
expect(screen.queryByLabelText("生成模型名")).toBeNull();
expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local");
expect(screen.getByText(/无需密钥/)).toBeTruthy();
expect(screen.getByText("技术类型:Embedding")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not replace an edit draft with a newer external profile revision", async () => {
const view = render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
@@ -661,9 +811,45 @@ describe("ModelSettings", () => {
expect(container.textContent).not.toContain("secret-value");
await user.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(planActivation).toHaveBeenCalledWith("profile-1", 1);
expect(activate).toHaveBeenCalledWith("profile-1");
});
it("asks before activation when the search index must be rebuilt", async () => {
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(
await screen.findByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(activate).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
+512 -361
View File
@@ -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",
+10 -1
View File
@@ -17,6 +17,10 @@ import {
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
import { AppMark } from "./app-mark";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import {
defaultHostSourceIds,
@@ -319,8 +323,11 @@ export function OnboardingFlow({
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
if (
alive.current &&
!modeChoiceTouched.current &&
@@ -498,9 +505,11 @@ export function OnboardingFlow({
return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
await setMemoryMode(modeChoice);
const changed = await setMemoryMode(modeChoice);
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
if (modeChoice === "lite") {
await leave(modeDestination);
return;
@@ -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;
+136 -72
View File
@@ -5,6 +5,7 @@ import type {
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -12,6 +13,7 @@ import {
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
const defaultPreset = PROVIDER_PRESETS.find(
@@ -68,6 +70,7 @@ export function OnboardingModelController({
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
@@ -82,6 +85,9 @@ export function OnboardingModelController({
state: "passed" | "failed" | "stale";
message: string;
} | null>(null);
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
null,
);
const flight = useRef(false);
const draftTouched = useRef(false);
@@ -115,6 +121,27 @@ export function OnboardingModelController({
});
}, [models.profiles, saved]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchPendingJobId ||
!finished ||
finished.job_id !== switchPendingJobId
)
return;
if (finished.state === "done") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
@@ -273,6 +300,18 @@ export function OnboardingModelController({
setError(null);
const trace = traceUiAction("memory", "submit");
try {
const plan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
saved.profile_id,
saved.revision,
);
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
return;
}
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
@@ -287,77 +326,102 @@ export function OnboardingModelController({
}
return (
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
<>
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
}}
onStart={() => {
flight.current = true;
setPhase("enabling");
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (job) {
setSwitchPendingJobId(job.job_id);
return;
}
flight.current = false;
setPhase("idle");
});
}}
/>
) : null}
</>
);
}
+177 -9
View File
@@ -1,10 +1,22 @@
// @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,
planMemoryMode,
setSedimentation,
} from "./api";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
@@ -19,7 +31,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 +39,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", () => ({
@@ -77,6 +93,8 @@ vi.mock("./api", () => ({
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
vi.mock("./ModelSettings", async () => {
@@ -85,14 +103,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 +156,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 +175,93 @@ 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: {},
},
});
vi.mocked(planMemoryMode).mockResolvedValueOnce({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
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 +270,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 +289,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 +300,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 +328,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
View File
@@ -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)}
+26
View File
@@ -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);
});
});
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { beforeEach, expect, it, vi } from "vitest";
const fake = vi.hoisted(() => ({
get: vi.fn(),
put: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get: fake.get,
put: fake.put,
interceptors: { request: { use: vi.fn() } },
}),
isAxiosError: () => false,
},
}));
import { getHomeProjection, getMemoryMode, setMemoryMode } from "./api";
import {
confirmedMemoryModeSnapshot,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
const graph = {
state: "idle",
started_at: null,
finished_at: null,
users: {},
};
beforeEach(() => {
resetConfirmedMemoryModeForTests();
fake.get.mockReset();
fake.put.mockReset();
});
it("keeps transport reads and writes free of confirmed-mode side effects", async () => {
fake.get
.mockResolvedValueOnce({
data: {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 0,
display_state: "empty",
latest: null,
observed_at: "2026-11-04T00:00:00Z",
},
})
.mockResolvedValueOnce({
data: {
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: graph,
},
});
fake.put.mockResolvedValue({
data: {
mode: "ultra",
configured_mode: "ultra",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: graph,
previous_mode: "pro",
effects: [],
},
});
await getHomeProjection();
await getMemoryMode();
await setMemoryMode("ultra");
expect(confirmedMemoryModeSnapshot().mode).toBeNull();
});
+63 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CORE_URL, isHealthResponse } from "./api";
import {
DEFAULT_CORE_URL,
homeProjectionUnsupported,
isHealthResponse,
memoryModePlanUnsupported,
parseHomeProjection,
} from "./api";
describe("Core API contract", () => {
it("defaults to the local-only Core address", () => {
@@ -17,3 +23,59 @@ describe("Core API contract", () => {
).toBe(false);
});
});
describe("home projection parser", () => {
const projection = {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "no_user_excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
source_id: "cursor",
excerpt: "",
truncated: false,
display_state: "no_user_excerpt",
time: null,
time_basis: "import",
},
observed_at: "2026-11-04T00:00:00Z",
};
it("accepts a self-consistent lite projection", () => {
expect(parseHomeProjection(projection)).toEqual(projection);
});
it("rejects cross-mode and extra fields", () => {
expect(
parseHomeProjection({ ...projection, effective_mode: "pro" }),
).toBeNull();
expect(parseHomeProjection({ ...projection, surprise: true })).toBeNull();
});
});
it("accepts only a bare route-level Not Found as unsupported", () => {
const error = (data: unknown) => ({
isAxiosError: true,
response: { status: 404, data },
});
expect(homeProjectionUnsupported(error({ detail: "Not Found" }))).toBe(true);
expect(memoryModePlanUnsupported(error({ detail: "Not Found" }))).toBe(true);
expect(homeProjectionUnsupported(error({ detail: "memory_not_found" }))).toBe(
false,
);
expect(
memoryModePlanUnsupported(
error({ detail: "Not Found", context: "domain" }),
),
).toBe(false);
});
+320 -1
View File
@@ -2,6 +2,11 @@ import {
CORE_SERVICE,
type ConnectSourcesResponse,
type DetectionResponse,
type EmbeddingSpaceDeleteReceipt,
type EmbeddingSpaces,
type EmbeddingSwitchCurrent,
type EmbeddingSwitchJob,
type EmbeddingSwitchPlan,
type EvolveOverview,
type EvolveRunReceipt,
type EvolveSchedule,
@@ -11,6 +16,7 @@ import {
type GraphWidthDecisionReceipt,
type GraphWidthStatus,
type HealthResponse,
type HomeProjectionV1,
type HostAskMemoryEvent,
type HostHomeSummary,
type ImportIssue,
@@ -21,6 +27,7 @@ import {
type MemoryAddReceipt,
type MemoryGraphResponse,
type MemoryModeChange,
type MemoryModePlan,
type MemoryModeState,
type MemoryPipelineMode,
type MemoryProcessingSummary,
@@ -48,20 +55,37 @@ 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,
isBareNotFound,
memoryAddStatusPath,
normalizeManualExtractEvidence,
parseEmbeddingSpaceDeleteReceipt,
parseEmbeddingSpaces,
parseEmbeddingSwitchCurrent,
parseEmbeddingSwitchJob,
parseEmbeddingSwitchPlan,
parseEmbeddingSwitchRefusal,
parseEmbeddingSwitchSummary,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryAddReceipt,
parseMemoryModePlan,
parseModelProfileReceipt,
retiredEmbeddingSpacePath,
SourceSyncInputError,
} from "@one-memory/operations";
import axios from "axios";
@@ -594,6 +618,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 &&
isBareNotFound(reason.response?.data)
);
}
/** 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,
@@ -665,6 +818,22 @@ export async function getHomeSummary(): Promise<HostHomeSummary> {
return response.data;
}
export async function getHomeProjection(): Promise<HomeProjectionV1> {
const response = await client.get<unknown>("/api/v2/home-projection");
const projection = parseHomeProjection(response.data);
if (projection === null) throw new Error("home_projection_invalid");
return projection;
}
/** A route-level 404 only: domain errors must not masquerade as old Core. */
export function homeProjectionUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
isBareNotFound(reason.response?.data)
);
}
export async function getLiveSyncHealth(): Promise<LiveSyncHealth[]> {
const response = await client.get<LiveSyncHealth[]>("/api/v1/live-sync");
if (!Array.isArray(response.data) || !response.data.every(isLiveSyncHealth))
@@ -926,6 +1095,27 @@ export async function getMemoryMode(): Promise<MemoryModeState> {
return response.data;
}
export async function planMemoryMode(
mode: MemoryPipelineMode,
): Promise<MemoryModePlan> {
const response = await client.post<unknown>(
"/api/v1/settings/memory-mode/plan",
{ mode },
);
const plan = parseMemoryModePlan(response.data);
if (plan === null || plan.target !== mode)
throw new Error("memory_mode_plan_invalid");
return plan;
}
export function memoryModePlanUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
isBareNotFound(reason.response?.data)
);
}
export async function setMemoryMode(
mode: MemoryPipelineMode,
): Promise<MemoryModeChange> {
@@ -1215,7 +1405,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)
);
}
@@ -1372,6 +1565,132 @@ function measuredCount(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
export function parseHomeProjection(value: unknown): HomeProjectionV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const projection = value as Record<string, unknown>;
const allowed = new Set([
"projection_version",
"user_id",
"effective_mode",
"capabilities",
"visible_count",
"display_state",
"latest",
"observed_at",
]);
const capabilities = projection.capabilities as Record<
string,
unknown
> | null;
if (
Object.keys(projection).some((key) => !allowed.has(key)) ||
projection.projection_version !== 1 ||
typeof projection.user_id !== "string" ||
!["lite", "pro", "ultra"].includes(String(projection.effective_mode)) ||
!capabilities ||
Object.keys(capabilities).some(
(key) =>
![
"content_plane",
"generation_required",
"graph_enabled",
"evolve_available",
].includes(key),
) ||
!["raw", "memory"].includes(String(capabilities.content_plane)) ||
typeof capabilities.generation_required !== "boolean" ||
typeof capabilities.graph_enabled !== "boolean" ||
typeof capabilities.evolve_available !== "boolean" ||
capabilities.content_plane !==
(projection.effective_mode === "lite" ? "raw" : "memory") ||
capabilities.generation_required !==
(projection.effective_mode !== "lite") ||
capabilities.graph_enabled !== (projection.effective_mode === "ultra") ||
capabilities.evolve_available !== (projection.effective_mode === "ultra") ||
!measuredCount(projection.visible_count) ||
!["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"].includes(
String(projection.display_state),
) ||
!validProjectionLatest(projection.latest, projection.effective_mode) ||
!projectionLatestDisplayConsistent(
projection.latest,
projection.display_state,
) ||
((projection.visible_count as number) === 0 &&
projection.latest !== null) ||
((projection.visible_count as number) > 0 &&
projection.display_state === "empty") ||
typeof projection.observed_at !== "string" ||
!Number.isFinite(Date.parse(projection.observed_at))
)
return null;
return projection as unknown as HomeProjectionV1;
}
function projectionLatestDisplayConsistent(
value: unknown,
displayState: unknown,
): boolean {
if (value === null)
return displayState !== "excerpt" && displayState !== "no_user_excerpt";
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const latest = value as Record<string, unknown>;
return latest.type === "raw"
? displayState === latest.display_state
: latest.type === "memory" && displayState === "excerpt";
}
function validProjectionLatest(value: unknown, mode: unknown): boolean {
if (value === null) return true;
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const latest = value as Record<string, unknown>;
const base = [
"type",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
];
const raw = ["raw_id", "session_id", "display_state"];
const memory = ["memory_id", "scene", "kind", "variant"];
if (
Object.keys(latest).some(
(key) =>
![...base, ...(latest.type === "raw" ? raw : memory)].includes(key),
) ||
!(
latest.time === null ||
(typeof latest.time === "string" &&
Number.isFinite(Date.parse(latest.time)))
) ||
![null, "conversation", "import", "write", "update"].includes(
latest.time_basis as never,
) ||
typeof latest.excerpt !== "string" ||
typeof latest.truncated !== "boolean" ||
typeof latest.source_id !== "string"
)
return false;
if (latest.type === "raw")
return (
mode === "lite" &&
typeof latest.raw_id === "string" &&
typeof latest.session_id === "string" &&
["excerpt", "no_user_excerpt"].includes(String(latest.display_state))
);
return (
latest.type === "memory" &&
mode !== "lite" &&
typeof latest.memory_id === "string" &&
["chat", "working", "coding"].includes(String(latest.scene)) &&
["chat", "experience"].includes(String(latest.kind)) &&
(latest.variant === null ||
latest.variant === "working" ||
latest.variant === "coding")
);
}
function isSourceImportExample(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
+10
View File
@@ -270,6 +270,13 @@
overflow: hidden;
overflow-wrap: anywhere;
}
.r6-home-activity h3.r6-home-raw-excerpt {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.r6-home-activity small {
font-size: 12px;
color: #637786;
@@ -504,6 +511,9 @@ button.r6-home-expand:focus-visible,
.r6-home-activity > div {
flex-wrap: wrap;
}
.r6-home-activity section {
flex-basis: min(100%, 260px);
}
.r6-home-metrics {
grid-template-columns: 1fr;
}
@@ -151,9 +151,9 @@ it("clear-all uses the dedicated transport and never reuses page IDs", async ()
await expect(clearAllCallEvents()).rejects.toThrow();
});
it("uses one plain-language lookup vocabulary across summary surfaces", () => {
expect(callEventHeadline(event)).toBe("Codex 查到 1 条相关内容");
expect(callEventHeadline(event)).toBe("Codex 找到 1 条原始会话");
expect(callDetailHeadline({ ...event, memory_count: 2 })).toBe(
"Codex 查到 3 条相关内容",
"Codex 找到 2 条记忆 · 1 条原始会话",
);
expect(callDetailHeadline({ ...event, state: "empty" })).toBe(
"Codex 没有查到相关记忆",
@@ -167,7 +167,7 @@ it("uses one plain-language lookup vocabulary across summary surfaces", () => {
expect(callDetailHeadline({ ...event, state: "denied" })).toBe(
"Codex 未获准查记忆",
);
expect(callRowHeadline(event)).toBe("Codex 查到 1 条相关内容");
expect(callRowHeadline(event)).toBe("Codex 找到 1 条原始会话");
expect(callRowHeadline({ ...event, state: "unavailable" })).toBe(
"Codex 暂时无法查记忆",
);
@@ -175,6 +175,39 @@ it("uses one plain-language lookup vocabulary across summary surfaces", () => {
"后台正在整理记忆,查询超时,这次回答没有带上记忆",
);
});
it("classifies successful headlines by returned plane, never by mode or sum", () => {
expect(callEventHeadline({ ...event, raw_count: 0, memory_count: 2 })).toBe(
"Codex 找到 2 条记忆",
);
expect(callEventHeadline({ ...event, raw_count: 3, memory_count: 2 })).toBe(
"Codex 找到 2 条记忆 · 3 条原始会话",
);
expect(
callEventHeadline({
...event,
tool: "om_matters",
raw_count: 0,
memory_count: 0,
references: [],
}),
).toBe("Codex 调用已完成");
expect(
callEventHeadline({
...event,
state: "partial",
raw_count: 2,
memory_count: 0,
}),
).toBe("Codex 找到 2 条原始会话");
expect(
callEventHeadline({
...event,
state: "empty",
raw_count: 3,
memory_count: 2,
}),
).toBe("Codex 没有查到相关记忆");
});
it("keeps delivery and adoption separate for call history", async () => {
fake.list.mockResolvedValue({
...page,
+25 -6
View File
@@ -5,6 +5,7 @@ import {
translate,
translateCount,
} from "@one-memory/i18n";
import { classifyCallResult } from "@one-memory/operations";
import {
clearAllCallEventsRequest,
clearCallEventsRequest,
@@ -85,13 +86,31 @@ export function callEventHeadline(
locale: GuiLocale = "zh-Hans",
) {
const name = host ?? callHostName(item.host_id, locale);
const fixed = HEADLINES[item.state as keyof typeof HEADLINES];
const classification = classifyCallResult(item);
const fixed = HEADLINES[classification as keyof typeof HEADLINES];
if (fixed) return translate(locale, fixed, { host: name });
const count = item.raw_count + item.memory_count;
return translateCount(locale, "calls.headline.found", count, {
host: name,
count: plainCount(locale, count),
});
if (classification === "raw-only")
return translateCount(locale, "calls.headline.rawOnly", item.raw_count, {
host: name,
count: plainCount(locale, item.raw_count),
});
if (classification === "memory-only")
return translateCount(
locale,
"calls.headline.memoryOnly",
item.memory_count,
{
host: name,
count: plainCount(locale, item.memory_count),
},
);
if (classification === "mixed")
return translateCount(locale, "calls.headline.mixed", item.memory_count, {
host: name,
memories: plainCount(locale, item.memory_count),
raw: plainCount(locale, item.raw_count),
});
return translate(locale, "calls.headline.completed", { host: name });
}
export function callRowHeadline(
item: CallEvent,
@@ -0,0 +1,32 @@
import { beforeEach, expect, it } from "vitest";
import {
commitConfirmedMemoryMode,
confirmedMemoryModeSnapshot,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
beforeEach(resetConfirmedMemoryModeForTests);
it("does not let an older GET overwrite a confirmed PUT", () => {
const oldGet = issueConfirmedModeTicket("settings");
const put = issueConfirmedModeTicket("settings");
expect(commitConfirmedMemoryMode("lite", put)).toBe(true);
expect(commitConfirmedMemoryMode("pro", oldGet)).toBe(false);
expect(confirmedMemoryModeSnapshot().mode).toBe("lite");
});
it("keeps projection capabilities with the accepted mode", () => {
const ticket = issueConfirmedModeTicket("home");
commitConfirmedMemoryMode("ultra", ticket, {
content_plane: "memory",
generation_required: true,
graph_enabled: true,
evolve_available: true,
});
expect(confirmedMemoryModeSnapshot()).toMatchObject({
mode: "ultra",
capabilities: { graph_enabled: true, evolve_available: true },
});
});
@@ -0,0 +1,101 @@
import type {
HomeProjectionCapabilities,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { useSyncExternalStore } from "react";
let confirmedMode: MemoryPipelineMode | null = null;
let confirmedCapabilities: HomeProjectionCapabilities | null = null;
let revision = 0;
let issuedSequence = 0;
let committedSequence = 0;
const listeners = new Set<() => void>();
export interface ConfirmedModeTicket {
owner: "home" | "settings" | "onboarding";
sequence: number;
}
export function issueConfirmedModeTicket(
owner: ConfirmedModeTicket["owner"],
): ConfirmedModeTicket {
return { owner, sequence: ++issuedSequence };
}
export function isConfirmedModeTicketCurrent(
ticket: ConfirmedModeTicket,
): boolean {
return ticket.sequence >= committedSequence;
}
function capabilitiesFor(mode: MemoryPipelineMode): HomeProjectionCapabilities {
return {
content_plane: mode === "lite" ? "raw" : "memory",
generation_required: mode !== "lite",
graph_enabled: mode === "ultra",
evolve_available: mode === "ultra",
};
}
export function commitConfirmedMemoryMode(
mode: MemoryPipelineMode,
ticket: ConfirmedModeTicket,
capabilities: HomeProjectionCapabilities = capabilitiesFor(mode),
): boolean {
if (ticket.sequence < committedSequence) return false;
committedSequence = ticket.sequence;
if (
mode === confirmedMode &&
capabilities.content_plane === confirmedCapabilities?.content_plane &&
capabilities.generation_required ===
confirmedCapabilities.generation_required &&
capabilities.graph_enabled === confirmedCapabilities.graph_enabled &&
capabilities.evolve_available === confirmedCapabilities.evolve_available
)
return true;
confirmedMode = mode;
confirmedCapabilities = capabilities;
revision += 1;
for (const listener of listeners) listener();
return true;
}
export function confirmedMemoryModeSnapshot() {
return {
mode: confirmedMode,
capabilities: confirmedCapabilities,
revision,
};
}
let cached = confirmedMemoryModeSnapshot();
function snapshot() {
if (
cached.mode !== confirmedMode ||
cached.capabilities !== confirmedCapabilities ||
cached.revision !== revision
)
cached = confirmedMemoryModeSnapshot();
return cached;
}
export function useConfirmedMemoryMode() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
snapshot,
snapshot,
);
}
export function resetConfirmedMemoryModeForTests() {
confirmedMode = null;
confirmedCapabilities = null;
revision = 0;
issuedSequence = 0;
committedSequence = 0;
cached = confirmedMemoryModeSnapshot();
listeners.clear();
}
+271
View File
@@ -0,0 +1,271 @@
.r6-settings-dialog-layer.embedding-switch-dialog-layer {
z-index: 120;
}
.r6-settings-dialog.embedding-switch-dialog {
box-sizing: border-box;
width: min(600px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
overflow: auto;
color: var(--r6-text);
}
.embedding-switch-dialog > header {
display: flex;
align-items: flex-start;
}
.r6-settings-dialog.embedding-switch-dialog h2 {
margin: 0 0 6px;
font-size: 20px;
line-height: 28px;
}
.embedding-switch-safety p,
.embedding-switch-dialog header p {
margin: 0;
color: var(--r6-muted);
font-size: 13px;
line-height: 22px;
}
.embedding-switch-impact {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 22px 0;
border-block: 1px solid var(--bd);
}
.embedding-switch-impact > div {
padding: 14px 16px 14px 0;
}
.embedding-switch-impact > div + div {
padding-left: 16px;
border-left: 1px solid var(--bd);
}
.embedding-switch-impact dt,
.embedding-switch-details dt {
color: var(--meta-reading);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-impact dd,
.embedding-switch-details dd {
margin: 3px 0 0;
color: var(--r6-text);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 21px;
}
.embedding-switch-safety {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 10px 18px;
padding: 18px 0;
border-bottom: 1px solid var(--bd);
}
.embedding-switch-safety strong {
font-size: 13px;
line-height: 21px;
}
.embedding-switch-details {
margin: 16px 0 0;
}
.embedding-switch-details summary {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
color: var(--r6-muted);
font-size: 13px;
cursor: pointer;
}
.embedding-switch-details summary:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.embedding-switch-alert svg {
flex: 0 0 auto;
margin-top: 2px;
}
.embedding-switch-details[open] summary svg {
transform: rotate(180deg);
}
.embedding-switch-details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 24px;
margin: 12px 0 0;
padding: 16px;
border-top: 1px solid var(--bd);
border-radius: 0;
background: transparent;
}
.embedding-switch-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 11px 12px;
border-radius: 8px;
border: 1px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 13px;
line-height: 20px;
}
.r6-settings-dialog.embedding-switch-dialog footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.embedding-switch-strip,
.embedding-switch-outcome {
box-sizing: border-box;
display: flex;
min-height: 50px;
align-items: center;
gap: 16px;
padding: 9px 18px;
border-bottom: 0.5px solid var(--bd);
background: var(--panel);
color: var(--tx);
}
.embedding-switch-strip-copy,
.embedding-switch-outcome > div:first-child {
display: grid;
min-width: 190px;
gap: 2px;
}
.embedding-switch-strip strong,
.embedding-switch-outcome strong {
font-size: 13px;
font-weight: 600;
line-height: 19px;
}
.embedding-switch-strip span,
.embedding-switch-outcome span {
color: var(--tx2);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
.embedding-switch-progress {
height: 5px;
flex: 1;
overflow: hidden;
border-radius: 999px;
background: var(--bd);
}
.embedding-switch-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ac);
transition: width var(--motion-state) var(--motion-enter);
}
.embedding-switch-outcome {
justify-content: space-between;
background: var(--ok-bg);
}
.embedding-switch-outcome.is-error {
background: var(--dg-bg);
}
.embedding-switch-outcome-actions {
display: flex;
align-items: center;
gap: 8px;
}
.embedding-switch-cleanup-error {
margin: 0;
padding: 8px 18px;
border-bottom: 0.5px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-outcome-actions > .r6-button:last-child {
width: 36px;
padding-inline: 0;
}
@media (max-width: 700px) {
.embedding-switch-dialog-layer {
align-items: end;
padding: 0;
}
.embedding-switch-dialog {
width: 100%;
max-height: 92vh;
padding: 22px 18px;
border-radius: 14px 14px 0 0;
}
.embedding-switch-impact {
grid-template-columns: 1fr;
}
.embedding-switch-impact > div {
padding: 12px 0;
}
.embedding-switch-impact > div + div {
padding-left: 0;
border-top: 1px solid var(--bd);
border-left: 0;
}
.embedding-switch-safety,
.embedding-switch-details dl {
grid-template-columns: 1fr;
}
.embedding-switch-strip,
.embedding-switch-outcome {
align-items: flex-start;
flex-wrap: wrap;
}
.embedding-switch-progress {
order: 3;
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.embedding-switch-progress > span {
transition: none;
}
}
[data-native-reduce-motion="true"] .embedding-switch-progress > span {
transition: none;
}
+213
View File
@@ -0,0 +1,213 @@
import type { HomeProjectionV1 } from "@one-memory/contracts";
import { describe, expect, it } from "vitest";
import {
buildHomeViewModel,
type HomeProjectionReadState,
} from "./home-view-model";
function projection(
mode: "lite" | "pro" | "ultra",
overrides: Partial<HomeProjectionV1> = {},
): HomeProjectionV1 {
return {
projection_version: 1,
user_id: "local",
effective_mode: mode,
capabilities: {
content_plane: mode === "lite" ? "raw" : "memory",
generation_required: mode !== "lite",
graph_enabled: mode === "ultra",
evolve_available: mode === "ultra",
},
visible_count: 0,
display_state: "empty",
latest: null,
observed_at: "2026-11-04T00:00:00Z",
...overrides,
};
}
const base = {
processing: null,
sedimentation: null,
recentCall: null,
importsRunning: false,
hostState: "ready" as const,
syncEnabledCount: 0,
};
describe("home view model", () => {
it.each([
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
["pro", "memory", "apps.memories", "apps.running"],
["ultra", "memory", "apps.memories", "apps.running"],
] as const)(
"projects %s without component mode branches",
(mode, plane, count, processing) => {
const result = buildHomeViewModel({
...base,
projection: projection(mode),
projectionState: "ready",
sedimentation: {
enabled: true,
pending_retained: true,
imports_unaffected: true,
},
});
expect(result.plane).toBe(plane);
expect(result.countLabel).toBe(count);
expect(result.processingLabel).toBe(processing);
expect(result.currentModeLabel).toBe(mode);
},
);
it.each(["loading", "stale", "unavailable"] as HomeProjectionReadState[])(
"preserves explicit %s reads",
(state) => {
expect(
buildHomeViewModel({
...base,
projection: null,
projectionState: state,
}).state,
).toBe(state);
},
);
it("keeps a raw row with no user excerpt truthful", () => {
const result = buildHomeViewModel({
...base,
projectionState: "ready",
projection: projection("lite", {
visible_count: 1,
display_state: "no_user_excerpt",
latest: {
type: "raw",
raw_id: "r1",
session_id: "s1",
source_id: "cursor",
excerpt: "",
truncated: false,
display_state: "no_user_excerpt",
time: null,
time_basis: "import",
},
}),
});
expect(result.recent).toMatchObject({ kind: "raw", emptyExcerpt: true });
});
it("renders a checked lite empty state without generation copy", () => {
const result = buildHomeViewModel({
...base,
projection: projection("lite"),
projectionState: "ready",
processing: {
running: 4,
pending: 7,
waiting_for_model: 2,
} as never,
});
expect(result.recentMetric).toEqual({
valueKey: "apps.rawCheckedEmpty",
detailKey: "apps.checkedRecent",
});
expect(result.activity).toMatchObject({
kind: "empty",
headingKey: "apps.recentUpdate",
titleKey: "apps.rawCheckedEmpty",
detailKey: "apps.rawWaiting",
});
});
it("uses raw-saving activity during lite imports", () => {
const result = buildHomeViewModel({
...base,
projection: projection("lite"),
projectionState: "ready",
importsRunning: true,
});
expect(result.activity).toMatchObject({
kind: "importing",
headingKey: "apps.recentUpdate",
titleKey: "apps.rawSaving",
detailKey: "apps.rawSavingDetail",
});
});
it.each([
[{ running: 1 }, "first-running", "apps.firstRunning"],
[{ pending: 1 }, "first-queued", "apps.firstQueued"],
] as const)(
"keeps pro generation state in the view model",
(processing, kind, titleKey) => {
const result = buildHomeViewModel({
...base,
projection: projection("pro"),
projectionState: "ready",
processing: processing as never,
});
expect(result.activity).toMatchObject({ kind, titleKey });
},
);
it("renders a checked pro empty state", () => {
const result = buildHomeViewModel({
...base,
projection: projection("pro"),
projectionState: "ready",
});
expect(result.recentMetric).toEqual({
valueKey: "apps.memoryCheckedEmpty",
detailKey: "apps.checkedRecent",
});
expect(result.activity.kind).toBe("empty");
});
it("never treats a legacy memory summary as lite raw data", () => {
const result = buildHomeViewModel({
...base,
confirmedMode: "lite",
projection: null,
projectionState: "unavailable",
legacySummary: {
memory_count: 99,
latest: null,
observed_at: "2026-11-04T00:00:00Z",
},
});
expect(result.count).toBeNull();
expect(result.plane).toBe("raw");
});
it("distinguishes unknown count from a confirmed empty projection", () => {
expect(
buildHomeViewModel({
...base,
projection: null,
projectionState: "loading",
}).count,
).toBeNull();
expect(
buildHomeViewModel({
...base,
projection: projection("pro", { visible_count: 0 }),
projectionState: "ready",
}).count,
).toBe(0);
});
it.each([
["loading", "apps.rawReadingCount"],
["unavailable", "apps.rawCountWillUpdate"],
] as const)("uses lite-specific count copy while %s", (state, key) => {
expect(
buildHomeViewModel({
...base,
confirmedMode: "lite",
projection: null,
projectionState: state,
}).countDetail,
).toBe(key);
});
});
+298
View File
@@ -0,0 +1,298 @@
import type {
HomeProjectionV1,
HostHomeSummary,
MemoryProcessingSummary,
} from "@one-memory/contracts";
import type { SedimentationState } from "./api";
import type { CallEvent } from "./call-history-client";
export type HomeProjectionReadState =
| "loading"
| "ready"
| "stale"
| "unavailable";
export interface HomeViewModel {
mode: "lite" | "pro" | "ultra" | null;
plane: "raw" | "memory" | null;
count: number | null;
countLabel: "apps.rawConversations" | "apps.memories";
countDetail:
| "apps.readingCount"
| "apps.rawReadingCount"
| "apps.scope"
| "apps.showingPrevious"
| "apps.countWillUpdate"
| "apps.rawCountWillUpdate";
processingTitle: "apps.rawSavingStatus" | "apps.processing";
processingLabel: string;
processingDetail: string;
organizingTitle: "apps.rawSavingStatus" | "apps.organizing";
organizingLabel: string;
currentModeLabel: "lite" | "pro" | "ultra" | "—";
state: HomeProjectionReadState;
recent: {
kind: "raw" | "memory";
excerpt: string;
sourceId: string;
time: string | null;
timeBasis: string | null;
emptyExcerpt: boolean;
} | null;
recentMetric: {
valueKey:
| "apps.checkingRecent"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| null;
detailKey:
| "apps.rawSaved"
| "apps.saved"
| "apps.recentWillUpdate"
| "apps.checkedRecent";
};
activity: {
kind:
| "update"
| "importing"
| "first-running"
| "first-queued"
| "empty"
| "loading"
| "unavailable";
headingKey:
| "apps.recentUpdate"
| "apps.firstBatch"
| "apps.activityUnavailable";
titleKey:
| "apps.rawSaving"
| "apps.importing"
| "apps.firstRunning"
| "apps.firstQueued"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| "apps.rawReadingCount"
| "apps.readingActivity"
| "apps.memoryUnavailable"
| null;
detailKey:
| "apps.rawWaiting"
| "apps.rawSavingDetail"
| "apps.savingConversations"
| "apps.waitingMemories"
| "apps.recentWillUpdate"
| "apps.memoryReadFailure"
| null;
};
recentCall: CallEvent | null;
importsRunning: boolean;
hostState: "loading" | "ready" | "unavailable";
syncEnabledCount: number;
}
export function buildHomeViewModel(input: {
projection: HomeProjectionV1 | null;
confirmedMode?: "lite" | "pro" | "ultra" | null;
projectionState: HomeProjectionReadState;
legacySummary?: HostHomeSummary | null;
processing: MemoryProcessingSummary | null;
sedimentation: SedimentationState | null;
recentCall: CallEvent | null;
importsRunning: boolean;
hostState: "loading" | "ready" | "unavailable";
syncEnabledCount: number;
}): HomeViewModel {
const { projection } = input;
const mode = projection?.effective_mode ?? input.confirmedMode ?? null;
const plane =
projection?.capabilities.content_plane ??
(mode === "lite" ? "raw" : mode ? "memory" : null);
const legacy =
mode !== "lite" && !projection ? (input.legacySummary ?? null) : null;
const count = projection
? projection.visible_count
: legacy
? legacy.memory_count
: null;
const state = input.projectionState;
const generationRequired =
projection?.capabilities.generation_required ?? mode !== "lite";
const sedimenting = input.sedimentation?.enabled;
const processingLabel =
!generationRequired || mode === "lite"
? "apps.rawSaving"
: sedimenting === true
? "apps.running"
: sedimenting === false
? "apps.paused"
: "apps.checkingStatus";
const processingDetail =
!generationRequired || mode === "lite"
? "apps.rawSavingDetail"
: sedimenting === true
? "apps.organizingNew"
: sedimenting === false
? "apps.stoppedNew"
: "apps.checkingProcessing";
const latest = projection?.latest;
const legacyLatest = legacy?.latest ?? null;
const recent = latest
? {
kind: latest.type,
excerpt: latest.excerpt,
sourceId: latest.source_id,
time: latest.time,
timeBasis: latest.time_basis,
emptyExcerpt:
latest.type === "raw" && latest.display_state === "no_user_excerpt",
}
: legacyLatest
? {
kind: "memory" as const,
excerpt: legacyLatest.content,
sourceId:
"revision" in legacyLatest
? (legacyLatest.source_ids[0] ?? "")
: (legacyLatest.source ?? ""),
time:
"revision" in legacyLatest
? legacyLatest.updated_at
: legacyLatest.gmt_created === null
? null
: new Date(legacyLatest.gmt_created * 1000).toISOString(),
timeBasis: null,
emptyExcerpt: false,
}
: null;
const recentMetric = recent
? {
valueKey: null,
detailKey:
recent.kind === "raw"
? ("apps.rawSaved" as const)
: ("apps.saved" as const),
}
: state === "loading"
? {
valueKey: "apps.checkingRecent" as const,
detailKey: "apps.recentWillUpdate" as const,
}
: state === "ready" || state === "stale"
? {
valueKey:
plane === "raw"
? ("apps.rawCheckedEmpty" as const)
: ("apps.memoryCheckedEmpty" as const),
detailKey: "apps.checkedRecent" as const,
}
: {
valueKey: "apps.checkingRecent" as const,
detailKey: "apps.recentWillUpdate" as const,
};
const activity: HomeViewModel["activity"] =
state === "unavailable"
? {
kind: "unavailable",
headingKey: "apps.activityUnavailable",
titleKey: "apps.memoryUnavailable",
detailKey: "apps.memoryReadFailure",
}
: state === "loading"
? plane === "raw"
? {
kind: "loading",
headingKey: "apps.recentUpdate",
titleKey: "apps.rawReadingCount",
detailKey: "apps.recentWillUpdate",
}
: {
kind: "loading",
headingKey: "apps.firstBatch",
titleKey: "apps.readingActivity",
detailKey: "apps.recentWillUpdate",
}
: recent
? {
kind: "update",
headingKey: "apps.recentUpdate",
titleKey: null,
detailKey: null,
}
: input.importsRunning
? generationRequired
? {
kind: "importing",
headingKey: "apps.firstBatch",
titleKey: "apps.importing",
detailKey: "apps.savingConversations",
}
: {
kind: "importing",
headingKey: "apps.recentUpdate",
titleKey: "apps.rawSaving",
detailKey: "apps.rawSavingDetail",
}
: generationRequired && input.processing?.running
? {
kind: "first-running",
headingKey: "apps.firstBatch",
titleKey: "apps.firstRunning",
detailKey: "apps.waitingMemories",
}
: generationRequired &&
(input.processing?.pending ||
input.processing?.waiting_for_model)
? {
kind: "first-queued",
headingKey: "apps.firstBatch",
titleKey: "apps.firstQueued",
detailKey: "apps.waitingMemories",
}
: plane === "raw"
? {
kind: "empty",
headingKey: "apps.recentUpdate",
titleKey: "apps.rawCheckedEmpty",
detailKey: "apps.rawWaiting",
}
: {
kind: "empty",
headingKey: "apps.firstBatch",
titleKey: "apps.memoryCheckedEmpty",
detailKey: "apps.waitingMemories",
};
return {
mode,
plane,
count,
countLabel: plane === "raw" ? "apps.rawConversations" : "apps.memories",
countDetail:
state === "stale"
? "apps.showingPrevious"
: state === "unavailable"
? plane === "raw"
? "apps.rawCountWillUpdate"
: "apps.countWillUpdate"
: state === "loading"
? plane === "raw"
? "apps.rawReadingCount"
: "apps.readingCount"
: "apps.scope",
processingLabel,
processingDetail,
processingTitle:
plane === "raw" ? "apps.rawSavingStatus" : "apps.processing",
organizingTitle:
plane === "raw" ? "apps.rawSavingStatus" : "apps.organizing",
organizingLabel: processingLabel,
currentModeLabel: mode ?? "—",
state,
recent,
recentMetric,
activity,
recentCall: input.recentCall,
importsRunning: input.importsRunning,
hostState: input.hostState,
syncEnabledCount: input.syncEnabledCount,
};
}
@@ -27,7 +27,9 @@ vi.mock("./call-history-client", async (original) => ({
getCallHistory: history,
}));
vi.mock("./api", () => ({
getHomeProjection: vi.fn().mockRejectedValue(new Error("legacy core")),
getHomeSummary: summary,
homeProjectionUnsupported: vi.fn().mockReturnValue(true),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getSources: vi.fn().mockResolvedValue([]),
@@ -283,3 +283,50 @@ it("raw import alone has an import title, without a generated-memory promise", (
expect(result.phase).toBe("importing");
expect(memoryArrivalTitle(result)).toBe("正在导入对话");
});
it("lite capabilities never require a generation model", () => {
const result = resolveMemoryArrival({
...base,
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
arrival: {
importing: false,
importedMessages: 12,
importedConversations: 3,
phase: "model-required",
},
processing: {
...processing,
waiting_for_model: 4,
blocked_stages: [
{ kind: "extract", reason: "model_profile_not_active", count: 4 },
],
},
});
expect(result.phase).toBe("idle");
expect(memoryArrivalTitle(result)).not.toContain("模型");
});
it("lite ignores a closed sedimentation state instead of staying stopped", () => {
const result = resolveMemoryArrival({
...base,
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
arrival: {
importing: false,
importedMessages: 12,
importedConversations: 3,
phase: "stopped",
},
});
expect(result.phase).toBe("idle");
expect(memoryArrivalTitle(result)).toBe("待继续");
});
@@ -1,4 +1,5 @@
import type {
HomeProjectionCapabilities,
MemoryProcessingBlock,
MemoryProcessingSummary,
SourceResponse,
@@ -29,6 +30,8 @@ export interface MemoryArrival {
backlogPending?: number;
/** Confirmed memory count, never inferred from completed processing tasks. */
memoryCount?: number;
/** Confirmed mode semantics for this snapshot. */
capabilities?: HomeProjectionCapabilities | null;
}
export function resolveMemoryArrival({
@@ -36,11 +39,13 @@ export function resolveMemoryArrival({
processing,
sources,
loaded,
capabilities,
}: {
arrival?: MemoryArrival;
processing: MemoryProcessingSummary | null;
sources: readonly SourceResponse[];
loaded: boolean;
capabilities?: HomeProjectionCapabilities | null;
}): MemoryArrival {
const authorized = sources.filter((source) => source.authorized);
const importedMessages =
@@ -57,7 +62,25 @@ export function resolveMemoryArrival({
arrival?.blockedStages ??
[]
).filter((block) => block.count > 0);
const effectiveCapabilities = capabilities ?? arrival?.capabilities;
let phase: MemoryArrivalPhase = arrival?.phase ?? "idle";
if (effectiveCapabilities?.generation_required === false) {
const preserved =
arrival?.phase === "checking" || arrival?.phase === "unavailable"
? arrival.phase
: arrival?.importing
? "importing"
: "idle";
return {
...arrival,
importing: arrival?.importing ?? false,
importedMessages,
importedConversations,
capabilities: effectiveCapabilities,
phase: preserved,
...(blocks.length ? { blockedStages: blocks } : {}),
};
}
if (
arrival &&
["checking", "unavailable", "stopped", "model-required"].includes(
@@ -94,6 +117,7 @@ export function resolveMemoryArrival({
importedMessages,
importedConversations,
phase,
...(effectiveCapabilities ? { capabilities: effectiveCapabilities } : {}),
...(blocks.length ? { blockedStages: blocks } : {}),
...((processing?.backlog_pending ?? 0) > 0
? { backlogPending: processing?.backlog_pending }
@@ -29,6 +29,43 @@ it("traduce el progreso conservando mensajes y conversaciones como medidas disti
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("pluralizes each mixed count independently", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
render(
<LanguageProvider>
<MemoryArrivalPanel
arrival={{
importing: true,
importedMessages: 1,
importedConversations: 2,
phase: "importing",
}}
onConfigureModel={noop}
onConnect={noop}
/>
</LanguageProvider>,
);
expect(await screen.findByText("1 message saved")).toBeTruthy();
expect(screen.getByText("2 conversations")).toBeTruthy();
});
it("keeps partial import 0/0 explicit", () => {
render(
<MemoryArrivalPanel
arrival={{
importing: true,
importedMessages: 0,
importedConversations: 0,
phase: "importing",
}}
onConfigureModel={noop}
onConnect={noop}
/>,
);
expect(screen.getByText("已保存 0 条消息")).toBeTruthy();
expect(screen.getByText("0 个会话")).toBeTruthy();
});
import type { MemoryArrivalPhase } from "./memory-arrival-state";
afterEach(() => {
+3 -1
View File
@@ -39,12 +39,14 @@ export function MemoryArrivalPanel({
processing: null,
sources: [],
loaded: true,
capabilities: providedArrival.capabilities,
});
if (hasMemories && arrival.phase === "idle" && !arrival.importing)
return null;
const active =
arrival.phase === "processing" || arrival.phase === "importing";
const hasOriginals = arrival.importedMessages > 0;
const hasOriginals =
arrival.importedMessages > 0 || arrival.importedConversations > 0;
const title =
hasMemories && arrival.phase === "idle"
? t("arrival.importing")
+1
View File
@@ -28,6 +28,7 @@ export type PollKey =
| "applications"
| "calls"
| "model-profiles"
| "embedding-switch"
| "memory-add-processing"
| "sdk-raw-history"
| "health";
+47
View File
@@ -11,6 +11,41 @@
--r6-fast: 140ms;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgb(46 66 82 / 18%);
backdrop-filter: blur(8px);
}
.r6-settings-dialog {
width: min(420px, calc(100vw - 40px));
padding: 28px;
border: 1px solid white;
border-radius: 16px;
background: #f3f7f9;
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
}
.r6-settings-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.r6-settings-dialog p {
margin: 0 0 24px;
color: #63707b;
}
.r6-settings-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.r6-button {
position: relative;
display: inline-flex;
@@ -318,6 +353,18 @@ button.r6-switch:focus-visible,
[data-native-reduce-motion="true"] .r6-button-spinner {
animation: none;
}
@media (prefers-reduced-transparency: reduce) {
.r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
}
[data-native-reduce-transparency="true"] .r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
[data-native-reduce-motion="true"]
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
transition: none;
+357 -96
View File
@@ -24,7 +24,7 @@
padding: 20px 24px;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: rgb(255 255 255 / 78%);
background: #fff;
}
.r6-language-setting-title {
margin: 0 0 14px;
@@ -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");
});
+299
View File
@@ -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);
});
+10
View File
@@ -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,101 @@
# Home mode projection v1
Status: Core, contracts, and Web adoption implemented. The Web home consumes
this projection as its authority and uses the legacy summary only for older
Core builds; the CLI has no home surface and does not consume this endpoint.
## `GET /api/v2/home-projection`
Bearer token required. Optional `user_id` defaults to `local`.
Core owns this projection. Consumers do not inspect SDK raw or memory records
and do not branch on storage details.
```json
{
"projection_version": 1,
"user_id": "local",
"effective_mode": "lite",
"capabilities": {
"content_plane": "raw",
"generation_required": false,
"graph_enabled": false,
"evolve_available": false
},
"visible_count": 42,
"display_state": "excerpt",
"latest": {
"type": "raw",
"raw_id": "raw-1",
"session_id": "session-1",
"display_state": "excerpt",
"time": "2026-11-03T08:00:00Z",
"time_basis": "conversation",
"excerpt": "ship the release on Friday",
"truncated": false,
"source_id": "cursor"
},
"observed_at": "2026-11-03T08:01:00Z"
}
```
`display_state` is `empty | excerpt | no_user_excerpt |
no_displayable_memory`. `latest` is null for `empty`, and also for
`no_displayable_memory` when the SDK reports rows but none of the ten returned
candidates satisfies this projection's public shape. Its discriminant is
`type: raw | memory`.
- Both arms have `time`, `time_basis`, `excerpt`, `truncated`, and `source_id`.
- `time_basis` is `conversation | import | write | update | null`.
- Raw adds `raw_id`, `session_id`, and
`display_state: excerpt | no_user_excerpt`.
- Memory adds `memory_id`, `scene: chat | working | coding`,
`kind: chat | experience`, and `variant: working | coding | null`. Facts are
not in the SDK's `scene=all` list or its total.
## Read semantics
Lite performs one SDK list read whose projection payload is capped at ten:
`layers=["l1_raw"]`,
`statuses=["active","shadow"]`, newest first, K=10. The SDK page total is the
real `visible_count`. Core checks those ten rows for the newest displayable
user excerpt. If none has one, `latest` is the newest row with
`display_state=no_user_excerpt` and an empty excerpt.
Pro and Ultra perform one
`list_memories(scene="all", statuses=["active"], order="desc", limit=10)`
call. Count and latest candidates come from that same response, so they cannot
belong to different snapshots. Invalid rows are skipped within those ten;
`no_displayable_memory` is explicit if no candidate remains.
K=10 bounds only the response payload and Core projection work. In the current
SDK the chat lane still scans its backing vector store before sorting; this
contract does not claim storage-layer boundedness. Working/coding use the
SDK's indexed prefix paging.
Memory `time` is the same key `scene=all` used to rank the returned item:
chat uses `gmt_modified`/`updated_at`, falling back to `gmt_created`;
working/coding uses `updated_at`, falling back to `created_at`. `memory_at`
does not order this projection.
An SDK acquisition, read, or response-shape failure answers
`503 {"detail":"home_projection_read_failed"}`. Core never turns a failed read
into an empty projection.
## Excerpt privacy
Raw excerpts are a pure projection using the same balanced, position-aware
source-envelope parser as import:
1. Parse the SDK turn and retain only `role=user`.
2. Unwrap validated host sections, including `system_reminder`,
`system-reminder`, `user_info`, `rules`, `agent_transcripts`,
`additional_data`, `working_memory_reminder`, `attached_files`, and
`image_local_path`. The contents of an unambiguous `user_query` remain.
Unclosed, nested or otherwise ambiguous envelopes fail closed.
3. Flatten whitespace and skip empty or known control-only placeholders.
4. Keep short sentences, URLs, and paths.
5. Truncate to 200 Unicode code points.
The response never includes raw `content`, parsed messages, or custom metadata.
The old `GET /api/v2/home-summary` remains available unchanged.
+47 -4
View File
@@ -94,6 +94,16 @@ and they are the owner's:
- Vector collection names never depend on the mode. The mode is a separate
client parameter, not part of the embedding-space identity.
The sedimentation switch pauses model-based extraction, not Lite persistence.
Claim and both execution gates use the same predicate:
`effective_mode == lite OR sedimentation_enabled`. Consequently, Lite keeps
writing raw turns while `PUT /api/v1/sedimentation {"enabled":false}` is in
force. Source permission, history gate, embedding maintenance and per-user
evolve gates still apply. Toggling sedimentation or switching to Lite does not
call the broad parked-job readmission path: sedimentation-refused jobs remain
pending for the next claim tick, while permission/model parked states retain
their own reason-specific wakeups.
## 3. `GET /api/v1/settings/memory-mode`
No parameters. `200` response:
@@ -123,7 +133,39 @@ No parameters. `200` response:
`graph_build.users[<user_id>]` is
`{state: pending|running|succeeded|failed, memories_scanned, nodes, error_code}`.
## 4. `PUT /api/v1/settings/memory-mode`
## 4. Plan and apply a mode change
### `POST /api/v1/settings/memory-mode/plan`
Body: `{"mode": "lite" | "pro" | "ultra"}`. This endpoint changes nothing and
returns the validator result PUT uses:
```json
{
"current": "lite",
"target": "pro",
"effective_target": "pro",
"capabilities": {
"content_plane": "memory",
"generation_required": true,
"graph_enabled": false,
"evolve_available": false
},
"effects": ["earlier_raw_not_extracted", "lite_search_stops"],
"warnings": [],
"blockers": []
}
```
Blockers are `active_model_required` when changing effectively into Pro/Ultra,
`embedding_maintenance_in_progress`, `memory_lifecycle_busy`, and
`graph_store_unavailable`. `forced_by_env` is a warning and changes
`effective_target` to Ultra. `effective_no_change` warns that the effective
mode is already the target (including an environment-forced target).
When `blockers` is non-empty, `effects` is empty: no listed effect is implied
to execute for a refused plan.
### `PUT /api/v1/settings/memory-mode`
Body: `{"mode": "lite" | "pro" | "ultra"}`. `200` response: the GET shape,
plus these fields:
@@ -151,6 +193,8 @@ this order:
Errors:
- `409 {"detail": "active_model_required"}`: changing effectively into Pro or
Ultra needs an active generation model. An effective no-op remains allowed.
- `409 {"detail": "embedding_maintenance_in_progress"}`: an embedding switch,
startup scan or startup merge has closed the model-write gate. Nothing
changed; retry after embedding maintenance finishes. GET remains available.
@@ -160,9 +204,8 @@ Errors:
graph store package is not installed. Nothing changed.
- `422 request_validation_failed`: an unknown mode or an extra field.
Show the planned effects before calling PUT. Clients can compute them with the
table above: the CLI (`memhub settings mode <m>` without `--yes`) and Core
(`memory_mode_effects`) use the same rule.
Show the server plan before calling PUT. Plan and PUT use the same Core
validator, so clients do not duplicate blocker or effective-mode rules.
## 5. Evolve
@@ -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;
}
@@ -0,0 +1,55 @@
export type HomeProjectionMode = "lite" | "pro" | "ultra";
export type HomeProjectionTimeBasis =
| "conversation"
| "import"
| "write"
| "update";
export interface HomeProjectionCapabilities {
content_plane: "raw" | "memory";
generation_required: boolean;
graph_enabled: boolean;
evolve_available: boolean;
}
interface HomeProjectionLatestBase {
time: string | null;
time_basis: HomeProjectionTimeBasis | null;
excerpt: string;
truncated: boolean;
source_id: string;
}
export interface HomeProjectionRawLatest extends HomeProjectionLatestBase {
type: "raw";
raw_id: string;
session_id: string;
display_state: "excerpt" | "no_user_excerpt";
}
export interface HomeProjectionMemoryLatest extends HomeProjectionLatestBase {
type: "memory";
memory_id: string;
scene: "chat" | "working" | "coding";
kind: "chat" | "experience";
variant: "working" | "coding" | null;
}
export type HomeProjectionLatest =
| HomeProjectionRawLatest
| HomeProjectionMemoryLatest;
export interface HomeProjectionV1 {
projection_version: 1;
user_id: string;
effective_mode: HomeProjectionMode;
capabilities: HomeProjectionCapabilities;
visible_count: number;
display_state:
| "empty"
| "excerpt"
| "no_user_excerpt"
| "no_displayable_memory";
latest: HomeProjectionLatest | null;
observed_at: string;
}
+34 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { CORE_SERVICE, type RecallReceipt } from "./index";
import {
CORE_SERVICE,
type HomeProjectionV1,
type RecallReceipt,
} from "./index";
describe("Core contract", () => {
it("has one stable service identity", () => {
@@ -37,4 +41,33 @@ describe("Core contract", () => {
expect(receipt.mode).toBe("raw");
expect(receipt.hits[0].evidence_ids).toEqual([]);
});
it("types the discriminated home projection without raw payload fields", () => {
const projection: HomeProjectionV1 = {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
display_state: "excerpt",
time: null,
time_basis: null,
excerpt: "ship Friday",
truncated: false,
source_id: "cursor",
},
observed_at: "2026-11-03T00:00:00Z",
};
expect(projection.latest?.type).toBe("raw");
});
});
+46 -7
View File
@@ -52,6 +52,35 @@ 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";
export type {
HomeProjectionCapabilities,
HomeProjectionLatest,
HomeProjectionMemoryLatest,
HomeProjectionMode,
HomeProjectionRawLatest,
HomeProjectionTimeBasis,
HomeProjectionV1,
} from "./home-projection.js";
import type { EmbeddingSwitchSummary } from "./embedding-switch.js";
import type { HomeProjectionCapabilities } from "./home-projection.js";
export interface HealthResponse {
status: "ok";
@@ -72,6 +101,7 @@ export interface HealthResponse {
*/
recovery?: "pending" | "running" | "done" | "failed";
recovery_failed_steps?: string[];
embedding_switch?: EmbeddingSwitchSummary | null;
}
export type ImportStatus =
@@ -982,6 +1012,21 @@ export interface MemoryModeChange extends MemoryModeState {
>;
}
export interface MemoryModePlan {
current: MemoryPipelineMode;
target: MemoryPipelineMode;
effective_target: MemoryPipelineMode;
capabilities: HomeProjectionCapabilities;
effects: MemoryModeChange["effects"];
warnings: Array<"forced_by_env" | "effective_no_change">;
blockers: Array<
| "active_model_required"
| "embedding_maintenance_in_progress"
| "memory_lifecycle_busy"
| "graph_store_unavailable"
>;
}
export interface MemoryPipelineStatus {
mode: MemoryPipelineMode;
configured_mode: MemoryPipelineMode;
@@ -1058,13 +1103,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;
@@ -7,9 +7,27 @@ export const applicationsChineseMessages = {
"apps.back": "返回首页",
"apps.overview": "记忆概览",
"apps.memories": "已沉淀记忆",
"apps.rawConversations": "原始对话",
"apps.rawSaving": "正在保存原始对话",
"apps.rawSavingStatus": "保存状态",
"apps.rawSavingDetail": "原始对话持续保存,不需要生成模型",
"apps.rawNoExcerpt": "这条对话没有可显示的用户原文",
"apps.rawConversation": "原始对话",
"apps.rawSaved": "原始对话已保存",
"apps.rawEmpty": "还没有保存的原始对话",
"apps.rawCheckedEmpty": "已核对,暂无可显示的原始对话更新",
"apps.memoryCheckedEmpty": "已核对,暂无可显示的记忆更新",
"apps.rawWaiting": "原始对话保存后会显示在这里",
"apps.checkedRecent": "已核对当前内容",
"apps.currentMode": "当前模式",
"apps.mode.lite": "基础记忆(lite)",
"apps.mode.pro": "智能记忆(pro)",
"apps.mode.ultra": "深度记忆(ultra)",
"apps.readingCount": "正在核对本机记忆…",
"apps.rawReadingCount": "正在核对本机原始对话…",
"apps.startingRetry": "后台正在启动,稍后自动更新",
"apps.countWillUpdate": "记忆数量稍后自动更新",
"apps.rawCountWillUpdate": "原始对话数量稍后自动更新",
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
"apps.unreadable": "暂无法读取",
"apps.unit": "条",
@@ -100,6 +118,8 @@ export const applicationsChineseMessages = {
"apps.revision": "版本 {count}",
"apps.conversationTime": "对话时间",
"apps.importTime": "导入时间",
"apps.writeTime": "写入时间",
"apps.updateTime": "更新时间",
"apps.incompleteCalls": " · 有几次调用没被记下来,列表可能不全",
"apps.timeoutWarning":
"{host} 最近几次没查到记忆,后台可能正忙,稍后会自动恢复",
@@ -127,10 +147,33 @@ export const applicationsSpanishMessages: Record<
"apps.back": "Volver al inicio",
"apps.overview": "Resumen de memorias",
"apps.memories": "Memorias guardadas",
"apps.rawConversations": "Conversaciones originales",
"apps.rawSaving": "Guardando",
"apps.rawSavingStatus": "Estado de guardado",
"apps.rawSavingDetail":
"Las conversaciones originales se guardan sin un modelo generativo",
"apps.rawNoExcerpt": "Esta conversación no tiene texto del usuario visible",
"apps.rawConversation": "Conversación original",
"apps.rawSaved": "Conversación original guardada",
"apps.rawEmpty": "Aún no hay conversaciones originales guardadas",
"apps.rawCheckedEmpty":
"Comprobado: no hay actualizaciones de conversaciones para mostrar",
"apps.memoryCheckedEmpty":
"Comprobado: no hay actualizaciones de memorias para mostrar",
"apps.rawWaiting":
"Las conversaciones originales aparecerán aquí cuando se guarden",
"apps.checkedRecent": "Contenido actual comprobado",
"apps.currentMode": "Modo actual",
"apps.mode.lite": "Memoria básica (lite)",
"apps.mode.pro": "Memoria inteligente (pro)",
"apps.mode.ultra": "Memoria profunda (ultra)",
"apps.readingCount": "Comprobando las memorias locales…",
"apps.rawReadingCount": "Comprobando las conversaciones originales…",
"apps.startingRetry":
"El servicio se está iniciando; se actualizará automáticamente",
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
"apps.rawCountWillUpdate":
"El total de conversaciones originales se actualizará automáticamente",
"apps.showingPrevious":
"Se muestran los datos anteriores; se actualizarán automáticamente",
"apps.unreadable": "No disponible",
@@ -229,6 +272,8 @@ export const applicationsSpanishMessages: Record<
"apps.revision": "Versión {count}",
"apps.conversationTime": "Hora de conversación",
"apps.importTime": "Hora de importación",
"apps.writeTime": "Hora de escritura",
"apps.updateTime": "Hora de actualización",
"apps.incompleteCalls":
" · Algunas consultas no se registraron; la lista puede estar incompleta",
"apps.timeoutWarning":
@@ -8,6 +8,14 @@ export const callsChineseMessages = {
"calls.headline.empty": "{host} 没有查到相关记忆",
"calls.headline.found": "{host} 查到 {count} 条相关内容",
"calls.headline.found.one": "{host} 查到 {count} 条相关内容",
"calls.headline.rawOnly": "{host} 找到 {count} 条原始会话",
"calls.headline.rawOnly.one": "{host} 找到 {count} 条原始会话",
"calls.headline.memoryOnly": "{host} 找到 {count} 条记忆",
"calls.headline.memoryOnly.one": "{host} 找到 {count} 条记忆",
"calls.headline.mixed": "{host} 找到 {memories} 条记忆 · {raw} 条原始会话",
"calls.headline.mixed.one":
"{host} 找到 {memories} 条记忆 · {raw} 条原始会话",
"calls.headline.completed": "{host} 调用已完成",
"calls.detail.timedOut": "后台正在整理记忆,查询超时,这次回答没有带上记忆",
"calls.detail.unavailable": "MemHub 暂时不可用,这次回答没有带上记忆",
"calls.detail.denied": "当前授权不允许这次查询",
@@ -109,6 +117,15 @@ export const callsSpanishMessages: Record<
"calls.headline.empty": "{host} no encontró memorias relacionadas",
"calls.headline.found": "{host} encontró {count} contenidos relacionados",
"calls.headline.found.one": "{host} encontró {count} contenido relacionado",
"calls.headline.rawOnly": "{host} encontró {count} conversaciones originales",
"calls.headline.rawOnly.one": "{host} encontró {count} conversación original",
"calls.headline.memoryOnly": "{host} encontró {count} memorias",
"calls.headline.memoryOnly.one": "{host} encontró {count} memoria",
"calls.headline.mixed":
"{host} encontró {memories} memorias · {raw} conversaciones originales",
"calls.headline.mixed.one":
"{host} encontró {memories} memoria · {raw} conversaciones originales",
"calls.headline.completed": "La llamada de {host} se completó",
"calls.detail.timedOut":
"La consulta agotó el tiempo mientras se organizaban las memorias; la respuesta no incluyó memorias",
"calls.detail.unavailable":
@@ -30,6 +30,7 @@ import {
spanishMessages,
} from "./gui-i18n-messages.js";
import {
modeChineseMessages,
modeEnglishMessages,
modeFrenchMessages,
modeGermanMessages,
@@ -262,6 +263,33 @@ describe("Mode catalog", () => {
>)
expect(messages[key], key).not.toBe(modeEnglishMessages[key]);
});
it("keeps new plan and blocker copy in Traditional Chinese", () => {
const keys = [
"新对话会整理成记忆并写入知识图谱。",
"新对话会由模型整理成记忆。",
"会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。",
"会调用当前生成模型,可能产生服务商费用。",
"不调用生成模型,不再产生这部分费用。",
"切换后搜索原始对话。",
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。",
"已经处理完的旧对话不会补做;原始对话全部保留。",
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
"已有记忆会在后台加入知识图谱,期间仍可使用。",
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。",
] as const;
for (const key of keys) {
expect(modeTraditionalChineseMessages[key], key).not.toBe(
modeChineseMessages[key],
);
expect(modeTraditionalChineseMessages[key], key).toMatch(
/[記憶對話會圖譜費續讀]/,
);
}
});
});
describe("Spanish catalog", () => {
@@ -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",
]);
+48 -1
View File
@@ -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.
@@ -226,7 +227,7 @@ const baseEnglishMessages = {
"arrival.region": "Memory processing status",
"arrival.importing": "Originals are still importing",
"arrival.messages": "{count} messages saved",
"arrival.messages.one": "{count} messages saved",
"arrival.messages.one": "{count} message saved",
"arrival.conversations": "{count} conversations",
"arrival.conversations.one": "{count} conversation",
"arrival.details": "View progress and details",
@@ -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",
@@ -1319,10 +1333,31 @@ const baseEnglishMessages = {
"apps.back": "Back to home",
"apps.overview": "Memory overview",
"apps.memories": "Saved memories",
"apps.rawConversations": "Original conversations",
"apps.rawSaving": "Saving",
"apps.rawSavingStatus": "Saving status",
"apps.rawSavingDetail":
"Original conversations are saved without a generation model",
"apps.rawNoExcerpt": "This conversation has no displayable user excerpt",
"apps.rawConversation": "Original conversation",
"apps.rawSaved": "Original conversation saved",
"apps.rawEmpty": "No original conversations saved yet",
"apps.rawCheckedEmpty":
"Checked: no original-conversation updates to display",
"apps.memoryCheckedEmpty": "Checked: no memory updates to display",
"apps.rawWaiting": "Original conversations will appear here when saved",
"apps.checkedRecent": "Current content checked",
"apps.currentMode": "Current mode",
"apps.mode.lite": "Basic memory (lite)",
"apps.mode.pro": "Smart memory (pro)",
"apps.mode.ultra": "Deep memory (ultra)",
"apps.readingCount": "Checking local memories…",
"apps.rawReadingCount": "Checking original conversations…",
"apps.startingRetry":
"The background service is starting; this will update automatically",
"apps.countWillUpdate": "The memory count will update automatically",
"apps.rawCountWillUpdate":
"The original-conversation count will update automatically",
"apps.showingPrevious":
"Showing the previous data; this will update automatically",
"apps.unreadable": "Unavailable",
@@ -1420,6 +1455,8 @@ const baseEnglishMessages = {
"apps.revision": "Version {count}",
"apps.conversationTime": "Conversation time",
"apps.importTime": "Import time",
"apps.writeTime": "Write time",
"apps.updateTime": "Update time",
"apps.incompleteCalls":
" · Some calls were not recorded; the list may be incomplete",
"apps.timeoutWarning":
@@ -1862,6 +1899,15 @@ const baseEnglishMessages = {
"calls.headline.empty": "{host} found no matching memories",
"calls.headline.found": "{host} found {count} related items",
"calls.headline.found.one": "{host} found {count} related items",
"calls.headline.rawOnly": "{host} found {count} original conversations",
"calls.headline.rawOnly.one": "{host} found {count} original conversation",
"calls.headline.memoryOnly": "{host} found {count} memories",
"calls.headline.memoryOnly.one": "{host} found {count} memory",
"calls.headline.mixed":
"{host} found {memories} memories · {raw} original conversations",
"calls.headline.mixed.one":
"{host} found {memories} memory · {raw} original conversations",
"calls.headline.completed": "{host} call completed",
"calls.detail.timedOut":
"Memories are being processed. The lookup timed out, so this answer did not include memories.",
"calls.detail.unavailable":
@@ -2007,5 +2053,6 @@ const baseEnglishMessages = {
export const englishMessages = {
...baseEnglishMessages,
...currentEnglishMessages,
...embeddingSwitchEnglishMessages,
...modeEnglishMessages,
} as const;
@@ -16,7 +16,28 @@ 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[];
const rawSavingStatusKey = "apps.rawSavingStatus" satisfies MessageKey;
const rawSavingStatusTranslations = {
en: "Saving status",
"zh-Hans": "保存状态",
"zh-Hant": "儲存狀態",
ja: "保存状態",
es: "Estado de guardado",
de: "Speicherstatus",
pt: "Status de salvamento",
fr: "État de l’enregistrement",
ko: "저장 상태",
ru: "Статус сохранения",
id: "Status penyimpanan",
} as const satisfies Record<(typeof SUPPORTED_LOCALES)[number], string>;
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
const catalog = guiMessages[locale];
@@ -61,6 +82,61 @@ describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
});
});
describe("applications catalog", () => {
it("includes every application key in all 11 locales", () => {
const applicationKeys = keys.filter((key) => key.startsWith("apps."));
expect(applicationKeys.length).toBeGreaterThan(0);
for (const locale of SUPPORTED_LOCALES)
for (const key of applicationKeys)
expect(
Object.hasOwn(guiMessages[locale], key),
`${locale} ${key}`,
).toBe(true);
});
it("localizes the raw saving status in all 11 locales", () => {
for (const locale of SUPPORTED_LOCALES)
expect(guiMessages[locale][rawSavingStatusKey]).toBe(
rawSavingStatusTranslations[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 &
File diff suppressed because it is too large. Load diff
@@ -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",
@@ -1517,11 +1532,33 @@ export const germanMessages: Record<MessageKey, string> = {
"apps.back": "Zurück zur Startseite",
"apps.overview": "Erinnerungsübersicht",
"apps.memories": "Gespeicherte Erinnerungen",
"apps.rawConversations": "Originalunterhaltungen",
"apps.rawSaving": "Wird gespeichert",
"apps.rawSavingStatus": "Speicherstatus",
"apps.rawSavingDetail":
"Originalunterhaltungen werden ohne generatives Modell gespeichert",
"apps.rawNoExcerpt": "Kein anzeigbarer Benutzertext in dieser Unterhaltung",
"apps.rawConversation": "Originalunterhaltung",
"apps.rawSaved": "Originalunterhaltung gespeichert",
"apps.rawEmpty": "Noch keine Originalunterhaltungen gespeichert",
"apps.rawCheckedEmpty":
"Geprüft: keine anzeigbaren Aktualisierungen der Originalunterhaltungen",
"apps.memoryCheckedEmpty":
"Geprüft: keine anzeigbaren Erinnerungsaktualisierungen",
"apps.rawWaiting": "Gespeicherte Originalunterhaltungen erscheinen hier",
"apps.checkedRecent": "Aktueller Inhalt geprüft",
"apps.currentMode": "Aktueller Modus",
"apps.mode.lite": "Basisspeicher (lite)",
"apps.mode.pro": "Intelligenter Speicher (pro)",
"apps.mode.ultra": "Tiefenspeicher (ultra)",
"apps.readingCount": "Lokale Erinnerungen werden geprüft…",
"apps.rawReadingCount": "Originalunterhaltungen werden geprüft…",
"apps.startingRetry":
"Der Hintergrunddienst startet; die Anzeige wird automatisch aktualisiert",
"apps.countWillUpdate":
"Die Anzahl der Erinnerungen wird automatisch aktualisiert",
"apps.rawCountWillUpdate":
"Die Anzahl der Originalunterhaltungen wird automatisch aktualisiert",
"apps.showingPrevious":
"Vorherige Daten werden angezeigt; die Anzeige wird automatisch aktualisiert",
"apps.unreadable": "Nicht verfügbar",
@@ -1622,6 +1659,8 @@ export const germanMessages: Record<MessageKey, string> = {
"apps.revision": "Version {count}",
"apps.conversationTime": "Unterhaltungszeit",
"apps.importTime": "Importzeit",
"apps.writeTime": "Schreibzeit",
"apps.updateTime": "Aktualisierungszeit",
"apps.incompleteCalls":
" · Einige Aufrufe wurden nicht aufgezeichnet; die Liste ist möglicherweise unvollständig",
"apps.timeoutWarning":
@@ -2077,6 +2116,17 @@ export const germanMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} hat keine passenden Erinnerungen gefunden",
"calls.headline.found": "{host} hat {count} verwandte Einträge gefunden",
"calls.headline.found.one": "{host} hat {count} verwandten Eintrag gefunden",
"calls.headline.rawOnly":
"{host} hat {count} ursprüngliche Unterhaltungen gefunden",
"calls.headline.rawOnly.one":
"{host} hat {count} ursprüngliche Unterhaltung gefunden",
"calls.headline.memoryOnly": "{host} hat {count} Erinnerungen gefunden",
"calls.headline.memoryOnly.one": "{host} hat {count} Erinnerung gefunden",
"calls.headline.mixed":
"{host} hat {memories} Erinnerungen · {raw} ursprüngliche Unterhaltungen gefunden",
"calls.headline.mixed.one":
"{host} hat {memories} Erinnerung · {raw} ursprüngliche Unterhaltungen gefunden",
"calls.headline.completed": "Aufruf von {host} abgeschlossen",
"calls.detail.timedOut":
"Erinnerungen werden verarbeitet. Bei der Abfrage kam es zu einer Zeitüberschreitung, daher enthielt diese Antwort keine Erinnerungen.",
"calls.detail.unavailable":
@@ -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é",
@@ -1538,11 +1553,34 @@ export const frenchMessages: Record<MessageKey, string> = {
"apps.back": "Retour à l’accueil",
"apps.overview": "Aperçu des mémoires",
"apps.memories": "Mémoires enregistrées",
"apps.rawConversations": "Conversations d’origine",
"apps.rawSaving": "Enregistrement",
"apps.rawSavingStatus": "État de l’enregistrement",
"apps.rawSavingDetail":
"Les conversations d’origine sont enregistrées sans modèle génératif",
"apps.rawNoExcerpt": "Aucun extrait utilisateur affichable",
"apps.rawConversation": "Conversation d’origine",
"apps.rawSaved": "Conversation d’origine enregistrée",
"apps.rawEmpty": "Aucune conversation d’origine enregistrée",
"apps.rawCheckedEmpty":
"Vérifié : aucune mise à jour de conversation à afficher",
"apps.memoryCheckedEmpty":
"Vérifié : aucune mise à jour de mémoire à afficher",
"apps.rawWaiting":
"Les conversations d’origine enregistrées apparaîtront ici",
"apps.checkedRecent": "Contenu actuel vérifié",
"apps.currentMode": "Mode actuel",
"apps.mode.lite": "Mémoire de base (lite)",
"apps.mode.pro": "Mémoire intelligente (pro)",
"apps.mode.ultra": "Mémoire approfondie (ultra)",
"apps.readingCount": "Vérification des mémoires locales…",
"apps.rawReadingCount": "Vérification des conversations d’origine…",
"apps.startingRetry":
"Le service en arrière-plan démarre ; l’affichage se mettra à jour automatiquement",
"apps.countWillUpdate":
"Le nombre de mémoires se mettra à jour automatiquement",
"apps.rawCountWillUpdate":
"Le nombre de conversations d’origine se mettra à jour automatiquement",
"apps.showingPrevious":
"Affichage des données précédentes ; mise à jour automatique à venir",
"apps.unreadable": "Indisponible",
@@ -1644,6 +1682,8 @@ export const frenchMessages: Record<MessageKey, string> = {
"apps.revision": "Version {count}",
"apps.conversationTime": "Date de la conversation",
"apps.importTime": "Date d’importation",
"apps.writeTime": "Date d’écriture",
"apps.updateTime": "Date de mise à jour",
"apps.incompleteCalls":
" · Certains appels n’ont pas été enregistrés ; la liste peut être incomplète",
"apps.timeoutWarning":
@@ -2099,6 +2139,16 @@ export const frenchMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} n’a trouvé aucune mémoire correspondante",
"calls.headline.found": "{host} a trouvé {count} éléments associés",
"calls.headline.found.one": "{host} a trouvé {count} élément associé",
"calls.headline.rawOnly": "{host} a trouvé {count} conversations d’origine",
"calls.headline.rawOnly.one":
"{host} a trouvé {count} conversation d’origine",
"calls.headline.memoryOnly": "{host} a trouvé {count} mémoires",
"calls.headline.memoryOnly.one": "{host} a trouvé {count} mémoire",
"calls.headline.mixed":
"{host} a trouvé {memories} mémoires · {raw} conversations d’origine",
"calls.headline.mixed.one":
"{host} a trouvé {memories} mémoire · {raw} conversations d’origine",
"calls.headline.completed": "L’appel de {host} est terminé",
"calls.detail.timedOut":
"Les mémoires sont en cours de traitement. La recherche a expiré ; cette réponse n’inclut donc pas de mémoires.",
"calls.detail.unavailable":
@@ -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",
@@ -1492,10 +1507,30 @@ export const indonesianMessages: Record<MessageKey, string> = {
"apps.back": "Kembali ke beranda",
"apps.overview": "Ikhtisar memori",
"apps.memories": "Memori tersimpan",
"apps.rawConversations": "Percakapan asli",
"apps.rawSaving": "Menyimpan",
"apps.rawSavingStatus": "Status penyimpanan",
"apps.rawSavingDetail": "Percakapan asli disimpan tanpa model generatif",
"apps.rawNoExcerpt": "Tidak ada kutipan pengguna yang dapat ditampilkan",
"apps.rawConversation": "Percakapan asli",
"apps.rawSaved": "Percakapan asli disimpan",
"apps.rawEmpty": "Belum ada percakapan asli yang disimpan",
"apps.rawCheckedEmpty":
"Diperiksa: tidak ada pembaruan percakapan untuk ditampilkan",
"apps.memoryCheckedEmpty":
"Diperiksa: tidak ada pembaruan memori untuk ditampilkan",
"apps.rawWaiting": "Percakapan asli yang disimpan akan muncul di sini",
"apps.checkedRecent": "Konten saat ini telah diperiksa",
"apps.currentMode": "Mode saat ini",
"apps.mode.lite": "Memori dasar (lite)",
"apps.mode.pro": "Memori pintar (pro)",
"apps.mode.ultra": "Memori mendalam (ultra)",
"apps.readingCount": "Memeriksa memori lokal…",
"apps.rawReadingCount": "Memeriksa percakapan asli…",
"apps.startingRetry":
"Layanan latar belakang sedang dimulai; ini akan diperbarui otomatis",
"apps.countWillUpdate": "Jumlah memori akan diperbarui otomatis",
"apps.rawCountWillUpdate": "Jumlah percakapan asli akan diperbarui otomatis",
"apps.showingPrevious":
"Menampilkan data sebelumnya; ini akan diperbarui otomatis",
"apps.unreadable": "Tidak tersedia",
@@ -1594,6 +1629,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
"apps.revision": "Versi {count}",
"apps.conversationTime": "Waktu percakapan",
"apps.importTime": "Waktu impor",
"apps.writeTime": "Waktu penulisan",
"apps.updateTime": "Waktu pembaruan",
"apps.incompleteCalls":
" · Sebagian panggilan tidak tercatat; daftar mungkin tidak lengkap",
"apps.timeoutWarning":
@@ -2038,6 +2075,15 @@ export const indonesianMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} tidak menemukan memori yang cocok",
"calls.headline.found": "{host} menemukan {count} item terkait",
"calls.headline.found.one": "{host} menemukan {count} item terkait",
"calls.headline.rawOnly": "{host} menemukan {count} percakapan asli",
"calls.headline.rawOnly.one": "{host} menemukan {count} percakapan asli",
"calls.headline.memoryOnly": "{host} menemukan {count} memori",
"calls.headline.memoryOnly.one": "{host} menemukan {count} memori",
"calls.headline.mixed":
"{host} menemukan {memories} memori · {raw} percakapan asli",
"calls.headline.mixed.one":
"{host} menemukan {memories} memori · {raw} percakapan asli",
"calls.headline.completed": "Panggilan {host} selesai",
"calls.detail.timedOut":
"Memori sedang diproses. Pencarian habis waktu, sehingga jawaban ini tidak menyertakan memori.",
"calls.detail.unavailable":
@@ -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 · このデバイスで処理、追加設定不要",
"正在读取模型…": "モデルを読み込み中…",
模型状态暂不可读: "モデルの状態を取得できません",
尚未配置模型: "モデル未設定",
@@ -1489,10 +1504,28 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.back": "ホームに戻る",
"apps.overview": "記憶の概要",
"apps.memories": "保存済みの記憶",
"apps.rawConversations": "元の会話",
"apps.rawSaving": "保存中",
"apps.rawSavingStatus": "保存状態",
"apps.rawSavingDetail": "生成モデルを使わず元の会話を保存します",
"apps.rawNoExcerpt": "表示できるユーザーの原文がありません",
"apps.rawConversation": "元の会話",
"apps.rawSaved": "元の会話を保存しました",
"apps.rawEmpty": "保存済みの元の会話はまだありません",
"apps.rawCheckedEmpty": "確認済み:表示できる元の会話の更新はありません",
"apps.memoryCheckedEmpty": "確認済み:表示できる記憶の更新はありません",
"apps.rawWaiting": "保存された元の会話がここに表示されます",
"apps.checkedRecent": "現在の内容を確認済み",
"apps.currentMode": "現在のモード",
"apps.mode.lite": "基本メモリ(lite)",
"apps.mode.pro": "スマートメモリ(pro)",
"apps.mode.ultra": "深層メモリ(ultra)",
"apps.readingCount": "ローカルの記憶を確認中…",
"apps.rawReadingCount": "元の会話を確認中…",
"apps.startingRetry":
"バックグラウンドサービスを起動中です。自動的に更新されます",
"apps.countWillUpdate": "記憶の件数は自動的に更新されます",
"apps.rawCountWillUpdate": "元の会話の件数は自動的に更新されます",
"apps.showingPrevious": "前回のデータを表示しています。自動的に更新されます",
"apps.unreadable": "利用できません",
"apps.unit": "件",
@@ -1589,6 +1622,8 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.revision": "バージョン {count}",
"apps.conversationTime": "会話日時",
"apps.importTime": "インポート日時",
"apps.writeTime": "書き込み日時",
"apps.updateTime": "更新日時",
"apps.incompleteCalls":
" · 一部の呼び出しが記録されていないため、一覧が不完全な可能性があります",
"apps.timeoutWarning":
@@ -2023,6 +2058,15 @@ export const japaneseMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} では一致する記憶が見つかりませんでした",
"calls.headline.found": "{host} が関連する項目を {count} 件見つけました",
"calls.headline.found.one": "{host} が関連する項目を {count} 件見つけました",
"calls.headline.rawOnly": "{host} が元の会話を {count} 件見つけました",
"calls.headline.rawOnly.one": "{host} が元の会話を {count} 件見つけました",
"calls.headline.memoryOnly": "{host} が記憶を {count} 件見つけました",
"calls.headline.memoryOnly.one": "{host} が記憶を {count} 件見つけました",
"calls.headline.mixed":
"{host} が記憶 {memories} 件 · 元の会話 {raw} 件を見つけました",
"calls.headline.mixed.one":
"{host} が記憶 {memories} 件 · 元の会話 {raw} 件を見つけました",
"calls.headline.completed": "{host} の呼び出しが完了しました",
"calls.detail.timedOut":
"記憶を処理中のため照会がタイムアウトし、今回の回答には記憶が含まれていません。",
"calls.detail.unavailable":
@@ -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 · 로컬 처리, 추가 구성 필요 없음",
"正在读取模型…": "모델을 불러오는 중…",
模型状态暂不可读: "모델 상태를 확인할 수 없습니다",
尚未配置模型: "구성된 모델이 없습니다",
@@ -1453,10 +1468,28 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.back": "홈으로 돌아가기",
"apps.overview": "기억 개요",
"apps.memories": "저장된 기억",
"apps.rawConversations": "원본 대화",
"apps.rawSaving": "저장 중",
"apps.rawSavingStatus": "저장 상태",
"apps.rawSavingDetail": "생성 모델 없이 원본 대화를 저장합니다",
"apps.rawNoExcerpt": "표시할 수 있는 사용자 원문이 없습니다",
"apps.rawConversation": "원본 대화",
"apps.rawSaved": "원본 대화 저장됨",
"apps.rawEmpty": "저장된 원본 대화가 아직 없습니다",
"apps.rawCheckedEmpty": "확인됨: 표시할 원본 대화 업데이트가 없습니다",
"apps.memoryCheckedEmpty": "확인됨: 표시할 메모리 업데이트가 없습니다",
"apps.rawWaiting": "저장된 원본 대화가 여기에 표시됩니다",
"apps.checkedRecent": "현재 내용 확인됨",
"apps.currentMode": "현재 모드",
"apps.mode.lite": "기본 메모리 (lite)",
"apps.mode.pro": "스마트 메모리 (pro)",
"apps.mode.ultra": "심층 메모리 (ultra)",
"apps.readingCount": "로컬 기억 확인 중…",
"apps.rawReadingCount": "원본 대화 확인 중…",
"apps.startingRetry":
"백그라운드 서비스를 시작하는 중입니다. 자동으로 업데이트됩니다",
"apps.countWillUpdate": "기억 개수는 자동으로 업데이트됩니다",
"apps.rawCountWillUpdate": "원본 대화 수는 자동으로 업데이트됩니다",
"apps.showingPrevious":
"이전 데이터를 표시하는 중입니다. 자동으로 업데이트됩니다",
"apps.unreadable": "사용할 수 없음",
@@ -1553,6 +1586,8 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.revision": "버전 {count}",
"apps.conversationTime": "대화 시간",
"apps.importTime": "가져온 시간",
"apps.writeTime": "기록 시간",
"apps.updateTime": "업데이트 시간",
"apps.incompleteCalls":
" · 일부 호출이 기록되지 않아 목록이 불완전할 수 있습니다",
"apps.timeoutWarning":
@@ -1984,6 +2019,15 @@ export const koreanMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host}이(가) 일치하는 기억을 찾지 못했습니다",
"calls.headline.found": "{host}이(가) 관련 항목 {count}개를 찾았습니다",
"calls.headline.found.one": "{host}이(가) 관련 항목 {count}개를 찾았습니다",
"calls.headline.rawOnly": "{host}이(가) 원본 대화 {count}개를 찾았습니다",
"calls.headline.rawOnly.one": "{host}이(가) 원본 대화 {count}개를 찾았습니다",
"calls.headline.memoryOnly": "{host}이(가) 기억 {count}개를 찾았습니다",
"calls.headline.memoryOnly.one": "{host}이(가) 기억 {count}개를 찾았습니다",
"calls.headline.mixed":
"{host}이(가) 기억 {memories}개 · 원본 대화 {raw}개를 찾았습니다",
"calls.headline.mixed.one":
"{host}이(가) 기억 {memories}개 · 원본 대화 {raw}개를 찾았습니다",
"calls.headline.completed": "{host} 호출이 완료되었습니다",
"calls.detail.timedOut":
"기억을 정리하는 중입니다. 조회 시간이 초과되어 이번 답변에는 기억이 포함되지 않았습니다.",
"calls.detail.unavailable":
@@ -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",
@@ -1514,11 +1529,33 @@ export const portugueseMessages: Record<MessageKey, string> = {
"apps.back": "Voltar ao início",
"apps.overview": "Visão geral das memórias",
"apps.memories": "Memórias salvas",
"apps.rawConversations": "Conversas originais",
"apps.rawSaving": "Salvando",
"apps.rawSavingStatus": "Status de salvamento",
"apps.rawSavingDetail":
"As conversas originais são salvas sem um modelo generativo",
"apps.rawNoExcerpt": "Esta conversa não tem texto do usuário para exibir",
"apps.rawConversation": "Conversa original",
"apps.rawSaved": "Conversa original salva",
"apps.rawEmpty": "Ainda não há conversas originais salvas",
"apps.rawCheckedEmpty":
"Verificado: não há atualizações de conversas para mostrar",
"apps.memoryCheckedEmpty":
"Verificado: não há atualizações de memórias para mostrar",
"apps.rawWaiting": "As conversas originais salvas aparecerão aqui",
"apps.checkedRecent": "Conteúdo atual verificado",
"apps.currentMode": "Modo atual",
"apps.mode.lite": "Memória básica (lite)",
"apps.mode.pro": "Memória inteligente (pro)",
"apps.mode.ultra": "Memória profunda (ultra)",
"apps.readingCount": "Verificando memórias locais…",
"apps.rawReadingCount": "Verificando conversas originais…",
"apps.startingRetry":
"O serviço em segundo plano está iniciando; isto será atualizado automaticamente",
"apps.countWillUpdate":
"A contagem de memórias será atualizada automaticamente",
"apps.rawCountWillUpdate":
"A contagem de conversas originais será atualizada automaticamente",
"apps.showingPrevious":
"Mostrando os dados anteriores; isto será atualizado automaticamente",
"apps.unreadable": "Indisponível",
@@ -1618,6 +1655,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
"apps.revision": "Versão {count}",
"apps.conversationTime": "Hora da conversa",
"apps.importTime": "Hora da importação",
"apps.writeTime": "Hora da escrita",
"apps.updateTime": "Hora da atualização",
"apps.incompleteCalls":
" · Algumas chamadas não foram registradas; a lista pode estar incompleta",
"apps.timeoutWarning":
@@ -2069,6 +2108,15 @@ export const portugueseMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} não encontrou memórias correspondentes",
"calls.headline.found": "{host} encontrou itens relacionados: {count}",
"calls.headline.found.one": "{host} encontrou {count} item relacionado",
"calls.headline.rawOnly": "{host} encontrou {count} conversas originais",
"calls.headline.rawOnly.one": "{host} encontrou {count} conversa original",
"calls.headline.memoryOnly": "{host} encontrou {count} memórias",
"calls.headline.memoryOnly.one": "{host} encontrou {count} memória",
"calls.headline.mixed":
"{host} encontrou {memories} memórias · {raw} conversas originais",
"calls.headline.mixed.one":
"{host} encontrou {memories} memória · {raw} conversas originais",
"calls.headline.completed": "A chamada de {host} foi concluída",
"calls.detail.timedOut":
"As memórias estão sendo processadas. A consulta excedeu o tempo limite, então esta resposta não incluiu memórias.",
"calls.detail.unavailable":
@@ -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 · Локальная обработка, дополнительная настройка не требуется",
"正在读取模型…": "Загрузка модели…",
模型状态暂不可读: "Статус модели недоступен",
尚未配置模型: "Модель не настроена",
@@ -1497,10 +1512,30 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.back": "На главную",
"apps.overview": "Обзор памяти",
"apps.memories": "Сохранённые записи памяти",
"apps.rawConversations": "Исходные разговоры",
"apps.rawSaving": "Сохранение",
"apps.rawSavingStatus": "Статус сохранения",
"apps.rawSavingDetail":
"Исходные разговоры сохраняются без генеративной модели",
"apps.rawNoExcerpt": "Нет отображаемого фрагмента текста пользователя",
"apps.rawConversation": "Исходный разговор",
"apps.rawSaved": "Исходный диалог сохранён",
"apps.rawEmpty": "Сохранённых исходных диалогов пока нет",
"apps.rawCheckedEmpty":
"Проверено: нет обновлений исходных диалогов для показа",
"apps.memoryCheckedEmpty": "Проверено: нет обновлений памяти для показа",
"apps.rawWaiting": "Сохранённые исходные диалоги появятся здесь",
"apps.checkedRecent": "Текущее содержимое проверено",
"apps.currentMode": "Текущий режим",
"apps.mode.lite": "Базовая память (lite)",
"apps.mode.pro": "Умная память (pro)",
"apps.mode.ultra": "Глубокая память (ultra)",
"apps.readingCount": "Проверка локальной памяти…",
"apps.rawReadingCount": "Проверка исходных диалогов…",
"apps.startingRetry":
"Фоновая служба запускается; данные обновятся автоматически",
"apps.countWillUpdate": "Число записей памяти обновится автоматически",
"apps.rawCountWillUpdate": "Число исходных диалогов обновится автоматически",
"apps.showingPrevious":
"Показаны прежние данные; они обновятся автоматически",
"apps.unreadable": "Недоступно",
@@ -1600,6 +1635,8 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.revision": "Версия {count}",
"apps.conversationTime": "Время диалога",
"apps.importTime": "Время импорта",
"apps.writeTime": "Время записи",
"apps.updateTime": "Время обновления",
"apps.incompleteCalls":
" · Часть вызовов не записана; список может быть неполным",
"apps.timeoutWarning":
@@ -2042,6 +2079,15 @@ export const russianMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} не нашёл подходящих записей памяти",
"calls.headline.found": "{host}: найдено связанных элементов — {count}",
"calls.headline.found.one": "{host}: найден {count} связанный элемент",
"calls.headline.rawOnly": "{host}: найдено исходных диалогов — {count}",
"calls.headline.rawOnly.one": "{host}: найден {count} исходный диалог",
"calls.headline.memoryOnly": "{host}: найдено воспоминаний — {count}",
"calls.headline.memoryOnly.one": "{host}: найдено {count} воспоминание",
"calls.headline.mixed":
"{host}: воспоминаний — {memories} · исходных диалогов — {raw}",
"calls.headline.mixed.one":
"{host}: воспоминаний — {memories} · исходных диалогов — {raw}",
"calls.headline.completed": "Вызов {host} завершён",
"calls.detail.timedOut":
"Записи памяти обрабатываются. Время запроса истекло, поэтому в этот ответ память не вошла.",
"calls.detail.unavailable":
@@ -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 · 本機處理,無需額外設定",
"正在读取模型…": "正在讀取模型…",
模型状态暂不可读: "模型狀態暫不可讀",
尚未配置模型: "尚未設定模型",
@@ -1365,9 +1377,27 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.back": "返回首頁",
"apps.overview": "記憶概覽",
"apps.memories": "已建立的記憶",
"apps.rawConversations": "原始對話",
"apps.rawSaving": "儲存中",
"apps.rawSavingStatus": "儲存狀態",
"apps.rawSavingDetail": "原始對話會持續儲存,不需要生成模型",
"apps.rawNoExcerpt": "這段對話沒有可顯示的使用者原文",
"apps.rawConversation": "原始對話",
"apps.rawSaved": "原始對話已儲存",
"apps.rawEmpty": "還沒有儲存的原始對話",
"apps.rawCheckedEmpty": "已核對,暫無可顯示的原始對話更新",
"apps.memoryCheckedEmpty": "已核對,暫無可顯示的記憶更新",
"apps.rawWaiting": "原始對話儲存後會顯示在這裡",
"apps.checkedRecent": "已核對目前內容",
"apps.currentMode": "目前模式",
"apps.mode.lite": "基礎記憶(lite)",
"apps.mode.pro": "智慧記憶(pro)",
"apps.mode.ultra": "深度記憶(ultra)",
"apps.readingCount": "正在核對本機記憶…",
"apps.rawReadingCount": "正在核對本機原始對話…",
"apps.startingRetry": "背景服務正在啟動,稍後會自動更新",
"apps.countWillUpdate": "記憶數量稍後自動更新",
"apps.rawCountWillUpdate": "原始對話數量稍後自動更新",
"apps.showingPrevious": "正在顯示上次資料,稍後自動更新",
"apps.unreadable": "暫無法讀取",
"apps.unit": "則",
@@ -1458,6 +1488,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.revision": "版本 {count}",
"apps.conversationTime": "對話時間",
"apps.importTime": "匯入時間",
"apps.writeTime": "寫入時間",
"apps.updateTime": "更新時間",
"apps.incompleteCalls": " · 有些呼叫未被記錄,清單可能不完整",
"apps.timeoutWarning":
"{host} 最近無法查詢記憶,背景服務可能正忙,稍後會自動恢復。",
@@ -1862,6 +1894,14 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"calls.headline.empty": "{host} 沒有找到相關記憶",
"calls.headline.found": "{host} 找到 {count} 筆相關內容",
"calls.headline.found.one": "{host} 找到 {count} 筆相關內容",
"calls.headline.rawOnly": "{host} 找到 {count} 筆原始對話",
"calls.headline.rawOnly.one": "{host} 找到 {count} 筆原始對話",
"calls.headline.memoryOnly": "{host} 找到 {count} 筆記憶",
"calls.headline.memoryOnly.one": "{host} 找到 {count} 筆記憶",
"calls.headline.mixed": "{host} 找到 {memories} 筆記憶 · {raw} 筆原始對話",
"calls.headline.mixed.one":
"{host} 找到 {memories} 筆記憶 · {raw} 筆原始對話",
"calls.headline.completed": "{host} 呼叫已完成",
"calls.detail.timedOut": "記憶正在整理中,查詢逾時,這次回答沒有包含記憶。",
"calls.detail.unavailable": "MemHub 暫時無法使用,這次回答沒有包含記憶。",
"calls.detail.denied": "目前的授權不允許這次查詢。",
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { classifyCallResult } from "./call-result-operation.js";
describe("classifyCallResult", () => {
it.each(["timed_out", "unavailable", "denied", "empty"] as const)(
"gives failure state %s precedence over returned counts",
(state) => {
expect(
classifyCallResult({
state,
tool: "om_recall",
raw_count: 2,
memory_count: 3,
}),
).toBe(state);
},
);
it.each([
[1, 0, "raw-only"],
[0, 1, "memory-only"],
[1, 1, "mixed"],
[0, 0, "unclassified"],
] as const)(
"classifies grounded counts %i/%i as %s",
(raw_count, memory_count, expected) => {
expect(
classifyCallResult({
state: "grounded",
tool: "om_conversations",
raw_count,
memory_count,
}),
).toBe(expected);
},
);
it("uses the same content-plane classification for partial calls", () => {
expect(
classifyCallResult({
state: "partial",
tool: "om_ask",
raw_count: 4,
memory_count: 2,
}),
).toBe("mixed");
});
it("leaves a successful zero-result matters call unclassified", () => {
expect(
classifyCallResult({
state: "grounded",
tool: "om_matters",
raw_count: 0,
memory_count: 0,
}),
).toBe("unclassified");
});
});
@@ -0,0 +1,55 @@
export type CallResultState =
| "grounded"
| "empty"
| "partial"
| "timed_out"
| "unavailable"
| "denied";
export type CallResultTool =
| "om_recall"
| "om_conversations"
| "om_ask"
| "om_matters";
export type CallResultClassification =
| "timed_out"
| "unavailable"
| "denied"
| "empty"
| "raw-only"
| "memory-only"
| "mixed"
| "unclassified";
export interface CallResultLike {
state: CallResultState;
tool: CallResultTool;
raw_count: number;
memory_count: number;
}
/**
* Classify the observable result of a call without consulting the configured
* memory mode. Failure states always win; successful and partial calls are
* classified solely by the planes that actually returned content.
*/
export function classifyCallResult(
result: CallResultLike,
): CallResultClassification {
if (
result.state === "timed_out" ||
result.state === "unavailable" ||
result.state === "denied" ||
result.state === "empty"
) {
return result.state;
}
const hasRaw = result.raw_count > 0;
const hasMemory = result.memory_count > 0;
if (hasRaw && hasMemory) return "mixed";
if (hasRaw) return "raw-only";
if (hasMemory) return "memory-only";
// In particular, om_matters may complete without returning either plane.
return "unclassified";
}
@@ -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)}`;
}
+3
View File
@@ -8,7 +8,10 @@
* copies drifting apart.
*/
export * from "./call-result-operation.js";
export * from "./embedding-switch-operation.js";
export * from "./memory-add-operation.js";
export * from "./memory-mode-operation.js";
export * from "./memory-processing-operation.js";
export * from "./model-operation.js";
export * from "./model-profile-operation.js";
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
isBareNotFound,
legacyMemoryModePlan,
parseMemoryModePlan,
} from "./memory-mode-operation";
const valid = {
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
};
describe("memory mode plan", () => {
it("recognizes only FastAPI's bare route miss", () => {
expect(isBareNotFound({ detail: "Not Found" })).toBe(true);
expect(isBareNotFound({ detail: "memory_not_found" })).toBe(false);
expect(isBareNotFound({ detail: "Not Found", extra: true })).toBe(false);
});
it("strictly parses the server plan", () => {
expect(parseMemoryModePlan(valid)).toEqual(valid);
expect(parseMemoryModePlan({ ...valid, target: "future" })).toBeNull();
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
});
it("isolates the old-Core matrix in one helper", () => {
expect(
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
).toMatchObject({
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: true,
},
blockers: ["active_model_required"],
effects: expect.arrayContaining([
"earlier_raw_not_extracted",
"existing_memories_added_to_graph",
]),
});
});
});
@@ -0,0 +1,129 @@
import type {
HomeProjectionCapabilities,
MemoryModeChange,
MemoryModePlan,
MemoryPipelineMode,
} from "@one-memory/contracts";
const MODES = ["lite", "pro", "ultra"] as const;
const EFFECTS: MemoryModeChange["effects"] = [
"earlier_raw_not_extracted",
"higher_mode_memories_hidden",
"existing_memories_added_to_graph",
"evolve_stopped",
"lite_search_stops",
"graph_backfill_already_running",
];
const WARNINGS = ["forced_by_env", "effective_no_change"] as const;
const BLOCKERS = [
"active_model_required",
"embedding_maintenance_in_progress",
"memory_lifecycle_busy",
"graph_store_unavailable",
] as const;
/** FastAPI's route miss, excluding domain 404 bodies such as `{detail: code}`. */
export function isBareNotFound(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const body = value as Record<string, unknown>;
return Object.keys(body).length === 1 && body.detail === "Not Found";
}
function isMode(value: unknown): value is MemoryPipelineMode {
return MODES.includes(value as MemoryPipelineMode);
}
function listOf<T extends string>(
value: unknown,
allowed: readonly T[],
): value is T[] {
return (
Array.isArray(value) &&
value.every((item) => allowed.includes(item as T)) &&
new Set(value).size === value.length
);
}
function capabilitiesFor(mode: MemoryPipelineMode): HomeProjectionCapabilities {
return {
content_plane: mode === "lite" ? "raw" : "memory",
generation_required: mode !== "lite",
graph_enabled: mode === "ultra",
evolve_available: mode === "ultra",
};
}
export function parseMemoryModePlan(value: unknown): MemoryModePlan | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const plan = value as Record<string, unknown>;
const capabilities = plan.capabilities as Record<string, unknown> | null;
const allowed = [
"current",
"target",
"effective_target",
"capabilities",
"effects",
"warnings",
"blockers",
];
if (
Object.keys(plan).some((key) => !allowed.includes(key)) ||
!isMode(plan.current) ||
!isMode(plan.target) ||
!isMode(plan.effective_target) ||
!capabilities ||
Object.keys(capabilities).some(
(key) =>
![
"content_plane",
"generation_required",
"graph_enabled",
"evolve_available",
].includes(key),
) ||
(capabilities.content_plane !== "raw" &&
capabilities.content_plane !== "memory") ||
typeof capabilities.generation_required !== "boolean" ||
typeof capabilities.graph_enabled !== "boolean" ||
typeof capabilities.evolve_available !== "boolean" ||
capabilities.content_plane !==
(plan.effective_target === "lite" ? "raw" : "memory") ||
capabilities.generation_required !== (plan.effective_target !== "lite") ||
capabilities.graph_enabled !== (plan.effective_target === "ultra") ||
capabilities.evolve_available !== (plan.effective_target === "ultra") ||
!listOf(plan.effects, EFFECTS) ||
!listOf(plan.warnings, WARNINGS) ||
!listOf(plan.blockers, BLOCKERS)
)
return null;
return plan as unknown as MemoryModePlan;
}
/** Explicit compatibility matrix for Core builds predating the plan route. */
export function legacyMemoryModePlan(
current: MemoryPipelineMode,
target: MemoryPipelineMode,
options: { hasActiveModel: boolean },
): MemoryModePlan {
const effects: MemoryModeChange["effects"] = [];
if (target === "lite") {
effects.push("higher_mode_memories_hidden");
if (current === "ultra") effects.push("evolve_stopped");
} else if (current === "lite") {
effects.push("earlier_raw_not_extracted", "lite_search_stops");
}
if (target === "ultra" && current !== "ultra")
effects.push("existing_memories_added_to_graph");
return {
current,
target,
effective_target: target,
capabilities: capabilitiesFor(target),
effects,
warnings: [],
blockers:
target !== "lite" && !options.hasActiveModel
? ["active_model_required"]
: [],
};
}