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 4e7d752980 feat(memhub): add lite manual raw input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:05:36 +08:00
kristinheandCursor fb13a6fda2 fix(memhub): keep mode-specific memory views consistent
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 23:20:41 +08:00
kristinheandCursor 6ff8b3a3b4 fix(memhub): simplify topic node details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:49:47 +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
kristinheandCursor 5271bddce3 fix(memhub): preserve raw conversations in lite mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:18:12 +08:00
kristinhe eac795b2d2 Merge remote-tracking branch 'origin/main' into fix/memhub-model-center-main 2026-11-03 20:55:54 +08:00
kristinhe1111 d938f1d5b2 Merge pull request #294 from Tencent-Hunyuan/chore/memhub-build-39
chore(release): MemHub 1.0.6, build 39
2026-11-03 20:05:08 +08:00
kristinheandCursor 8cee64c813 chore(release): bump MemHub build number to 39
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:59:27 +08:00
kristinhe1111 1ffcc9eb57 Merge pull request #292 from Tencent-Hunyuan/feat/memhub-mode-ui
feat(memhub): complete memory mode app lifecycle
2026-11-03 19:43:30 +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
kristinheandCursor b36452d110 fix(memhub): reset mode state and satisfy lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:36:42 +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 e58a7c0d14 fix(memhub): polish mode selector and raw transitions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:32:56 +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
kristinheandCursor fba9d8cf26 fix(memhub): align lite and mode settings UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:30:43 +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
kristinheandCursor 18e2fca34d fix(memhub): harden mode startup recovery and search retry
Close review follow-ups for graph backfill gates, onboarding resume,
settings refresh races, modal focus trapping, and raw search retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:01:18 +08:00
kristinheandCursor 88feb0adca feat(memhub): add memory mode app lifecycle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:22:09 +08:00
feitianxiang d2e9cdaf06 Merge pull request #286 from Tencent-Hunyuan/chore/memhub-build-38
chore(release): MemHub 1.0.6, build 38
2026-11-02 21:47:36 +08:00
alvinfeiandClaude Opus 5.5 8277aaee51 chore(release): bump MemHub build number to 38
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:40:56 +08:00
feitianxiang 65550621e0 Merge pull request #285 from Tencent-Hunyuan/release/memhub-1.0.6-hy-2.0.13
chore(memhub): hy-memory 2.0.13; 1.0.6 changelog
2026-11-02 21:29:36 +08:00
140 changed files with 16767 additions and 1221 deletions

No files matched your search

