Compare commits

...
Author SHA1 Message Date
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
kristinhe1111 b58e95fdae Merge pull request #305 from Tencent-Hunyuan/chore/memhub-build-40
chore(release): MemHub 1.0.6, build 40
2026-11-04 13:27:01 +08:00
kristinheandCursor 42b577f6f8 chore(release): bump MemHub build to 40
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:16:08 +08:00
kristinhe1111 2554fb3b96 Merge pull request #304 from Tencent-Hunyuan/feat/memhub-onboarding-mode-model
feat(memhub): 重构冷启动记忆模式与模型配置
2026-11-04 13:12:38 +08:00
kristinheandCursor e32dc987c2 feat(memhub): redesign onboarding mode setup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 12:00:46 +08:00
kristinhe1111 598e83525e Merge pull request #298 from Tencent-Hunyuan/feat/memhub-mode-projection
feat(memhub): project home and settings by memory mode
2026-11-04 09:29:48 +08:00
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
139 changed files with 13182 additions and 2371 deletions

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 38, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 40, 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 38),GitHub 仓库为
(build 40),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",
+209 -46
View File
@@ -264,7 +264,12 @@ from one_memory.model_request_audit import (
ModelRequestAuditStore,
)
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.onboarding import (
OnboardingCompletedError,
OnboardingProgressUpdate,
OnboardingState,
OnboardingStore,
)
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -323,6 +328,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 +652,6 @@ class SdkLayerCounts(BaseModel):
statuses: tuple[str, ...] | None = None
#: Raw lifecycle states that are still a kept conversation. Pro marks a raw
#: SHADOW once extraction succeeds; lite leaves it ACTIVE.
RAW_STATUSES = ("active", "shadow")
class MemoryProcessingBlock(BaseModel):
kind: str
reason: str
@@ -771,6 +778,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")
@@ -1472,22 +1502,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,
@@ -1536,7 +1559,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
@@ -1949,6 +1972,12 @@ def create_app(
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:
@@ -2502,6 +2531,21 @@ def create_app(
def get_onboarding() -> OnboardingState:
return _onboarding_store(app).get()
@app.api_route(
"/api/v1/onboarding/progress",
methods=["PATCH", "PUT"],
response_model=OnboardingState,
dependencies=protected,
)
async def update_onboarding_progress(
update: OnboardingProgressUpdate,
) -> OnboardingState:
"""Persist only resumable onboarding choices; secrets are never accepted."""
try:
return await asyncio.to_thread(_onboarding_store(app).update_progress, update)
except OnboardingCompletedError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.post(
"/api/v1/onboarding/complete",
response_model=OnboardingState,
@@ -3847,6 +3891,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,
@@ -4013,7 +4081,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],
@@ -4398,9 +4468,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)
@@ -4409,8 +4480,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,
@@ -4693,6 +4763,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,
@@ -4711,17 +4790,12 @@ def create_app(
added to the graph once, in the background (`graph_build`). No raw is
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
"""
_refuse_pipeline_change_during_embedding_maintenance(app)
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
raise HTTPException(status_code=409, detail="graph_store_unavailable")
store = _config_store(app)
async with app.state.memory_mode_lock:
_refuse_pipeline_change_during_embedding_maintenance(app)
previous_effective = _memory_mode(app)
if request.mode != previous_effective and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
plan = _memory_mode_plan(app, request.mode)
if plan.blockers:
raise HTTPException(status_code=409, detail=plan.blockers[0])
previous_effective = plan.current
previous = store.memory_mode()
if previous != request.mode or not store.memory_mode_explicitly_configured():
await asyncio.to_thread(store.set_memory_mode, request.mode)
@@ -4742,6 +4816,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,
@@ -5015,16 +5091,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",
@@ -5032,11 +5108,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(
@@ -5836,10 +5907,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",
)
@@ -6216,6 +6296,55 @@ def _memory_mode_state(app: FastAPI) -> MemoryModeState:
)
def _memory_mode_plan(app: FastAPI, target: MemoryMode) -> MemoryModePlan:
"""One validator for previews and mutations, in stable blocker order."""
current = _memory_mode(app)
effective_target = cast(MemoryMode, effective_memory_mode(target))
blockers: list[MemoryModeBlocker] = []
if not _embedding_writes_open(app):
blockers.append("embedding_maintenance_in_progress")
if (
effective_target != current
and effective_target in {"pro", "ultra"}
and _model_profile_store(app).active_identity() is None
):
blockers.append("active_model_required")
if effective_target != current and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
blockers.append("memory_lifecycle_busy")
if (
effective_target != current
and effective_target == "ultra"
and importlib.util.find_spec("kuzu") is None
):
blockers.append("graph_store_unavailable")
warnings: list[MemoryModeWarning] = []
if graph_store_enabled():
warnings.append("forced_by_env")
if effective_target == current:
warnings.append("effective_no_change")
graph_backfill: bool | None = None
if effective_target == "ultra" and current != "ultra":
graph_backfill = not app.state.memory_graph_backfill.running
effects = memory_mode_effects(
current,
effective_target,
graph_backfill=graph_backfill,
)
return MemoryModePlan(
current=current,
target=target,
effective_target=effective_target,
capabilities=home_capabilities(effective_target),
effects=() if blockers else effects,
warnings=tuple(warnings),
blockers=tuple(blockers),
)
def _evolve_service(app: FastAPI) -> EvolveService:
return cast(EvolveService, app.state.evolve_service)
@@ -6589,7 +6718,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)
@@ -6598,6 +6727,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)
@@ -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}>")
@@ -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")
@@ -3190,6 +3190,51 @@ async def _061_write_job_retries(cursor: Any) -> None:
await cursor.execute(statement)
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
"""Append resumable onboarding choices without rewriting migration 12."""
if dialect == "sqlite":
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN setup_step TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id TEXT NULL;
"""
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
ALTER TABLE onboarding_state
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
"""
async def _062_onboarding_progress(cursor: Any) -> None:
if _dialect(cursor) == "sqlite":
existing = await cursor.columns("onboarding_state")
statements = _split_statements(onboarding_progress_ddl("sqlite"))
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
await cursor.execute(statement)
return
for statement in _split_statements(onboarding_progress_ddl("mysql")):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_column(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3490,4 +3535,10 @@ MIGRATIONS: tuple[Migration, ...] = (
("memory_note_requests",),
),
Migration(61, "write_job_retries", _061_write_job_retries),
Migration(
ONBOARDING_PROGRESS_MIGRATION_VERSION,
"onboarding_progress",
_062_onboarding_progress,
("onboarding_state",),
),
)
@@ -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
+171 -32
View File
@@ -4,9 +4,10 @@ from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, field_validator
from one_memory.database import (
take_the_write_lock_first,
@@ -14,11 +15,25 @@ from one_memory.database import (
from one_memory.database_access import main_database
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
from one_memory.memory_server.migrations import onboarding_state_ddl
from one_memory.memory_server.sql_compat import execute, is_mysql
from one_memory.memory_server.migrations import (
onboarding_progress_ddl,
onboarding_state_ddl,
)
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
from one_memory.memory_server.store_binding import store_for
SCHEMA = onboarding_state_ddl("sqlite")
ONBOARDING_COLUMNS = (
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
)
#: The start/complete upserts, per engine.
#:
@@ -51,11 +66,13 @@ START_MYSQL = """
updated_at = VALUES(updated_at)
"""
COMPLETE_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_INSERT_SQLITE = (
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
RESET_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = ? WHERE singleton = 1"
)
RESET_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = %s WHERE singleton = 1"
)
ENSURE_PROGRESS_SQLITE = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,?) "
"ON CONFLICT(singleton) DO NOTHING"
)
ENSURE_PROGRESS_MYSQL = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
)
class OnboardingState(BaseModel):
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
class OnboardingProgressUpdate(BaseModel):
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@field_validator("model_profile_id")
@classmethod
def validate_model_profile_id(cls, value: str | None) -> str | None:
if value is None:
return None
try:
if str(UUID(value)) != value:
raise ValueError
except (ValueError, TypeError, AttributeError):
raise ValueError("invalid_model_profile_id") from None
return value
class OnboardingCompletedError(RuntimeError):
pass
class OnboardingStore:
@@ -103,12 +163,31 @@ class OnboardingStore:
"""
with self._connect(initialize=True) as connection:
execute(connection, is_mysql(self._store), SCHEMA)
if not is_mysql(self._store):
existing = {
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
}
statements = tuple(
statement.strip()
for statement in onboarding_progress_ddl("sqlite").split(";")
if statement.strip()
)
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
connection.execute(statement)
def get(self) -> OnboardingState:
mysql = is_mysql(self._store)
with self._connect() as connection:
row = execute(
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
connection,
mysql,
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
"FROM onboarding_state WHERE singleton = 1",
).fetchone()
if row is None:
return OnboardingState(
@@ -116,8 +195,11 @@ class OnboardingStore:
selected_source_ids=(),
started_at=None,
completed_at=None,
mode_intent=None,
setup_step=None,
model_profile_id=None,
)
return self._to_state(row)
return self._to_state(row, mysql=mysql)
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
now = datetime.now(UTC).isoformat()
@@ -150,6 +232,43 @@ class OnboardingStore:
)
return self.get()
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
fields = tuple(
field
for field in ("mode_intent", "setup_step", "model_profile_id")
if field in update.model_fields_set
)
if not fields:
state = self.get()
if state.status == "completed":
raise OnboardingCompletedError("onboarding_completed")
return state
mysql = is_mysql(self._store)
placeholder = "%s" if mysql else "?"
assignments = [f"{field} = {placeholder}" for field in fields]
assignments.append(f"updated_at = {placeholder}")
values = [getattr(update, field) for field in fields]
now = datetime.now(UTC).isoformat()
values.append(now)
with self._connect(writing=True) as connection:
execute(
connection,
mysql,
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
(now,),
)
updated = execute(
connection,
mysql,
"UPDATE onboarding_state SET "
+ ", ".join(assignments)
+ " WHERE singleton = 1 AND status != 'completed'",
tuple(values),
)
if updated.rowcount != 1:
raise OnboardingCompletedError("onboarding_completed")
return self.get()
def reset(self) -> OnboardingState:
"""Return onboarding to its first-run state, touching nothing else.
@@ -207,33 +326,53 @@ class OnboardingStore:
yield connection
@staticmethod
def _to_state(row: Any) -> OnboardingState:
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
`SELECT *` makes the column order part of the contract here, which is
why the query is `SELECT *` and the indices are named: relying on
`row["..."]` would only work on SQLite, and relying on position alone
without saying which column is which would be unreadable.
Timestamps are a further engine difference: SQLite hands back the stored
string, MySQL hands back a `datetime`. Accepting both is not padding —
parsing a `datetime` raises, and treating a string as one would be
silently wrong.
"""
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
raw_ids = json.loads(row[2])
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
if mysql:
if not isinstance(row, tuple):
raise ValueError("onboarding_row_shape_invalid")
elif not isinstance(row, sqlite3.Row):
raise ValueError("onboarding_row_shape_invalid")
if len(row) != len(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
if mysql:
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
else:
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
if set(values) != set(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
raw_ids = json.loads(values["selected_source_ids_json"])
return OnboardingState(
status=row[1],
status=values["status"],
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
started_at=_as_datetime(row[3]),
completed_at=_as_datetime(row[4]),
started_at=_as_datetime(values["started_at"], mysql=mysql),
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
mode_intent=values["mode_intent"],
setup_step=values["setup_step"],
model_profile_id=values["model_profile_id"],
)
def _as_datetime(value: object) -> datetime | None:
"""SQLite stores these as text; MySQL returns `datetime` objects."""
if value is None or value == "":
def _as_datetime(
value: object,
*,
mysql: bool,
nullable: bool = True,
) -> datetime | None:
"""Validate the engine's timestamp shape and normalize it to UTC."""
if value is None:
if not nullable:
raise ValueError("onboarding_datetime_invalid")
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value))
if mysql:
if not isinstance(value, datetime):
raise ValueError("onboarding_datetime_invalid")
parsed = value
else:
if not isinstance(value, str) or value == "":
raise ValueError("onboarding_datetime_invalid")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
@@ -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.
@@ -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())
@@ -177,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
asyncio.run(scenario())
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
from one_memory import sdk_read_handle
callback_reads = 0
def current_mode() -> str:
nonlocal callback_reads
callback_reads += 1
return "pro"
monkeypatch.setattr(
sdk_read_handle,
"SdkClientHandle",
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
)
reads = sdk_read_handle.ActiveProfileReadHandle(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=tmp_path,
memory_mode=current_mode,
)
async def scenario():
client = await reads.client_for_mode("lite")
assert client.mode == "lite"
await reads.close()
asyncio.run(scenario())
assert callback_reads == 0
class _FakeHandle:
def __init__(self, mode: str, profile_id: str = "p") -> None:
self.mode = mode
@@ -307,7 +338,7 @@ class _SdkHandle:
return None
async def _noop() -> None:
async def _noop(_requires_generation: bool) -> None:
return None
@@ -330,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(
@@ -354,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
@@ -812,22 +874,35 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
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"]
@@ -851,6 +926,114 @@ 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
@@ -917,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}
@@ -1034,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(
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
InMemoryMigrationStore,
MigrationResult,
MigrationSchemaMismatchError,
SqliteMigrationStore,
ledger_sql,
migrate,
)
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
path = tmp_path / "old-onboarding.sqlite3"
with sqlite3.connect(path) as database:
database.execute(
"""
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
database.execute(
"INSERT INTO onboarding_state VALUES "
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
)
migration = next(item for item in MIGRATIONS if item.version == 62)
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
assert result.applied == (62,)
with sqlite3.connect(path) as database:
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
row = database.execute(
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
).fetchone()
assert columns == [
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
]
assert row == (None, None, None)
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
class DuplicateColumnError(Exception):
pass
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
self.applied: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
raise DuplicateColumnError(1060, "duplicate column")
self.applied.append(statement)
cursor = Cursor()
migration = next(item for item in MIGRATIONS if item.version == 62)
asyncio.run(migration.apply(cursor))
assert len(cursor.attempted) == 3
assert len(cursor.applied) == 1
assert "model_profile_id" in cursor.applied[0]
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
@@ -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":
@@ -35,6 +35,12 @@ def test_profile_uses_one_model_and_keeps_key_out_of_database(tmp_path) -> None:
)
assert profile.has_api_key is True
assert profile.embedding_provider_kind is ProviderKind.LOCAL_ONNX
assert profile.embedding_model == "bge-small-zh-v1.5"
assert profile.embedding_dimensions == 512
assert profile.timeout_seconds == 240
assert profile.max_concurrency == 4
assert profile.context_window is None
assert "api_key" not in profile.model_dump()
assert API_KEY.encode() not in database.read_bytes()
assert secrets.resolve(store.secret_ref(profile.profile_id) or "") == API_KEY
@@ -1,13 +1,17 @@
"""Onboarding endpoints manage UI state without touching SDK memory."""
import asyncio
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.onboarding import OnboardingStore
def _app(tmp_path: Path):
@@ -63,13 +67,29 @@ def test_reset_returns_to_first_run_without_touching_sdk_data(tmp_path: Path) ->
sdk_marker = tmp_path / "data" / "hy-memory" / "governed-v2" / "memory.marker"
sdk_marker.parent.mkdir(parents=True, exist_ok=True)
sdk_marker.write_text("keep-sdk-memory", encoding="utf-8")
progress = await client.patch(
"/api/v1/onboarding/progress",
json={
"mode_intent": "pro",
"setup_step": "model",
"model_profile_id": "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
},
)
assert progress.status_code == 200
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
completed = await client.post("/api/v1/onboarding/complete")
assert completed.status_code == 200
assert completed.json()["mode_intent"] is None
assert completed.json()["setup_step"] is None
assert completed.json()["model_profile_id"] is None
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
assert reset.json()["completed_at"] is None
assert reset.json()["mode_intent"] is None
assert reset.json()["setup_step"] is None
assert reset.json()["model_profile_id"] is None
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
assert sdk_marker.read_text(encoding="utf-8") == "keep-sdk-memory"
@@ -92,6 +112,7 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
async def unavailable() -> dict[str, object]:
raise RuntimeError("hy_sdk_unavailable")
@@ -102,3 +123,197 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
assert finished.json()["status"] == "completed"
asyncio.run(scenario())
def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
patched = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "pro", "setup_step": "mode"},
)
assert patched.status_code == 200
assert patched.json()["mode_intent"] == "pro"
assert patched.json()["setup_step"] == "mode"
assert patched.json()["model_profile_id"] is None
profile_id = "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
replaced = await client.put(
"/api/v1/onboarding/progress",
json={"setup_step": "model", "model_profile_id": profile_id},
)
assert replaced.status_code == 200
assert replaced.json()["mode_intent"] == "pro"
assert replaced.json()["setup_step"] == "model"
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
asyncio.run(scenario())
def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
secret = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite", "api_key": "must-not-be-stored"},
)
invalid_id = await client.put(
"/api/v1/onboarding/progress",
json={"model_profile_id": "not-a-profile-id"},
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
)
assert secret.status_code == 422
assert "must-not-be-stored" not in secret.text
assert invalid_id.status_code == 422
assert invalid_mode.status_code == 422
state = (await client.get("/api/v1/onboarding")).json()
assert state["mode_intent"] is None
assert state["model_profile_id"] is None
asyncio.run(scenario())
def test_progress_row_adapter_accepts_mysql_tuple_shape() -> None:
started_at = datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
updated_at = datetime(2026, 11, 4, 1, 1)
state = OnboardingStore._to_state(
(
1,
"importing",
'["cursor"]',
started_at,
None,
updated_at,
"lite",
"review",
None,
),
mysql=True,
)
assert state.selected_source_ids == ("cursor",)
assert state.started_at == started_at
assert state.mode_intent == "lite"
assert state.setup_step == "review"
def test_progress_row_adapter_accepts_real_sqlite_row_and_text_datetime() -> None:
with sqlite3.connect(":memory:") as connection:
connection.row_factory = sqlite3.Row
row = connection.execute(
"SELECT 1 AS singleton, 'importing' AS status, '[\"cursor\"]' "
"AS selected_source_ids_json, '2026-11-04T01:00:00+00:00' AS started_at, "
"NULL AS completed_at, '2026-11-04T01:01:00+00:00' AS updated_at, "
"'pro' AS mode_intent, 'model' AS setup_step, "
"'9be6d465-4cf2-4c72-93f7-15f3c012bc80' AS model_profile_id"
).fetchone()
assert row is not None
state = OnboardingStore._to_state(row, mysql=False)
assert state.started_at == datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
assert state.mode_intent == "pro"
assert state.setup_step == "model"
def test_progress_row_adapter_rejects_wrong_length() -> None:
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state((1, "importing"), mysql=True)
def test_progress_row_adapter_rejects_wrong_row_types() -> None:
values = (
1,
"importing",
"[]",
None,
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(list(values), mysql=True)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(values, mysql=False)
def test_progress_row_adapter_rejects_wrong_engine_datetime_shape() -> None:
with pytest.raises(ValueError, match="onboarding_datetime_invalid"):
OnboardingStore._to_state(
(
1,
"importing",
"[]",
"2026-11-04T01:00:00+00:00",
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
),
mysql=True,
)
def test_runtime_sqlite_upgrade_does_not_record_progress_migration(tmp_path: Path) -> None:
database = tmp_path / "old.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO schema_migrations VALUES (61, 'write_job_retries', '2026-11-03');
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
);
"""
)
OnboardingStore(database)
with sqlite3.connect(database) as connection:
recorded = connection.execute(
"SELECT name FROM schema_migrations WHERE version=62"
).fetchone()
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")}
assert recorded is None
assert {"mode_intent", "setup_step", "model_profile_id"} <= columns
def test_progress_rejects_updates_after_completion(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
populated = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite"},
)
empty = await client.put("/api/v1/onboarding/progress", json={})
assert populated.status_code == 409
assert populated.json()["detail"] == "onboarding_completed"
assert empty.status_code == 409
state = (await client.get("/api/v1/onboarding")).json()
assert state["status"] == "completed"
assert state["mode_intent"] is None
asyncio.run(scenario())
@@ -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"
@@ -16,7 +16,7 @@ from one_memory.imports.connections import SourceConnectionStore
from one_memory.imports.grants import SourceGrantStore
from one_memory.imports.jobs import ImportJobStore
from one_memory.imports.sources.registry import build_source_catalog
from one_memory.onboarding import OnboardingStore
from one_memory.onboarding import OnboardingProgressUpdate, OnboardingStore
@pytest.mark.parametrize("entry", ["connect", "authorize"])
@@ -371,7 +371,14 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
database = tmp_path / "data.sqlite"
grants = SourceGrantStore(database, home=home)
jobs = ImportJobStore(database)
OnboardingStore(database)
onboarding = OnboardingStore(database)
onboarding.update_progress(
OnboardingProgressUpdate(
mode_intent="pro",
setup_step="model",
model_profile_id="9be6d465-4cf2-4c72-93f7-15f3c012bc80",
)
)
store = SourceConnectionStore(grants, jobs, home=home)
catalog = build_source_catalog(home=home, environment={})
cutoff = datetime(2026, 10, 13, 5, 0, tzinfo=UTC)
@@ -391,3 +398,7 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
assert receipt.accepted_source_ids == ("claude-code", "codex")
assert {job.history_after for job in receipt.jobs} == {cutoff}
assert store.get("bounded-first-import") == receipt
state = onboarding.get()
assert state.mode_intent == "pro"
assert state.setup_step == "model"
assert state.model_profile_id == "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
+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: "主要导航" });
+5 -2
View File
@@ -153,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();
@@ -216,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",
+12 -4
View File
@@ -23,6 +23,7 @@ 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";
@@ -35,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";
@@ -48,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",
@@ -327,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"}
+316 -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,50 @@ 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.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
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 +1610,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 +1627,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("查记忆"),
@@ -27,6 +27,7 @@ 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) => ({
@@ -101,9 +102,59 @@ const job: EmbeddingSwitchJob = {
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(
+143 -104
View File
@@ -3,50 +3,68 @@ import type {
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";
function count(value: number | null | undefined) {
return new Intl.NumberFormat().format(value ?? 0);
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(seconds: number | null) {
if (seconds === null) return "暂时无法估算";
if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`;
return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`;
function 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(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${Math.ceil(bytes / 1024 / 1024)} MB`;
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 blockerCopy: Record<string, string> = {
embedding_probe_required: "请先测试新的搜索模型连接。",
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:
"旧版本数据仍在升级,请等待升级完成后再更换。",
sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。",
insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。",
embedding_switch_running: "另一次索引更新正在进行。",
memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。",
"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 failureCopy: Record<string, string> = {
writes_did_not_drain: "等待正在进行的记忆整理结束超时。",
embedding_service_failed: "搜索模型服务连续请求失败。",
embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。",
embedding_verification_failed: "新的记忆索引没有通过完整性校验。",
target_profile_changed: "更新期间搜索模型配置发生了变化。",
embedding_switch_cancelled: "索引更新已取消。",
model_secret_unavailable: "无法读取搜索服务的 API Key。",
secure_storage_unavailable: "系统安全存储暂时不可用。",
embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。",
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({
@@ -62,9 +80,10 @@ export function EmbeddingSwitchConfirmDialog({
onCancel(): void;
onStart(): void;
}) {
const { locale, t } = useGuiLanguage();
const panel = useRef<HTMLElement>(null);
const blockers = plan.blockers.map(
(code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。",
const blockers = plan.blockers.map((code) =>
t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"),
);
useEffect(() => {
@@ -113,78 +132,88 @@ export function EmbeddingSwitchConfirmDialog({
>
<header>
<div>
<h2 id="embedding-switch-title">更新记忆搜索索引?</h2>
<p>
不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。
</p>
<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>已有记忆</dt>
<dt>{t("embeddingSwitch.impact.memories")}</dt>
<dd>
{count(plan.total_vectors)} 条
{t("embeddingSwitch.impact.memoryCount", {
count: count(locale, plan.total_vectors),
})}
{plan.users && plan.users > 1
? ` · ${count(plan.users)} 位用户`
? t("embeddingSwitch.impact.userCount", {
count: count(locale, plan.users),
})
: ""}
</dd>
</div>
<div>
<dt>预计耗时</dt>
<dd>{duration(plan.estimate_s)}</dd>
<dt>{t("embeddingSwitch.impact.duration")}</dt>
<dd>{duration(locale, t, plan.estimate_s)}</dd>
</div>
<div>
<dt>远程请求</dt>
<dt>{t("embeddingSwitch.impact.requests")}</dt>
<dd>
{plan.target_local
? "不会产生"
? t("embeddingSwitch.requests.none")
: plan.requests === null
? "按服务商计费"
: `约 ${count(plan.requests)} 次`}
? t("embeddingSwitch.requests.billed")
: t("embeddingSwitch.requests.approx", {
count: count(locale, plan.requests),
})}
</dd>
</div>
</dl>
<div className="embedding-switch-safety">
<strong>更新期间仍可搜索记忆</strong>
<p>
MemHub
会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。
</p>
<strong>失败不会影响已有数据</strong>
<p>
只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。
</p>
<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>当前搜索模型</dt>
<dd>{plan.source.model ?? "旧版本索引"}</dd>
</div>
<div>
<dt>新的搜索模型</dt>
<dd>{plan.target.model ?? "待确认"}</dd>
</div>
<div>
<dt>索引维度</dt>
<dt>{t("embeddingSwitch.details.currentModel")}</dt>
<dd>
{plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"}
{plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
</dd>
</div>
<div>
<dt>额外磁盘空间</dt>
<dd>{size(plan.disk_bytes_needed)}</dd>
<dt>{t("embeddingSwitch.details.newModel")}</dt>
<dd>
{plan.target.model ?? t("embeddingSwitch.details.pending")}
</dd>
</div>
<div>
<dt>旧索引保留</dt>
<dd>{plan.retention_days} 天</dd>
<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>
@@ -200,15 +229,15 @@ export function EmbeddingSwitchConfirmDialog({
<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"
@@ -216,7 +245,7 @@ export function EmbeddingSwitchConfirmDialog({
disabled={blockers.length > 0}
onClick={onStart}
>
开始更新索引
{t("embeddingSwitch.action.start")}
</R6Button>
</footer>
</section>
@@ -224,14 +253,14 @@ export function EmbeddingSwitchConfirmDialog({
);
}
function jobTitle(job: EmbeddingSwitchJob) {
if (job.kind === "merge") return "正在整理旧版本留下的搜索索引";
if (job.state === "preparing") return "正在准备更新记忆索引";
if (job.state === "reembedding") return "正在更新记忆索引";
if (job.state === "verifying") return "正在校验新的记忆索引";
if (job.state === "committing") return "正在启用新的搜索模型";
if (job.state === "failed") return "更新没有完成,正在恢复原设置";
return "记忆索引更新";
function 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) {
@@ -247,30 +276,36 @@ function ActiveSwitchBanner({
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(job) : "正在检查本机的记忆搜索索引"}</strong>
<strong>
{job ? jobTitle(t, job) : t("embeddingSwitch.job.scanning")}
</strong>
<span>
{job?.state === "failed"
? "原来的搜索模型仍在使用,记忆没有丢失。"
? t("embeddingSwitch.job.failedSafety")
: job
? `${Math.round(percent)}%${
? `${new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(percent / 100)}${
job.progress.eta_s === null
? ""
: ` · ${duration(job.progress.eta_s)}`
: ` · ${duration(locale, t, job.progress.eta_s)}`
}`
: "完成前暂不整理新记忆,搜索仍可使用。"}
: t("embeddingSwitch.job.scanPaused")}
</span>
</div>
{job ? (
<div
className="embedding-switch-progress"
role="progressbar"
aria-label="记忆索引更新进度"
aria-label={t("embeddingSwitch.progress.aria")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
@@ -280,7 +315,7 @@ function ActiveSwitchBanner({
) : null}
{job && canCancel(job) ? (
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
取消更新
{t("embeddingSwitch.action.cancel")}
</R6Button>
) : null}
</aside>
@@ -294,6 +329,7 @@ export function EmbeddingSwitchGlobalStatus({
onConfigureSearch(): void;
onDiagnostics(): void;
}) {
const { t } = useGuiLanguage();
const controller = useEmbeddingSwitch();
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
const [cleaning, setCleaning] = useState(false);
@@ -332,11 +368,11 @@ export function EmbeddingSwitchGlobalStatus({
{controller.refreshFailed && controller.supported === true ? (
<aside className="embedding-switch-outcome is-error" role="status">
<div>
<strong>索引状态暂时无法读取</strong>
<span>当前显示可能不是最新状态,可以继续使用已有记忆。</span>
<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}
@@ -344,15 +380,15 @@ export function EmbeddingSwitchGlobalStatus({
["failed", "unavailable"].includes(controller.current.scan_state) ? (
<aside className="embedding-switch-outcome is-error" role="alert">
<div>
<strong>旧版本搜索索引检查未完成</strong>
<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}
@@ -363,8 +399,8 @@ export function EmbeddingSwitchGlobalStatus({
aria-live="polite"
>
<div>
<strong>搜索模型已更换</strong>
<span>暂停期间保存的新对话正在整理成记忆。</span>
<strong>{t("embeddingSwitch.success.title")}</strong>
<span>{t("embeddingSwitch.success.body")}</span>
</div>
<div className="embedding-switch-outcome-actions">
{recent.source.space_id ? (
@@ -387,12 +423,12 @@ export function EmbeddingSwitchGlobalStatus({
});
}}
>
清理旧索引
{t("embeddingSwitch.action.cleanup")}
</R6Button>
) : null}
<R6Button
variant="quiet"
aria-label="关闭完成提示"
aria-label={t("embeddingSwitch.action.closeSuccess")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
@@ -402,7 +438,7 @@ export function EmbeddingSwitchGlobalStatus({
) : null}
{cleanupError ? (
<p className="embedding-switch-cleanup-error" role="alert">
旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。
{t("embeddingSwitch.cleanup.failed")}
</p>
) : null}
{recent?.state === "rolled_back" ? (
@@ -412,23 +448,26 @@ export function EmbeddingSwitchGlobalStatus({
aria-live="assertive"
>
<div>
<strong>索引更新没有完成</strong>
<strong>{t("embeddingSwitch.rollback.title")}</strong>
<span>
{failureCopy[recent.error_code ?? ""]
? `${failureCopy[recent.error_code ?? ""]} 仍在使用原来的搜索模型,记忆没有丢失。`
: "仍在使用原来的搜索模型,记忆没有丢失。"}
{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="关闭失败提示"
aria-label={t("embeddingSwitch.action.closeFailure")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
@@ -239,7 +239,7 @@ it("emits controlled connection drafts only and passes continuation without comp
screen.getByRole<HTMLInputElement>("checkbox", { name: "一键接入应用" })
.checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(next).toHaveBeenCalledWith("settings");
});
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -147,7 +147,7 @@ it("explains that one large harness limits every first-import source to three da
it("continues to step3 while sample GET is pending without calling completion APIs", () => {
api.receipt.mockReturnValue(new Promise(() => {}));
const { props } = setup();
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(props.onContinue).toHaveBeenCalledWith("settings");
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
});
+2 -4
View File
@@ -373,7 +373,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
{text("配置模型,沉淀记忆")}
{text("选择记忆模式")}
</R6Button>
)}
</div>
@@ -393,9 +393,7 @@ export function FirstImportReview({
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
@@ -0,0 +1,345 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { ReactNode, Ref } from "react";
import { OptionMenu } from "./OptionMenu";
import { PROVIDER_PRESETS } from "./provider-presets";
export interface GenerationModelValues {
provider_kind: ProviderKind;
model: string;
base_url: string;
timeout_seconds: string;
max_concurrency: string;
context_window: string;
disable_thinking: boolean;
}
export type GenerationModelField = keyof GenerationModelValues | "api_key";
export type GenerationModelErrors = Partial<
Record<GenerationModelField, string>
>;
export const GENERATION_PROTOCOLS: readonly {
value: ProviderKind;
label: string;
supported: boolean;
}[] = [
{
value: "openai_compatible",
label: "OpenAI-compatible",
supported: true,
},
{ value: "ollama", label: "Ollama(本机)", supported: true },
{ value: "anthropic", label: "Anthropic(暂不支持)", supported: false },
{ value: "hy_default", label: "HY 托管(当前不可用)", supported: false },
];
export function presetForGeneration(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(preset) =>
preset.provider === profile.provider_kind &&
endpointKey(preset.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
export function generationProviderLabel(provider: ProviderKind): string {
return (
GENERATION_PROTOCOLS.find((item) => item.value === provider)?.label ??
provider
);
}
export function generationSummary(
presetId: string,
provider: ProviderKind,
model: string,
text: (value: string) => string,
): string {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId);
return `${preset ? text(preset.label) : text(generationProviderLabel(provider))} · ${
model.trim() || text("模型待填写")
}`;
}
export function validGenerationEndpoint(value: string): boolean {
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
export function validateGenerationModel(
values: GenerationModelValues,
options: {
apiKey: string;
keyRequired: boolean;
requireRuntimeValues?: boolean;
},
): GenerationModelErrors {
const errors: GenerationModelErrors = {};
if (!values.model.trim()) errors.model = "请填写实际可用的模型 ID。";
if (!validGenerationEndpoint(values.base_url))
errors.base_url = "请填写有效的模型接口地址。";
if (
options.apiKey.trim() &&
/^(?:[a-z][a-z\d+.-]*:\/\/|www\.|\/\/)/i.test(options.apiKey.trim())
)
errors.api_key = "这里需要 API Key,不是网址。请填写服务方提供的密钥。";
else if (options.keyRequired && !options.apiKey.trim())
errors.api_key = "请填写 API Key。";
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const raw = values[key].trim();
if (!raw && !options.requireRuntimeValues) return;
const value = Number(raw);
if (!raw || !Number.isInteger(value) || value < min || value > max)
errors[key] = "onboardingIntegerRange";
};
integer("timeout_seconds", 1, 600);
integer("max_concurrency", 1, 32);
if (values.context_window.trim()) integer("context_window", 1024, 10_000_000);
return errors;
}
export function ProviderPresetField({
id,
labelledBy,
ariaLabel,
describedBy,
value,
disabled,
text,
includePlaceholder = false,
onChange,
}: {
id?: string;
labelledBy?: string;
ariaLabel?: string;
describedBy?: string;
value: string;
disabled?: boolean;
text: (value: string) => string;
includePlaceholder?: boolean;
onChange(value: string): void;
}) {
return (
<OptionMenu
id={id}
labelledBy={labelledBy}
ariaLabel={ariaLabel}
describedBy={describedBy}
value={value}
disabled={disabled}
options={[
...(includePlaceholder && !value
? [{ value: "", label: text("选择服务商"), disabled: true }]
: []),
...PROVIDER_PRESETS.map((preset) => ({
value: preset.id,
label: text(preset.label),
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={onChange}
/>
);
}
export function GenerationTechnicalDisclosure({
open,
disabled,
controls,
text,
onChange,
children,
}: {
open: boolean;
disabled?: boolean;
controls: string;
text: (value: string) => string;
onChange(open: boolean): void;
children: ReactNode;
}) {
return (
<section className="generation-technical-disclosure">
<button
type="button"
className="generation-technical-toggle"
aria-expanded={open}
aria-controls={controls}
disabled={disabled}
onClick={() => onChange(!open)}
>
{text("配置详情与高级设置")}
{open ? (
<ChevronUp size={16} aria-hidden="true" />
) : (
<ChevronDown size={16} aria-hidden="true" />
)}
</button>
<div id={controls} className="generation-technical-fields" hidden={!open}>
{children}
</div>
</section>
);
}
export function GenerationTechnicalFields({
idPrefix,
values,
errors = {},
disabled,
text,
modelRef,
onChange,
}: {
idPrefix: string;
values: GenerationModelValues;
errors?: GenerationModelErrors;
disabled?: boolean;
text: (value: string) => string;
modelRef?: Ref<HTMLInputElement>;
onChange(patch: Partial<GenerationModelValues>): void;
}) {
const field = (
key:
| "model"
| "base_url"
| "timeout_seconds"
| "max_concurrency"
| "context_window",
label: string,
options: {
type?: string;
min?: number;
max?: number;
placeholder?: string;
ref?: Ref<HTMLInputElement>;
} = {},
) => {
const error = errors[key];
const inputId = `${idPrefix}-${key}`;
return (
<label className="generation-model-field" htmlFor={inputId}>
<span>{text(label)}</span>
<input
ref={options.ref}
id={inputId}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
inputMode={options.type === "url" ? "url" : undefined}
autoComplete="off"
spellCheck={false}
value={values[key]}
disabled={disabled}
min={options.min}
max={options.max}
maxLength={key === "model" ? 256 : undefined}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
onChange={(event) => onChange({ [key]: event.target.value })}
/>
{error ? (
<small id={`${inputId}-error`} className="generation-model-error">
{text(error)}
</small>
) : null}
</label>
);
};
return (
<>
<label
className="generation-model-field"
htmlFor={`${idPrefix}-protocol`}
>
<span>{text("协议")}</span>
<select
id={`${idPrefix}-protocol`}
name="provider"
aria-label={text("协议")}
value={values.provider_kind}
disabled={disabled}
onChange={(event) =>
onChange({ provider_kind: event.target.value as ProviderKind })
}
>
{GENERATION_PROTOCOLS.map((protocol) => (
<option
key={protocol.value}
value={protocol.value}
disabled={!protocol.supported}
>
{text(protocol.label)}
</option>
))}
</select>
</label>
{field("model", "生成模型名", {
placeholder: "例如:gpt-4o-mini…",
ref: modelRef,
})}
{field("base_url", "Base URL", { type: "url" })}
<div className="generation-runtime-fields">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
placeholder: "自动 / 可选",
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
placeholder: "自动 / 可选",
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10_000_000,
placeholder: "自动 / 可选",
})}
</div>
<small className="generation-runtime-note">
{text("留空使用 Core 默认值。")}
</small>
<label className="generation-thinking">
<input
name="disable-thinking"
type="checkbox"
checked={values.disable_thinking}
disabled={disabled}
onChange={(event) =>
onChange({ disable_thinking: event.target.checked })
}
/>
{text("关闭思考模式")}
</label>
</>
);
}
function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
@@ -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" });
+56 -10
View File
@@ -1,5 +1,6 @@
import type {
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
MemoryDetail,
MemoryGraphNode,
@@ -92,6 +93,7 @@ type Props = {
hidden?: boolean;
id?: string;
arrival?: MemoryArrival;
modeCapabilities?: HomeProjectionCapabilities | null;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
@@ -268,6 +270,7 @@ export function MemoryMap(props: Props) {
processing: props.processing,
sources,
loaded,
capabilities: props.modeCapabilities,
});
useEffect(() => {
if (!loaded || library) return;
@@ -419,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(
@@ -436,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
@@ -448,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);
@@ -466,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);
@@ -475,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("已清除筛选,定位到关联记忆。");
@@ -485,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 (
@@ -605,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("所选记忆不在当前筛选内,已取消选择。");
@@ -623,6 +653,7 @@ export function MemoryMap(props: Props) {
overlaysSuspended,
library?.loading,
pendingPageOffset,
projectedMemoryIds,
]);
function closeInspector() {
if (!compact) {
@@ -788,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(
@@ -1359,7 +1401,10 @@ export function MemoryMap(props: Props) {
: selectedSource
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
? visibleGroupNode.node_type === "topic" ||
visibleGroupNode.node_type === "schema"
? visibleGroupNode.label
: text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
@@ -1499,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,64 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
afterEach(cleanup);
const modes = [
{ mode: "lite" as const, title: "Lite", description: "Raw conversations" },
{ mode: "pro" as const, title: "Pro", description: "Extracted memories" },
];
it("supports native radio keyboard selection and reports the chosen control", async () => {
const changed = vi.fn();
function Harness() {
const [selected, setSelected] = useState<"lite" | "pro">("lite");
return (
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
selected={selected}
onChange={(mode, control) => {
changed(mode, control);
setSelected(mode as "lite" | "pro");
}}
/>
);
}
const user = userEvent.setup();
render(<Harness />);
const lite = screen.getByRole("radio", { name: /Lite/ });
lite.focus();
await user.keyboard("{ArrowDown}");
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /Pro/ });
expect(pro.checked).toBe(true);
expect(changed).toHaveBeenCalledWith("pro", pro);
});
it("exposes current and recommended badges while respecting per-mode disabled state", () => {
render(
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
current="lite"
selected="lite"
currentBadge="Current"
recommended={{ mode: "pro", badge: "Recommended" }}
disabled={(mode) => mode === "pro"}
onChange={vi.fn()}
/>,
);
expect(screen.getByText("Current")).toBeTruthy();
expect(screen.getByText("Recommended")).toBeTruthy();
expect(screen.getByRole("radio", { name: /Pro/ })).toHaveProperty(
"disabled",
true,
);
});
@@ -0,0 +1,84 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { type ReactNode, useId } from "react";
import "./r6-controls.css";
export type MemoryModeChoice = {
mode: MemoryPipelineMode;
title: ReactNode;
description: ReactNode;
};
export function MemoryModeChoiceList({
modes,
current,
selected,
disabled = false,
onChange,
currentBadge,
recommended,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
modes: readonly MemoryModeChoice[];
current?: MemoryPipelineMode;
selected: MemoryPipelineMode | null;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
currentBadge?: ReactNode;
recommended?: {
mode: MemoryPipelineMode;
badge: ReactNode;
};
"aria-label": string;
"aria-busy"?: boolean;
}) {
const name = useId();
const isDisabled = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className="memory-mode-choice-list"
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{modes.map((choice) => {
const checked = selected === choice.mode;
const unavailable = isDisabled(choice.mode);
return (
<label
key={choice.mode}
className="memory-mode-choice"
data-selected={checked || undefined}
data-current={current === choice.mode || undefined}
data-disabled={unavailable || undefined}
>
<input
type="radio"
name={name}
value={choice.mode}
checked={checked}
disabled={unavailable}
onChange={(event) => onChange(choice.mode, event.currentTarget)}
/>
<span className="memory-mode-choice-copy">
<span className="memory-mode-choice-heading">
<strong>{choice.title}</strong>
{recommended?.mode === choice.mode ? (
<span className="memory-mode-choice-badge">
{recommended.badge}
</span>
) : null}
</span>
<small>{choice.description}</small>
</span>
{current === choice.mode && currentBadge ? (
<span className="memory-mode-choice-current">{currentBadge}</span>
) : null}
</label>
);
})}
</div>
);
}
+269 -65
View File
@@ -1,13 +1,22 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
} from "./api";
@@ -21,6 +30,8 @@ vi.mock("./api", () => ({
getEvolveOverview: vi.fn(),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn(),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
@@ -53,21 +64,60 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
}
async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const picker = await screen.findByRole("combobox", { name: "当前模式" });
await user.click(picker);
await user.click(screen.getByRole("option", { name }));
return picker;
const option = await screen.findByRole("radio", { name });
await user.click(option);
return option;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
});
it("confirms the data effects before switching to lite", async () => {
@@ -81,29 +131,43 @@ it("confirms the data effects before switching to lite", async () => {
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(screen.getByText(/高级记忆会保留/)).toBeTruthy();
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "切换模式" }));
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
prepare("pro");
const outside = document.createElement("button");
document.body.append(outside);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const lite = await chooseMode(user, /基础记忆(lite)/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换模式" }),
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(outside.inert).toBeFalsy();
expect(document.activeElement).toBe(lite);
outside.remove();
});
it("keeps focus inside the modal while the mode change is pending", async () => {
@@ -112,7 +176,9 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(screen.getByRole("button", { name: "切换模式" }));
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
@@ -122,6 +188,20 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
it("routes to model setup instead of switching pro without a model", async () => {
prepare("lite");
vi.mocked(planMemoryMode).mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
const configure = vi.fn();
const user = userEvent.setup();
render(
@@ -129,10 +209,72 @@ it("routes to model setup instead of switching pro without a model", async () =>
);
await chooseMode(user, /智能记忆(pro)/);
expect(configure).toHaveBeenCalledTimes(1);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("uses the isolated legacy plan only for a route-level old-Core 404", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
});
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
});
it("focuses the Evolve settings after switching to ultra", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("ultra"),
previous_mode: "pro",
effects: ["existing_memories_added_to_graph"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
screen.getByRole("heading", { name: "记忆整合(Evolve)" }),
).toBeTruthy();
});
@@ -145,37 +287,14 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect(
(screen.getByLabelText("自动整合时间") as HTMLInputElement).value,
).toBe("03:00");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("ignores an older refresh that finishes after a newer mode read", async () => {
prepare("pro");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const picker = await screen.findByRole("combobox", { name: "当前模式" });
let resolveOld!: (value: ReturnType<typeof state>) => void;
vi.mocked(getMemoryMode)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
)
.mockResolvedValueOnce(state("lite"));
const refresh = screen.getByRole("button", { name: "刷新记忆模式" });
await user.click(refresh);
await user.click(refresh);
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
resolveOld(state("pro"));
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
});
it("does not let an older refresh undo a saved schedule change", async () => {
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: false,
@@ -183,26 +302,28 @@ it("does not let an older refresh undo a saved schedule change", async () => {
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const toggle = await screen.findByRole("switch", {
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await user.click(automatic);
let resolveOld!: (value: { enabled: true; time: string }) => void;
vi.mocked(getEvolveSchedule).mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
await user.click(screen.getByRole("button", { name: "刷新记忆模式" }));
await user.click(toggle);
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }),
);
resolveOld({ enabled: true, time: "03:00" });
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
await waitFor(() =>
expect(toggle.getAttribute("aria-checked")).toBe("false"),
);
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
expect((pro as HTMLInputElement).checked).toBe(true);
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getEvolveSchedule).toHaveBeenCalledTimes(1);
});
});
it("keeps the mode usable when an evolve status read fails", async () => {
@@ -211,15 +332,55 @@ it("keeps the mode usable when an evolve status read fails", async () => {
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("combobox", {
name: "当前模式",
})) as HTMLButtonElement,
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
).toHaveProperty("disabled", false);
});
it("offers a text recovery action when the mode read fails", async () => {
prepare("pro");
vi.mocked(getMemoryMode).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
);
});
it("keeps a failed switch explanation inside the open confirmation dialog", async () => {
prepare("pro");
vi.mocked(apiErrorCode).mockReturnValue("graph_store_unavailable");
vi.mocked(setMemoryMode).mockRejectedValue(new Error("unavailable"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
),
).toBeTruthy();
});
it("keeps lite available while model status is unknown", async () => {
prepare("pro");
const user = userEvent.setup();
render(
<MemoryModeSettings
hasActiveModel={false}
@@ -228,14 +389,57 @@ it("keeps lite available while model status is unknown", async () => {
/>,
);
await user.click(await screen.findByRole("combobox", { name: "当前模式" }));
expect(
screen.getByRole("option", { name: /基础记忆(lite)/ }),
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("option", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", true);
expect(
screen.getByRole("option", { name: /深度记忆(ultra)/ }),
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
).toHaveProperty("disabled", true);
});
it("blocks mode and Evolve controls during embedding maintenance", async () => {
prepare("ultra");
vi.mocked(getMemoryProcessing).mockResolvedValue({
embedding_switch: { phase: "running" },
} as never);
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
it("blocks lifecycle controls while the knowledge graph is being built", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
graph_build: {
state: "running",
started_at: "2026-11-03T09:00:00Z",
finished_at: null,
users: {},
},
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("正在把已有高级记忆加入知识图谱…"),
).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
+250 -112
View File
@@ -3,10 +3,11 @@ import type {
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { RefreshCw } from "lucide-react";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
@@ -14,13 +15,20 @@ import {
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
import { OptionMenu } from "./OptionMenu";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
const MODE_COPY: Record<
MemoryPipelineMode,
@@ -28,44 +36,84 @@ const MODE_COPY: Record<
> = {
lite: {
title: "基础记忆(lite)",
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "使用当前模型从新对话中提取事实、偏好和计划。",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
const EFFECT_COPY: Record<string, string> = {
earlier_raw_not_extracted:
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。",
lite_search_stops: "原始对话检索会停止,改为使用高级记忆。",
higher_mode_memories_hidden:
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。",
evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。",
existing_memories_added_to_graph:
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。",
graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。",
};
function plannedEffects(
current: MemoryPipelineMode,
target: MemoryPipelineMode,
): string[] {
if (current === target) return [];
const effects: string[] = [];
if (current === "lite" && target !== "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
if (target === "lite") effects.push("higher_mode_memories_hidden");
if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped");
if (target === "ultra") effects.push("existing_memories_added_to_graph");
return effects;
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function MemoryModeSettings({
hasActiveModel,
modelStateKnown = true,
@@ -74,10 +122,10 @@ export function MemoryModeSettings({
}: {
hasActiveModel: boolean;
modelStateKnown?: boolean;
onConfigureModel(): void;
onConfigureModel(message?: string): void;
onModeChange?(mode: MemoryPipelineMode): void;
}) {
const { text } = useOnboardingTranslation();
const { text, locale } = useOnboardingTranslation();
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
@@ -85,25 +133,41 @@ export function MemoryModeSettings({
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [busy, setBusy] = useState(false);
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = panel.parentElement ?? panel;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
setPendingPlan(null);
setPendingMode(null);
return;
}
@@ -127,6 +191,7 @@ export function MemoryModeSettings({
panel.addEventListener("keydown", handleKey);
return () => {
panel.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
@@ -135,8 +200,27 @@ export function MemoryModeSettings({
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
return;
focusEvolveWhenReady.current = false;
document.getElementById(`${id}-evolve`)?.focus();
}, [id, pendingMode, state?.mode]);
useEffect(() => {
const target = focusModeWhenReady.current;
if (!target || busy || pendingMode || state?.mode !== target) return;
focusModeWhenReady.current = null;
document
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
@@ -146,11 +230,14 @@ export function MemoryModeSettings({
]);
if (version !== refreshVersion.current) return;
if (modeResult.status === "fulfilled") {
commitConfirmedMemoryMode(modeResult.value.mode, modeTicket);
setState(modeResult.value);
onModeChange?.(modeResult.value.mode);
setError("");
setReadFailed(false);
} else {
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
@@ -181,26 +268,46 @@ export function MemoryModeSettings({
run?.state === "running" ||
state?.graph_build.state === "running";
function choose(mode: MemoryPipelineMode) {
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
if (mode !== "lite" && !hasActiveModel) {
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
onConfigureModel();
setError("");
setBusy(true);
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
setBusy(false);
return;
}
plan = legacyMemoryModePlan(state.mode, mode, { hasActiveModel });
}
setBusy(false);
if (plan.blockers.includes("active_model_required")) {
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
setError("");
dialogOpener.current = document.getElementById(`${id}-select`);
dialogOpener.current = opener;
setPendingPlan(plan);
setPendingMode(mode);
}
async function commitMode() {
if (!pendingMode) return;
const targetMode = pendingMode;
refreshVersion.current++;
const modeTicket = issueConfirmedModeTicket("settings");
setBusy(true);
setError("");
try {
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
commitConfirmedMemoryMode(changed.mode, modeTicket);
focusEvolveWhenReady.current = targetMode === "ultra";
focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode;
setState(changed);
onModeChange?.(changed.mode);
setPendingPlan(null);
setPendingMode(null);
await refresh();
} catch (reason) {
@@ -261,42 +368,27 @@ export function MemoryModeSettings({
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新记忆模式")}
title={text("刷新记忆模式")}
disabled={busy}
onClick={() => void refresh()}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
</div>
<div
className="r6-language-setting-row r6-mode-setting-row"
<MemoryModeChoiceList
aria-label={text("当前模式")}
aria-busy={state === null}
>
<label id={`${id}-label`} htmlFor={`${id}-select`}>
{text("当前模式")}
</label>
<OptionMenu
id={`${id}-select`}
labelledBy={`${id}-label`}
value={state?.mode ?? ""}
disabled={!state || busy || lifecycleBusy}
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
value: mode,
label: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
disabled: mode !== "lite" && !modelStateKnown,
}))}
className="r6-language-option-menu r6-mode-option-menu"
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
onChange={(value) => {
if (value === "lite" || value === "pro" || value === "ultra")
choose(value);
}}
/>
</div>
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
current={state?.mode}
selected={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
@@ -312,20 +404,38 @@ export function MemoryModeSettings({
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" && schedule ? (
<div className="r6-evolve-settings">
<div className="r6-setting-row">
<div>
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
@@ -335,40 +445,45 @@ export function MemoryModeSettings({
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-evolve-time`}>
{text("执行时间")}
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
id={`${id}-evolve-time`}
type="time"
value={schedule.time}
disabled={busy || maintenance}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
/>
</div>
) : null}
</div>
<div className="r6-setting-row">
<div>
<h3>{text("自动整合时间")}</h3>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
type="time"
value={schedule.time}
disabled={busy || maintenance}
aria-label={text("自动整合时间")}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
/>
</div>
<div className="r6-setting-row">
<div>
<h3>{text("立即整合记忆")}</h3>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(),
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
variant="primary"
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
@@ -376,40 +491,63 @@ export function MemoryModeSettings({
{text("立即整合")}
</R6Button>
</div>
</div>
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog"
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
</h2>
<ul>
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
(effect) => (
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
),
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
<li>{text("原始对话在所有模式下都会保留。")}</li>
</ul>
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换模式")}
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
+266 -4
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";
@@ -91,12 +93,14 @@ 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"
@@ -108,7 +112,7 @@ vi.mock("./MemoryAddComposer", () => ({
})
}
>
补充记忆
{rawOnly ? "手动添加" : "补充记忆"}
</button>
),
}));
@@ -207,6 +211,34 @@ function setup() {
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({
@@ -262,7 +294,12 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
).toBe(false);
expect(screen.getByTestId("graph-owner")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "补充记忆" })).toBeNull();
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(
@@ -273,7 +310,228 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
signal: expect.any(AbortSignal),
}),
);
expect(screen.getByText(/当前为 lite/)).toBeTruthy();
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 () => {
@@ -317,7 +575,11 @@ it("does not request advanced memories before the pipeline mode is known", async
},
});
});
await screen.findByText(/当前为 lite/);
await waitFor(() =>
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
),
);
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
});
@@ -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));
+84 -38
View File
@@ -1,6 +1,7 @@
import type {
ExtractionWindowPreset,
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
ImportJob,
MemoryDetail,
@@ -216,8 +217,21 @@ export function MemorySpace({
processing?.pipeline?.mode ??
(processingResolved && !processingFailed ? "pro" : null);
const lite = pipelineMode === "lite";
const sdkPage =
sdkLibrary.settledMode === pipelineMode ? sdkLibrary.page : null;
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(),
@@ -447,6 +461,7 @@ export function MemorySpace({
),
refreshing: checkingSetup,
memoryCount: undefined,
capabilities: modeCapabilities,
blockedStages: processing?.blocked_stages,
backlogPending: processing?.backlog_pending,
phase: sourceSnapshotStale ? "unavailable" : "checking",
@@ -682,7 +697,7 @@ export function MemorySpace({
setDetail(null);
setSelectedRaw(null);
if (lite) {
const record = rawRecords.current.get(memoryId);
let record = rawRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
@@ -691,6 +706,38 @@ export function MemorySpace({
});
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);
@@ -831,9 +878,14 @@ 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
? pipelineMode === null
@@ -869,22 +921,21 @@ export function MemorySpace({
: null}
</span>
</div>
{!lite ? (
<MemoryAddComposer
suspended={!active}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
) : null}
<MemoryAddComposer
suspended={!active}
rawOnly={lite}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
{!simplified && (
<div
className="memory-mode-tabs"
@@ -952,19 +1003,6 @@ export function MemorySpace({
</div>
)}
</header>
{simplified && pipelineMode ? (
<p className="memory-mode-notice" role="status">
{lite
? t(
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。",
)
: pipelineMode === "ultra"
? t(
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。",
)
: t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -1064,13 +1102,20 @@ export function MemorySpace({
/>
</>
) : null}
{simplified && !lite && pendingAdd ? (
{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>
@@ -1134,6 +1179,7 @@ export function MemorySpace({
id="memory-mode-panel-map"
simplified={simplified}
contentKind={lite ? "raw" : "memory"}
modeCapabilities={modeCapabilities}
rawRecord={selectedRaw}
libraryView={
simplified
+56 -7
View File
@@ -900,6 +900,51 @@ describe("ModelSettings", () => {
);
});
it("keeps required generation fields mounted when technical details collapse", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
const modelField =
document.querySelector<HTMLInputElement>('[name="model"]');
const baseUrlField =
document.querySelector<HTMLInputElement>('[name="base_url"]');
expect(modelField).toBeTruthy();
expect(baseUrlField).toBeTruthy();
expect(modelField?.closest("[hidden]")).toBeTruthy();
expect(baseUrlField?.closest("[hidden]")).toBeTruthy();
});
it("reopens collapsed generation details and focuses the first invalid field", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
const modelField = await screen.findByLabelText("生成模型名");
expect(
screen
.getByRole("button", { name: "配置详情与高级设置" })
.getAttribute("aria-expanded"),
).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(modelField));
expect(create).not.toHaveBeenCalled();
});
it("clears inherited runtime values when an edit changes protocol and creates with Core defaults", async () => {
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "60");
await userEvent.selectOptions(screen.getByLabelText("协议"), "ollama");
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(create).toHaveBeenCalledOnce();
expect(create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
expect(create.mock.calls[0][0]).not.toHaveProperty("max_concurrency");
expect(create.mock.calls[0][0]).not.toHaveProperty("context_window");
expect(update).not.toHaveBeenCalled();
});
it("shows field-local Chinese required errors for mouse and keyboard submission", async () => {
const user = userEvent.setup();
render(<ModelSettings />);
@@ -1022,7 +1067,7 @@ describe("ModelSettings", () => {
);
});
it("后端激活不了的协议根本不出现在下拉里", () => {
it("后端激活不了的协议显示但不可选择", () => {
render(<ModelSettings />);
const providerSelect = screen.getByLabelText("协议") as HTMLSelectElement;
@@ -1035,12 +1080,16 @@ describe("ModelSettings", () => {
);
expect(labels.some((text) => text.startsWith("Ollama(本机)"))).toBe(true);
// ModelProfileStore.activate() 对这两个 provider 抛
// model_provider_not_supported。之前它们以 disabled 的形态留在下拉里,
// 等于把两条死路摆在明面上让人去读懂括号里的「暂不支持」。
expect(labels.some((text) => text.includes("Anthropic"))).toBe(false);
expect(labels.some((text) => text.includes("HY Memory"))).toBe(false);
expect(providerSelect.options.length).toBe(2);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("Anthropic"),
)?.disabled,
).toBe(true);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("HY 托管"),
)?.disabled,
).toBe(true);
});
it("已保存的配置不印内置向量模型", () => {
+252 -149
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,
@@ -17,9 +17,18 @@ import {
useState,
} from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import { OptionMenu } from "./OptionMenu";
import {
type GenerationModelValues,
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
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";
@@ -94,8 +103,11 @@ const PROVIDERS: Array<{
type FieldName =
| "model"
| "base-url"
| "api-key"
| "base_url"
| "api_key"
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "embedding-model"
| "embedding-base-url";
@@ -126,11 +138,11 @@ function probeFieldErrors(
: undefined;
};
if (!result.capabilities.endpoint_reachable) {
errors["base-url"] = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
errors.base_url = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
}
if (!result.capabilities.model_available) {
if (stages.get("model")?.code === "unauthorized") {
errors["api-key"] = FIELD_PROBE_TEXT.unauthorized;
errors.api_key = FIELD_PROBE_TEXT.unauthorized;
} else {
errors.model = text("model") ?? FIELD_PROBE_TEXT.model_not_found;
}
@@ -146,19 +158,6 @@ function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
function presetForProfile(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(item) =>
item.provider === profile.provider_kind &&
endpointKey(item.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
function validEndpoint(value: string): boolean {
try {
const url = new URL(value);
@@ -238,7 +237,7 @@ export function ModelSettings({
focusSection?: ModelSettingsFocusSection;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -259,6 +258,10 @@ export function ModelSettings({
const [embeddingModel, setEmbeddingModel] = useState("");
const [apiKey, setApiKey] = useState("");
const [disableThinking, setDisableThinking] = useState(true);
const [technicalOpen, setTechnicalOpen] = useState(true);
const [timeoutSeconds, setTimeoutSeconds] = useState("");
const [maxConcurrency, setMaxConcurrency] = useState("");
const [contextWindow, setContextWindow] = useState("");
const [customOpen, setCustomOpen] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
const [editing, setEditing] = useState<ModelProfile | null>(null);
@@ -278,6 +281,15 @@ export function ModelSettings({
const queuedOpen = useRef<ModelOpenIntent | null>(null);
const savedCard = useRef<HTMLElement>(null);
const formEl = useRef<HTMLFormElement>(null);
const modelInput = useRef<HTMLInputElement>(null);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
useEffect(() => {
alive.current = true;
return () => {
@@ -324,12 +336,34 @@ export function ModelSettings({
}, [saved]);
useLayoutEffect(() => {
const first = Object.keys(fields).find((key) => fields[key as FieldName]);
if (advancedEmbedding && first?.startsWith("embedding-")) {
if (!first) return;
if (
[
"model",
"base_url",
"api_key",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first)
) {
if (!technicalOpen && first !== "api_key") {
setTechnicalOpen(true);
return;
}
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus();
?.focus({ preventScroll: true });
} else if (advancedEmbedding && first.startsWith("embedding-")) {
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus({ preventScroll: true });
}
}, [advancedEmbedding, fields]);
}, [advancedEmbedding, fields, technicalOpen]);
useEffect(() => {
if (technicalOpen && !model.trim())
modelInput.current?.focus({ preventScroll: true });
}, [technicalOpen, model]);
const selected =
PROVIDERS.find((item) => item.kind === provider) ?? PROVIDERS[0];
const presetProfiles = models.profiles.filter(
@@ -376,6 +410,14 @@ export function ModelSettings({
nextModel.trim()
? `${providerLabel(nextProvider)} · ${nextModel.trim()}`.slice(0, 128)
: "";
const clearInheritedRuntimeForNewProfile = (nextProvider: ProviderKind) => {
if (!editing || nextProvider === editing.provider_kind) return;
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setDisableThinking(true);
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
@@ -388,10 +430,17 @@ export function ModelSettings({
setName(profile.name);
setNameEdited(true);
setProvider(profile.provider_kind);
setProviderPreset(presetForProfile(profile));
setProviderPreset(presetForGeneration(profile));
setBaseUrl(profile.base_url ?? "");
setModel(profile.model);
setDisableThinking(profile.disable_thinking);
setTimeoutSeconds(String(profile.timeout_seconds));
setMaxConcurrency(String(profile.max_concurrency));
setContextWindow(
profile.context_window === null ? "" : String(profile.context_window),
);
setTechnicalOpen(true);
technicalTouched.current.clear();
setApiKey("");
setAdvancedEmbedding(
profile.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND &&
@@ -429,6 +478,11 @@ export function ModelSettings({
setModel("");
setApiKey("");
setDisableThinking(true);
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setTechnicalOpen(true);
technicalTouched.current.clear();
setAdvancedEmbedding(false);
setEmbeddingMode("local");
setEmbeddingModel("");
@@ -658,11 +712,37 @@ export function ModelSettings({
return;
const errors: Partial<Record<FieldName, string>> = {};
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;
const generationErrors = validateGenerationModel(
{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
},
{ apiKey, keyRequired },
);
if (generationErrors.model) errors.model = "请填写生成模型名。";
if (generationErrors.base_url)
errors.base_url = "请填写完整的 http:// 或 https:// 接口地址。";
if (generationErrors.api_key) errors.api_key = generationErrors.api_key;
if (generationErrors.timeout_seconds)
errors.timeout_seconds = t("onboardingIntegerRange", {
min: 1,
max: 600,
});
if (generationErrors.max_concurrency)
errors.max_concurrency = t("onboardingIntegerRange", {
min: 1,
max: 32,
});
if (generationErrors.context_window)
errors.context_window = t("onboardingIntegerRange", {
min: 1024,
max: 10_000_000,
});
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (!embeddingModel.trim())
@@ -675,6 +755,19 @@ export function ModelSettings({
setSetupError(null);
const first = Object.keys(errors)[0];
if (first) {
if (
[
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first) &&
!technicalOpen
) {
setTechnicalOpen(true);
return;
}
if (first.startsWith("embedding-") && !advancedEmbedding) {
setAdvancedEmbedding(true);
return;
@@ -701,6 +794,20 @@ export function ModelSettings({
...(provider === "hy_default" ? {} : { base_url: baseUrl }),
...(apiKey ? { api_key: apiKey } : {}),
};
if (
technicalTouched.current.has("timeout_seconds") &&
timeoutSeconds.trim()
)
input.timeout_seconds = Number(timeoutSeconds);
if (
technicalTouched.current.has("max_concurrency") &&
maxConcurrency.trim()
)
input.max_concurrency = Number(maxConcurrency);
if (technicalTouched.current.has("context_window"))
input.context_window = contextWindow.trim()
? Number(contextWindow)
: null;
try {
const patch: ModelProfilePatch = editing
? { expected_revision: editing.revision }
@@ -714,6 +821,21 @@ export function ModelSettings({
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
if (
technicalTouched.current.has("timeout_seconds") &&
input.timeout_seconds !== editing.timeout_seconds
)
patch.timeout_seconds = input.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
input.max_concurrency !== editing.max_concurrency
)
patch.max_concurrency = input.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
input.context_window !== editing.context_window
)
patch.context_window = input.context_window;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (embeddingModel !== editing.embedding_model)
@@ -899,18 +1021,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
@@ -976,8 +1092,14 @@ export function ModelSettings({
</div>
<h3>{savedProfile.name}</h3>
<p>
{savedProfile.model} · {text(endpointLabel(savedProfile.base_url))}{" "}
· {text("版本")} {savedProfile.revision}
{generationSummary(
presetForGeneration(savedProfile),
savedProfile.provider_kind,
savedProfile.model,
text,
)}{" "}
· {text(endpointLabel(savedProfile.base_url))} · {text("版本")}{" "}
{savedProfile.revision}
</p>
{savedProfile.credential_state === "unavailable" && (
<p role="status">
@@ -1062,29 +1184,34 @@ export function ModelSettings({
<>
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
<ProviderPresetField
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("自定义兼容服务") },
]}
text={text}
onChange={(value) => {
const next = PROVIDER_PRESETS.find(
(item) => item.id === value,
);
setProviderPreset(value);
if (!next) return;
if (!next) {
clearInheritedRuntimeForNewProfile("openai_compatible");
setProvider("openai_compatible");
setBaseUrl("");
setModel("");
setApiKey("");
setTechnicalOpen(true);
if (!nameEdited) setName("");
return;
}
clearInheritedRuntimeForNewProfile(next.provider);
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
setTechnicalOpen(true);
if (editing) {
setName(next.label);
setNameEdited(true);
@@ -1101,34 +1228,6 @@ export function ModelSettings({
)}
</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
@@ -1148,34 +1247,70 @@ export function ModelSettings({
{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}
<GenerationTechnicalDisclosure
open={technicalOpen}
disabled={formLocked}
controls="model-generation-technical"
text={text}
onChange={setTechnicalOpen}
>
<GenerationTechnicalFields
idPrefix="model-generation"
values={{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
}}
errors={{
model: fields.model,
base_url: fields.base_url,
timeout_seconds: fields.timeout_seconds,
max_concurrency: fields.max_concurrency,
context_window: fields.context_window,
}}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
text={text}
modelRef={modelInput}
onChange={(patch: Partial<GenerationModelValues>) => {
if (patch.provider_kind !== undefined) {
clearInheritedRuntimeForNewProfile(patch.provider_kind);
setProvider(patch.provider_kind);
setProviderPreset("custom");
if (!nameEdited)
setName(suggestedName(model, patch.provider_kind));
}
if (patch.model !== undefined) {
setModel(patch.model);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(patch.model));
}
if (patch.base_url !== undefined) {
setBaseUrl(patch.base_url);
setProviderPreset("custom");
}
if (patch.timeout_seconds !== undefined) {
setTimeoutSeconds(patch.timeout_seconds);
technicalTouched.current.add("timeout_seconds");
}
if (patch.max_concurrency !== undefined) {
setMaxConcurrency(patch.max_concurrency);
technicalTouched.current.add("max_concurrency");
}
if (patch.context_window !== undefined) {
setContextWindow(patch.context_window);
technicalTouched.current.add("context_window");
}
if (patch.disable_thinking !== undefined) {
setDisableThinking(patch.disable_thinking);
technicalTouched.current.add("disable_thinking");
}
}}
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>
</GenerationTechnicalDisclosure>
</>
)}
{/* Disclosure only changes visibility; mode determines what is saved. */}
@@ -1342,40 +1477,13 @@ export function ModelSettings({
)}
{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"
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"]}
aria-describedby={`model-key-help${fields.api_key ? " model-key-error" : ""}`}
aria-invalid={!!fields.api_key}
type="password"
autoComplete="off"
required={keyRequired}
@@ -1393,22 +1501,12 @@ export function ModelSettings({
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
{fields.api_key && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
{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">
@@ -1480,8 +1578,13 @@ export function ModelSettings({
<div>
<strong>{profile.name}</strong>
<p>
{text(providerLabel(profile.provider_kind))} ·{" "}
{profile.model} ·
{generationSummary(
presetForGeneration(profile),
profile.provider_kind,
profile.model,
text,
)}{" "}
·
{/* Core fills in the built-in vector model on every profile,
* so printing it unconditionally put a line about
* bge-small-zh-v1.5 under a profile whose owner never chose
@@ -16,6 +16,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
@@ -49,6 +52,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
@@ -130,7 +136,7 @@ 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: "继续" }));
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -159,7 +165,7 @@ it("lets an empty first-time user enter without granting or importing a source",
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 userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -182,7 +188,7 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
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 userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -31,6 +31,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
const models = {
pendingProbe: false,
@@ -44,6 +47,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",
@@ -93,6 +112,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
beforeEach(() => {
delete window.oneMemory;
@@ -249,7 +271,7 @@ it("translates import review and preserves independent permission disclosure", a
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
expect(
@@ -279,20 +301,20 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
name: "Elegir el modo de memoria",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -400,7 +422,7 @@ it("translates completion recovery live without repeating completion writes", as
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" }));
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -423,9 +445,14 @@ it("translates model configuration, validation and advanced fields without activ
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
name: "Configurar el modelo de organización de recuerdos",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", {
name: "Detalles y configuración avanzada",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
@@ -436,6 +463,42 @@ it("translates model configuration, validation and advanced fields without activ
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。",
"No se completó la actualización del índice. Se sigue usando el modelo de búsqueda anterior. Comprueba la conexión y vuelve a intentarlo.",
],
[
"上次模型测试结果仍待核对,请稍后重试。",
"El resultado de la última prueba del modelo aún debe comprobarse. Vuelve a intentarlo más tarde.",
],
[
"模型写入暂时关闭。请等待索引维护完成后重试。",
"La escritura del modelo está temporalmente pausada. Espera a que termine el mantenimiento del índice y vuelve a intentarlo.",
],
[
"模型未能确认启用,请在设置中核对。",
"No se pudo confirmar que el modelo esté habilitado. Compruébalo en Configuración.",
],
[
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。",
"La configuración guardada del modelo no se puede leer temporalmente. No se creará un duplicado. Vuelve a actualizar.",
],
])("recognizes controller message key %s", async (key, translated) => {
render(
<LanguageProvider>
<OnboardingModelController
sourceNames={[]}
flowError={key}
onBack={vi.fn()}
/>
</LanguageProvider>,
);
expect(await screen.findByText(translated)).toBeTruthy();
expect(screen.queryByText(key)).toBeNull();
});
it.each([
[
"completed",
@@ -32,6 +32,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -74,6 +77,9 @@ const state: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
let sources: SourceResponse[];
let jobs: ImportJob[];
@@ -146,6 +152,17 @@ function noUnrequestedWrites() {
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
blockers: [],
} as never);
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
vi.stubGlobal("crypto", webcrypto);
delete window.oneMemory;
@@ -302,7 +319,7 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -354,7 +371,7 @@ it("partial acceptance preserves the successful job and failure through model-st
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: "继续" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -21,6 +21,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
@@ -41,6 +44,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
+365 -19
View File
@@ -9,6 +9,7 @@ import type {
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -17,6 +18,10 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./api";
import {
confirmedMemoryModeSnapshot,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { readConnectionPreferences } from "./connection-preferences";
import { OnboardingFlow, PLUGIN_CONSENT_KEY } from "./OnboardingFlow";
import {
@@ -27,6 +32,9 @@ import {
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const planMemoryMode = vi.fn();
const putOnboardingProgress = vi.fn();
const patchOnboardingProgress = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -48,6 +56,10 @@ vi.mock("./api", () => ({
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
planMemoryMode: (...args: unknown[]) => planMemoryMode(...args),
putOnboardingProgress: (...args: unknown[]) => putOnboardingProgress(...args),
patchOnboardingProgress: (...args: unknown[]) =>
patchOnboardingProgress(...args),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -62,20 +74,33 @@ vi.mock("./OnboardingModelController", () => ({
OnboardingModelController: ({
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken,
}: {
onBack(): void;
onSkip(): void;
onEnabled(): void;
onSkip?(): void;
onProfilePersisted(profileId: string): void;
onModelActiveConfirmed(token: number, profileId: string): void;
generationToken: number;
}) => (
<section aria-label="模型步骤边界">
<button type="button" onClick={onBack}>
返回导入
</button>
<button type="button" onClick={onEnabled}>
<button
type="button"
onClick={() => {
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"9be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
模型启用回执已确认
</button>
<button type="button" onClick={onSkip}>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
</section>
@@ -87,6 +112,9 @@ const onboarding: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
const health = {
status: "ok" as const,
@@ -184,13 +212,13 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
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("button", { name: "选择记忆模式" }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
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: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
@@ -225,6 +253,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
completeOnboarding.mockResolvedValue({
@@ -232,6 +261,27 @@ describe("OnboardingFlow S1.3", () => {
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
patchOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
@@ -371,7 +421,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
const continueButton = await screen.findByRole("button", {
name: "配置模型,沉淀记忆",
name: "选择记忆模式",
});
expect(continueButton).toHaveProperty("disabled", false);
await openModelStep(user);
@@ -695,13 +745,47 @@ 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: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(controller.refresh).toHaveBeenCalledOnce();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
it("shows the compact Lite and Pro mode step with contextual actions", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
const modeStep = screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]');
expect(modeStep?.getAttribute("aria-current")).toBe("step");
await user.click(screen.getByRole("radio", { name: /lite/i }));
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
@@ -735,7 +819,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(lite.checked).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
});
@@ -760,7 +844,7 @@ describe("OnboardingFlow S1.3", () => {
true,
);
await user.click(lite);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存。你的选择已保留,请重试。",
@@ -769,13 +853,277 @@ describe("OnboardingFlow S1.3", () => {
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode.mock.calls.map(([mode]) => mode)).toEqual([
"pro",
"lite",
]);
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = 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 }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "选择记忆模式" }),
).toBeTruthy(),
);
expect(confirmedMemoryModeSnapshot().revision).toBe(revisionBeforePut);
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("serializes model and back progress writes so setup mode wins", async () => {
let resolveModelProgress!: (value: OnboardingState) => void;
putOnboardingProgress
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "model",
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveModelProgress = resolve;
}),
)
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "mode",
});
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(2));
await user.click(screen.getByRole("button", { name: "返回导入" }));
expect(putOnboardingProgress).toHaveBeenCalledTimes(2);
await act(async () =>
resolveModelProgress({
...onboarding,
mode_intent: "pro",
setup_step: "model",
}),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(3));
expect(putOnboardingProgress).toHaveBeenLastCalledWith({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
});
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("moves focus to the mode title on entry and after returning from model", async () => {
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 firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
});
await waitFor(() => expect(document.activeElement).toBe(returnedTitle));
});
it("treats completed progress conflicts as terminal instead of retryable failures", async () => {
putOnboardingProgress.mockRejectedValueOnce(
Object.assign(new Error("completed"), {
response: {
status: 409,
data: { detail: "onboarding_completed" },
},
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("persists a late profile receipt after leaving model but fences late activation", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
await user.click(screen.getByRole("button", { name: "返回导入" }));
fireEvent.click(lateReceipt);
await waitFor(() =>
expect(patchOnboardingProgress).toHaveBeenCalledWith({
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("restores a persisted Pro model step directly after restart", async () => {
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "pro",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "选择记忆模式" })).toBeNull();
});
it("keeps the Pro choice retryable when progress persistence fails", async () => {
putOnboardingProgress.mockRejectedValueOnce(new Error("offline"));
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 pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
});
it("returns from skipped pro setup to an explicit mode choice", async () => {
const user = userEvent.setup();
const controller = imports();
@@ -792,11 +1140,11 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
expect(screen.getByRole("heading", { name: "选择记忆方式" })).toBeTruthy();
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: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -1749,7 +2097,7 @@ describe("OnboardingFlow S1.3", () => {
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "配置模型,沉淀记忆" });
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
@@ -2336,9 +2684,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(host.disabled).toBe(true);
expect(host.checked).toBe(false);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
+365 -82
View File
@@ -15,8 +15,20 @@ import {
useState,
} from "react";
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
import {
apiErrorCode,
completeOnboarding,
getMemoryMode,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import {
defaultHostSourceIds,
@@ -28,6 +40,7 @@ import {
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
@@ -47,6 +60,28 @@ const SCOPE_CHANGED =
"来源位置、身份或状态已变化,请重新选择并核对。尚未提交本次授权。";
type LocationCheck = "idle" | "checking" | "ready" | "failed";
function isCompletedProgressConflict(reason: unknown): boolean {
if (apiErrorCode(reason) === "onboarding_completed") return true;
if (!reason || typeof reason !== "object" || !("response" in reason))
return false;
const response = (
reason as {
response?: { status?: unknown; data?: { detail?: unknown } };
}
).response;
return (
response?.status === 409 && response.data?.detail === "onboarding_completed"
);
}
function isGenerationInvalid(reason: unknown): boolean {
return (
reason instanceof Error &&
(reason.message === "onboarding_generation_invalid" ||
reason.message === "onboarding_progress_generation_invalid")
);
}
function defaultConnectionDraft(
sources: SourceResponse[],
sourceIds: readonly string[],
@@ -89,19 +124,37 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
: "review";
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
"review",
resumedStep,
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
null,
state.mode_intent,
);
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 [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
const modeChoiceTouched = useRef(state.mode_intent !== null);
const [visitedSetup, setVisitedSetup] = useState(resumedStep !== "review");
const [visitedModel, setVisitedModel] = useState(resumedStep === "model");
const [modelGenerationToken, setModelGenerationToken] = useState(
resumedStep === "model" ? 1 : 0,
);
const modelGeneration = useRef(resumedStep === "model" ? 1 : 0);
const intentRef = useRef<OnboardingMemoryMode | null>(state.mode_intent);
const setupStepRef = useRef<"review" | "mode" | "model">(resumedStep);
const modelProfileId = useRef<string | null>(state.model_profile_id);
const modeWriteQueue = useRef<Promise<void>>(Promise.resolve());
const progressWriteQueue = useRef<Promise<void>>(Promise.resolve());
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
{ hostSourceIds: [] },
@@ -319,8 +372,11 @@ export function OnboardingFlow({
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
if (
alive.current &&
!modeChoiceTouched.current &&
@@ -427,6 +483,93 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
intentRef.current = nextIntent;
setupStepRef.current = nextStep;
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
},
[],
);
const queueModeWrite = useCallback(
(mode: OnboardingMemoryMode, allowed: () => boolean = () => true) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof setMemoryMode>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<Awaited<ReturnType<typeof setMemoryMode>>>(
(resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
},
);
modeWriteQueue.current = modeWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_generation_invalid"));
return;
}
try {
resolveResult(await setMemoryMode(mode));
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const queueProgressWrite = useCallback(
(
operation: () => ReturnType<typeof putOnboardingProgress>,
allowed: () => boolean = () => true,
) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof putOnboardingProgress>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<
Awaited<ReturnType<typeof putOnboardingProgress>>
>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
progressWriteQueue.current = progressWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_progress_generation_invalid"));
return;
}
try {
resolveResult(await operation());
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const persistFinish = useCallback(
async (destination: OnboardingDestination) => {
setExitDestination(destination);
@@ -446,6 +589,8 @@ export function OnboardingFlow({
: "onboarding_complete_result_unknown",
});
if (alive.current) {
modelGeneration.current += 1;
setupStepRef.current = "review";
setCompletionFailed(true);
setSetupStep("review");
// A definite refusal means it was not saved; anything else (timeout,
@@ -479,13 +624,19 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
setSetupStep("mode");
setModeFocusToken((value) => value + 1);
}
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
setSetupStep("mode");
},
[],
[leaveModelStep, modeChoice],
);
const saveModeChoice = useCallback(async () => {
@@ -498,21 +649,134 @@ export function OnboardingFlow({
return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
await setMemoryMode(modeChoice);
if (!alive.current) return;
if (modeChoice === "lite") {
await leave(modeDestination);
if (modeChoice === "pro") {
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
return;
}
setVisitedModel(true);
setSetupStep("model");
} catch {
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
leaveModelStep("lite", "mode");
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "lite",
setup_step: "mode",
model_profile_id: null,
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
}
}, [leave, modeChoice, modeDestination, modeSaving]);
}, [
enterModelStep,
leave,
leaveModelStep,
modeChoice,
modeDestination,
modeSaving,
finishOnce,
queueModeWrite,
queueProgressWrite,
]);
const persistModelProfile = useCallback(
async (profileId: string) => {
modelProfileId.current = profileId;
try {
await queueProgressWrite(() =>
patchOnboardingProgress({ model_profile_id: profileId }),
);
} catch (reason) {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("模型配置已保存,但恢复进度未能保存。请重试当前步骤。");
}
},
[queueProgressWrite],
);
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
},
[
finishOnce,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
if (flight.current || locked) return;
@@ -760,8 +1024,9 @@ export function OnboardingFlow({
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆方式")}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
@@ -769,7 +1034,20 @@ export function OnboardingFlow({
disabled={modeSaving || busy === "leaving"}
onClick={() => {
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
@@ -785,7 +1063,15 @@ export function OnboardingFlow({
}
onClick={() => void saveModeChoice()}
>
{text(modeSaving ? "正在保存…" : "继续")}
{text(
modeSaving
? "正在保存…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
}
@@ -795,68 +1081,40 @@ export function OnboardingFlow({
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<h2 id="onboarding-memory-mode-title">
{text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<fieldset
className="onboarding-r6-mode-options"
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={[
{
mode: "lite",
title: "Lite",
description: text(
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
),
},
{
mode: "pro",
title: "Pro",
description: text(
"使用模型理解对话,提取并整理可持续生长的记忆。",
),
},
]}
selected={modeChoice}
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>
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
setModeError(null);
}}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
@@ -868,11 +1126,24 @@ export function OnboardingFlow({
{visitedModel && (
<div hidden={setupStep !== "model"}>
<OnboardingModelController
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
flowError={modeError}
connectionAlreadyApplied={
connectionApplication === "applied" ||
connectionApplication === "partial"
}
onEnabled={() => void leave("sources")}
onProfilePersisted={(profileId) =>
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)
@@ -884,8 +1155,20 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setSetupStep("mode")}
onSkip={() => setSetupStep("mode")}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("恢复进度未能保存。你的选择已保留,请重试。");
});
}}
/>
</div>
)}
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { models } = vi.hoisted(() => ({
const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
models: {
profiles: [
{
@@ -26,58 +26,392 @@ const { models } = vi.hoisted(() => ({
is_active: true,
},
],
pendingProbe: false,
pendingProbe: null as null | {
operationId: string;
profileId: string;
inputRevision: number;
},
probeStorageFailed: false,
pendingCreation: false,
creationMissing: null,
creationStorageFailed: false,
refreshing: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
activate: vi.fn(),
reconcileCreation: vi.fn(),
discardMissingCreation: vi.fn(),
},
getModelProfiles: vi.fn(),
embedding: {
current: null as null | {
writes_paused: boolean;
last_finished?: { job_id: string; state: string };
},
plan: null,
busy: null,
errorCode: null,
refreshFailed: false,
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
},
step: { current: null as Record<string, unknown> | null },
}));
vi.mock("./api", () => ({
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
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>
),
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => embedding,
}));
vi.mock("./OnboardingModelStep", () => ({
OnboardingModelStep: (props: Record<string, unknown>) => {
step.current = props;
const probe = props.probe as { message: string } | null | undefined;
return (
<section>
<p>{probe?.message}</p>
<p>{String(props.error ?? "")}</p>
<button
type="button"
onClick={() => void (props.onSaveAndEnable as () => Promise<void>)()}
>
{String(props.enableActionLabel ?? "")}
</button>
<button
type="button"
disabled={!props.canTest}
onClick={() => void (props.onTest as () => Promise<void>)()}
>
{String(props.testActionLabel ?? "测试模型连通性")}
</button>
{props.onRecoverCreation ? (
<button
type="button"
onClick={() =>
void (props.onRecoverCreation as () => Promise<void>)()
}
>
核对创建结果
</button>
) : null}
</section>
);
},
}));
function currentStep(): Record<string, unknown> {
if (!step.current) throw new Error("onboarding_model_step_not_rendered");
return step.current;
}
const activeProfile = { ...models.profiles[0] };
beforeEach(() => {
models.profiles = [{ ...activeProfile }];
models.pendingCreation = false;
models.pendingProbe = null;
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
step.current = null;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
it("does not claim or auto-complete an arbitrary active profile without an onboarding id", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onEnabled={enabled}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(currentStep().profile).toBeNull();
expect(enabled).not.toHaveBeenCalled();
expect(models.activate).not.toHaveBeenCalled();
});
it("resumes and confirms the active profile explicitly owned by onboarding", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe("active-profile"),
);
expect(models.create).not.toHaveBeenCalled();
expect(active).not.toHaveBeenCalled();
});
it("recovers a pending creation and persists its profile id", async () => {
models.profiles = [];
models.pendingCreation = true;
models.reconcileCreation.mockResolvedValueOnce(activeProfile);
const persisted = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onProfilePersisted={persisted}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对创建结果" }),
);
await waitFor(() => expect(persisted).toHaveBeenCalledWith("active-profile"));
expect(models.create).not.toHaveBeenCalled();
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
profileId: "active-profile",
inputRevision: 3,
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: true }],
});
getModelProfiles.mockResolvedValueOnce([inactive]);
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对测试结果" }),
);
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
await screen.findByText("模型写入暂时关闭。请等待索引维护完成后重试。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
expect(active).not.toHaveBeenCalled();
});
it("does not confirm a late embedding gate opening after generation invalidation", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
let current = true;
const view = render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await screen.findByText("当前模型已经启用,可以继续完成首次设置。");
current = false;
embedding.current = {
writes_paused: false,
last_finished: { job_id: "late-job", state: "done" },
};
view.rerender(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() => expect(active).not.toHaveBeenCalled());
});
it("creates a cold-start profile without embedding or Core-owned runtime defaults", async () => {
models.profiles = [];
const created = {
...activeProfile,
profile_id: "created-profile",
is_active: false,
timeout_seconds: 240,
};
models.create.mockResolvedValueOnce(created);
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
api_key: "test-key",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create).toHaveBeenCalledOnce();
const input = models.create.mock.calls[0][0];
expect(input).toMatchObject({
provider_kind: "openai_compatible",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "test-key",
});
for (const key of [
"embedding_provider_kind",
"embedding_model",
"embedding_base_url",
"embedding_dimensions",
"timeout_seconds",
"max_concurrency",
"context_window",
])
expect(input).not.toHaveProperty(key);
});
it("clears inherited runtime display on provider switch and creates with Core defaults", async () => {
models.profiles = [];
models.create.mockResolvedValueOnce({
...activeProfile,
profile_id: "ollama-profile",
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
timeout_seconds: 240,
is_active: false,
});
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
timeout_seconds: "90",
});
});
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
provider_id: "ollama",
});
});
expect(
(currentStep().draft as { timeout_seconds: string }).timeout_seconds,
).toBe("");
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "qwen2.5",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create.mock.calls[0][0]).toMatchObject({
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
});
expect(models.create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
});
it("updates generation without overwriting an existing custom embedding", async () => {
const custom = {
...activeProfile,
is_active: false,
embedding_provider_kind: "ollama" as const,
embedding_model: "nomic-embed-text",
embedding_base_url: null,
embedding_dimensions: 768,
};
models.profiles = [custom];
models.update.mockResolvedValueOnce({ ...custom, model: "model-2" });
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={custom.profile_id}
onBack={vi.fn()}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(custom.profile_id),
);
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "model-2",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.update).toHaveBeenCalledWith(custom.profile_id, {
expected_revision: custom.revision,
model: "model-2",
});
});
+281 -108
View File
@@ -2,10 +2,15 @@ import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -31,8 +36,8 @@ const initial: OnboardingModelDraft = {
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "90",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
};
@@ -57,15 +62,23 @@ export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken = 1,
isGenerationCurrent = () => true,
initialProfileId = null,
flowError = null,
connectionAlreadyApplied = false,
}: {
sourceNames: readonly string[];
importedConversationCount?: number;
onBack(): void;
onSkip?(): void;
onEnabled?(): void;
onProfilePersisted?(profileId: string): void;
onModelActiveConfirmed?(token: number, ownerProfileId: string): void;
generationToken?: number;
isGenerationCurrent?(token: number): boolean;
initialProfileId?: string | null;
flowError?: string | null;
connectionAlreadyApplied?: boolean;
}) {
const { text, t } = useOnboardingTranslation();
@@ -90,36 +103,72 @@ export function OnboardingModelController({
);
const flight = useRef(false);
const draftTouched = useRef(false);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
const autoConfirmed = useRef<string | null>(null);
useEffect(() => {
const active = (models.profiles ?? []).find((profile) => profile.is_active);
if (!active || saved || draftTouched.current) return;
setSaved(active);
const profile = initialProfileId
? (models.profiles ?? []).find(
(candidate) => candidate.profile_id === initialProfileId,
)
: undefined;
if (!profile || saved || draftTouched.current) return;
setSaved(profile);
setDraft((current) => ({
...current,
name: active.name,
provider_kind: active.provider_kind,
model: active.model,
base_url: active.base_url ?? "",
disable_thinking: active.disable_thinking,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
disable_thinking: profile.disable_thinking,
embedding_mode:
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
profile.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
active.embedding_provider_kind === "ollama"
profile.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),
embedding_model: profile.embedding_model ?? "",
embedding_base_url: profile.embedding_base_url ?? "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
active.context_window === null ? "" : String(active.context_window),
profile.context_window === null ? "" : String(profile.context_window),
}));
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [models.profiles, saved]);
if (profile.is_active)
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [initialProfileId, models.profiles, saved]);
useEffect(() => {
if (
!saved?.is_active ||
!isGenerationCurrent(generationToken) ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
autoConfirmed.current = key;
onModelActiveConfirmed?.(generationToken, saved.profile_id);
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
@@ -133,18 +182,34 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
}, [
embeddingSwitch.current?.last_finished,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
for (const key of [
"timeout_seconds",
"max_concurrency",
"context_window",
"disable_thinking",
] as const) {
if (key in patch) technicalTouched.current.add(key);
}
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
const preset = PROVIDER_PRESETS.find(
(item) => item.id === patch.provider_id,
@@ -154,9 +219,14 @@ export function OnboardingModelController({
provider_kind: preset?.provider ?? "openai_compatible",
base_url: preset?.baseUrl ?? "",
model: preset?.model ?? "",
name: preset?.label ?? "",
name: preset?.label ?? "自定义兼容服务",
api_key: "",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
disable_thinking: true,
};
technicalTouched.current.clear();
setSaved(null);
setAdvanced(!preset?.model);
}
@@ -167,82 +237,42 @@ export function OnboardingModelController({
}
function input(): ModelProfileInput | null {
const next: Partial<Record<keyof OnboardingModelDraft, string>> = {};
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const value = Number(draft[key]);
if (
!draft[key].trim() ||
!Number.isInteger(value) ||
value < min ||
value > max
)
next[key] = "onboardingIntegerRange";
return value;
};
const timeout = integer("timeout_seconds", 1, 600);
const concurrency = integer("max_concurrency", 1, 32);
const context = draft.context_window.trim()
? integer("context_window", 1024, 10_000_000)
: null;
if (!draft.name.trim()) next.name = "请填写配置名称。";
if (!draft.model.trim()) next.model = "请填写实际可用的模型 ID。";
try {
const url = new URL(draft.base_url);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error();
} catch {
next.base_url = "请填写有效的模型接口地址。";
}
if (
draft.provider_kind !== "ollama" &&
!draft.api_key.trim() &&
!saved?.has_api_key
)
next.api_key = "请填写 API Key。";
if (draft.embedding_mode === "custom" && !draft.embedding_model.trim())
next.embedding_model = "请填写向量模型 ID。";
const next: Partial<Record<keyof OnboardingModelDraft, string>> =
validateGenerationModel(draft, {
apiKey: draft.api_key,
keyRequired:
draft.provider_kind !== "ollama" &&
!(saved?.has_configured_api_key ?? saved?.has_api_key),
});
if (Object.keys(next).length) {
setFields(next);
setAdvanced(true);
if (Object.keys(next).some((key) => key !== "api_key" && key !== "name"))
setAdvanced(true);
return null;
}
return {
const value: ModelProfileInput = {
name: draft.name.trim(),
model: draft.model.trim(),
provider_kind: draft.provider_kind,
base_url: draft.base_url.trim(),
api_key: draft.api_key.trim() || undefined,
timeout_seconds: timeout,
max_concurrency: concurrency,
context_window: context,
disable_thinking: draft.disable_thinking,
embedding_provider_kind:
draft.embedding_mode === "local"
? "local_onnx"
: draft.embedding_provider_kind,
embedding_model:
draft.embedding_mode === "local"
? "bge-small-zh-v1.5"
: draft.embedding_model.trim(),
// Core no longer lets the embedding endpoint follow the generation
// one, so an empty field is written as the endpoint it means.
embedding_base_url:
draft.embedding_mode === "local"
? null
: draft.embedding_base_url.trim() ||
(draft.embedding_provider_kind === "ollama"
? "http://127.0.0.1:11434"
: draft.base_url.trim()),
embedding_dimensions: draft.embedding_mode === "local" ? 512 : null,
};
if (
technicalTouched.current.has("timeout_seconds") &&
draft.timeout_seconds.trim()
)
value.timeout_seconds = Number(draft.timeout_seconds);
if (
technicalTouched.current.has("max_concurrency") &&
draft.max_concurrency.trim()
)
value.max_concurrency = Number(draft.max_concurrency);
if (technicalTouched.current.has("context_window"))
value.context_window = draft.context_window.trim()
? Number(draft.context_window)
: null;
return value;
}
async function save(test: boolean) {
@@ -252,27 +282,66 @@ export function OnboardingModelController({
flight.current = true;
setError(null);
setPhase("saving");
const owner = generationToken;
try {
const { provider_kind: providerKind, ...patch } = value;
const { provider_kind: providerKind } = value;
const patch: ModelProfilePatch = {
expected_revision: saved?.revision ?? 0,
};
if (saved) {
if (value.name !== saved.name) patch.name = value.name;
if (value.model !== saved.model) patch.model = value.model;
if (value.base_url !== saved.base_url) patch.base_url = value.base_url;
if (draft.api_key.trim()) patch.api_key = draft.api_key.trim();
if (
technicalTouched.current.has("disable_thinking") &&
value.disable_thinking !== saved.disable_thinking
)
patch.disable_thinking = value.disable_thinking;
if (
technicalTouched.current.has("timeout_seconds") &&
value.timeout_seconds !== saved.timeout_seconds
)
patch.timeout_seconds = value.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
value.max_concurrency !== saved.max_concurrency
)
patch.max_concurrency = value.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
value.context_window !== saved.context_window
)
patch.context_window = value.context_window;
}
const profile =
saved && saved.provider_kind === providerKind
? await models.update(saved.profile_id, {
...patch,
expected_revision: saved.revision,
})
? await models.update(saved.profile_id, patch)
: await models.create(value);
if (!profile) {
setError("配置结果尚未确认,请在模型设置中核对后重试。");
return;
}
onProfilePersisted?.(profile.profile_id);
setSaved(profile);
setDraft((current) => ({ ...current, api_key: "" }));
if (!test) return;
technicalTouched.current.clear();
setDraft((current) => ({
...current,
api_key: "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null ? "" : String(profile.context_window),
disable_thinking: profile.disable_thinking,
}));
if (!test || !isGenerationCurrent(owner)) return;
setPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
@@ -289,12 +358,61 @@ export function OnboardingModelController({
}
}
async function testModel() {
const pending = models.pendingProbe;
if (!pending) {
await save(true);
return;
}
if (
flight.current ||
!saved ||
pending.profileId !== saved.profile_id ||
!isGenerationCurrent(generationToken)
)
return;
const owner = generationToken;
flight.current = true;
setError(null);
setPhase("testing");
try {
const result = await models.probeProfile(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === saved.profile_id,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
message: passed
? "模型连接测试通过,可以启用。"
: "连接未通过,请核对模型、密钥和接收方。",
});
} catch {
setError("上次模型测试结果仍待核对,请稍后重试。");
} finally {
flight.current = false;
setPhase("idle");
}
}
async function enable() {
if (flight.current || probe?.state !== "passed" || !saved) return;
if (saved.is_active) {
onEnabled?.();
if (
embeddingSwitch.current?.writes_paused ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current)
) {
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
return;
}
const owner = generationToken;
flight.current = true;
setPhase("enabling");
setError(null);
@@ -306,6 +424,7 @@ export function OnboardingModelController({
saved.profile_id,
saved.revision,
);
if (!isGenerationCurrent(owner)) return;
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
@@ -313,9 +432,10 @@ export function OnboardingModelController({
return;
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onEnabled?.();
onModelActiveConfirmed?.(owner, active.profile_id);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -350,8 +470,17 @@ export function OnboardingModelController({
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
canTest={
!models.probeStorageFailed &&
(!models.pendingProbe ||
models.pendingProbe.profileId === saved?.profile_id)
}
testActionLabel={models.pendingProbe ? "核对测试结果" : undefined}
canSaveDraft={
!models.pendingCreation &&
!models.creationStorageFailed &&
(!initialProfileId || !!saved)
}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
@@ -368,7 +497,17 @@ export function OnboardingModelController({
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
error={
(error ? text(error) : null) ??
(flowError ? text(flowError) : null) ??
(embeddingSwitch.current?.writes_paused
? text("模型写入暂时关闭。请等待索引维护完成后重试。")
: null) ??
(initialProfileId && !saved && !models.refreshing
? text("已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。")
: null) ??
(models.error ? text(models.error) : null)
}
probe={probe}
profile={
saved
@@ -393,10 +532,44 @@ export function OnboardingModelController({
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onTest={testModel}
onSaveAndEnable={enable}
onRecoverCreation={
models.pendingCreation
? async () => {
const profile = await models.reconcileCreation();
if (!profile) return;
onProfilePersisted?.(profile.profile_id);
technicalTouched.current.clear();
setSaved(profile);
setDraft((current) => ({
...current,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
api_key: "",
disable_thinking: profile.disable_thinking,
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null
? ""
: String(profile.context_window),
}));
}
: undefined
}
onDiscardMissingCreation={
models.creationMissing
? async () => {
await models.discardMissingCreation();
}
: undefined
}
onBack={onBack}
onSkip={onSkip}
focusToken={generationToken}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
+163 -381
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { guiMessages, SUPPORTED_LOCALES } from "@one-memory/i18n";
import {
act,
cleanup,
@@ -6,9 +7,8 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
OnboardingModelStep,
@@ -20,417 +20,199 @@ afterEach(() => {
localStorage.clear();
});
it.each(["es", "zh-Hans"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const p = props();
p.onSkip = vi.fn();
let finish!: () => void;
p.onTest = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const labels =
locale === "es"
? [
"Omitir por ahora",
"Probar conexión",
"Confirmar y habilitar",
"Probando el modelo…",
]
: ["跳过,先进入应用", "测试模型连通性", "确认启用", "正在测试模型…"];
const draw = (probe?: OnboardingModelStepProps["probe"]) => (
<LanguageProvider>
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={probe}
advancedOpen
/>
</LanguageProvider>
);
const view = render(draw());
const skip = screen.getByRole("button", { name: labels[0] });
if (locale === "es") {
expect(
document.getElementById(skip.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("Aplicaciones");
}
fireEvent.click(skip);
expect(p.onSkip).toHaveBeenCalledOnce();
expect(p.onBack).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: labels[1] }));
expect(screen.getByRole("button", { name: labels[3] })).toHaveProperty(
"disabled",
true,
);
expect(skip).toHaveProperty("disabled", true);
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
await act(async () => finish());
view.rerender(draw({ state: "failed", message: "synthetic failure" }));
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
expect(p.onDraftChange).not.toHaveBeenCalled();
view.rerender(draw({ state: "passed", message: "synthetic success" }));
fireEvent.click(screen.getByRole("button", { name: labels[2] }));
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(
screen.getByText(
locale === "es"
? /La prueba de conexión solo envía/
: /测试连接只发送测试内容/,
),
).toBeTruthy();
},
);
function props(): OnboardingModelStepProps {
return {
draft: {
provider_id: "fixture",
name: "My profile",
provider_id: "deepseek",
name: "DeepSeek",
provider_kind: "openai_compatible",
model: "user-model",
base_url: "https://example.invalid/v1",
api_key: "secret",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "",
disable_thinking: true,
embedding_mode: "local",
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "240",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
},
providers: [
{ id: "fixture", label: "用户服务", keyRequired: true, supported: true },
{
id: "deepseek",
label: "DeepSeek",
keyRequired: true,
supported: true,
},
{
id: "ollama",
label: "Ollama(本机)",
keyRequired: false,
supported: true,
},
],
advancedOpen: false,
onAdvancedChange: vi.fn(),
onDraftChange: vi.fn(),
phase: "idle",
capability: { saveAndEnable: false },
capability: { saveAndEnable: true },
canTest: true,
canSaveDraft: true,
canSaveAndEnable: true,
sourceNames: ["Source A"],
importedConversationCount: 37,
generationRecipient: "用户服务 / 生成",
embeddingRecipient: "本机 / 向量",
canSaveAndEnable: false,
sourceNames: ["Cursor", "Codex"],
importedConversationCount: 12,
generationRecipient: "https://api.deepseek.com",
embeddingRecipient: "本机",
onTest: vi.fn().mockResolvedValue(undefined),
onSaveAndEnable: vi.fn().mockResolvedValue(undefined),
onSaveDraft: vi.fn().mockResolvedValue(undefined),
onBack: vi.fn(),
onActionError: vi.fn(),
};
}
it("renders step3 without engineering-only recipients, with unavailable enable failing closed", () => {
const p = props();
render(<OnboardingModelStep {...p} />);
expect(
screen.getByRole("heading", { name: "选好模型,让记忆开始沉淀" }),
).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("沉淀记忆");
fireEvent.click(screen.getByLabelText("模型服务商"));
expect(screen.getByRole("option", { name: "用户服务" })).toBeTruthy();
expect(screen.queryByRole("option", { name: "DeepSeek" })).toBeNull();
expect(screen.queryByText("用户服务 / 生成")).toBeNull();
expect(screen.queryByText("本机 / 向量")).toBeNull();
expect(screen.getByText("Source A")).toBeTruthy();
expect(screen.getAllByText(/当前不能在此启用模型/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.queryByRole("checkbox", { name: /同意|允许发送/ })).toBeNull();
});
it("keeps a real multi-source summary compact", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
sourceNames={["WorkBuddy", "Cursor", "Codex", "Claude Code", "Pi"]}
/>,
);
expect(
screen.getByText("WorkBuddy · Cursor · Codex 等 5 个应用"),
).toBeTruthy();
expect(screen.queryByText("Pi")).toBeNull();
});
it("guides first-run keychain authorization when a saved key is unreadable", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
profile={{
profile_id: "saved-profile",
revision: 1,
has_configured_api_key: true,
credential_state: "unavailable",
}}
/>,
);
expect(screen.getByText(/需要允许 MemHub 访问钥匙串/)).toBeTruthy();
expect(screen.getByText(/始终允许/)).toBeTruthy();
});
it("draft edits and advanced mode are controlled; return never clears or saves values", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "new-secret" },
});
expect(p.onDraftChange).toHaveBeenCalledWith({ api_key: "new-secret" });
expect((screen.getByLabelText("API Key") as HTMLInputElement).value).toBe(
"secret",
);
fireEvent.click(screen.getByRole("button", { name: "配置详情与高级设置" }));
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
fireEvent.click(screen.getByRole("button", { name: "返回服务商选择" }));
expect(p.onAdvancedChange).toHaveBeenLastCalledWith(false);
expect(
screen.getByRole("heading", { name: "配置详情,按需要调整" }),
).toBeTruthy();
expect((screen.getByLabelText("超时(秒)") as HTMLInputElement).max).toBe(
"600",
);
expect((screen.getByLabelText("最大并发") as HTMLInputElement).max).toBe(
"32",
);
expect((screen.getByLabelText("上下文窗口") as HTMLInputElement).min).toBe(
"1024",
);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.draft.api_key).toBe("secret");
});
it("keeps skip available while an uncertain write is reconciled", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
inputsLocked
capability={{ saveAndEnable: true }}
enableActionLabel="核对提交状态"
/>,
);
expect((screen.getByLabelText("配置名称") as HTMLInputElement).disabled).toBe(
true,
);
expect(
(
screen.getByRole("button", {
name: "跳过,先进入应用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledOnce();
expect(
(screen.getByRole("button", { name: "核对提交状态" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
it.each(["testing", "saving", "enabling"] as const)(
"%s locks controlled actions and fields",
(phase) => {
const p = props();
render(<OnboardingModelStep {...p} phase={phase} />);
describe("OnboardingModelStep", () => {
it.each(SUPPORTED_LOCALES)("renders named controls in %s", (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const title = guiMessages[locale]["配置记忆整理模型"];
const back = guiMessages[locale]["返回选择模式"];
if (!title || !back) throw new Error(`missing ${locale} model copy`);
render(
<LanguageProvider>
<OnboardingModelStep {...props()} />
</LanguageProvider>,
);
expect(
(screen.getByLabelText("API Key") as HTMLInputElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).not.toHaveBeenCalled();
if (phase === "saving")
expect(screen.queryByRole("status", { name: "模型提交状态" })).toBeNull();
else
expect(screen.getByRole("status", { name: "模型提交状态" })).toBeTruthy();
},
);
it("one in-flight callback across double click, errors stay owner-controlled without exposing thrown secrets", async () => {
const p = props();
let reject!: (e: Error) => void;
p.onSaveAndEnable = vi.fn(
() =>
new Promise<void>((_, r) => {
reject = r;
screen.getByRole("heading", {
name: title,
}),
);
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "passed", message: "测试通过" }}
/>,
);
const button = screen.getByRole("button", { name: "确认启用" });
fireEvent.click(button);
fireEvent.click(button);
expect(p.onSaveAndEnable).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
await act(async () => reject(new Error("SECRET_TOKEN")));
expect(p.onActionError).toHaveBeenCalledWith("save-and-enable");
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false),
);
});
it("renders field errors with links and preserves custom embedding in advanced mode", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
error="配置已变化,请核对。"
fieldErrors={{ api_key: "密钥未通过验证" }}
/>,
);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("密钥未通过验证");
view.rerender(
<OnboardingModelStep
{...p}
advancedOpen
draft={{
...p.draft,
embedding_mode: "custom",
embedding_model: "saved-vector",
embedding_base_url: "http://127.0.0.1:11434",
}}
/>,
);
expect(
(screen.getByLabelText("向量模型名称") as HTMLInputElement).value,
).toBe("saved-vector");
expect(screen.queryByRole("button", { name: "收起高级配置" })).toBeNull();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
).toBeTruthy();
expect(
screen.getByRole("button", {
name: back,
}),
).toBeTruthy();
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
});
it("discloses that testing does not send historical conversations", () => {
render(<OnboardingModelStep {...props()} />);
it("keeps step 3 on memory mode in a generation-only column", () => {
render(<OnboardingModelStep {...props()} />);
expect(
screen.getByRole("heading", { name: "配置记忆整理模型" }),
).toBeTruthy();
expect(screen.getByText("智能记忆 Pro · 配置记忆整理")).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("记忆模式");
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
expect(screen.queryByText("本次沉淀")).toBeNull();
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
expect(
document.querySelector(
".onboarding-model-layout--single-column-600" +
'[data-responsive="single-column-600"]',
),
).toBeTruthy();
});
expect(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
expect(screen.getByText(/启用后会使用此模型整理/)).toBeTruthy();
});
it("focuses the page title when entering the model substep", async () => {
render(<OnboardingModelStep {...props()} focusToken={7} />);
const title = screen.getByRole("heading", {
name: "配置记忆整理模型",
});
await waitFor(() => expect(document.activeElement).toBe(title));
});
it("does not expose an extra backlog selector in the first-run model page", () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.getByText(/先测试模型连通性/)).toBeTruthy();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
});
it("separates connectivity testing from final enable", async () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
expect(screen.getByRole("button", { name: "测试模型连通性" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
expect(screen.getByRole("button", { name: "确认启用" })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: "测试模型连通性" }));
await act(async () => {});
expect(p.onTest).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("uses back, test, and enable actions without a skip path", async () => {
const p = props();
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "返回选择模式" }));
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await act(async () => {});
expect(p.onBack).toHaveBeenCalledOnce();
expect(p.onTest).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /跳过/ })).toBeNull();
expect(screen.getByRole("button", { name: "启用并进入" })).toHaveProperty(
"disabled",
true,
);
});
it("requires owner validation as well as policy capability and truthful recipients", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
canSaveAndEnable={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
view.rerender(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
generationRecipient=""
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("renders shared technical generation fields without embedding", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const mountedModel =
document.querySelector<HTMLInputElement>('[name="model"]');
expect(mountedModel).toBeTruthy();
expect(mountedModel?.closest("[hidden]")).toBeTruthy();
const disclosure = screen.getByRole("button", {
name: "配置详情与高级设置",
});
expect(disclosure.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(disclosure);
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("协议")).toBeTruthy();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
"deepseek-flash",
);
expect(screen.getByLabelText("Base URL")).toBeTruthy();
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
expect(screen.getByLabelText("最大并发")).toBeTruthy();
expect(screen.getByLabelText("上下文窗口")).toBeTruthy();
expect(screen.getByLabelText("关闭思考模式")).toBeTruthy();
expect(screen.getByText("留空使用 Core 默认值。")).toBeTruthy();
expect(screen.queryByText(/向量模型/)).toBeNull();
});
it("opens the advanced design state with a route back to provider selection", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const toggle = screen.getByRole("button", { name: "配置详情与高级设置" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(
document.getElementById(toggle.getAttribute("aria-controls") ?? ""),
).toBeTruthy();
toggle.focus();
fireEvent.click(toggle);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByRole("button", { name: "返回服务商选择" })).toBeTruthy();
expect(screen.getByLabelText("API Key")).toBeTruthy();
expect(
screen.getByText("预设已填好,只有自定义服务或特殊需求才需要修改。"),
).toBeTruthy();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
it("expands technical details and focuses the first invalid required field", async () => {
const p = props();
p.draft = { ...p.draft, model: "" };
p.fieldErrors = { model: "请填写实际可用的模型 ID。" };
const view = render(<OnboardingModelStep {...p} />);
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(true));
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("生成模型名")),
);
});
it("uses the failure-state title and omits consent and scope-management UI", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "failed", message: "synthetic failure" }}
/>,
);
expect(
screen.getByRole("heading", { name: "连接未通过,配置已保留" }),
).toBeTruthy();
expect(screen.getByText(/连接未通过,请检查 API Key/)).toBeTruthy();
expect(screen.getByText("synthetic failure")).toBeTruthy();
expect(screen.queryByText("调整处理范围")).toBeNull();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
});
it("links field errors and keeps secrets out of callback errors", async () => {
const p = props();
p.fieldErrors = { api_key: "请填写 API Key。" };
p.canSaveAndEnable = true;
p.probe = { state: "passed", message: "模型连接测试通过,可以启用。" };
p.onSaveAndEnable = vi.fn().mockRejectedValue(new Error("SECRET_TOKEN"));
render(<OnboardingModelStep {...p} />);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("请填写 API Key");
const enable = screen.getByRole("button", { name: "启用并进入" });
fireEvent.click(enable);
fireEvent.click(enable);
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
});
it("keeps the first-run model form free of a second consent strip", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
draft={{ ...p.draft, embedding_mode: "custom" }}
/>,
);
const fields = screen.getByRole("region", { name: "模型参数" });
expect(within(fields).getByLabelText("向量 Base URL")).toBeTruthy();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
expect(screen.queryByText(/通过后自动启用/)).toBeNull();
expect(screen.queryByText(p.generationRecipient)).toBeNull();
expect(screen.queryByText(p.embeddingRecipient)).toBeNull();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
it("keeps recovery controls while embedding UI remains hidden", async () => {
const p = props();
p.onRecoverCreation = vi.fn().mockResolvedValue(undefined);
p.onDiscardMissingCreation = vi.fn().mockResolvedValue(undefined);
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "核对创建结果" }));
fireEvent.click(
screen.getByRole("button", { name: "放弃旧记录,重新配置" }),
);
await act(async () => {});
expect(p.onRecoverCreation).toHaveBeenCalledOnce();
expect(p.onDiscardMissingCreation).toHaveBeenCalledOnce();
});
});
+6 -615
View File
@@ -1,11 +1,4 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
/** Strings preserve partial numeric edits. The owner validates and maps to API payloads. */
export interface OnboardingModelDraft {
@@ -16,6 +9,7 @@ export interface OnboardingModelDraft {
base_url: string;
api_key: string;
disable_thinking: boolean;
/** Retained in controller state only for persisted-profile recovery; cold start never renders or writes it. */
embedding_mode: "local" | "custom";
embedding_provider_kind: "openai_compatible" | "ollama";
embedding_model: string;
@@ -24,13 +18,16 @@ export interface OnboardingModelDraft {
max_concurrency: string;
context_window: string;
}
export interface OnboardingModelProvider {
id: string;
label: string;
keyRequired: boolean;
supported: boolean;
}
type Action = "test" | "save-draft" | "save-and-enable";
export interface OnboardingModelStepProps {
draft: OnboardingModelDraft;
providers: readonly OnboardingModelProvider[];
@@ -41,7 +38,6 @@ export interface OnboardingModelStepProps {
capability: { saveAndEnable: boolean; reason?: string };
canTest: boolean;
canSaveDraft: boolean;
/** Owner validation/current revision gate; absence fails closed. */
canSaveAndEnable?: boolean;
enableActionLabel?: string;
testActionLabel?: string;
@@ -66,613 +62,8 @@ export interface OnboardingModelStepProps {
onRecoverCreation?(): Promise<void>;
onDiscardMissingCreation?(): Promise<void>;
onBack(): void;
onSkip?(): void;
/** Owner must preserve receipts/unknown results and supply a safe display error. */
focusToken?: number;
onActionError?(action: Action): void;
}
export function OnboardingModelStep(props: OnboardingModelStepProps) {
const { text, t, locale } = useOnboardingTranslation();
const { draft, advancedOpen, onDraftChange, fieldErrors = {}, phase } = props;
const id = useId();
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-Hans" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const locked = phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
// biome-ignore lint/correctness/useExhaustiveDependencies: Restore only after the controlled mode change has rendered its replacement toggle.
useEffect(() => {
if (!restoreToggleFocus.current) return;
restoreToggleFocus.current = false;
if (fields.current) fields.current.scrollTop = 0;
advancedToggle.current?.focus({ preventScroll: true });
}, [advancedOpen]);
function changeAdvanced(open: boolean) {
restoreToggleFocus.current = true;
props.onAdvancedChange(open);
}
const provider = props.providers.find(
(item) => item.id === draft.provider_id,
);
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
!!props.embeddingRecipient.trim() &&
provider?.supported;
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "save-and-enable" && !canEnable) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-draft" && !props.canSaveDraft) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test"
? props.onTest()
: action === "save-draft"
? props.onSaveDraft()
: props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
function field(
key: keyof OnboardingModelDraft,
label: string,
options: {
type?: string;
min?: number;
max?: number;
maxLength?: number;
required?: boolean;
placeholder?: string;
} = {},
) {
const error = fieldErrors[key];
return (
<label className="onboarding-model-field" htmlFor={`${id}-${key}`}>
<span>{text(label)}</span>
<input
id={`${id}-${key}`}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
autoComplete="off"
spellCheck={false}
value={String(draft[key])}
disabled={fieldsLocked}
aria-invalid={!!error}
aria-describedby={error ? `${id}-${key}-error` : undefined}
min={options.min}
max={options.max}
maxLength={options.maxLength}
required={options.required}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
onChange={(event) => {
if (!flight.current && !fieldsLocked)
onDraftChange({ [key]: event.target.value });
}}
/>
{error && (
<small id={`${id}-${key}-error`} className="onboarding-model-error">
{text(error)}
</small>
)}
</label>
);
}
const displayedPhase =
phase !== "idle"
? phase
: pending === "test"
? "testing"
: pending === "save-draft"
? "saving"
: pending
? "enabling"
: "idle";
const connectionFailed = props.probe?.state === "failed";
const pageTitle = advancedOpen
? "配置详情,按需要调整"
: connectionFailed
? "连接未通过,配置已保留"
: "选好模型,让记忆开始沉淀";
const connectionReceipt = props.connectionSummary?.includes("已启用")
? "连接设置已保存\n成功项保留,不重复执行。"
: props.connectionSummary === "持续同步 · 一键接入"
? "已选择:持续同步 · 一键接入"
: props.connectionSummary;
return (
<div
className="onboarding-model-r6"
data-integration={
props.capability.saveAndEnable
? "capability-provided"
: "not-integrated"
}
>
<R6ColdStartFrame
step={3}
eyebrow={text("沉淀记忆 · 使用你选择的模型")}
title={text(pageTitle)}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
{skipDestination && (
<span id={`${id}-skip-destination`} hidden>
{skipDestination}
</span>
)}
<R6Button
aria-describedby={
skipDestination ? `${id}-skip-destination` : undefined
}
disabled={locked}
onClick={() => {
if (!flight.current && !locked)
(props.onSkip ?? props.onBack)();
}}
>
{text("跳过,先进入应用")}
</R6Button>
<R6Button
disabled={locked || !props.canTest}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用"),
)}
</R6Button>
</div>
</>
}
>
<div className="onboarding-model-layout">
<section
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={advancedOpen}
>
<section
ref={fields}
id={`${id}-fields`}
className="onboarding-model-fields"
aria-label={text("模型参数")}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The parameter pane needs keyboard scrolling.
tabIndex={0}
>
{!advancedOpen ? (
<>
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>
{text("模型服务商")}
</span>
<OptionMenu
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={draft.provider_id}
disabled={fieldsLocked}
options={[
...(!provider
? [{ value: "", label: text("选择服务商") }]
: []),
...props.providers.map((item) => ({
value: item.id,
label: text(item.label),
disabled: !item.supported,
})),
]}
onChange={(value) => {
if (!flight.current)
onDraftChange({ provider_id: value });
}}
/>
</label>
<div className="onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
</div>
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={false}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(true)}
>
{text("配置详情与高级设置")}{" "}
<ChevronDown size={16} aria-hidden="true" />
</button>
<p className="onboarding-model-profile">
{provider ? text(provider.label) : text("服务商待选择")} ·{" "}
{draft.model || text("模型待填写")}
<br />
{text(safeEndpoint(draft.base_url))} ·{" "}
{draft.embedding_mode === "local"
? text("本地向量模型")
: draft.embedding_model || text("向量模型待填写")}{" "}
·{" "}
{text(draft.disable_thinking ? "思考已关闭" : "思考未关闭")}
</p>
</>
) : (
<>
<div className="onboarding-model-advanced-head">
<p>
{text("预设已填好,只有自定义服务或特殊需求才需要修改。")}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={true}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(false)}
>
{text("返回服务商选择")}{" "}
<ChevronUp size={16} aria-hidden="true" />
</button>
</div>
<div className="onboarding-model-advanced-grid">
{field("name", "配置名称", { maxLength: 128 })}
{field("model", "模型名称", {
required: true,
maxLength: 256,
})}
<div className="onboarding-model-wide">
{field("base_url", "Base URL", {
type: "url",
required: true,
})}
</div>
<div className="onboarding-model-wide onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
</div>
<label className="onboarding-model-field">
<span>{text("协议")}</span>
<select
aria-label={text("协议")}
value={draft.provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
provider_kind: e.target.value as ProviderKind,
});
}}
>
<option value="openai_compatible">
OpenAI-compatible
</option>
<option value="ollama">Ollama</option>
<option value="anthropic" disabled>
{text("Anthropic(暂不支持)")}
</option>
<option value="hy_default" disabled>
{text("HY 托管(当前不可用)")}
</option>
</select>
</label>
<label className="onboarding-model-field">
<span>{text("向量模型")}</span>
<select
aria-label={text("向量模型")}
value={draft.embedding_mode}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_mode: e.target.value as
| "local"
| "custom",
});
}}
>
<option value="local">
{text("本地 ONNX · 内置")}
</option>
<option value="custom">{text("自定义向量")}</option>
</select>
</label>
<div className="onboarding-model-numbers">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10000000,
placeholder: "自动 / 可选",
})}
</div>
<label className="onboarding-model-thinking">
<input
type="checkbox"
checked={draft.disable_thinking}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
disable_thinking: e.target.checked,
});
}}
/>
{text("关闭思考模式")}
</label>
<p className="onboarding-model-vector-note">
{text(
"自定义向量配置可展开;远程向量服务须遵守凭据边界。",
)}
<br />
{text("切换服务商或模型后,需要重新测试并启用。")}
</p>
{draft.embedding_mode === "custom" && (
<>
{field("embedding_model", "向量模型名称", {
required: true,
maxLength: 256,
})}
<label className="onboarding-model-field">
<span>{text("向量协议")}</span>
<select
aria-label={text("向量协议")}
value={draft.embedding_provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_provider_kind: e.target.value as
| "ollama"
| "openai_compatible",
});
}}
>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 Key)")}
</option>
<option value="ollama">
{text("本机 Ollama")}
</option>
</select>
</label>
<div className="onboarding-model-wide">
{field("embedding_base_url", "向量 Base URL", {
type: "url",
required: true,
})}
<small>
{text(
"远程向量地址须与生成服务同源;本机地址仍需校验。",
)}
</small>
</div>
</>
)}
</div>
</>
)}
</section>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation && (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
)}
{props.onDiscardMissingCreation && (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
)}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe && (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
)}
{(props.error || callbackFailed) && (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
)}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("本次沉淀")}
data-full-height={advancedOpen || connectionFailed}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the full permission disclosure.
tabIndex={0}
>
<div className="onboarding-model-summary-heading">
<p>{text("本次沉淀")}</p>
<h2>{text("让记忆从这里开始")}</h2>
</div>
<div className="onboarding-model-connections">
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<small>
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
</small>
</div>
<div className="onboarding-model-summary-status">
{connectionReceipt && (
<small className="onboarding-model-connection-receipt">
{text(connectionReceipt)}
</small>
)}
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
<br />
{text("相关对话会发送给所选服务商。")}
</small>
<small>
{text(
"测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。",
)}
</small>
</div>
{!props.capability.saveAndEnable && (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
)}
</aside>
</div>
</R6ColdStartFrame>
</div>
);
}
function safeEndpoint(value: string): string {
try {
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
return "接口地址待核对";
return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
} catch {
return "接口地址待核对";
}
}
export { OnboardingModelStepNext as OnboardingModelStep } from "./OnboardingModelStepNext";
@@ -0,0 +1,355 @@
import { useEffect, useId, useRef, useState } from "react";
import {
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
} from "./GenerationModelPrimitives";
import type {
OnboardingModelDraft,
OnboardingModelStepProps,
} from "./OnboardingModelStep";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
type Action = "test" | "save-and-enable";
export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
const { text, t } = useOnboardingTranslation();
const id = useId();
const modelRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLElement>(null);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
const provider = props.providers.find(
(candidate) => candidate.id === props.draft.provider_id,
);
const locked = props.phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
provider?.supported;
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
const firstError = (
[
"api_key",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
] as const
).find((key) => props.fieldErrors?.[key]);
const technicalError = firstError && firstError !== "api_key";
if (technicalError && !props.advancedOpen) {
props.onAdvancedChange(true);
return;
}
if (firstError && (!technicalError || props.advancedOpen)) {
formRef.current
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
?.focus({ preventScroll: true });
return;
}
if (props.advancedOpen && !props.draft.model.trim())
modelRef.current?.focus({ preventScroll: true });
}, [
props.advancedOpen,
props.draft.model,
props.fieldErrors,
props.onAdvancedChange,
]);
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-and-enable" && !canEnable) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test" ? props.onTest() : props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
const displayedPhase =
props.phase !== "idle"
? props.phase
: pending === "test"
? "testing"
: pending
? "enabling"
: "idle";
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
const runtimeErrors = Object.fromEntries(
Object.entries(props.fieldErrors ?? {}).filter(([key]) =>
[
"provider_kind",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(key),
),
) as Partial<Record<keyof OnboardingModelDraft, string>>;
return (
<div className="onboarding-model-r6">
<R6ColdStartFrame
step={3}
eyebrow={text("智能记忆 Pro · 配置记忆整理")}
title={text("配置记忆整理模型")}
focusToken={props.focusToken}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
<R6Button
disabled={locked}
onClick={() => {
if (!flight.current && !locked) props.onBack();
}}
>
{text("返回选择模式")}
</R6Button>
<R6Button
disabled={locked || !props.canTest || !provider?.supported}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试连接"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "启用并进入"),
)}
</R6Button>
</div>
</>
}
>
<div
className="onboarding-model-layout onboarding-model-layout--single-column-600"
data-responsive="single-column-600"
>
<section
ref={formRef}
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={props.advancedOpen}
>
<div className="onboarding-model-group">
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>{text("模型服务商")}</span>
<ProviderPresetField
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={props.draft.provider_id}
disabled={fieldsLocked}
text={text}
onChange={(provider_id) =>
props.onDraftChange({ provider_id })
}
/>
</label>
<label
className="onboarding-model-field onboarding-model-key"
htmlFor={`${id}-api-key`}
>
<span>
API Key{provider?.keyRequired ? "" : text("(可选)")}
</span>
<input
id={`${id}-api-key`}
name="api_key"
aria-label={`API Key${provider?.keyRequired ? "" : text("(可选)")}`}
type="password"
autoComplete="off"
value={props.draft.api_key}
disabled={fieldsLocked}
required={
provider?.keyRequired &&
!props.profile?.has_configured_api_key
}
aria-invalid={!!props.fieldErrors?.api_key}
aria-describedby={
props.fieldErrors?.api_key
? `${id}-api-key-error`
: `${id}-api-key-help`
}
placeholder={text(
props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
)}
onChange={(event) =>
props.onDraftChange({ api_key: event.target.value })
}
/>
<small id={`${id}-api-key-help`}>
{text(
props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</small>
{props.fieldErrors?.api_key ? (
<small
id={`${id}-api-key-error`}
className="generation-model-error"
>
{text(props.fieldErrors.api_key)}
</small>
) : null}
</label>
<div className="onboarding-model-default-summary">
<span>{text("默认模型")}</span>
<strong>
{generationSummary(
props.draft.provider_id,
props.draft.provider_kind,
props.draft.model,
text,
)}
</strong>
</div>
<div className="onboarding-model-disclosure">
<p>
{text("相关对话会发送给所选服务商。")}
{sourceSummary ? ` ${sourceSummary}` : ""}
</p>
<p>
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</p>
</div>
<GenerationTechnicalDisclosure
open={props.advancedOpen}
disabled={fieldsLocked}
controls={`${id}-technical`}
text={text}
onChange={props.onAdvancedChange}
>
<GenerationTechnicalFields
idPrefix={`${id}-generation`}
values={props.draft}
errors={runtimeErrors}
disabled={fieldsLocked}
text={text}
modelRef={modelRef}
onChange={(patch) =>
props.onDraftChange(patch as Partial<OnboardingModelDraft>)
}
/>
</GenerationTechnicalDisclosure>
</div>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation ? (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
) : null}
{props.onDiscardMissingCreation ? (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
) : null}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe ? (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
) : null}
{props.error || callbackFailed ? (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
) : null}
{!props.capability.saveAndEnable ? (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
) : null}
</div>
</section>
</div>
</R6ColdStartFrame>
</div>
);
}
+14 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
import { LanguageSelector } from "./LanguageSelector";
import { R6LocalStorage, R6Stepper } from "./R6Controls";
import "./onboarding-r6.css";
@@ -9,13 +9,20 @@ export function R6ColdStartFrame({
eyebrow,
children,
footer,
focusToken,
}: {
step: 1 | 2 | 3;
title: string;
eyebrow: string;
children: ReactNode;
footer: ReactNode;
focusToken?: number;
}) {
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focusToken === undefined) return;
titleRef.current?.focus({ preventScroll: true });
}, [focusToken]);
return (
<main className="onboarding-r6" aria-label={title}>
{/* The source (143:3007 / 143:3008) puts a mark + "MemHub" on the
@@ -33,7 +40,12 @@ export function R6ColdStartFrame({
<header className="onboarding-r6-head">
<div>
<p>{eyebrow}</p>
<h1>{title}</h1>
<h1
ref={titleRef}
tabIndex={focusToken === undefined ? undefined : -1}
>
{title}
</h1>
</div>
<R6Stepper active={step} />
</header>
+1 -1
View File
@@ -40,7 +40,7 @@ export function R6Stepper({ active }: { active: 1 | 2 | 3 }) {
return (
<nav className="r6-stepper" aria-label={text("首次设置进度")}>
<ol>
{["导入历史", "连接应用", "沉淀记忆"].map((label, index) => (
{["导入历史", "连接应用", "记忆模式"].map((label, index) => (
<li
key={label}
aria-current={active === index + 1 ? "step" : undefined}
+117 -5
View File
@@ -1,10 +1,22 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from "@testing-library/react";
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import {
getMemoryMode,
getModelProfiles,
getSedimentation,
planMemoryMode,
setSedimentation,
} from "./api";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
@@ -19,7 +31,7 @@ it("changes settings copy live without rereading profiles or changing permission
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Modelo de memorias" }),
await screen.findByRole("heading", { name: "Modo de memoria" }),
).toBeTruthy();
expect(
screen.queryByRole("switch", {
@@ -27,9 +39,13 @@ it("changes settings copy live without rereading profiles or changing permission
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.click(
screen.getByRole("button", { name: "Idioma de la interfaz" }),
);
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByRole("heading", { name: "模型配置" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
vi.mock("./api", () => ({
@@ -77,6 +93,8 @@ vi.mock("./api", () => ({
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
vi.mock("./ModelSettings", async () => {
@@ -138,6 +156,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
} as NonNullable<typeof window.oneMemory>;
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("combobox", { name: "语言" }));
expect(screen.getAllByRole("option")).toHaveLength(12);
@@ -156,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({
@@ -164,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");
@@ -182,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);
@@ -198,6 +306,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
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: "模型配置" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
@@ -219,13 +328,14 @@ 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: "模型配置" }));
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: "配置模型" }),
await within(generationLane).findByRole("button", { name: "配置模型" }),
);
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
@@ -242,6 +352,7 @@ it("reaches the same recovery entry when no model has been configured yet", asyn
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: "记忆整理" })
@@ -267,6 +378,7 @@ it("shows separate compact lanes and opens the requested presentation", async ()
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: "用模型沉淀记忆",
+93 -34
View File
@@ -1,5 +1,5 @@
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
import { Download, RefreshCw } from "lucide-react";
import { Download } from "lucide-react";
import {
useCallback,
useEffect,
@@ -20,6 +20,7 @@ import {
} from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
import {
refreshLoginPreference,
setLoginPreference,
@@ -32,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 "本机 / 受管端点";
@@ -66,10 +62,18 @@ 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] =
@@ -132,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 {
@@ -149,7 +152,7 @@ export function R6Settings({
useEffect(() => {
if (!active) return;
if (group !== "model") return;
if (group !== "models") return;
void readSedimentation();
}, [active, group, readSedimentation]);
@@ -281,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) => (
@@ -292,39 +294,95 @@ export function R6Settings({
aria-controls={`${id}-panel-${key}`}
aria-current={group === key ? "page" : undefined}
onKeyDown={moveGroupFocus}
onClick={() => setGroup(key)}
onClick={() => {
setModeHandoffMessage("");
setGroup(key);
}}
>
{text(labels[key])}
{groupLabel(key)}
</button>
))}
</nav>
<div className="r6-settings-content">
<section
id={`${id}-panel-model`}
aria-labelledby={`${id}-nav-model`}
hidden={group !== "model"}
className="r6-settings-model"
id={`${id}-panel-language`}
aria-labelledby={`${id}-nav-language`}
hidden={group !== "language"}
className="r6-settings-language"
>
<LanguageSelector />
</section>
<section
id={`${id}-panel-mode`}
aria-labelledby={`${id}-nav-mode`}
hidden={group !== "mode"}
className="r6-settings-mode"
>
<MemoryModeSettings
hasActiveModel={currentModel !== null}
modelStateKnown={profiles !== null && !modelError}
onConfigureModel={() => openConfiguration("generation")}
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"
>
<div className="r6-model-heading">
<div>
<h2 className="r6-model-title">{text("记忆模型")}</h2>
<h2
id={`${id}-model-title`}
className="r6-model-title"
tabIndex={-1}
>
{text("模型配置")}
</h2>
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
</div>
{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">
@@ -336,8 +394,8 @@ export function R6Settings({
profiles === null
? "正在读取模型…"
: modelError
? "状态暂不可读"
: "尚未配置",
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
@@ -389,13 +447,14 @@ export function R6Settings({
</div>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
<R6StateRecovery
message={text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
+26
View File
@@ -0,0 +1,26 @@
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
export function R6StateRecovery({
message,
onRetry,
pending = false,
role = "alert",
}: {
message: string;
onRetry(): void;
pending?: boolean;
role?: "alert" | "status";
}) {
const { t } = useOnboardingTranslation();
return (
<div className="r6-state-recovery">
<p className="r6-settings-error" role={role}>
{message}
</p>
<R6Button pending={pending} onClick={onRetry}>
{t("startupRetry")}
</R6Button>
</div>
);
}
@@ -0,0 +1,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();
});
+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,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);
});
+192 -2
View File
@@ -16,6 +16,7 @@ import {
type GraphWidthDecisionReceipt,
type GraphWidthStatus,
type HealthResponse,
type HomeProjectionV1,
type HostAskMemoryEvent,
type HostHomeSummary,
type ImportIssue,
@@ -26,6 +27,7 @@ import {
type MemoryAddReceipt,
type MemoryGraphResponse,
type MemoryModeChange,
type MemoryModePlan,
type MemoryModeState,
type MemoryPipelineMode,
type MemoryProcessingSummary,
@@ -35,6 +37,7 @@ import {
type ModelProfile,
type ModelProfileInput,
type ModelProfilePatch,
type OnboardingProgressUpdate,
type OnboardingState,
type SdkMemoryKind,
type SdkMemoryPage,
@@ -67,6 +70,7 @@ import {
type EmbeddingSwitchStartInput,
embeddingSwitchCancelPath,
hasSourceSyncTarget,
isBareNotFound,
memoryAddStatusPath,
normalizeManualExtractEvidence,
parseEmbeddingSpaceDeleteReceipt,
@@ -80,6 +84,7 @@ import {
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryAddReceipt,
parseMemoryModePlan,
parseModelProfileReceipt,
retiredEmbeddingSpacePath,
SourceSyncInputError,
@@ -474,6 +479,26 @@ export async function getOnboarding(): Promise<OnboardingState> {
return response.data;
}
export async function patchOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.patch<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function putOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.put<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function completeOnboarding(
traceId?: string,
): Promise<OnboardingState> {
@@ -629,7 +654,7 @@ export function embeddingSwitchUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
embeddingSwitchRefusal(reason) === null
isBareNotFound(reason.response?.data)
);
}
@@ -814,6 +839,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))
@@ -942,17 +983,19 @@ 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;
@@ -1073,6 +1116,27 @@ export async function getMemoryMode(): Promise<MemoryModeState> {
return response.data;
}
export async function planMemoryMode(
mode: MemoryPipelineMode,
): Promise<MemoryModePlan> {
const response = await client.post<unknown>(
"/api/v1/settings/memory-mode/plan",
{ mode },
);
const plan = parseMemoryModePlan(response.data);
if (plan === null || plan.target !== mode)
throw new Error("memory_mode_plan_invalid");
return plan;
}
export function memoryModePlanUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
isBareNotFound(reason.response?.data)
);
}
export async function setMemoryMode(
mode: MemoryPipelineMode,
): Promise<MemoryModeChange> {
@@ -1522,6 +1586,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();
}
+214
View File
@@ -0,0 +1,214 @@
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.rawSavingCompact"],
["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.recentEmptyCompact",
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.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
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);
});
});
+291
View File
@@ -0,0 +1,291 @@
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.recentEmptyCompact" | 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.rawSavingCompact"
: 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: "apps.recentEmptyCompact" 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")
+75
View File
@@ -119,6 +119,81 @@
margin: 0;
}
.model-settings .generation-technical-disclosure {
grid-column: 1 / -1;
border-block: 1px solid var(--bd, #d3d9e0);
padding-block: 4px;
}
.model-settings .generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: #4e6478;
}
.model-settings .generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-block: 12px;
}
.model-settings .generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
}
.model-settings .generation-model-field:has([name="base-url"]),
.model-settings .generation-runtime-fields,
.model-settings .generation-thinking {
grid-column: 1 / -1;
}
.model-settings .generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.model-settings .generation-technical-fields[hidden] {
display: none;
}
.model-settings .generation-runtime-note {
grid-column: 1 / -1;
color: #63707b;
font-size: 12px;
line-height: 1.5;
}
.model-settings .generation-thinking {
display: flex;
align-items: center;
gap: 8px;
}
.model-settings .generation-model-error {
color: #96453e;
}
@media (max-width: 600px) {
.model-settings .generation-technical-fields,
.model-settings .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.model-settings .generation-technical-toggle,
.model-settings .generation-model-field :is(input, select) {
transition: none;
}
}
.model-settings
:is(
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select
):focus-visible {
outline: 2px solid #4e6478;
outline-offset: 3px;
}
.model-settings .model-saved-card .source-actions,
.model-settings .preset-profile .source-actions {
flex-wrap: wrap;
+189
View File
@@ -587,3 +587,192 @@
) {
transition: none;
}
/* Generation-only cold start. Embedding remains a separate settings lane. */
.onboarding-model-r6 .onboarding-r6-head > div {
width: min(760px, 100%);
}
.onboarding-model-layout {
display: block;
width: min(760px, 100%);
height: auto;
margin-inline: auto;
}
.onboarding-model-form {
overflow: visible;
}
.onboarding-model-group {
display: grid;
gap: 20px;
padding: 24px;
border: 1px solid #d8e2e8;
border-radius: 14px;
background: #fff;
}
.onboarding-model-provider,
.onboarding-model-key {
margin-top: 0;
}
.onboarding-model-field {
gap: 7px;
}
.onboarding-model-field :is(input, select),
.onboarding-model-field .om-option-menu-button,
.generation-model-field :is(input, select) {
width: 100%;
min-width: 0;
height: 44px;
padding: 9px 12px;
border: 1px solid #c8d3db;
border-radius: 8px;
background: #fff;
color: #262a2e;
font:
400 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
}
.onboarding-model-field small,
.generation-model-field small {
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.onboarding-model-default-summary {
display: grid;
gap: 6px;
padding: 14px 0;
border-block: 1px solid #e4eaee;
}
.onboarding-model-default-summary span,
.onboarding-model-disclosure {
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.onboarding-model-default-summary strong {
color: #33424d;
font-size: 14px;
font-weight: 600;
}
.onboarding-model-disclosure {
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
border-top: 1px solid #e4eaee;
padding-top: 4px;
}
.generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border: 0;
background: transparent;
color: #4e6478;
font:
500 13px / 21px "Noto Sans SC",
"PingFang SC",
sans-serif;
}
.generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-top: 12px;
}
.generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base-url"]),
.generation-runtime-fields,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.generation-technical-fields[hidden] {
display: none;
}
.generation-runtime-note {
grid-column: 1 / -1;
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.generation-thinking {
display: flex;
min-height: 32px;
align-items: center;
gap: 9px;
color: #416e5c;
font-size: 13px;
}
.generation-model-error {
color: #a24646 !important;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
}
.onboarding-model-feedback {
margin-top: 8px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: auto;
}
.onboarding-model-form {
display: block;
overflow: visible;
}
}
@media (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
padding: 16px;
border-radius: 10px;
}
.onboarding-model-layout--single-column-600 .generation-technical-fields,
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-footer-actions .r6-button,
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.generation-technical-toggle,
.generation-model-field :is(input, select) {
transition: none;
}
}
[data-native-reduce-motion="true"]
:is(
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select
) {
transition: none;
}
.onboarding-model-r6
:is(
.om-option-menu-button,
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select,
.onboarding-model-field input
):focus-visible {
outline: 2px solid #557c93;
outline-offset: 3px;
}
+19 -78
View File
@@ -582,89 +582,36 @@
.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;
margin: 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;
.onboarding-r6-mode .memory-mode-choice-list {
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 .memory-mode-choice {
min-height: 82px;
padding: 15px 18px;
}
.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 .memory-mode-choice-copy {
gap: 4px;
}
.onboarding-r6-mode-card:has(input:focus-visible) {
outline: 2px solid #557c93;
outline-offset: 3px;
.onboarding-r6-mode .memory-mode-choice-copy strong {
font-size: 16px;
line-height: 25px;
}
.onboarding-r6-mode-card input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 4px 0 0;
accent-color: #557c93;
.onboarding-r6-mode .memory-mode-choice-copy small {
font-size: 13px;
line-height: 22px;
}
.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-ultra-note {
margin: -6px 0 0;
color: #637381;
font-size: 12px;
line-height: 20px;
text-align: center;
}
.onboarding-r6-mode-error {
min-height: 20px;
@@ -811,12 +758,6 @@
.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;
+113 -1
View File
@@ -11,6 +11,109 @@
--r6-fast: 140ms;
}
.memory-mode-choice-list {
min-width: 0;
overflow: hidden;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.memory-mode-choice {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 74px;
padding: 12px 16px;
color: var(--r6-text);
cursor: pointer;
transition:
background-color var(--r6-fast),
border-color var(--r6-fast);
}
.memory-mode-choice + .memory-mode-choice {
border-top: 1px solid #d8e2ea;
}
.memory-mode-choice:hover:not([data-disabled="true"]) {
background: #f6f9fa;
}
.memory-mode-choice[data-selected="true"] {
background: #edf3f6;
}
.memory-mode-choice[data-disabled="true"] {
color: #71808b;
cursor: default;
}
.memory-mode-choice:focus-within {
outline: 2px solid var(--r6-focus);
outline-offset: -2px;
}
.memory-mode-choice input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--r6-action);
}
.memory-mode-choice input:focus-visible {
outline: 0;
}
.memory-mode-choice-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.memory-mode-choice-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.memory-mode-choice-copy strong {
color: var(--r6-text);
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.memory-mode-choice-copy small {
color: var(--r6-muted);
font-size: 12px;
line-height: 20px;
overflow-wrap: normal;
word-break: normal;
}
.memory-mode-choice-badge {
padding: 1px 7px;
border-radius: 999px;
color: #41677e;
background: #dceaf2;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.memory-mode-choice-current {
align-self: start;
color: #405e75;
font-size: 12px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
@@ -340,6 +443,7 @@ button.r6-switch:focus-visible,
.r6-button-spinner {
animation: none;
}
.memory-mode-choice,
button.r6-button,
.r6-switch-track,
.r6-switch-thumb {
@@ -366,9 +470,17 @@ button.r6-switch:focus-visible,
backdrop-filter: none;
}
[data-native-reduce-motion="true"]
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
:is(.memory-mode-choice, .r6-button, .r6-switch-track, .r6-switch-thumb) {
transition: none;
}
@media (max-width: 700px) {
.memory-mode-choice {
grid-template-columns: 20px minmax(0, 1fr);
}
.memory-mode-choice-current {
grid-column: 2;
}
}
[data-native-reduce-motion="true"] :is(.r6-stepper li, .r6-stepper li::after) {
transition: none;
}
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more