Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470ea4459a | ||
|
|
b58e95fdae | ||
|
|
42b577f6f8 | ||
|
|
2554fb3b96 | ||
|
|
e32dc987c2 | ||
|
|
598e83525e | ||
|
|
01ace26d08 | ||
|
|
ba2f647a30 | ||
|
|
82d9514d62 | ||
|
|
369351719d | ||
|
|
1429ba2d19 | ||
|
|
4e7d752980 | ||
|
|
fb13a6fda2 | ||
|
|
6ff8b3a3b4 | ||
|
|
17441d368c | ||
|
|
89d29c9c41 | ||
|
|
5271bddce3 | ||
|
|
eac795b2d2 | ||
|
|
d938f1d5b2 | ||
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 |
No files matched your search
+1
-1
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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: "主要导航" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) ?? "")}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("已保存的配置不印内置向量模型", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "用模型沉淀记忆",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -39,12 +39,14 @@ export function MemoryArrivalPanel({
|
||||
processing: null,
|
||||
sources: [],
|
||||
loaded: true,
|
||||
capabilities: providedArrival.capabilities,
|
||||
});
|
||||
if (hasMemories && arrival.phase === "idle" && !arrival.importing)
|
||||
return null;
|
||||
const active =
|
||||
arrival.phase === "processing" || arrival.phase === "importing";
|
||||
const hasOriginals = arrival.importedMessages > 0;
|
||||
const hasOriginals =
|
||||
arrival.importedMessages > 0 || arrival.importedConversations > 0;
|
||||
const title =
|
||||
hasMemories && arrival.phase === "idle"
|
||||
? t("arrival.importing")
|
||||
|
||||
@@ -119,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user