+9 -1
View File
@@ -4,6 +4,14 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub App now exposes the complete memory-mode lifecycle. New installs
explicitly choose lite or pro during onboarding; pro completes only after a
generation model is active, while ultra remains an advanced Settings choice.
Settings shows mode effects, graph backfill, the default 03:00 evolve
schedule and manual evolve. The memory page shows original conversations in
lite and advanced memories in pro/ultra. Existing installs remain pro unless
the user changes them, and embedding maintenance blocks mode/evolve changes.
## Hy Memory 2.0.13 — 2026-11-02
- A model failure during a write now reports failure and stores
@@ -75,7 +83,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 39, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
(build 39),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+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",
+228 -49
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
@@ -740,6 +742,8 @@ class MemoryModeState(BaseModel):
mode: MemoryMode
#: The saved setting (default pro).
configured_mode: MemoryMode
#: True once a person chose a mode, including explicitly choosing pro.
explicitly_configured: bool
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
forced_by_env: bool
modes: tuple[MemoryMode, ...] = MEMORY_MODES
@@ -769,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")
@@ -1440,10 +1467,15 @@ def create_app(
service=app.state.evolve_service,
schedule=lambda: _config_store(app).evolve_schedule(),
users_with_memories=lambda: _users_with_memories(app),
lifecycle_lock=app.state.memory_mode_lock,
lifecycle_open=lambda: (
_embedding_writes_open(app) and not app.state.memory_graph_backfill.running
),
)
app.state.memory_graph_backfill = MemoryGraphBackfill(
coordinator=app.state.sdk_write_coordinator,
state=app.state.evolve_state_store,
lifecycle_open=lambda: _embedding_writes_open(app),
)
# C2 owns the single production budget (handle 5s + reserve 250ms,
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
@@ -1465,22 +1497,15 @@ def create_app(
async def admit_hy_invocation(
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize pause with the last boundary before an HY call."""
source_lock = app.state.source_permission_locks.setdefault(
source_id, asyncio.Lock()
return await _admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark_invoked,
)
async with app.state.sedimentation_lock, source_lock:
if not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
# Re-checked at the last boundary: a switch that began
# after the claim keeps this job queued, unsent.
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
memory_write_drain = DurableWriteDrain(
memory_write_queue,
@@ -1529,7 +1554,7 @@ def create_app(
# therefore skipped on every drain tick — the note stayed
# "saved, waiting" with no task behind it, forever.
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
model_claim_allowance=app.state.model_backoff.allowance,
model_claim_allowance=lambda: _model_claim_allowance(app),
)
app.state.memory_write_drain = memory_write_drain
@@ -1842,6 +1867,16 @@ def create_app(
name="one-memory-durable-write-drain",
)
async def resume_graph_backfill() -> None:
saved = app.state.evolve_state_store.graph_build()
if saved.state != "running":
return
if _memory_mode(app) != "ultra":
app.state.evolve_state_store.set_graph_build(GraphBuildState())
return
users = await asyncio.to_thread(_known_user_ids, app)
app.state.memory_graph_backfill.recover(users)
async def start_evolve_schedule() -> None:
# Idle outside ultra: each tick asks the mode first.
app.state.evolve_scheduler.start()
@@ -1930,7 +1965,14 @@ def create_app(
# served from the live space throughout.
if sdk:
await step("embedding_spaces", resume_embedding_spaces)
await step("graph_backfill", resume_graph_backfill)
await step("write_drain", start_write_drain)
if sdk and _memory_mode(app) == "lite":
# A previous pro run may have parked jobs while model
# sedimentation was off. Lite needs no generation model:
# readmit them in the background so startup can finish
# while raw conversations begin appearing.
_resume_sedimentation(app)
if sdk:
await step("evolve_schedule", start_evolve_schedule)
else:
@@ -3829,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,
@@ -3995,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],
@@ -4380,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)
@@ -4391,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,
@@ -4675,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,
@@ -4693,13 +4770,14 @@ 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.
"""
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:
previous_effective = _memory_mode(app)
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 "memory_mode" not in store.document().model_fields_set:
if previous != request.mode or not store.memory_mode_explicitly_configured():
await asyncio.to_thread(store.set_memory_mode, request.mode)
effective = _memory_mode(app)
backfill: bool | None = None
@@ -4718,6 +4796,8 @@ def create_app(
outcome="succeeded",
details={"mode": effective, "previous_mode": previous_effective},
)
if effective == "lite" and effective != previous_effective:
_resume_sedimentation(app)
return MemoryModeChange(
**_memory_mode_state(app).model_dump(),
previous_mode=previous_effective,
@@ -4862,14 +4942,18 @@ def create_app(
in flight and holds their next writes until it ends, and repeats
while the SDK says there is more to do (up to 20 rounds).
"""
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
async with app.state.memory_mode_lock:
_refuse_pipeline_change_during_embedding_maintenance(app)
if app.state.memory_graph_backfill.running:
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
@app.get(
"/api/v2/evolve",
@@ -4987,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",
@@ -5004,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(
@@ -5808,10 +5887,19 @@ def _resume_sedimentation(app: FastAPI) -> None:
"""
running = cast("asyncio.Task[None] | None", getattr(app.state, "sedimentation_resume", None))
if running is not None and not running.done():
app.state.sedimentation_resume_again = True
return
async def resume_until_stable() -> None:
while True:
app.state.sedimentation_resume_again = False
await _sdk_memory_runtime(app).resume_allowed()
if not app.state.sedimentation_resume_again:
return
app.state.sedimentation_resume = watched_task(
asyncio.get_running_loop(),
lambda: _sdk_memory_runtime(app).resume_allowed(),
resume_until_stable,
component="memory.sedimentation",
name="one-memory-sedimentation-resume",
)
@@ -6178,14 +6266,65 @@ def _memory_mode(app: FastAPI) -> MemoryMode:
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
mode = _memory_mode(app)
store = _config_store(app)
return MemoryModeState(
mode=mode,
configured_mode=_config_store(app).memory_mode(),
configured_mode=store.memory_mode(),
explicitly_configured=store.memory_mode_explicitly_configured(),
forced_by_env=graph_store_enabled(),
graph_build=app.state.evolve_state_store.graph_build(),
)
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)
@@ -6559,7 +6698,7 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
"""
return (
_config_store(app).sedimentation_enabled()
_pipeline_writes_enabled(app)
and _embedding_writes_open(app)
and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None)
and _history_import_gate_released(app)
@@ -6568,6 +6707,40 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
)
async def _admit_pipeline_invocation(
app: FastAPI,
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize the final source and pipeline checks with the durable mark."""
source_lock = app.state.source_permission_locks.setdefault(source_id, asyncio.Lock())
async with app.state.sedimentation_lock, source_lock:
if requires_generation and not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
def _pipeline_writes_enabled(app: FastAPI) -> bool:
"""Raw-only lite writes run even when model sedimentation is paused."""
return _memory_mode(app) == "lite" or _config_store(app).sedimentation_enabled()
def _model_claim_allowance(app: FastAPI) -> int | None:
"""A failed generation model never pauses raw-only lite ingestion."""
backoff = cast(ModelBackoff, app.state.model_backoff)
if _memory_mode(app) == "lite":
return backoff.allowance(kind="embedding")
return backoff.allowance()
def _user_evolving(app: FastAPI, user_id: str) -> bool:
"""Whether evolve holds this user's writes right now."""
coordinator = getattr(app.state, "sdk_write_coordinator", None)
@@ -6612,6 +6785,12 @@ def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
def _refuse_pipeline_change_during_embedding_maintenance(app: FastAPI) -> None:
"""Keep mode/evolve lifecycle changes out of embedding maintenance."""
if not _embedding_writes_open(app):
raise HTTPException(status_code=409, detail="embedding_maintenance_in_progress")
_EMBEDDING_PATCH_FIELDS = frozenset(
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
)
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
# One lifecycle lock orders mode/evolve/backfill and embedding
# maintenance. Without it each endpoint could pass its own preflight
# and then begin concurrently.
async with request.app.state.memory_mode_lock:
evolve = request.app.state.evolve_service
backfill = request.app.state.memory_graph_backfill
if evolve.running() or backfill.running:
return refusal("memory_lifecycle_busy")
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
return job.model_dump(mode="json")
@router.get("/api/v1/embedding-switches/current")
@@ -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}>")
@@ -465,12 +465,16 @@ class EvolveScheduler:
service: EvolveService,
schedule: Callable[[], EvolveSchedule],
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
lifecycle_lock: asyncio.Lock | None = None,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
check_seconds: float = SCHEDULE_CHECK_SECONDS,
) -> None:
self.service = service
self.schedule = schedule
self.users_with_memories = users_with_memories
self.lifecycle_lock = lifecycle_lock
self.lifecycle_open = lifecycle_open
self._now = now
self.check_seconds = check_seconds
self._task: asyncio.Task[None] | None = None
@@ -492,7 +496,15 @@ class EvolveScheduler:
if not evolve_due(now, schedule, last, anchor):
continue
try:
receipt = self.service.start(user_id, trigger="scheduled")
if self.lifecycle_lock is None:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
else:
async with self.lifecycle_lock:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
except EvolveModeError:
break
await self.service.wait(receipt.run_id)
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
*,
coordinator: EvolveCoordinator,
state: EvolveStateStore,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.coordinator = coordinator
self.state = state
self.lifecycle_open = lifecycle_open
self._now = now
self._task: asyncio.Task[None] | None = None
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
return state
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
"""Resume an interrupted backfill without repeating completed users."""
if self.running:
return self.state.graph_build()
saved = self.state.graph_build()
if saved.state != "running":
return saved
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
pending = tuple(
user_id
for user_id in users
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
)
resumed = saved.model_copy(deep=True)
for user_id in pending:
resumed.users[user_id] = GraphBuildUser()
if not pending:
resumed.state = "succeeded"
resumed.finished_at = self._now()
self.state.set_graph_build(resumed)
return resumed
resumed.state = "running"
resumed.finished_at = None
self.state.set_graph_build(resumed)
self._task = asyncio.create_task(
self._run(pending), name="one-memory-graph-backfill-recovery"
)
return resumed
async def wait(self) -> None:
if self._task is not None:
await asyncio.shield(self._task)
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
async def _run(self, users: tuple[str, ...]) -> None:
state = self.state.graph_build()
for user_id in users:
while not self.lifecycle_open():
await asyncio.sleep(0.2)
state.users[user_id] = GraphBuildUser(state="running")
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
try:
@@ -153,6 +153,11 @@ class ConfigStore:
with self._lock:
return self._document.memory_mode
def memory_mode_explicitly_configured(self) -> bool:
"""Whether a person chose the mode, including choosing the default."""
with self._lock:
return "memory_mode" in self._document.model_fields_set
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
if mode not in MEMORY_MODES:
raise ValueError("invalid_memory_mode")
@@ -2540,7 +2540,7 @@ async def execute_job(
evidence: EvidenceStore,
owner: str | None = None,
source_writes_permitted: Callable[[str], bool] | None = None,
admit_hy_invocation: Callable[[str, Callable[[], Awaitable[bool]]], Awaitable[bool]]
admit_hy_invocation: Callable[[str, bool, Callable[[], Awaitable[bool]]], Awaitable[bool]]
| None = None,
model_failures: Callable[[], AbstractContextManager[ModelAttempt | None]] | None = None,
) -> None:
@@ -2593,7 +2593,7 @@ async def execute_job(
hy_invoked = False
async def mark_hy_invocation() -> None:
async def mark_hy_invocation(requires_generation: bool) -> None:
"""Persist the durable boundary immediately before the real HY call."""
nonlocal hy_invoked
if hy_invoked:
@@ -2621,7 +2621,7 @@ async def execute_job(
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
if admit_hy_invocation is not None:
admitted = await admit_hy_invocation(source_id, mark)
admitted = await admit_hy_invocation(source_id, requires_generation, mark)
else:
if source_writes_permitted is not None and not source_writes_permitted(source_id):
raise RuntimeError("model_content_source_disabled")
@@ -247,15 +247,17 @@ class ModelBackoff:
and self._clock() < self._until
)
def allowance(self) -> int | None:
"""How many model jobs may be claimed now: None for no limit.
def allowance(self, *, kind: str | None = None) -> int | None:
"""How many matching provider jobs may be claimed now.
0 while paused; once the pause is over, one probe at a time until it
reports or `PROBE_HOLD_SECONDS` pass.
reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat
model from pausing raw-only work that needs embeddings, while an
embedding outage still paces that work.
"""
now = self._clock()
with self._lock:
if self._failures == 0:
if self._failures == 0 or (kind is not None and kind not in self._failing):
return None
if now < self._until or now < self._probe_until:
return 0
@@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -309,7 +309,10 @@ class SdkMemoryRuntime:
return []
async def resume_allowed(self) -> None:
await asyncio.to_thread(self.write_queue.readmit_parked)
await asyncio.to_thread(
self.write_queue.readmit_parked,
states=(WAITING_FOR_MODEL,),
)
def accepted_backfill_status(self) -> None:
return None
@@ -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}
@@ -23,11 +23,12 @@ from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
language_kwargs,
retrieval_view,
)
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
from one_memory.input_sanitization import sanitize_model_input
from one_memory.memory_features import FeatureOverrides
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
PRODUCT_AGENT_ID = "all-apps"
@@ -112,7 +113,7 @@ class SdkWriteProcessor:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not records:
raise RuntimeError("hy_memory_task_without_inputs")
@@ -139,7 +140,7 @@ class SdkWriteProcessor:
if extracted and not self._lite:
contents = [sanitize_model_input(record.verbatim) for record in records]
await before_hy_invocation()
await before_hy_invocation(True)
result = await _write(
request_id,
client.add_extracted,
@@ -167,7 +168,7 @@ class SdkWriteProcessor:
]
if not messages:
raise RuntimeError("hy_memory_task_without_inputs")
await before_hy_invocation()
await before_hy_invocation(not self._lite)
result = await _write(
request_id,
client.add,
@@ -283,10 +284,8 @@ class SdkWriteCoordinator:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not self.sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not self.writes_open():
# An embedding switch paused writes: the drain puts the job back.
raise RuntimeError("embedding_switch_in_progress")
@@ -297,9 +296,8 @@ class SdkWriteCoordinator:
self._writing[user_id] = self._writing.get(user_id, 0) + 1
try:
async with self._active_handle() as handle:
return await SdkWriteProcessor(
handle, lite=getattr(handle, "mode", None) == "lite"
).process_sdk_job(
lite = getattr(handle, "mode", None) == "lite"
return await SdkWriteProcessor(handle, lite=lite).process_sdk_job(
records,
request_id=request_id,
before_hy_invocation=before_hy_invocation,
@@ -466,24 +464,27 @@ class SdkWriteCoordinator:
return (builtin.profile_id, builtin.revision), builtin, None, space
identity = (space.profile_id, space.profile_revision)
profile = self.profiles.snapshot(*identity)
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if profile.has_api_key and secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, profile, secret, space
view = retrieval_view(profile)
secret: str | None = None
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, view, secret, space
def _read_active_credentials(
self,
mode: str = "pro",
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
if mode == "lite":
# A lite write is an embedding-only view of the live space. Never
# resolve or inject the generation model endpoint or its secret.
return self._lite_credentials()
identity = self.profiles.active_identity()
if identity is None:
if mode == "lite":
# lite stores raw turns with one embedding and calls no model:
# the live space's own, so they land where reads look.
return self._lite_credentials()
raise RuntimeError("model_profile_not_active")
profile = self.profiles.snapshot(*identity)
space = self._live_space() if self.spaces is not None else None
@@ -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.
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
asyncio.run(scenario())
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
backend.store.put(
builtin_base(),
512,
{"m1": ("memory", "local")},
model="bge-small-zh-v1.5",
)
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
await recovered(client)
profile = remote_profile(app)
plan = (
await client.post(
"/api/v1/embedding-switches/plan",
json={"profile_id": profile.profile_id},
)
).json()
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
response = await client.post(
"/api/v1/embedding-switches",
json={
"profile_id": profile.profile_id,
"expected_revision": profile.revision,
"plan_token": plan["plan_token"],
},
)
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
assert app.state.embedding_switch.writes_open() is True
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
asyncio.run(scenario())
def test_cancel_over_the_api(tmp_path, backend):
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
@@ -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"
+1 -1
View File
@@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
assert current is not None
assert ingest_journal.matches_current(connection, current)
connection.rollback()
await before_hy_invocation()
await before_hy_invocation(True)
return SdkWriteReceipt()
drain = DurableWriteDrain(queue, execute=lambda _job: None)
@@ -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())
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
EvolveScheduler,
EvolveService,
EvolveStateStore,
GraphBuildState,
GraphBuildUser,
MemoryGraphBackfill,
evolve_due,
)
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
assert effective_memory_mode(store.memory_mode()) == "pro"
store.set_memory_mode("lite")
assert store.memory_mode_explicitly_configured() is True
assert ConfigStore(tmp_path).memory_mode() == "lite"
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
store.set_memory_mode("max") # type: ignore[arg-type]
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
(tmp_path / "config.json").write_text(
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
store.set_memory_mode("pro")
assert store.memory_mode_explicitly_configured() is True
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
store = ConfigStore(tmp_path)
store.set_sync("cursor", True)
@@ -158,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
@@ -288,7 +338,7 @@ class _SdkHandle:
return None
async def _noop() -> None:
async def _noop(_requires_generation: bool) -> None:
return None
@@ -311,14 +361,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None
]
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
def test_a_lite_write_ignores_the_model_sedimentation_pause(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: True,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
@@ -335,6 +385,37 @@ def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch)
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
@@ -624,6 +705,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
assert state.user("wife").last_result["trigger"] == "scheduled"
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk(continuing=0)
state = EvolveStateStore(tmp_path)
state.anchor(_at(1, 12))
open_gate = [False]
service = EvolveService(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
memory_mode=lambda: "ultra",
now=lambda: _at(2, 3, 2),
)
async def users():
return ("local",)
async def scenario():
scheduler = EvolveScheduler(
service=service,
schedule=EvolveSchedule,
users_with_memories=users,
lifecycle_lock=asyncio.Lock(),
lifecycle_open=lambda: open_gate[0],
now=lambda: _at(2, 3, 2),
)
assert await scheduler.tick() == ()
assert sdk.calls == []
open_gate[0] = True
assert await scheduler.tick() == ("local",)
asyncio.run(scenario())
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
@@ -643,6 +756,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
}
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
state.set_graph_build(
GraphBuildState(
state="running",
started_at=_at(1, 3),
users={
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
"wife": GraphBuildUser(state="running"),
},
)
)
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
async def scenario():
resumed = backfill.recover(["local", "wife"])
assert resumed.state == "running"
assert resumed.users["local"].state == "succeeded"
assert resumed.users["wife"].state == "pending"
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["wife"]
assert state.graph_build().state == "succeeded"
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
gate = [False]
checked = asyncio.Event()
def lifecycle_open() -> bool:
checked.set()
return gate[0]
backfill = MemoryGraphBackfill(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
lifecycle_open=lifecycle_open,
)
async def scenario():
backfill.start(["local"])
await checked.wait()
assert sdk.graph_builds == []
assert state.graph_build().state == "running"
assert state.graph_build().users["local"].state == "pending"
gate[0] = True
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["local"]
assert state.graph_build().state == "succeeded"
# --- the HTTP surface -----------------------------------------------------------
@@ -701,23 +871,38 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
current = (await client.get("/api/v1/settings/memory-mode")).json()
assert current["mode"] == current["configured_mode"] == "pro"
assert current["explicitly_configured"] is False
assert current["forced_by_env"] is False
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
assert (
await client.put("/api/v1/sedimentation", json={"enabled": False})
).status_code == 200
from one_memory.api import _sedimentation_claim_enabled
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.
from one_memory.api import _sedimentation_claim_enabled
assert app.state.model_profile_store.active_identity() is None
app.state.history_import_gate_released = True
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"]
@@ -741,6 +926,173 @@ 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")
store.set_memory_mode("lite")
store.set_sedimentation_enabled(False)
resumed = asyncio.Event()
original = SdkMemoryRuntime.resume_allowed
async def resume_allowed(runtime):
await original(runtime)
resumed.set()
monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed)
async def scenario(app, _sdk, _client):
await asyncio.wait_for(resumed.wait(), 5)
assert app.state.config_store.sedimentation_enabled() is False
assert app.state.config_store.memory_mode() == "lite"
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
tmp_path, monkeypatch
) -> None:
async def scenario(app, _sdk, client):
switch = app.state.embedding_switch
switch._scan_hold = True
current = await client.get("/api/v1/settings/memory-mode")
assert current.status_code == 200
assert current.json()["explicitly_configured"] is False
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
assert app.state.config_store.memory_mode_explicitly_configured() is False
switch._scan_hold = False
switch._open_cache = True
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
switch._open_cache = False
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert explicitly_pro.status_code == 200
assert explicitly_pro.json()["explicitly_configured"] is True
assert explicitly_pro.json()["effects"] == []
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
finally:
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_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -748,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}
@@ -865,3 +1223,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary(
tmp_path, monkeypatch
) -> None:
from one_memory.api import _admit_pipeline_invocation
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
seen: list[str] = []
class Sdk:
mode = "lite"
def add(self, messages, **kwargs):
seen.append("add")
return {"success": True, "raw_ids": ["raw-lite"]}
def add_extracted(self, contents, **kwargs):
seen.append("add_extracted")
raise AssertionError("lite must never call add_extracted")
monkeypatch.setattr(
sdk_write_processor,
"SdkClientHandle",
lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"),
)
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",
)
async def never(_job_id):
return None
async def scenario(app, _sdk, client):
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
await app.state.memory_write_drain_task
app.state.memory_write_drain_task = None
await client.put("/api/v1/sedimentation", json={"enabled": False})
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
app.state.history_import_gate_released = True
written = await client.post(
"/api/v2/memory-writes",
json={
"request_id": "paused-lite-turn",
"session_id": "paused-lite-session",
"source_id": "one-memory-manual",
"messages": [
{"role": "user", "content": "保留原始会话"},
{"role": "assistant", "content": "已保留"},
],
},
)
assert written.status_code == 202
queue = app.state.memory_write_queue
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
[job_id] = drain.claim(limit=1)
await execute_job(
job_id,
queue=queue,
supervisor=coordinator,
evidence=None, # type: ignore[arg-type]
admit_hy_invocation=lambda source_id, requires_generation, mark: (
_admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark,
)
),
)
current = queue.get("paused-lite-turn")
assert current is not None and current.state == "completed"
assert current.sdk_raw_ids == ("raw-lite",)
assert seen == ["add"]
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@@ -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",
(),
@@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
)
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
async def process(self, records, *, request_id, before_hy_invocation):
client = await self._client_handle.client()
await before_hy_invocation()
await before_hy_invocation(getattr(client, "mode", None) != "lite")
seen.append((client.profile_id, client.closed))
if client.profile_id == "profile-a":
await writing.wait()
@@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
async def noop():
async def noop(_requires_generation: bool):
return None
async def scenario():
@@ -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:
@@ -4758,7 +4758,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
assert drain.claim(limit=1) == (record.job_id,)
async def refuse_before_hy(_source_id, _mark_invoked):
async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked):
raise RuntimeError("model_sedimentation_disabled")
asyncio.run(
@@ -259,7 +259,7 @@ def test_old_branch_job_executes_its_frozen_revision_after_harness_rewind(tmp_pa
):
assert request_id == old_job.request_id
captured.extend(records)
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=(), memory_ids=())
asyncio.run(
+12 -1
View File
@@ -184,6 +184,17 @@ def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> No
assert backoff.paused()
def test_claim_allowance_can_pace_embedding_without_pausing_lite_for_chat() -> None:
backoff = ModelBackoff(clock=_Clock())
backoff.record_failure("model_not_found", "p:1", "chat")
assert backoff.allowance(kind="embedding") is None
assert backoff.allowance(kind="chat") == 0
backoff.reset()
backoff.record_failure("embedding_service_failed", "p:1", "embedding")
assert backoff.allowance(kind="embedding") == 0
def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
@@ -310,7 +321,7 @@ class _FakeSdk:
self.fail_with: str | None = None
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
await before_hy_invocation()
await before_hy_invocation(True)
self.request_ids.append(request_id)
if request_id in self.outcomes:
if self.outcomes[request_id] == "failed":
@@ -1613,7 +1613,7 @@ def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
class Supervisor:
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
del records, request_id
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
asyncio.run(
@@ -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: "主要导航" });
+39 -2
View File
@@ -37,7 +37,41 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
vi.mock("./api", () => ({
getOnboarding,
getModelProfiles: vi.fn().mockResolvedValue([]),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
setMemoryMode: vi.fn(),
setEvolveSchedule: vi.fn(),
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", () => ({
@@ -119,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();
@@ -182,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"}
+314 -18
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");
@@ -944,6 +1198,48 @@ it("shows a paused switch immediately even when task rows still say running", as
expect(screen.queryByText("新的对话正在整理")).toBeNull();
});
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,
pipeline: { mode: "lite" },
});
sedimentation.mockResolvedValue({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
it("keeps the import-in-progress empty state stable without a progress link", async () => {
summary.mockResolvedValue({
memory_count: 0,
@@ -1312,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,
@@ -1329,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 -114
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,72 +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 stateLabel =
sedimentation?.enabled === true
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.checkingStatus");
const stateDetail =
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
@@ -784,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) {
@@ -832,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>
@@ -862,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>
@@ -905,7 +1028,7 @@ export function ApplicationsHome({
? t("apps.recentCall")
: activity === "memory"
? t("apps.recentUpdate")
: t("apps.firstBatch")}
: t(viewModel.activity.headingKey)}
</p>
)}
<div>
@@ -935,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")
@@ -947,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"
@@ -966,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" ||
@@ -987,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" />
)}
@@ -1049,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}>
+111 -1
View File
@@ -46,6 +46,7 @@ const api = vi.hoisted(() => ({
vi.mock("./api", () => api);
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
const OLD_REQUEST_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const addition: MemoryAddReceipt = {
evidence_id: "evidence-1",
text: "项目周会不排在周五。",
@@ -122,13 +123,14 @@ it("explains source verification failure without retrying or claiming success",
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
function page() {
function page(rawOnly = false) {
const onSaved = vi.fn(),
onOpenMemory = vi.fn(),
onConfigureModel = vi.fn(),
onPendingChange = vi.fn();
const view = render(
<MemoryAddComposer
rawOnly={rawOnly}
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
/>,
);
@@ -198,6 +200,114 @@ it("saves from an empty library with the linked-app scope by default", async ()
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
});
it("saves a lite manual addition as original content without model UI", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(screen.getByRole("dialog", { name: "手动添加" })).toBeTruthy();
expect(screen.queryByText(/整理模型:/)).toBeNull();
expect(api.getModelProfiles).not.toHaveBeenCalled();
await userEvent.type(
screen.getByRole("textbox", { name: "记下这件事" }),
addition.text,
);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByText("已保存原文")).toBeTruthy();
expect(callbacks.onSaved).toHaveBeenCalledOnce();
expect(
callbacks.onPendingChange.mock.calls.some(([value]) => value === null),
).toBe(true);
expect(screen.queryByRole("dialog", { name: "手动添加" })).toBeNull();
});
it("refreshes the lite raw page when an asynchronous manual save completes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledTimes(2), {
timeout: 2_500,
});
expect(screen.getByText("已保存原文")).toBeTruthy();
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith(null);
});
it("does not treat an older completed addition as a newly saved raw turn", async () => {
localStorage.setItem(
RECENT_ADDITIONS_KEY,
JSON.stringify([REQUEST_ID, OLD_REQUEST_ID]),
);
let currentState: MemoryAddReceipt["state"] = "running";
const calls = new Map<string, number>();
api.getMemoryAddStatus.mockImplementation(async (requestId: string) => {
calls.set(requestId, (calls.get(requestId) ?? 0) + 1);
return requestId === OLD_REQUEST_ID
? {
...addition,
evidence_id: "old-evidence",
state: "no_memories",
}
: { ...addition, state: currentState };
});
const callbacks = page(true);
await waitFor(
() => expect(calls.get(REQUEST_ID) ?? 0).toBeGreaterThanOrEqual(2),
{ timeout: 2_500 },
);
expect(callbacks.onSaved).not.toHaveBeenCalled();
currentState = "no_memories";
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledOnce(), {
timeout: 2_500,
});
});
it("keeps a failed lite raw write visible after the composer closes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "failed",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(screen.queryByText("已保存原文")).toBeNull();
await waitFor(
() =>
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
evidenceId: addition.evidence_id,
text: addition.text,
state: "failed",
}),
{ timeout: 2_500 },
);
await waitFor(() =>
expect(screen.getByText("原话已保存,整理未完成")).toBeTruthy(),
);
});
it("preserves draft and request identity after an uncertain save", async () => {
api.addMemory
.mockRejectedValueOnce(new Error("timeout"))
+118 -30
View File
@@ -103,12 +103,14 @@ export function MemoryAddComposer({
onConfigureModel,
onPendingChange,
suspended = false,
rawOnly = false,
}: {
onSaved(): void;
onOpenMemory(id: string): void;
onConfigureModel(): void;
onPendingChange?(addition: PendingMemoryAdd | null): void;
suspended?: boolean;
rawOnly?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
@@ -128,6 +130,7 @@ export function MemoryAddComposer({
const additionsRequest = useRef(0);
const intent = useRef<{ text: string; requestId: string } | null>(null);
const completed = useRef(new Set<string>());
const watched = useRef(new Set<string>());
const lastSaved = useRef<string | null>(null);
const onSavedRef = useRef(onSaved);
onSavedRef.current = onSaved;
@@ -156,34 +159,72 @@ export function MemoryAddComposer({
}, []);
const refreshAdditions = useCallback(
async (failure: string, notifyResults = false) => {
async (
failure: string,
notifyResults = false,
allowWhileClosed = false,
) => {
const request = ++additionsRequest.current;
try {
const value = await loadRecentAdditions();
if (!isOpen.current || request !== additionsRequest.current) return;
if (
(!allowWhileClosed && !isOpen.current) ||
request !== additionsRequest.current
)
return;
setAdditions(value);
reportPending(value);
if (!notifyResults) return;
if (!notifyResults) {
for (const { receipt } of value) {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
}
return;
}
const latest = value.find(
(item) => item.receipt.evidence_id === lastSaved.current,
);
if (latest) setNotice(states[latest.receipt.state]);
if (latest) {
setNotice(
rawOnly && latest.receipt.state === "no_memories"
? "已保存原文"
: states[latest.receipt.state],
);
if (rawOnly && latest.receipt.state === "failed")
setSavedToast(states.failed);
}
const newResults = value.filter(
({ receipt }) =>
receipt.state === "ready" &&
(receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")) &&
watched.current.has(receipt.evidence_id) &&
!completed.current.has(receipt.evidence_id),
);
if (newResults.length) {
for (const { receipt } of newResults)
for (const { receipt } of newResults) {
completed.current.add(receipt.evidence_id);
watched.current.delete(receipt.evidence_id);
}
if (rawOnly) setSavedToast("已保存原文");
onSavedRef.current();
}
for (const { receipt } of value)
if (receipt.state === "failed")
watched.current.delete(receipt.evidence_id);
} catch {
if (isOpen.current && request === additionsRequest.current)
if (
(isOpen.current || allowWhileClosed) &&
request === additionsRequest.current
)
setError(failure);
}
},
[reportPending],
[rawOnly, reportPending],
);
useEffect(() => {
@@ -193,6 +234,15 @@ export function MemoryAddComposer({
const before = document.activeElement;
panel.current?.querySelector("textarea")?.focus();
void refreshAdditions("最近添加的内容读取失败。");
if (rawOnly) {
setModel(null);
setModelRead(true);
return () => {
isOpen.current = false;
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}
setModelRead(false);
getModelProfiles()
.then((value) => {
@@ -211,32 +261,38 @@ export function MemoryAddComposer({
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}, [open, refreshAdditions, suspended]);
}, [open, rawOnly, refreshAdditions, suspended]);
useEffect(() => {
if (open || suspended || !onPendingChange) return;
void loadRecentAdditions()
.then((value) => reportPending(value))
.catch(() => {
// The page already keeps the last known state when a background read
// fails; do not turn an unknown pending count into zero here.
});
}, [open, onPendingChange, reportPending, suspended]);
void refreshAdditions(
"最近添加的内容读取失败。",
lastSaved.current !== null,
true,
);
}, [open, onPendingChange, refreshAdditions, suspended]);
const working = additions.some(
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
);
useEffect(() => {
if (!open || suspended || !working) return;
if (suspended || !working) return;
const stop = subscribePoll({
// A key of its own: sharing one with another surface would make one
// tick read the wrong endpoint for the other caller.
key: "memory-add-processing",
intervalMs: 1_500,
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
run: () =>
refreshAdditions(
rawOnly
? "保存状态暂时不可用,原文已保存。"
: "整理状态暂时不可用,原话已保存。",
true,
!open,
),
});
return stop;
}, [open, working, refreshAdditions, suspended]);
}, [open, rawOnly, working, refreshAdditions, suspended]);
useEffect(() => {
if (!savedToast) return;
@@ -273,12 +329,27 @@ export function MemoryAddComposer({
setNotice("");
const trace = traceUiAction("memory", "submit");
const completeSave = (receipt: MemoryAddReceipt) => {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
remember({ requestId, receipt });
lastSaved.current = receipt.evidence_id;
setText("");
intent.current = null;
setNotice("");
setSavedToast("已保存,正在后台沉淀记忆");
setSavedToast(
rawOnly
? receipt.state === "ready" || receipt.state === "no_memories"
? "已保存原文"
: receipt.state === "failed"
? states.failed
: "正在保存…"
: "已保存,正在后台沉淀记忆",
);
isOpen.current = false;
additionsRequest.current += 1;
setOpen(false);
@@ -321,7 +392,8 @@ export function MemoryAddComposer({
data-tour-id="memory-add-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
<FilePlus2 size={16} aria-hidden="true" />{" "}
{copy(rawOnly ? "手动添加" : "添加记忆")}
</button>
{savedToast && (
<p className="memory-add-toast" role="status">
@@ -360,11 +432,13 @@ export function MemoryAddComposer({
}}
>
<header className="memory-add-heading">
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
<h2 id="memory-add-title">
{copy(rawOnly ? "手动添加" : "添加记忆")}
</h2>
<button
className="quiet"
type="button"
aria-label={copy("关闭新增记忆")}
aria-label={copy(rawOnly ? "关闭" : "关闭新增记忆")}
title={copy("关闭")}
disabled={busy}
onClick={close}
@@ -390,11 +464,13 @@ export function MemoryAddComposer({
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{!rawOnly && (
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
)}
{error && (
<p className="error-banner" role="alert">
{copy(error)}
@@ -402,7 +478,7 @@ export function MemoryAddComposer({
)}
{notice && <p role="status">{copy(notice)}</p>}
<div className="consent-actions">
{modelRead && !model && (
{!rawOnly && modelRead && !model && (
<button
type="button"
className="secondary"
@@ -430,7 +506,19 @@ export function MemoryAddComposer({
{additions.map(({ requestId, receipt }) => (
<li key={requestId}>
<p>{receipt.text}</p>
<small>{copy(states[receipt.state])}</small>
<small>
{copy(
rawOnly &&
(receipt.state === "pending" ||
receipt.state === "running")
? "正在保存…"
: rawOnly &&
(receipt.state === "ready" ||
receipt.state === "no_memories")
? "已保存原文"
: states[receipt.state],
)}
</small>
{memoryWriteErrorText(receipt.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(receipt.error) ?? "")}
+99
View File
@@ -149,6 +149,102 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("replaces a topic detail with an off-page graph memory detail", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
projected_memories: 2,
hy_graph_available: true,
nodes: [
{
node_id: "memory:m0",
node_type: "memory",
memory_id: "m0",
label: "第一条",
},
{
node_id: "memory:outside",
node_type: "memory",
memory_id: "outside",
label: "图中但不在当前页的记忆",
},
{
node_id: "topic:git",
node_type: "topic",
memory_id: null,
label: "git",
},
{
node_id: "schema:workflow",
node_type: "schema",
memory_id: null,
label: "先验证,再推送",
},
],
edges: [
{
edge_id: "topic-m0",
source_node_id: "topic:git",
target_node_id: "memory:m0",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "topic-outside",
source_node_id: "topic:git",
target_node_id: "memory:outside",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "schema-m0",
source_node_id: "schema:workflow",
target_node_id: "memory:m0",
relation: "derived_from",
origin: "hy_relation",
},
],
} as MemoryGraphResponse;
render(
<Host
simplified
items={[memories[0]]}
graphValue={topicGraph}
onOpenSpy={onOpen}
libraryView={pagedView({
items: [memories[0]],
total: 2,
offset: 0,
limit: 1,
source_ids_by_memory: { m0: ["codex"] },
})}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
expect(screen.getByRole("heading", { name: "先验证,再推送" })).toBeTruthy();
expect(screen.queryByText("关系归纳")).toBeNull();
expect(screen.queryByText("归纳自")).toBeNull();
await userEvent.click(
screen.getByRole("button", {
name: "图中选择 图中但不在当前页的记忆",
}),
);
expect(onOpen).toHaveBeenLastCalledWith("outside");
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:outside");
expect(screen.queryByText("所选记忆不在当前筛选内,已取消选择。")).toBeNull();
});
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
@@ -391,6 +487,7 @@ function Host({
deferMemoryId,
failMemoryId,
graphValue = graph,
onOpenSpy,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -408,6 +505,7 @@ function Host({
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onOpenSpy?(memoryId: string): void;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -445,6 +543,7 @@ function Host({
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
onOpenSpy?.(id);
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
+139 -46
View File
@@ -1,5 +1,6 @@
import type {
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
MemoryDetail,
MemoryGraphNode,
@@ -8,6 +9,8 @@ import type {
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -30,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { useGuiLanguage } from "./gui-language";
import { HandoffBar } from "./HandoffBar";
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
import { MemoryGraph3D } from "./MemoryGraph3D";
@@ -48,6 +52,7 @@ import {
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SelectedRawDetail } from "./SelectedRawDetail";
import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -88,7 +93,10 @@ type Props = {
hidden?: boolean;
id?: string;
arrival?: MemoryArrival;
modeCapabilities?: HomeProjectionCapabilities | null;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
libraryView?: {
query: string;
source: string;
@@ -171,6 +179,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const { t } = useGuiLanguage();
const raw = props.contentKind === "raw";
const {
memories,
graph: baseGraph,
@@ -260,6 +270,7 @@ export function MemoryMap(props: Props) {
processing: props.processing,
sources,
loaded,
capabilities: props.modeCapabilities,
});
useEffect(() => {
if (!loaded || library) return;
@@ -411,6 +422,15 @@ export function MemoryMap(props: Props) {
)),
) ?? null)
: null;
const projectedMemoryIds = useMemo(
() =>
new Set(
projection.nodes.flatMap((node) =>
node.memory_id ? [node.memory_id] : [],
),
),
[projection.nodes],
);
const selectedSource =
visibleGroupNode?.node_type === "source"
? sources.find(
@@ -428,9 +448,12 @@ export function MemoryMap(props: Props) {
}
}, [groupNode, visibleGroupNode]);
const requestedId = selectedId ?? detail?.memory.memory_id ?? null;
const activeId = active.some((memory) => memory.memory_id === requestedId)
? requestedId
: null;
const activeId =
requestedId &&
(active.some((memory) => memory.memory_id === requestedId) ||
projectedMemoryIds.has(requestedId))
? requestedId
: null;
const inspectorIdentity = activeId
? `memory:${activeId}`
: selectedSource
@@ -440,7 +463,8 @@ export function MemoryMap(props: Props) {
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
filtered.some((m) => m.memory_id === detail.memory.memory_id)
(filtered.some((m) => m.memory_id === detail.memory.memory_id) ||
projectedMemoryIds.has(detail.memory.memory_id))
? detail
: null;
const lastDetail = useRef(visibleDetail);
@@ -458,7 +482,8 @@ export function MemoryMap(props: Props) {
props.detailLoad.status === "failed";
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
const listed = active.some((memory) => memory.memory_id === memoryId);
if (!listed && !projectedMemoryIds.has(memoryId)) return;
if (pendingPageOffset !== null && library?.failed && library.page)
library.onPage(library.page.offset);
setPendingSourceSelection(null);
@@ -467,7 +492,7 @@ export function MemoryMap(props: Props) {
pageRetrySawLoading.current = false;
setDetailResetRevision((current) => current + 1);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
setSearch("");
setFilterNotice("已清除筛选,定位到关联记忆。");
@@ -477,7 +502,14 @@ export function MemoryMap(props: Props) {
setInspectorOpen(true);
onOpenMemory(memoryId);
},
[active, filtered, library, onOpenMemory, pendingPageOffset],
[
active,
filtered,
library,
onOpenMemory,
pendingPageOffset,
projectedMemoryIds,
],
);
useEffect(() => {
if (
@@ -597,12 +629,18 @@ export function MemoryMap(props: Props) {
return;
if (
requestedId &&
!active.some((memory) => memory.memory_id === requestedId)
!active.some((memory) => memory.memory_id === requestedId) &&
!projectedMemoryIds.has(requestedId)
) {
clear();
return;
}
if (!activeId || filtered.some((memory) => memory.memory_id === activeId))
const listed = active.some((memory) => memory.memory_id === activeId);
if (
!activeId ||
filtered.some((memory) => memory.memory_id === activeId) ||
(!listed && projectedMemoryIds.has(activeId))
)
return;
clear();
setFilterNotice("所选记忆不在当前筛选内,已取消选择。");
@@ -615,6 +653,7 @@ export function MemoryMap(props: Props) {
overlaysSuspended,
library?.loading,
pendingPageOffset,
projectedMemoryIds,
]);
function closeInspector() {
if (!compact) {
@@ -699,6 +738,7 @@ export function MemoryMap(props: Props) {
: "";
if (
props.simplified &&
!raw &&
(!library ||
(!library.loading &&
!library.failed &&
@@ -779,6 +819,17 @@ export function MemoryMap(props: Props) {
value={controlledSource}
options={[
{ value: "", label: text("全部应用") },
...(raw &&
!sources.some(
(source) => source.source_id === "one-memory-manual",
)
? [
{
value: "source:one-memory-manual",
label: text("手动添加"),
},
]
: []),
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
@@ -860,11 +911,15 @@ export function MemoryMap(props: Props) {
>
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
name="map-memory-search"
autoComplete="off"
placeholder={
props.simplified ? text("搜索记忆") : text("筛选记忆…")
raw
? t("sdk.raw.searchLabel")
: props.simplified
? text("搜索记忆")
: text("筛选记忆…")
}
value={library ? searchDraft : search}
maxLength={library ? 512 : undefined}
@@ -971,7 +1026,7 @@ export function MemoryMap(props: Props) {
<ul
ref={memoryList}
className="map-memory-list"
aria-label={text("地图记忆列表")}
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
id="memory-pane-memories"
>
{filtered.map((m) => (
@@ -1014,12 +1069,12 @@ export function MemoryMap(props: Props) {
m.updated_at,
locale,
)}`}
{/* D99: the conversation date alone would hide that this
one was made today, so the generated day stays too. */}
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
{!raw ? (
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
) : null}
</>
) : (
text("已可用 · {count} 段原文", {
@@ -1033,9 +1088,11 @@ export function MemoryMap(props: Props) {
</ul>
{library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<p>
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
</p>
<R6Button onClick={retryLibrary}>
{text("重新读取地图记忆")}
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
</R6Button>
</div>
)}
@@ -1045,13 +1102,17 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
{raw
? library.query
? t("sdk.raw.found", { count: 0 })
: t("sdk.raw.empty")
: library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{library?.page?.search_window &&
@@ -1151,12 +1212,20 @@ export function MemoryMap(props: Props) {
<span className="map-kicker" hidden={props.simplified}>
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
</span>
<h2>{props.simplified ? text("记忆关联") : label}</h2>
<h2>
{props.simplified
? raw
? t("memory:原始对话")
: text("记忆关联")
: label}
</h2>
{props.simplified && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: text("可在沉淀记忆后查看视图")}
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
@@ -1195,21 +1264,33 @@ export function MemoryMap(props: Props) {
: {})}
>
{library?.stale
? text("可在沉淀记忆后查看视图")
? raw
? t("sdk.raw.loadFailed")
: text("可在沉淀记忆后查看视图")
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
? raw
? t("sdk.raw.loadFailed")
: text("记忆暂时无法读取")
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
? raw
? t("sdk.raw.recent")
: text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: raw
? t("sdk.raw.empty")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
@@ -1313,13 +1394,18 @@ export function MemoryMap(props: Props) {
>
<header className="map-inspector-heading">
<h2>
{inspectorDetail
? text("记忆详情")
: selectedSource
{raw
? t("memory:原始对话")
: inspectorDetail
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
: text("记忆详情")}
: selectedSource
? text("记忆详情")
: visibleGroupNode
? visibleGroupNode.node_type === "topic" ||
visibleGroupNode.node_type === "schema"
? visibleGroupNode.label
: text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
<button
@@ -1339,7 +1425,9 @@ export function MemoryMap(props: Props) {
{library?.failed &&
library.source === selectedSource.source_id ? (
<>
<p role="alert">{text("记忆暂时无法读取")}</p>
<p role="alert">
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
</p>
<button
type="button"
className="secondary"
@@ -1356,10 +1444,12 @@ export function MemoryMap(props: Props) {
<i />
<i />
</div>
<p role="status">{text("正在读取记忆详情…")}</p>
<p role="status">
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
</p>
</>
) : (
<p>{text("还没有已生成的记忆")}</p>
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
)}
</div>
) : props.simplified && inspectorDetail ? (
@@ -1423,6 +1513,8 @@ export function MemoryMap(props: Props) {
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : raw && props.rawRecord ? (
<SelectedRawDetail record={props.rawRecord} sources={sources} />
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
@@ -1452,6 +1544,7 @@ export function MemoryMap(props: Props) {
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
onOpenMemory={pick}
requestIds={props.requestIds}
onClose={closeInspector}
/>
@@ -0,0 +1,432 @@
// @vitest-environment jsdom
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";
import { MemoryModeSettings } from "./MemoryModeSettings";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn(),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn(),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn(),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
function state(mode: "lite" | "pro" | "ultra" = "pro") {
return {
mode,
configured_mode: mode,
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"] as Array<"lite" | "pro" | "ultra">,
graph_build: {
state: "idle" as const,
started_at: null,
finished_at: null,
users: {},
},
};
}
function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
vi.mocked(getMemoryMode).mockResolvedValue(state(mode));
vi.mocked(getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(getEvolveOverview).mockResolvedValue({
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
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 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 () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("lite"),
previous_mode: "pro",
effects: ["higher_mode_memories_hidden"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
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: "切换到基础记忆(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 () => {
prepare("pro");
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(modal);
});
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(
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
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.getByRole("heading", { name: "记忆整合(Evolve)" }),
).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("switch", { name: "每天自动整合" })).getAttribute(
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
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,
time: "03:00",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
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 () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(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");
render(
<MemoryModeSettings
hasActiveModel={false}
modelStateKnown={false}
onConfigureModel={vi.fn()}
/>,
);
expect(
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
"disabled",
true,
);
expect(
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,
);
});
+584
View File
@@ -0,0 +1,584 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
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,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
});
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,
onConfigureModel,
onModeChange,
}: {
hasActiveModel: boolean;
modelStateKnown?: boolean;
onConfigureModel(message?: string): void;
onModeChange?(mode: MemoryPipelineMode): void;
}) {
const { text, locale } = useOnboardingTranslation();
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const [overview, setOverview] = useState<EvolveOverview | null>(null);
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;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
panel.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
panel.addEventListener("keydown", handleKey);
return () => {
panel.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
useEffect(() => {
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
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(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
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);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
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;
}
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) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能切换记忆模式。"
: code === "graph_store_unavailable"
? "当前安装缺少知识图谱组件,无法开启 ultra。"
: "模式切换未能确认,仍显示上次确认的模式。",
);
} finally {
setBusy(false);
}
}
async function changeSchedule(update: Partial<EvolveSchedule>) {
refreshVersion.current++;
setBusy(true);
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
} catch {
setError("自动整合设置未能保存,请重试。");
} finally {
setBusy(false);
}
}
async function evolveNow() {
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能整合记忆。"
: "未能开始整合记忆,请重试。",
);
} finally {
setBusy(false);
}
}
const last = overview?.users.local;
return (
<section
className="r6-memory-mode-settings"
aria-labelledby="memory-mode-title"
>
<div className="r6-mode-heading">
<div>
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<div
className="r6-mode-choice-list"
role="radiogroup"
aria-label={text("当前模式")}
aria-busy={state === null}
>
{(["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">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{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 ? (
<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(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-evolve-time`}>
{text("执行时间")}
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<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-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(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
</div>
) : null}
</section>
);
}
+389 -1
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import type { SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -7,6 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemorySpace } from "./MemorySpace";
@@ -74,6 +76,8 @@ const { reads } = vi.hoisted(() => ({
sdkGraph: vi.fn(),
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawSearch: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -82,17 +86,21 @@ vi.mock("./api", async (importOriginal) => ({
getSdkMemoryGraph: reads.sdkGraph,
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
searchSdkRaw: reads.rawSearch,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
MemoryAddComposer: ({
onPendingChange,
rawOnly,
}: {
onPendingChange?(addition: {
evidenceId: string;
text: string;
state: string;
}): void;
rawOnly?: boolean;
}) => (
<button
type="button"
@@ -104,7 +112,7 @@ vi.mock("./MemoryAddComposer", () => ({
})
}
>
补充记忆
{rawOnly ? "手动添加" : "补充记忆"}
</button>
),
}));
@@ -194,8 +202,388 @@ function setup() {
completed: 1,
});
reads.profiles.mockResolvedValue([]);
reads.raw.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
reads.rawSearch.mockResolvedValue([]);
}
function modeProcessing(mode: "lite" | "pro" | "ultra") {
return {
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode,
configured_mode: mode,
forced_by_env: false,
evolve: {
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
};
}
it("shows raw conversations instead of advanced memory in lite", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-1",
content: "原始对话内容",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "原始对话内容" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
expect(
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
).toBe(false);
expect(screen.getByTestId("graph-owner")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(
screen.getByText(/正在保存… 项目周会统一安排在周三上午。/),
).toBeTruthy();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
expect(screen.getByRole("option", { name: "手动添加" })).toBeTruthy();
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: undefined,
offset: 0,
limit: 20,
signal: expect.any(AbortSignal),
}),
);
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
return {
items: [
{
raw_id: rawId,
content: `原始会话 ${rawId}`,
user_id: "local",
agent_id: "all-apps",
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
limit: 20,
offset,
};
});
const user = userEvent.setup();
render(
<MemorySpace
simplified
sources={
[
{
source_id: "cursor",
display_name: "Cursor",
authorized: true,
},
] as SourceResponse[]
}
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
}),
);
});
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
reads.processing.mockResolvedValue(modeProcessing("pro"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-after-switch",
content: "切换后的原始会话",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-after-switch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "切换后的原始会话" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(reads.sdkLibrary).toHaveBeenCalled();
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
reads.processing.mockResolvedValue(modeProcessing("lite"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(() => expect(reads.raw).toHaveBeenCalled(), {
timeout: 1_000,
});
expect(screen.getAllByText("切换后的原始会话")).toHaveLength(2);
expect(screen.queryByText("已保存的记忆")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
const libraryCalls = reads.sdkLibrary.mock.calls.length;
reads.processing.mockResolvedValue(modeProcessing("ultra"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(
() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(libraryCalls),
{ timeout: 1_000 },
);
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
expect(screen.queryByText("切换后的原始会话")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"ultra",
);
});
it("opens a search hit with the complete role transcript", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockImplementation(async (options = {}) =>
"sessionId" in options && options.sessionId
? {
items: [
{
raw_id: "raw-search",
content: "[user]: 完整问题\n[assistant]: 完整回答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "完整问题" },
{ role: "assistant", content: "完整回答" },
],
},
],
total: 1,
limit: 500,
offset: 0,
}
: { items: [], total: 0, limit: 20, offset: 0 },
);
reads.rawSearch.mockResolvedValue([
{
kind: "raw",
raw_id: "raw-search",
content: "被截断的搜索摘要",
source: "cursor",
session_id: "session-search",
memory_at: null,
score: 0.9,
truncated: true,
},
]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
const search = await screen.findByRole("textbox", {
name: "搜索原始对话",
});
await user.type(search, "完整");
await user.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: "cursor",
sessionId: "session-search",
limit: 500,
}),
);
});
it("does not request advanced memories before the pipeline mode is known", async () => {
setup();
let resolveProcessing!: (value: unknown) => void;
reads.processing.mockReturnValueOnce(
new Promise((resolve) => {
resolveProcessing = resolve;
}),
);
render(<MemorySpace simplified />);
await act(async () => Promise.resolve());
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
await act(async () => {
resolveProcessing({
running: 0,
pending: 0,
blocked_stages: [],
completed: 0,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
});
await waitFor(() =>
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
),
);
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
});
it("keeps an added memory visible while it waits for model processing", async () => {
setup();
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
@@ -389,7 +389,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
@@ -419,6 +419,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
+280 -84
View File
@@ -1,6 +1,7 @@
import type {
ExtractionWindowPreset,
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
ImportJob,
MemoryDetail,
@@ -36,7 +37,9 @@ import {
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
manualExtract,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
@@ -54,6 +57,12 @@ import {
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -109,6 +118,10 @@ export function MemorySpace({
return isMessageKey(key) ? t(key) : text;
};
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [selectedRaw, setSelectedRaw] = useState<RawMapRecord | null>(null);
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -183,17 +196,44 @@ export function MemorySpace({
/** The filter the shown page was read with, for the count above it. */
settledSource: string;
settledQuery: string;
settledMode: "lite" | "pro" | "ultra" | null;
}>({
page: null,
loading: false,
failed: false,
settledSource: "",
settledQuery: "",
settledMode: null,
});
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [processingFailed, setProcessingFailed] = useState(false);
const [processingResolved, setProcessingResolved] = useState(false);
const pipelineMode =
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 &&
sdkLibrary.settledSource === sdkLibrarySource &&
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
@@ -213,8 +253,9 @@ export function MemorySpace({
});
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
sdkPageRef.current = sdkPage;
const sdkMapVisible =
simplified && active && displayedMode === "map" && pipelineMode !== null;
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
@@ -224,7 +265,7 @@ export function MemorySpace({
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery.trim() || undefined;
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
const windowKey = JSON.stringify([pipelineMode, query ?? "", source ?? ""]);
if (sdkSearchWindow.current.key !== windowKey)
sdkSearchWindow.current = { key: windowKey, id: null };
const read = async (first: boolean) => {
@@ -233,42 +274,68 @@ export function MemorySpace({
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// the full active/source-filtered scene up to the API's visual cap.
// Reading only the first list-page prefix made "All applications"
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
for (const item of items) next.set(item.raw_id, item);
rawRecords.current = next;
};
const [page, graph] = lite
? await Promise.all([
query
? searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: getSdkRaw({
source,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
}),
Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
}),
])
: await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
offset: 0,
limit: 300,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
).then(sdkLibraryPage),
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
if (!lite && sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
key: windowKey,
id: page.window_id ?? null,
@@ -276,11 +343,12 @@ export function MemorySpace({
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page: sdkLibraryPage(page),
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
@@ -305,6 +373,8 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
lite,
pipelineMode,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
@@ -337,9 +407,6 @@ export function MemorySpace({
}
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
const [checkingSetup, setCheckingSetup] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
@@ -394,6 +461,7 @@ export function MemorySpace({
),
refreshing: checkingSetup,
memoryCount: undefined,
capabilities: modeCapabilities,
blockedStages: processing?.blocked_stages,
backlogPending: processing?.backlog_pending,
phase: sourceSnapshotStale ? "unavailable" : "checking",
@@ -408,7 +476,11 @@ export function MemorySpace({
const update = async () => {
try {
const next = await getMemoryProcessing();
if (!stopped) setProcessing(next);
if (!stopped) {
setProcessing(next);
setProcessingFailed(false);
setProcessingResolved(true);
}
} catch {
// Background read failures do not invalidate the last verified state.
}
@@ -438,8 +510,29 @@ export function MemorySpace({
const closeDetail = useCallback(() => {
detailEpoch.current++;
setDetail(null);
setSelectedRaw(null);
setDetailLoad(null);
}, []);
const previousPipelineMode = useRef(pipelineMode);
useEffect(() => {
if (pipelineMode === null || previousPipelineMode.current === pipelineMode)
return;
previousPipelineMode.current = pipelineMode;
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
...current,
page: null,
loading: true,
failed: false,
settledQuery: "",
settledMode: null,
}));
closeDetail();
}, [pipelineMode, closeDetail]);
const previousSourceKey = useRef(sourceKey);
useEffect(() => {
@@ -460,14 +553,23 @@ export function MemorySpace({
* this subscriber and the background arrival poll read the same endpoint,
* so one `fetch` per tick is the whole point of sharing the key. */
const consumeProcessing = (value: unknown) => {
if (!cancelled) setProcessing(value as MemoryProcessingSummary);
if (!cancelled) {
setProcessing(value as MemoryProcessingSummary);
setProcessingFailed(false);
setProcessingResolved(true);
}
};
// `subscribePoll` only schedules ticks; the first read still has to happen
// on mount. Do it once through the shared fetch so mount does not double-read
// with the background arrival subscriber.
void getMemoryProcessing()
.then(consumeProcessing)
.catch(() => {});
.catch(() => {
if (!cancelled) {
setProcessingFailed(true);
setProcessingResolved(true);
}
});
// The processing poll shares one read with the background arrival poll on
// the same key (3s wins over 12s).
const stopProcessing = subscribePoll({
@@ -593,6 +695,54 @@ export function MemorySpace({
const epoch = ++detailEpoch.current;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
let record = rawRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if ("kind" in record) {
setDetailLoad({ memoryId, status: "loading" });
try {
let offset = 0;
while (true) {
const page = await getSdkRaw({
source: record.source || undefined,
sessionId: record.session_id || undefined,
offset,
limit: 500,
});
const full = page.items.find((item) => item.raw_id === memoryId);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
break;
}
offset += page.items.length;
if (!page.items.length || offset >= page.total)
throw new Error("raw_not_found");
}
} catch {
if (epoch === detailEpoch.current)
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if (epoch !== detailEpoch.current) return;
}
setSelectedRaw(record);
setDetail(rawDetail(record));
setDetailLoad(null);
return;
}
setDetailLoad({ memoryId, status: "loading" });
try {
// The inspector reads the memory through v2 and renders it in place.
@@ -728,37 +878,56 @@ export function MemorySpace({
</div>
<header className="memory-instrument-header">
<div>
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<div className="memory-title-line">
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
{simplified && pipelineMode ? (
<span className="memory-mode-label">{pipelineMode}</span>
) : null}
</div>
<span className="memory-loaded-count">
{simplified && !management
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
: sdkPage
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkPage.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", {
filter: sdkFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
sdkPage.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
: null}
</span>
</div>
<MemoryAddComposer
suspended={!active}
rawOnly={lite}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
@@ -920,22 +1089,33 @@ export function MemorySpace({
</details>
) : null}
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
{!lite ? (
<>
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkPage?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
</>
) : null}
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:正在等待整理:")}
{lite
? t(
pendingAdd.state === "failed"
? "memory:原话已保存,整理未完成"
: "memory:正在保存…",
)
: t("memory:正在等待整理:")}
{lite ? " " : null}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
{!lite && pendingAdd.state === "waiting_for_model" ? (
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
@@ -954,7 +1134,7 @@ export function MemorySpace({
id="memory-mode-panel-history"
hidden={displayedMode !== "history"}
>
<SdkRawHistory />
<SdkRawHistory active={active && displayedMode === "history"} />
</div>
)}
@@ -995,14 +1175,18 @@ export function MemorySpace({
{/* Native hiding retains the WebGL owner; its visibility observer pauses drawing. */}
<MemoryMap
key={simplified ? (pipelineMode ?? "unknown") : "full"}
id="memory-mode-panel-map"
simplified={simplified}
contentKind={lite ? "raw" : "memory"}
modeCapabilities={modeCapabilities}
rawRecord={selectedRaw}
libraryView={
simplified
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
page: sdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
@@ -1020,14 +1204,18 @@ export function MemorySpace({
}
: undefined
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
hidden={
!active ||
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={sdkLibrary.page !== null}
loaded={sdkPage !== null}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
@@ -1035,7 +1223,7 @@ export function MemorySpace({
deleteCount > 0 ||
// Only the paged map waits for its page; the three-column surface
// never reads one.
(simplified && sdkLibrary.page === null)
(simplified && sdkPage === null)
}
draft={draft}
busy={busy}
@@ -1128,10 +1316,12 @@ export function MemorySpace({
className="memory-library-layout"
hidden={displayedMode !== "library"}
>
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
{!simplified || (pipelineMode !== null && !lite) ? (
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
) : null}
</div>
</section>
);
@@ -1350,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:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1363,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>
);
}
@@ -8,7 +8,15 @@ import { completeOnboarding } from "./api";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
FirstImportReview: ({
@@ -121,10 +129,13 @@ it("moves from import to the actual third step without completing onboarding", a
/>,
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
expect(screen.getByText("Import accepted")).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
});
@@ -147,6 +158,8 @@ it("lets an empty first-time user enter without granting or importing a source",
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -168,6 +181,8 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -25,6 +25,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
}));
const models = {
pendingProbe: false,
@@ -38,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",
@@ -393,6 +415,8 @@ it("translates completion recovery live without repeating completion writes", as
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "Continuar" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -26,6 +26,12 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -161,6 +167,34 @@ beforeEach(() => {
...state,
status: "completed",
});
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.setMemoryMode).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: {},
},
previous_mode: "pro",
effects: [],
});
vi.mocked(api.connectSources).mockImplementation(
async (ids, _confirmed, options) => ({
request_id: options?.requestId ?? null,
@@ -267,6 +301,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
true,
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -317,6 +353,8 @@ it("partial acceptance preserves the successful job and failure through model-st
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -13,7 +13,15 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
<p>已受理 {sourceIds.join(",")}</p>
+115 -21
View File
@@ -25,6 +25,8 @@ import {
} from "./use-import-sources";
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -44,6 +46,8 @@ vi.mock("./api", () => ({
getImportJob: vi.fn(),
apiErrorCode: vi.fn(() => null),
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -176,9 +180,18 @@ async function skipWelcome(user?: ReturnType<typeof userEvent.setup>) {
await openApps(user);
}
async function continueImport(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "模型启用回执已确认" }));
}
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -218,6 +231,12 @@ describe("OnboardingFlow S1.3", () => {
...onboarding,
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
});
});
afterEach(() => {
cleanup();
@@ -355,7 +374,7 @@ describe("OnboardingFlow S1.3", () => {
name: "配置模型,沉淀记忆",
});
expect(continueButton).toHaveProperty("disabled", false);
await user.click(continueButton);
await openModelStep(user);
const modelBoundary = screen.getByRole("region", {
name: "模型步骤边界",
});
@@ -365,6 +384,7 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
@@ -388,9 +408,7 @@ describe("OnboardingFlow S1.3", () => {
onFinish={finish}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(userEvent.setup());
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -649,9 +667,7 @@ describe("OnboardingFlow S1.3", () => {
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(onFinish).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
await user.click(
@@ -678,13 +694,89 @@ describe("OnboardingFlow S1.3", () => {
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(controller.refresh).toHaveBeenCalledOnce();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
it("allows entering the app from step three without creating a model", async () => {
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
configured_mode: "pro";
explicitly_configured: true;
}) => void;
getMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolveMode = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
await user.click(lite);
await act(async () => {
resolveMode({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
});
});
expect(lite.checked).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
});
it("retains the explicit lite choice when saving the mode fails and retries", async () => {
const user = userEvent.setup();
const controller = imports({ sources: [] });
const onFinish = vi.fn();
setMemoryMode.mockRejectedValueOnce(new Error("offline"));
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={onFinish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
await user.click(lite);
await user.click(screen.getByRole("button", { name: "继续" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存。你的选择已保留,请重试。",
);
expect(lite.checked).toBe(true);
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
it("returns from skipped pro setup to an explicit mode choice", async () => {
const user = userEvent.setup();
const controller = imports();
const onFinish = vi.fn();
@@ -698,10 +790,13 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
expect(screen.getByRole("heading", { name: "选择记忆方式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -721,6 +816,7 @@ describe("OnboardingFlow S1.3", () => {
);
await choose(user);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
for (const fn of [
controller.connectForOnboarding,
@@ -775,6 +871,7 @@ describe("OnboardingFlow S1.3", () => {
expect(choice()).toHaveProperty("disabled", false);
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
@@ -798,6 +895,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -1868,6 +1966,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "查看未导入的应用" }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -2042,6 +2141,7 @@ describe("OnboardingFlow S1.3", () => {
/>,
);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
@@ -2143,9 +2243,7 @@ describe("OnboardingFlow S1.3", () => {
expect(readConnectionPreferences().hostSourceIds).toContain("claude");
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
@@ -2192,9 +2290,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(insertion);
expect(insertion.checked).toBe(false);
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
@@ -2355,9 +2451,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(insertion.disabled).toBe(false));
expect(previewRecallInstall).not.toHaveBeenCalled();
expect(insertion.checked).toBe(true);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
expect(window.oneMemory?.installRecall).toHaveBeenCalledTimes(1);
+196 -12
View File
@@ -15,8 +15,12 @@ import {
useState,
} from "react";
import { completeOnboarding } from "./api";
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,
@@ -38,6 +42,7 @@ import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -88,7 +93,18 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [modelStep, setModelStep] = useState(false);
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
"review",
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
null,
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modeError, setModeError] = useState<string | null>(null);
const modeChoiceTouched = useRef(false);
const [visitedSetup, setVisitedSetup] = useState(false);
const [visitedModel, setVisitedModel] = useState(false);
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
@@ -135,7 +151,7 @@ export function OnboardingFlow({
const consideredSources = useRef(new Set<string>());
const heading = useRef<HTMLHeadingElement>(null);
const reviewingImport =
visitedModel ||
(visitedSetup && !completionFailed) ||
accepted !== null ||
startingImport !== null ||
state.status === "importing";
@@ -307,6 +323,23 @@ 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 &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
)
setModeChoice(memoryMode.configured_mode);
},
() => {
// The choice screen remains usable; saving rechecks Core.
},
);
return () => {
alive.current = false;
};
@@ -421,7 +454,7 @@ export function OnboardingFlow({
});
if (alive.current) {
setCompletionFailed(true);
setModelStep(false);
setSetupStep("review");
// A definite refusal means it was not saved; anything else (timeout,
// 5xx, no response) leaves the outcome unknown, and the copy must not
// assert a failure the UI cannot prove.
@@ -450,6 +483,46 @@ export function OnboardingFlow({
[persistFinish],
);
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
setSetupStep("mode");
},
[],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const changed = await setMemoryMode(modeChoice);
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
if (modeChoice === "lite") {
await leave(modeDestination);
return;
}
setVisitedModel(true);
setSetupStep("model");
} catch {
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
}, [leave, modeChoice, modeDestination, modeSaving]);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
@@ -636,7 +709,7 @@ export function OnboardingFlow({
return (
<>
<div
hidden={modelStep}
hidden={setupStep !== "review"}
data-import-submission={startingImport ? "pending" : "settled"}
>
<FirstImportReview
@@ -663,6 +736,10 @@ export function OnboardingFlow({
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionFailed) {
void leave(destination);
return;
}
const selection = {
hostSourceIds: hostConnectionAvailable
? [...activeDraft.hostSourceIds]
@@ -676,9 +753,8 @@ export function OnboardingFlow({
setApplyTick((value) => value + 1);
if (destination === "settings") {
setModelSourceIds([...sourceIds]);
setVisitedModel(true);
setModelStep(true);
} else void leave(destination);
}
requestModeChoice(destination);
}}
recovery={
completionFailed && exitDestination
@@ -690,8 +766,116 @@ export function OnboardingFlow({
}
/>
</div>
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆方式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
onClick={() => {
setModeError(null);
setSetupStep("review");
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
busy === "leaving" ||
(modeChoice === "lite" && flight.current)
}
onClick={() => void saveModeChoice()}
>
{text(modeSaving ? "正在保存…" : "继续")}
</R6Button>
</>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<h2 id="onboarding-memory-mode-title">
{text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<fieldset
className="onboarding-r6-mode-options"
disabled={modeSaving || busy === "leaving"}
>
<legend className="onboarding-r6-sr-only">
{text("记忆模式")}
</legend>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "pro"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="pro"
checked={modeChoice === "pro"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("pro");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>pro</strong>
<span>{text("推荐")}</span>
</span>
<span>
{text("使用模型理解对话,提取并整理可持续生长的记忆。")}
</span>
</span>
</label>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "lite"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="lite"
checked={modeChoice === "lite"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("lite");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>lite</strong>
</span>
<span>
{text("无需模型,只保存并检索原始对话,不生成提炼记忆。")}
</span>
</span>
</label>
</fieldset>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
)}
{visitedModel && (
<div hidden={!modelStep}>
<div hidden={setupStep !== "model"}>
<OnboardingModelController
connectionAlreadyApplied={
connectionApplication === "applied" ||
@@ -709,8 +893,8 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setModelStep(false)}
onSkip={() => void leave("sources")}
onBack={() => setSetupStep("mode")}
onSkip={() => setSetupStep("mode")}
/>
</div>
)}
@@ -763,7 +947,7 @@ export function OnboardingFlow({
<R6Button
variant="quiet"
disabled={locked}
onClick={() => void leave("sources")}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { models } = vi.hoisted(() => ({
models: {
profiles: [
{
profile_id: "active-profile",
revision: 3,
name: "Current model",
provider_kind: "openai_compatible",
model: "model-1",
base_url: "https://models.example/v1",
disable_thinking: true,
embedding_provider_kind: "local_onnx",
embedding_model: "bge-small-zh-v1.5",
embedding_base_url: null,
timeout_seconds: 90,
max_concurrency: 4,
context_window: null,
has_api_key: true,
is_active: true,
},
],
pendingProbe: false,
probeStorageFailed: false,
pendingCreation: false,
creationStorageFailed: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
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(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
vi.mock("./OnboardingModelStep", () => ({
OnboardingModelStep: (props: {
probe?: { message: string } | null;
enableActionLabel?: string;
onSaveAndEnable(): Promise<void>;
}) => (
<section>
<p>{props.probe?.message}</p>
<button type="button" onClick={() => void props.onSaveAndEnable()}>
{props.enableActionLabel}
</button>
</section>
),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onEnabled={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
});
+173 -72
View File
@@ -3,8 +3,9 @@ import type {
ModelProfile,
ModelProfileInput,
} from "@one-memory/contracts";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -12,6 +13,7 @@ import {
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
const defaultPreset = PROVIDER_PRESETS.find(
@@ -68,6 +70,7 @@ export function OnboardingModelController({
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
@@ -82,10 +85,66 @@ 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);
useEffect(() => {
const active = (models.profiles ?? []).find((profile) => profile.is_active);
if (!active || saved || draftTouched.current) return;
setSaved(active);
setDraft((current) => ({
...current,
name: active.name,
provider_kind: active.provider_kind,
model: active.model,
base_url: active.base_url ?? "",
disable_thinking: active.disable_thinking,
embedding_mode:
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
active.embedding_provider_kind === "ollama"
? "ollama"
: "openai_compatible",
embedding_model: active.embedding_model ?? "",
embedding_base_url: active.embedding_base_url ?? "",
timeout_seconds: String(active.timeout_seconds),
max_concurrency: String(active.max_concurrency),
context_window:
active.context_window === null ? "" : String(active.context_window),
}));
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [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;
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
const preset = PROVIDER_PRESETS.find(
(item) => item.id === patch.provider_id,
@@ -232,11 +291,27 @@ export function OnboardingModelController({
async function enable() {
if (flight.current || probe?.state !== "passed" || !saved) return;
if (saved.is_active) {
onEnabled?.();
return;
}
flight.current = true;
setPhase("enabling");
setError(null);
const trace = traceUiAction("memory", "submit");
try {
const plan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
saved.profile_id,
saved.revision,
);
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
return;
}
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
@@ -251,76 +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}
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}
</>
);
}
+17 -2
View File
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
description?: string;
disabled?: boolean;
lang?: string;
}
@@ -265,7 +266,14 @@ export function OptionMenu(props: {
}
}}
>
{visibleLabel}
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{visibleLabel}</span>
<small>{selected.description}</small>
</span>
) : (
visibleLabel
)}
</button>
{open &&
createPortal(
@@ -298,7 +306,14 @@ export function OptionMenu(props: {
}}
onClick={() => choose(index)}
>
{item.label}
{item.description ? (
<span className="om-option-menu-copy">
<span>{item.label}</span>
<small>{item.description}</small>
</span>
) : (
item.label
)}
</button>
))}
</div>,
+210 -12
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("combobox"));
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", () => ({
@@ -48,6 +64,38 @@ vi.mock("./api", () => ({
imports_unaffected: true,
}),
setSedimentation: vi.fn(),
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: {},
},
}),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
vi.mock("./ModelSettings", async () => {
const { createElement, useImperativeHandle } = await import("react");
@@ -55,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" }, "核对测试结果"),
);
},
};
});
@@ -97,8 +156,9 @@ 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()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
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);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
@@ -115,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({
@@ -123,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");
@@ -141,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);
@@ -151,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");
@@ -172,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: "用模型沉淀记忆",
+221 -102
View File
@@ -1,5 +1,5 @@
import type { ModelProfile } from "@one-memory/contracts";
import { Download, RefreshCw } from "lucide-react";
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
import { Download } from "lucide-react";
import {
useCallback,
useEffect,
@@ -12,9 +12,15 @@ import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import { MemoryModeSettings } from "./MemoryModeSettings";
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,
@@ -27,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 "本机 / 受管端点";
@@ -61,12 +62,25 @@ 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,
);
const [quitOpen, setQuitOpen] = useState(false);
const [quitting, setQuitting] = useState(false);
const [quitError, setQuitError] = useState("");
@@ -122,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 {
@@ -139,7 +152,7 @@ export function R6Settings({
useEffect(() => {
if (!active) return;
if (group !== "model") return;
if (group !== "models") return;
void readSedimentation();
}, [active, group, readSedimentation]);
@@ -208,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();
@@ -268,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) => (
@@ -279,108 +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"}
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={(message) => {
setModeHandoffMessage(message ?? "");
setGroup("models");
openConfiguration("generation");
window.setTimeout(() => {
document.getElementById(`${id}-model-title`)?.focus();
}, 0);
}}
onModeChange={setMemoryModeState}
/>
</section>
<section
id={`${id}-panel-models`}
aria-labelledby={`${id}-nav-models`}
hidden={group !== "models"}
className="r6-settings-model"
>
<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}
<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 && (
<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}
@@ -388,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>
);
}
+156 -1
View File
@@ -1,5 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
@@ -15,6 +21,8 @@ import { SdkRawHistory } from "./SdkRawHistory";
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
vi.useRealTimers();
});
it("localizes controls without translating original conversation fields", async () => {
@@ -87,3 +95,150 @@ it("lists and searches SDK raw without legacy conversation APIs", async () => {
expect(await screen.findByText("搜索结果")).toBeTruthy();
expect(searchSdkRaw).toHaveBeenCalledWith("查询");
});
it("does not call a zero-result search an empty conversation history", async () => {
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-existing",
content: "已有原始对话",
session_id: "session-existing",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
searchSdkRaw.mockResolvedValue([]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "没有命中");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("找到 0 条")).toBeTruthy();
expect(
screen.queryByText("还没有原始对话。连接来源后,对话会显示在这里。"),
).toBeNull();
});
it("retries the lite history read in place after a load failure", async () => {
getSdkRaw.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({
items: [
{
raw_id: "raw-recovered",
content: "恢复后的原始对话",
session_id: "session-recovered",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
render(<SdkRawHistory />);
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
expect(await screen.findByText("恢复后的原始对话")).toBeTruthy();
expect(getSdkRaw).toHaveBeenCalledTimes(2);
});
it("retries the failed raw search instead of reloading history", async () => {
getSdkRaw.mockResolvedValue({
items: [],
total: 0,
offset: 0,
limit: 100,
});
searchSdkRaw
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce([
{
kind: "raw",
raw_id: "raw-search-recovered",
content: "重试后的搜索结果",
source: "cursor",
session_id: "session-search",
memory_at: null,
score: 0.8,
truncated: false,
},
]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "恢复搜索");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
expect(await screen.findByText("重试后的搜索结果")).toBeTruthy();
expect(searchSdkRaw).toHaveBeenCalledTimes(2);
expect(getSdkRaw).toHaveBeenCalledTimes(1);
});
it("does not let a history poll clear an independent search failure", async () => {
vi.useFakeTimers();
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-old",
content: "已有历史",
session_id: "session-old",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
searchSdkRaw.mockRejectedValue(new Error("search offline"));
render(<SdkRawHistory />);
await act(async () => Promise.resolve());
fireEvent.change(screen.getByLabelText("搜索原始对话"), {
target: { value: "失败查询" },
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
await Promise.resolve();
});
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
await act(async () => {
await vi.advanceTimersByTimeAsync(12_000);
});
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
});
it("ignores a stale initial-history failure after a newer search succeeds", async () => {
let rejectHistory!: (reason: Error) => void;
getSdkRaw.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectHistory = reject;
}),
);
searchSdkRaw.mockResolvedValue([
{
kind: "raw",
raw_id: "raw-new-search",
content: "较新的搜索结果",
source: "cursor",
session_id: "session-new",
memory_at: null,
score: 0.9,
truncated: false,
},
]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "新查询");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("较新的搜索结果")).toBeTruthy();
await act(async () => rejectHistory(new Error("late history failure")));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("较新的搜索结果")).toBeTruthy();
});
+76 -20
View File
@@ -2,8 +2,9 @@ import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
export function SdkRawHistory() {
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
@@ -11,35 +12,67 @@ export function SdkRawHistory() {
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState("");
const [errorKind, setErrorKind] = useState<"load" | "search" | null>(null);
const errorKindRef = useRef<"load" | "search" | null>(null);
const searchEpoch = useRef(0);
const [busy, setBusy] = useState(false);
const [revision, setRevision] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
useEffect(() => {
if (!active) return;
let current = true;
void getSdkRaw({ limit: 100 }).then(
(page) => {
if (current) setRecent(page.items);
},
() => {
if (current) setError(translation.current("sdk.raw.loadFailed"));
},
);
const startedBeforeSearch = searchEpoch.current;
const consume = (value: unknown) => {
if (!current) return;
setRecent((value as Awaited<ReturnType<typeof getSdkRaw>>).items);
if (errorKindRef.current !== "search") {
errorKindRef.current = null;
setError("");
setErrorKind(null);
}
};
void getSdkRaw({ limit: 100 }).then(consume, () => {
if (current && startedBeforeSearch === searchEpoch.current) {
setError(translation.current("sdk.raw.loadFailed"));
errorKindRef.current = "load";
setErrorKind("load");
}
});
const stop = subscribePoll({
key: "sdk-raw-history",
intervalMs: 12_000,
fetch: () => getSdkRaw({ limit: 100 }),
onValue: consume,
});
return () => {
current = false;
stop();
};
}, []);
}, [active, revision]);
async function runSearch(value: string) {
searchEpoch.current += 1;
setBusy(true);
setResults(null);
setError("");
errorKindRef.current = null;
setErrorKind(null);
try {
setResults(await searchSdkRaw(value));
} catch {
setError(t("sdk.raw.searchFailed"));
errorKindRef.current = "search";
setErrorKind("search");
} finally {
setBusy(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!query.trim()) return;
setBusy(true);
setError("");
try {
setResults(await searchSdkRaw(query.trim()));
} catch {
setError(t("sdk.raw.searchFailed"));
} finally {
setBusy(false);
}
await runSearch(query.trim());
}
const items = results ?? recent.map(asContextItem);
@@ -57,12 +90,35 @@ export function SdkRawHistory() {
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
</button>
</form>
{error ? <p role="alert">{error}</p> : null}
{error ? (
<p role="alert">
{error}{" "}
<button
type="button"
disabled={busy}
onClick={() => {
if (errorKind === "search" && query.trim()) {
void runSearch(query.trim());
} else {
setError("");
errorKindRef.current = null;
setErrorKind(null);
setRevision((value) => value + 1);
}
}}
>
{t("connectionFallback.retry")}
</button>
</p>
) : null}
<p aria-live="polite">
{results === null
? t("sdk.raw.recent")
: tn("sdk.raw.found", items.length)}
</p>
{!error && results === null && items.length === 0 ? (
<p>{t("sdk.raw.empty")}</p>
) : null}
<ol>
{items.map((item) => (
<li key={item.raw_id}>
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SelectedRawDetail } from "./SelectedRawDetail";
beforeEach(() => localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans"));
afterEach(() => {
cleanup();
localStorage.clear();
});
it("shows every role and preserves a long original message in the detail pane", () => {
const long = "原文不能截断。".repeat(500);
render(
<LanguageProvider>
<SelectedRawDetail
record={{
raw_id: "raw-long",
content: `[user]: ${long}\n[assistant]: 已收到`,
user_id: "local",
agent_id: "all-apps",
session_id: "session-1",
source: "cursor",
memory_at: 1_793_707_200,
gmt_created: 1_793_707_210,
custom: {},
messages: [
{ role: "user", content: long },
{ role: "assistant", content: "已收到" },
],
}}
sources={[]}
/>
</LanguageProvider>,
);
expect(screen.getByText("你")).toBeTruthy();
expect(screen.getByText("助手")).toBeTruthy();
expect(screen.getByText(long)).toHaveProperty("textContent", long);
expect(screen.getByText("已收到")).toBeTruthy();
});
+67
View File
@@ -0,0 +1,67 @@
import type {
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
if (role === "user") return t("selectedMemory.author.user");
if (role === "assistant" || role === "agent")
return t("selectedMemory.author.agent");
return t("selectedMemory.author.unknown");
}
export function SelectedRawDetail({
record,
sources,
}: {
record: RawRecord;
sources: SourceResponse[];
}) {
const { locale, t } = useGuiLanguage();
const messages =
"messages" in record && record.messages?.length
? record.messages
: [
{
role:
"role" in record && typeof record.role === "string"
? record.role
: "unknown",
content: record.content,
},
];
const source =
record.source === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
: sourceNameOr(
sources,
record.source,
record.source || t("selectedMemory.sourceUnknown"),
);
return (
<article className="map-dialogue-reading">
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
<small>{authorLabel(message.role, t)}</small>
<p>{message.content}</p>
</li>
))}
</ul>
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", {
source,
time: clockStamp(rawRecordTime(record), locale),
})}
</p>
</article>
);
}
+73 -25
View File
@@ -26,6 +26,7 @@ export function NodeDetail({
onDraft,
onSave,
onDelete,
onOpenMemory,
requestIds,
onClose,
compact = false,
@@ -39,6 +40,7 @@ export function NodeDetail({
onDraft(next: string): void;
onSave(event: FormEvent<HTMLFormElement>): void;
onDelete(): void;
onOpenMemory?(memoryId: string): void;
requestIds: RefObject<Map<string, string>>;
onClose(): void;
compact?: boolean;
@@ -138,33 +140,79 @@ export function NodeDetail({
if (node) {
const trace = graph ? traceFrom(graph, node.node_id, 2) : [];
const direct = trace.filter((step) => step.hop === 1);
const relatedMemories = direct.filter(
(step) => step.node.memory_id !== null,
);
const compactNames =
compact && (node.node_type === "topic" || node.node_type === "schema");
return (
<article className="wb-detail" aria-label={t("wb.node")}>
<div className="wb-detail-head">
<span className="wb-status">{nodeTypeLabel(node.node_type, t)}</span>
<strong>{node.label}</strong>
</div>
<p className="wb-detail-note">
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
</span>
{relationLabel(step.edge.relation, t)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin, t)}
</span>
</li>
))}
</ul>
{compactNames ? null : (
<div className="wb-detail-head">
<span className="wb-status">
{compact && node.node_type === "schema"
? t("memory:关系归纳")
: nodeTypeLabel(node.node_type, t)}
</span>
<strong>{node.label}</strong>
</div>
)}
{compact ? (
relatedMemories.length ? (
<section
className={
compactNames
? "wb-node-related wb-node-related--names"
: "wb-node-related"
}
>
{compactNames ? null : <h3>{t("selectedMemory.related")}</h3>}
<ul>
{relatedMemories.slice(0, 8).map((step) => (
<li key={step.edge.edge_id}>
<button
type="button"
className="map-related"
onClick={() =>
step.node.memory_id &&
onOpenMemory?.(step.node.memory_id)
}
>
<span>{step.node.label}</span>
{compactNames ? null : (
<small>{relationLabel(step.edge.relation, t)}</small>
)}
</button>
</li>
))}
</ul>
</section>
) : null
) : (
<>
<p className="wb-detail-note">
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
</span>
{relationLabel(step.edge.relation, t)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin, t)}
</span>
</li>
))}
</ul>
</>
)}
</article>
);
}
@@ -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();
});
+57
View File
@@ -0,0 +1,57 @@
import { beforeEach, expect, it, vi } from "vitest";
const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get,
post,
interceptors: { request: { use: vi.fn() } },
}),
},
}));
import { getSdkRaw, searchSdkRaw } from "./api";
beforeEach(() => vi.clearAllMocks());
it("lists raw conversations for the selected application", async () => {
get.mockResolvedValue({
data: { items: [], total: 0, offset: 0, limit: 20 },
});
const signal = new AbortController().signal;
await getSdkRaw({ source: "cursor", offset: 20, limit: 20, signal });
expect(get).toHaveBeenCalledWith("/api/v2/raw", {
params: {
source: "cursor",
session_id: undefined,
offset: 20,
limit: 20,
},
signal,
});
});
it("keeps the application filter when searching raw conversations", async () => {
post.mockResolvedValue({ data: { items: [] } });
const signal = new AbortController().signal;
await searchSdkRaw("release plan", { source: "claude-code", signal });
expect(post).toHaveBeenCalledWith(
"/api/v2/context/search",
{
query: "release plan",
kinds: ["raw"],
include_sources: ["claude-code"],
limit: 20,
character_budget: 12_000,
},
{ signal, timeout: 10_000 },
);
});
+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);
});
+385 -4
View File
@@ -2,12 +2,21 @@ import {
CORE_SERVICE,
type ConnectSourcesResponse,
type DetectionResponse,
type EmbeddingSpaceDeleteReceipt,
type EmbeddingSpaces,
type EmbeddingSwitchCurrent,
type EmbeddingSwitchJob,
type EmbeddingSwitchPlan,
type EvolveOverview,
type EvolveRunReceipt,
type EvolveSchedule,
type ExtractionSettings,
type ExtractionWindowPreset,
type GraphWidthDecision,
type GraphWidthDecisionReceipt,
type GraphWidthStatus,
type HealthResponse,
type HomeProjectionV1,
type HostAskMemoryEvent,
type HostHomeSummary,
type ImportIssue,
@@ -17,6 +26,10 @@ import {
type MemoryAddInput,
type MemoryAddReceipt,
type MemoryGraphResponse,
type MemoryModeChange,
type MemoryModePlan,
type MemoryModeState,
type MemoryPipelineMode,
type MemoryProcessingSummary,
type ModelCreationOperation,
type ModelProbeOperationReceipt,
@@ -42,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";
@@ -588,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,
@@ -659,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))
@@ -787,32 +962,38 @@ function isMemoryNotFound(reason: unknown): boolean {
export async function getSdkRaw({
source,
sessionId,
offset = 0,
limit = 100,
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, offset, limit },
params: { source, session_id: sessionId, offset, limit },
signal,
});
return response.data;
}
export async function searchSdkRaw(
query: string,
signal?: AbortSignal,
options: { source?: string; signal?: AbortSignal } = {},
): Promise<SdkRawContextItem[]> {
const response = await client.post(
"/api/v2/context/search",
{
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
limit: 20,
character_budget: 12_000,
},
{ signal, timeout: 10_000 },
{ signal: options.signal, timeout: 10_000 },
);
const items = response.data?.items;
return Array.isArray(items)
@@ -907,6 +1088,77 @@ export async function getMemoryProcessing(): Promise<MemoryProcessingSummary> {
return response.data;
}
export async function getMemoryMode(): Promise<MemoryModeState> {
const response = await client.get<MemoryModeState>(
"/api/v1/settings/memory-mode",
);
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> {
const response = await client.put<MemoryModeChange>(
"/api/v1/settings/memory-mode",
{ mode },
);
return response.data;
}
export async function getEvolveSchedule(): Promise<EvolveSchedule> {
const response = await client.get<EvolveSchedule>(
"/api/v1/settings/evolve-schedule",
);
return response.data;
}
export async function setEvolveSchedule(
update: Partial<EvolveSchedule>,
): Promise<EvolveSchedule> {
const response = await client.put<EvolveSchedule>(
"/api/v1/settings/evolve-schedule",
update,
);
return response.data;
}
export async function getEvolveOverview(
userId = "local",
): Promise<EvolveOverview> {
const response = await client.get<EvolveOverview>("/api/v2/evolve", {
params: { user_id: userId },
});
return response.data;
}
export async function startEvolve(userId = "local"): Promise<EvolveRunReceipt> {
const response = await client.post<EvolveRunReceipt>("/api/v2/evolve", {
user_id: userId,
});
return response.data;
}
export async function getDetailedLogging(): Promise<boolean> {
const response = await client.get("/api/v1/diagnostics/settings");
const value = response.data as Record<string, unknown>;
@@ -1153,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)
);
}
@@ -1310,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")
+119
View File
@@ -561,6 +561,119 @@
line-height: 20px;
margin: 0;
}
.onboarding-r6-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.onboarding-r6-mode {
width: min(720px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.onboarding-r6-mode-intro {
text-align: center;
}
.onboarding-r6-mode-intro h2 {
margin: 0;
color: #263947;
font-size: 20px;
line-height: 30px;
}
.onboarding-r6-mode-intro p {
margin: 8px 0 0;
color: #637381;
font-size: 14px;
line-height: 22px;
}
.onboarding-r6-mode-options {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.onboarding-r6-mode-card {
min-height: 136px;
padding: 20px;
border: 1px solid #c9d5de;
border-radius: 10px;
background: rgb(255 255 255 / 72%);
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 14px;
transition:
border-color var(--r6-fast, 140ms),
background var(--r6-fast, 140ms),
box-shadow var(--r6-fast, 140ms);
}
.onboarding-r6-mode-card:hover {
border-color: #8299aa;
background: rgb(255 255 255 / 76%);
}
.onboarding-r6-mode-card[data-selected="true"] {
border-color: #557c93;
background: #edf3f6;
box-shadow: 0 0 0 2px rgb(85 124 147 / 10%);
}
.onboarding-r6-mode-card:has(input:focus-visible) {
outline: 2px solid #557c93;
outline-offset: 3px;
}
.onboarding-r6-mode-card input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 4px 0 0;
accent-color: #557c93;
}
.onboarding-r6-mode-copy {
min-width: 0;
color: #5f6e79;
font-size: 14px;
line-height: 23px;
display: flex;
flex-direction: column;
gap: 10px;
}
.onboarding-r6-mode-heading {
display: flex;
align-items: center;
gap: 9px;
}
.onboarding-r6-mode-heading strong {
color: #263947;
font-size: 17px;
line-height: 26px;
}
.onboarding-r6-mode-heading > span {
padding: 2px 8px;
border-radius: 999px;
color: #41677e;
background: #dceaf2;
font-size: 11px;
line-height: 18px;
font-weight: 600;
}
.onboarding-r6-mode-error {
min-height: 20px;
margin: -6px 0 0;
color: #944242;
text-align: center;
font-size: 13px;
line-height: 20px;
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;
@@ -698,6 +811,12 @@
.onboarding-r6-body {
padding: 24px 20px;
}
.onboarding-r6-mode-options {
grid-template-columns: 1fr;
}
.onboarding-r6-mode-card {
min-height: 0;
}
.onboarding-r6-discovery {
height: auto;
display: flex;
+2
View File
@@ -28,7 +28,9 @@ export type PollKey =
| "applications"
| "calls"
| "model-profiles"
| "embedding-switch"
| "memory-add-processing"
| "sdk-raw-history"
| "health";
export interface PollTask {
+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;
+64
View File
@@ -18,6 +18,17 @@
font-size: 26px;
font-weight: 600;
}
.r6-memory .memory-title-line {
display: flex;
align-items: baseline;
gap: 10px;
}
.r6-memory .memory-mode-label {
color: #287b67;
font-size: 12px;
font-weight: 500;
line-height: 21px;
}
.r6-memory .memory-loaded-count {
font-size: 13px;
color: #637786;
@@ -174,6 +185,59 @@
gap: 12px;
flex-wrap: wrap;
}
.r6-memory .wb-node-related {
margin-top: 28px;
}
.r6-memory .wb-node-related--names {
margin-top: 0;
}
.r6-memory .wb-node-related--names li:first-child {
border-top: 0;
}
.r6-memory .wb-node-related h3 {
margin: 0 0 10px;
color: #5e6e7a;
font-size: 12px;
font-weight: 500;
line-height: 21px;
}
.r6-memory .wb-node-related ul {
margin: 0;
padding: 0;
list-style: none;
}
.r6-memory .wb-node-related li {
border-top: 1px solid #d8e2ea;
}
.r6-memory .wb-node-related .map-related {
min-height: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
background: transparent;
color: #384a57;
font-weight: 400;
box-shadow: none;
}
.r6-memory .wb-node-related .map-related:hover:not(:disabled),
.r6-memory .wb-node-related .map-related:active:not(:disabled) {
border: 0;
background: transparent;
color: #304757;
transform: none;
}
.r6-memory .wb-node-related .map-related span {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-height: 1.7;
}
.r6-memory .wb-node-related .map-related small {
flex: none;
color: #637786;
white-space: nowrap;
}
.r6-memory .map-inspector .wb-detail-actions button {
min-height: 36px;
border-radius: 8px;
+416 -56
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,247 @@
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;
border-bottom: 1px solid #d8e2ea;
}
.r6-mode-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.r6-settings .r6-memory-mode-settings .r6-mode-heading h2 {
margin: 0;
font-size: 20px;
line-height: 32px;
}
.r6-mode-heading p {
margin: 4px 0 0;
color: #5e6e7a;
font-size: 13px;
line-height: 22px;
}
.r6-mode-choice-list {
max-width: 760px;
overflow: hidden;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.r6-mode-choice {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 74px;
padding: 12px 16px;
color: #304757;
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-choice-copy small {
color: #5e6e7a;
font-size: 12px;
line-height: 20px;
overflow-wrap: normal;
word-break: 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;
padding: 10px 12px;
border-radius: 8px;
background: #f1f5f8;
color: #405665;
font-size: 13px;
line-height: 22px;
}
.r6-evolve-settings {
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"] {
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;
color: #4f6373;
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-setting-row {
box-sizing: border-box;
display: flex;
@@ -374,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;
@@ -436,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;
}
@@ -614,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;
@@ -729,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;
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { rawDetail, rawLibraryPage, rawSearchPage } from "./sdk-raw-map";
const record = {
raw_id: "raw-1",
content: "[user]: Keep the original words.",
user_id: "local",
agent_id: "all-apps",
session_id: "session-1",
source: "cursor",
memory_at: 1_761_000_000,
gmt_created: 1_761_000_100,
custom: {},
messages: [{ role: "user", content: "Keep the original words." }],
};
describe("raw map projection", () => {
it("projects a raw page into the shared three-column library shape", () => {
const page = rawLibraryPage({
items: [record],
total: 3,
offset: 0,
limit: 20,
});
expect(page.items[0]).toMatchObject({
memory_id: "raw-1",
content: record.content,
status: "confirmed",
});
expect(page.source_ids_by_memory).toEqual({ "raw-1": ["cursor"] });
expect(page.source_time_by_memory?.["raw-1"]?.basis).toBe("conversation");
expect(page.total).toBe(3);
});
it("keeps search results read-only and page-local", () => {
const page = rawSearchPage([
{
kind: "raw",
raw_id: "raw-search",
content: "Matched original",
source: "claude",
session_id: "session-2",
memory_at: "2026-11-03T09:00:00Z",
score: 0.8,
truncated: false,
},
]);
expect(page).toMatchObject({ total: 1, offset: 0, limit: 1 });
expect(page.items[0].memory_id).toBe("raw-search");
});
it("falls back to import time when conversation time is unavailable", () => {
const page = rawLibraryPage({
items: [{ ...record, memory_at: null }],
total: 1,
offset: 0,
limit: 20,
});
expect(page.source_time_by_memory?.["raw-1"]).toEqual({
at: new Date(record.gmt_created * 1_000).toISOString(),
basis: "import",
});
expect(rawDetail({ ...record, memory_at: null }).sdk_memory_at).toBe(
new Date(record.gmt_created * 1_000).toISOString(),
);
});
it("adapts raw only for the shared selection identity", () => {
expect(rawDetail(record)).toMatchObject({
memory: { memory_id: "raw-1", content: record.content },
source_id: "cursor",
evidence: [],
history: [],
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryLibraryPage,
SdkRawContextItem,
SdkRawPage,
SdkRawRecord,
} from "@one-memory/contracts";
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
function isoTime(value: number | string | null | undefined): string {
const milliseconds =
typeof value === "number"
? value * 1_000
: typeof value === "string"
? Date.parse(value)
: Number.NaN;
if (
Number.isFinite(milliseconds) &&
milliseconds >= EARLIEST_PLAUSIBLE &&
milliseconds <= LATEST_PLAUSIBLE()
)
return new Date(milliseconds).toISOString();
return "";
}
export function rawRecordTime(record: RawMapRecord): string | null {
return (
isoTime(record.memory_at) ||
("gmt_created" in record ? isoTime(record.gmt_created) : "") ||
null
);
}
function sourceOf(record: RawMapRecord): string {
return record.source || "default";
}
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
const created = rawRecordTime(record) ?? "";
return {
memory_id: record.raw_id,
user_id: "user_id" in record ? record.user_id : "local",
kind: "chat",
status: "confirmed",
content: record.content,
scopes: [],
evidence_ids: [],
// GovernedMemory is only the map's shared projection. Raw remains raw;
// this inert value is never shown or written back.
origin: "extracted",
tags: [],
supersedes: [],
superseded_by: [],
revision: 0,
created_at: created,
updated_at: created,
deleted_at: null,
};
}
function pageFor(
records: RawMapRecord[],
total: number,
offset: number,
limit: number,
): MemoryLibraryPage {
const sourceIds: Record<string, string[]> = {};
const sourceTime: Record<
string,
{ at: string; basis: "conversation" | "import" }
> = {};
for (const record of records) {
sourceIds[record.raw_id] = [sourceOf(record)];
const conversationAt = isoTime(record.memory_at);
const importedAt =
"gmt_created" in record ? isoTime(record.gmt_created) : "";
if (conversationAt)
sourceTime[record.raw_id] = {
at: conversationAt,
basis: "conversation",
};
else if (importedAt)
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
}
return {
items: records.map(rawMapMemory),
total,
offset,
limit,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: null,
window_id: null,
window_reset: false,
sdk_memory_refs: {},
};
}
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
return pageFor(page.items, page.total, page.offset, page.limit);
}
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
return pageFor(items, items.length, 0, Math.max(items.length, 1));
}
export function rawDetail(record: RawMapRecord): MemoryDetail {
const memory = rawMapMemory(record);
return {
memory,
evidence: [],
history: [],
index_sync: null,
source_id: sourceOf(record),
sdk_memory_at: rawRecordTime(record),
sdk_created_at:
"gmt_created" in record ? isoTime(record.gmt_created) || null : null,
};
}
+1
View File
@@ -236,6 +236,7 @@
font-size: 18px;
line-height: 31px;
font-weight: 500;
overflow-wrap: anywhere;
}
.selected-memory-detail {
display: flex;
@@ -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);
});
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more