Compare commits

...
Author SHA1 Message Date
kristinheandCursor e7d82ee5e8 fix(web): expand settings controls to content width
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:54:55 +08:00
kristinhe1111 63e009bf6c Merge pull request #307 from Tencent-Hunyuan/fix/lite-home-saving-copy
fix(memhub): keep home status copy compact
2026-11-04 16:26:27 +08:00
kristinhe1111 d4cfd1bb74 Merge pull request #309 from Tencent-Hunyuan/fix/memhub-durable-drain-liveness
fix(memhub): keep durable write drain alive
2026-11-04 16:26:08 +08:00
kristinhe1111 2822cbf368 Merge pull request #310 from Tencent-Hunyuan/fix/memhub-inline-model-config
fix(memhub): inline model configuration panels
2026-11-04 16:25:47 +08:00
kristinhe1111 8d23347e1a Merge pull request #312 from Tencent-Hunyuan/fix/memory-mode-confirm-copy
fix(memhub): clarify memory mode switch effects
2026-11-04 16:25:22 +08:00
kristinhe1111 8ae0d1889e Merge pull request #315 from Tencent-Hunyuan/fix/ultra-topic-hover-only
fix(memhub): make Ultra topic nodes hover-only
2026-11-04 16:24:58 +08:00
kristinhe1111 2479de22eb Merge pull request #316 from Tencent-Hunyuan/fix/memhub-lite-turn-view
fix(memhub): show Lite raw conversations by turn
2026-11-04 16:24:44 +08:00
kristinheandCursor 93d0c64490 style(memhub): format graph topic changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:30 +08:00
kristinheandCursor 1589ae31bc fix(memhub): make topic nodes hover-only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:06:56 +08:00
kristinheandCursor 38ad2292d1 fix(memhub): clarify memory mode switch effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:58:03 +08:00
kristinheandCursor 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
kristinheandCursor 0953c5af35 fix(memhub): keep durable write drain alive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:22:29 +08:00
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
36 changed files with 1102 additions and 249 deletions

No files matched your search

@@ -3261,11 +3261,9 @@ class DurableWriteDrain:
"durable_write_drain_job_ceiling_exceeded job_id=%s",
job_id,
)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_work_ceiling_exceeded",
owner=self.owner,
)
except Exception as error:
logger.error(
@@ -3274,11 +3272,9 @@ class DurableWriteDrain:
type(error).__name__,
)
self._record_database_error(error)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_executor_error",
owner=self.owner,
)
finally:
execution.cancel()
@@ -3287,6 +3283,36 @@ class DurableWriteDrain:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _settle_worker_error(self, job_id: str, *, error_code: str) -> None:
"""Best-effort settlement must not kill the long-lived drain.
The row still has a durable lease when this fallback runs. If the
single SQLite writer is busy, waiting and retrying here only extends
the same contention that made the executor fail. Leave the row where
it is instead: lease recovery later moves a never-invoked job back to
pending and a possibly-invoked job to unknown, preserving the
no-blind-retry boundary.
Only transient lock/busy refusals are deferred. A malformed query or
other programming error still escapes to the outer per-job fence.
"""
try:
await in_the_background(
self.queue.settle_worker_error,
job_id,
error_code=error_code,
owner=self.owner,
)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
self._sample_database_failure("settle_worker_error", error)
logger.warning(
"durable_write_worker_settlement_deferred job_id=%s error_type=%s",
job_id,
type(error).__name__,
)
async def _execute_with_ceiling(self, job_id: str) -> None:
ceiling = (
await in_the_background(self.work_ceiling)
@@ -3324,7 +3350,26 @@ class DurableWriteDrain:
finished = {task for task in active if task.done()}
active.difference_update(finished)
for task in finished:
await task
try:
await task
except asyncio.CancelledError:
raise
except _LeaseRenewalFatalError:
# Renewal is the ownership fence. If it is broken,
# continuing could let another worker reclaim a job
# while this process is still executing it.
raise
except Exception as error:
# A job task is a fault-containment boundary. Its row
# retains the durable lease and is recovered according
# to hy_invoked; killing this only consumer would strand
# every unrelated write behind it.
self._record_database_error(error)
logger.error(
"durable_write_drain_job_task_failed task_name=%s error_type=%s",
task.get_name(),
type(error).__name__,
)
if self.budget is not None and work_deadline is not None:
now = _time.monotonic()
# Always admit the first unit of a cycle even when the
@@ -3349,7 +3394,12 @@ class DurableWriteDrain:
else ()
)
for job_id in ids:
active.add(asyncio.create_task(self._run_claimed_job(job_id)))
active.add(
asyncio.create_task(
self._run_claimed_job(job_id),
name=f"durable-write:{job_id}",
)
)
executed = len(ids)
self._database_failure_streaks.pop("claim", None)
except asyncio.CancelledError:
@@ -4138,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
assert queue.get("idem-1").state == COMPLETED
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
"""The incident shape: executor and its fallback both lose the writer.
The first row keeps its lease for normal reclaim; an unrelated row must
still run instead of being stranded behind a dead drain task.
"""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def execute(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("executor failed")
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
def locked_settlement(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert queue.get("first").state == RUNNING
assert queue.get("second").state == COMPLETED
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
tmp_path, monkeypatch, caplog
) -> None:
"""A programming error stays visible without taking down the consumer."""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def run_job(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("unexpected job task failure")
drain.stop()
drain = DurableWriteDrain(
queue,
execute=_never_execute,
max_concurrent_sources=1,
)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert any(
"durable_write_drain_job_task_failed" in message.getMessage()
and "error_type=RuntimeError" in message.getMessage()
for message in caplog.records
)
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
"""A late settle must not overwrite an outcome that already happened.
@@ -4980,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
asyncio.run(drain.run_one())
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request())
drain = DurableWriteDrain(
queue,
execute=lambda _job_id: asyncio.sleep(0.03),
lease=timedelta(seconds=1),
renew_every=timedelta(milliseconds=10),
)
monkeypatch.setattr(
drain,
"renew",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
sqlite3.OperationalError("database schema is corrupt")
),
)
with pytest.raises(RuntimeError):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -1235,7 +1235,9 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
/>,
);
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
+17 -1
View File
@@ -6,7 +6,11 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryGraph3D } from "./MemoryGraph3D";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
afterEach(cleanup);
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+58 -24
View File
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const { locale, text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.filter((node) => node.node_type === "source")
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
}
};
const selectable: THREE.Mesh[] = [];
const hittable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
selectable.push(mesh);
hittable.push(mesh);
visuals.push({
mesh,
material,
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(selectable, false)[0];
return raycaster.intersectObjects(hittable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
setHovered(
node
? {
label: node.label,
label: graphNodeTooltip(node, topicPrefixRef.current),
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
</div>
) : null}
{available && (
+7 -7
View File
@@ -149,7 +149,7 @@ 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 () => {
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => {
/>,
);
await act(async () => {});
onOpen.mockClear();
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();
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
+3 -5
View File
@@ -684,6 +684,9 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -708,11 +711,6 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
+41 -28
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
@@ -64,31 +65,9 @@ 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: [],
}));
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
@@ -227,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
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, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
@@ -238,6 +231,8 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
});
@@ -248,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
expect(
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
).toBeTruthy();
});
it("does not show automatic consolidation when switching from lite to pro", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
});
it("focuses the Evolve settings after switching to ultra", async () => {
+7 -5
View File
@@ -85,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
+280 -9
View File
@@ -72,6 +72,7 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
@@ -224,6 +225,7 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
@@ -279,6 +281,7 @@ describe("ModelSettings", () => {
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
@@ -576,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -666,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -687,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -850,6 +854,190 @@ describe("ModelSettings", () => {
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -1044,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -1278,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
+113 -34
View File
@@ -11,6 +11,7 @@ import {
type FormEvent,
type Ref,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
@@ -26,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -238,6 +240,7 @@ export function ModelSettings({
onSetupComplete?(): void;
} = {}) {
const { text, t } = useOnboardingTranslation();
const fieldId = useId();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -275,6 +278,9 @@ export function ModelSettings({
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const [switchRefreshCompleted, setSwitchRefreshCompleted] = useState<
string | null
>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
@@ -308,10 +314,11 @@ export function ModelSettings({
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupPhase("activating");
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
void models.refreshProfiles().then(() => {
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
});
onProfilesChanged?.();
return;
}
@@ -323,6 +330,7 @@ export function ModelSettings({
);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}
}, [
embeddingSwitch.current?.last_finished,
@@ -331,6 +339,45 @@ export function ModelSettings({
switchJobId,
switchTarget,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
finished?.job_id !== switchJobId ||
finished.state !== "done" ||
switchRefreshCompleted !== switchJobId
)
return;
const active = models.profiles.find(
(profile) =>
profile.profile_id === switchTarget.profile_id &&
profile.revision >= switchTarget.revision &&
profile.is_active,
);
if (!active) {
setSetupPhase(null);
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
return;
}
if (active.name !== PRESET_PROFILE_NAME) setSaved(active);
setSetupPhase(null);
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
onSetupComplete?.();
}, [
embeddingSwitch.current?.last_finished,
models.profiles,
onSetupComplete,
switchJobId,
switchRefreshCompleted,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
@@ -381,6 +428,7 @@ export function ModelSettings({
saved);
const editorLocked =
saving ||
switchJobId !== null ||
models.busy !== null ||
!!models.pendingCreation ||
!!models.pendingProbe ||
@@ -864,6 +912,9 @@ export function ModelSettings({
if (!alive.current) return;
if (created) {
if (thenSetup) {
// Retrying after probe, activation, or switch confirmation failed
// must update this saved revision instead of creating a duplicate.
setEditing(created);
// The probe decides whether this form closes. A rejected key or a
// failed endpoint leaves the filled-in form on screen with the
// reason; only a passing test returns to the saved card.
@@ -1339,20 +1390,37 @@ export function ModelSettings({
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-mode-label`}
htmlFor={`${fieldId}-embedding-mode`}
>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
</label>
<OptionMenu
id={`${fieldId}-embedding-mode`}
labelledBy={`${fieldId}-embedding-mode-label`}
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{
value: "local",
label: text("本地内置(推荐)"),
},
{
value: "custom",
label: text("自定义搜索模型"),
},
]}
onChange={(value) => {
setEmbeddingMode(value as "local" | "custom");
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
@@ -1360,11 +1428,8 @@ export function ModelSettings({
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
/>
</div>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
@@ -1397,25 +1462,38 @@ export function ModelSettings({
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-provider-label`}
htmlFor={`${fieldId}-embedding-provider`}
>
{text("向量模型来源")}
</label>
<OptionMenu
id={`${fieldId}-embedding-provider`}
labelledBy={`${fieldId}-embedding-provider-label`}
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{ value: "ollama", label: text("本机 Ollama") },
{
value: "openai_compatible",
label: text(
"同源 OpenAI-compatible(共用 API Key)",
),
},
]}
onChange={(value) => {
const next = value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
/>
</div>
<label>
{text("向量模型 Base URL")}
<input
@@ -1669,13 +1747,14 @@ export function ModelSettings({
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSwitchRefreshCompleted(null);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
+1 -1
View File
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
if (item.value !== props.value) props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
+47 -5
View File
@@ -21,6 +21,9 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
const editProfileAction = vi.fn(() => true);
const addProfileAction = vi.fn(() => true);
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -112,8 +115,8 @@ vi.mock("./ModelSettings", async () => {
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement(
@@ -304,6 +307,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 () => {
editProfileAction.mockReturnValueOnce(false);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
@@ -321,10 +325,13 @@ it("opens the model area even when a pending probe fences the editor, so recover
expect(alert.textContent).toContain("待核对");
// What changed is that the recovery entry no longer hides with the form it
// is supposed to unlock.
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
expect(
within(generationLane).getByRole("button", { name: "核对测试结果" }),
).toBeTruthy();
});
it("reaches the same recovery entry when no model has been configured yet", async () => {
addProfileAction.mockReturnValueOnce(false);
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
@@ -368,11 +375,46 @@ it("shows separate compact lanes and opens the requested presentation", async ()
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
expect(await within(embeddingLane).findByText("embedding")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(1);
expect(editProfileAction).toHaveBeenLastCalledWith(
expect.objectContaining({ profile_id: "profile-1" }),
);
expect(embeddingLane.dataset.expanded).toBe("true");
expect(
within(embeddingLane)
.getByRole("button", { name: "收起" })
.getAttribute("aria-expanded"),
).toBe("true");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
expect(await within(generationLane).findByText("generation")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(generationLane.dataset.expanded).toBe("true");
expect(within(embeddingLane).queryByText("embedding")).toBeNull();
await user.click(
within(generationLane).getByRole("button", { name: "收起" }),
);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(true);
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+187 -82
View File
@@ -78,6 +78,7 @@ export function R6Settings({
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -101,6 +102,10 @@ export function R6Settings({
const [sedimentationUnavailable, setSedimentationUnavailable] =
useState(false);
const modelActions = useRef<ModelSettingsActions>(null);
const configurationStarted = useRef(false);
const configurationStartedFocus = useRef<ModelSettingsFocusSection | null>(
null,
);
const quitPanel = useRef<HTMLElement>(null);
const quitOpener = useRef<HTMLElement | null>(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
@@ -224,18 +229,49 @@ export function R6Settings({
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
if (
configurationStarted.current &&
configurationStartedFocus.current === focusSection
) {
setConfigurationFocus(focusSection);
setConfigurationOpen(true);
return;
}
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
configurationStarted.current = true;
configurationStartedFocus.current = focusSection;
setConfigurationOpen(true);
setConfigurationRequest((value) => value + 1);
}
// The focused editor can move between the two lanes. Fill it only after
// React has mounted it in the requested lane, otherwise an edit sent to the
// old lane would be lost during the move.
// biome-ignore lint/correctness/useExhaustiveDependencies: configurationRequest is the explicit intent trigger.
useEffect(() => {
if (!configurationOpen || configurationRequest === 0) return;
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
// The area opens even when the editor refuses: the only recovery entry
// lives inside it, and hiding both is what locked people out of their own
// settings. They needed the form open to reach “核对测试结果”, and that
// button to clear the pending record that kept the form shut. The fence
// itself is untouched (see ModelSettings): refusing still returns false.
setConfigurationOpen(true);
// The area stays open even when the editor refuses: its recovery entry is
// the only way to reconcile the pending operation.
if (!opened) setConfigurationRefusal(true);
}, [configurationRequest]);
function toggleConfiguration(focusSection: ModelSettingsFocusSection) {
if (configurationOpen && configurationFocus === focusSection) {
setConfigurationOpen(false);
return;
}
openConfiguration(focusSection);
}
function completeConfiguration() {
configurationStarted.current = false;
configurationStartedFocus.current = null;
setConfigurationRefusal(false);
setConfigurationOpen(false);
}
async function quitApplication() {
@@ -384,67 +420,156 @@ export function R6Settings({
</div>
)}
<div className="r6-model-overview">
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "generation"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
aria-expanded={
configurationOpen && configurationFocus === "generation"
}
aria-controls={
configurationFocus === "generation"
? `${id}-generation-configuration`
: undefined
}
onClick={() => toggleConfiguration("generation")}
>
{configurationOpen && configurationFocus === "generation"
? t("wb.collapse")
: text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{configurationFocus === "generation" && (
<section
id={`${id}-generation-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "embedding"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !==
"local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
{currentModel && (
</strong>
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
aria-expanded={
configurationOpen && configurationFocus === "embedding"
}
aria-controls={
configurationFocus === "embedding"
? `${id}-embedding-configuration`
: undefined
}
onClick={() => toggleConfiguration("embedding")}
>
{configurationOpen && configurationFocus === "embedding"
? t("wb.collapse")
: text("配置")}
</R6Button>
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
{configurationFocus === "embedding" && (
<section
id={`${id}-embedding-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
@@ -456,13 +581,6 @@ export function R6Settings({
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
@@ -499,19 +617,6 @@ export function R6Settings({
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
/>
</section>
</section>
<section
id={`${id}-panel-connections`}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
function declaration(file: string, selector: string): string {
const css = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-choice-list",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
});
+15 -2
View File
@@ -132,12 +132,17 @@ button svg {
content: "";
position: absolute;
right: 18px;
top: 22px;
top: 50%;
width: 8px;
height: 8px;
border-right: 1.5px solid #4e6478;
border-bottom: 1.5px solid #4e6478;
transform: rotate(45deg);
transform: translateY(-65%) rotate(45deg);
transform-origin: 50% 50%;
transition: transform 150ms ease-out;
}
.om-option-menu-button[aria-expanded="true"]::after {
transform: translateY(-35%) rotate(225deg);
}
.om-option-menu-button:disabled {
color: #66727b;
@@ -981,6 +986,14 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
+4 -3
View File
@@ -38,7 +38,7 @@ const base = {
describe("home view model", () => {
it.each([
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
["lite", "raw", "apps.rawConversations", "apps.rawSavingCompact"],
["pro", "memory", "apps.memories", "apps.running"],
["ultra", "memory", "apps.memories", "apps.running"],
] as const)(
@@ -109,7 +109,7 @@ describe("home view model", () => {
} as never,
});
expect(result.recentMetric).toEqual({
valueKey: "apps.rawCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity).toMatchObject({
@@ -158,9 +158,10 @@ describe("home view model", () => {
projectionState: "ready",
});
expect(result.recentMetric).toEqual({
valueKey: "apps.memoryCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
expect(result.activity.kind).toBe("empty");
});
+3 -10
View File
@@ -40,11 +40,7 @@ export interface HomeViewModel {
emptyExcerpt: boolean;
} | null;
recentMetric: {
valueKey:
| "apps.checkingRecent"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| null;
valueKey: "apps.checkingRecent" | "apps.recentEmptyCompact" | null;
detailKey:
| "apps.rawSaved"
| "apps.saved"
@@ -120,7 +116,7 @@ export function buildHomeViewModel(input: {
const sedimenting = input.sedimentation?.enabled;
const processingLabel =
!generationRequired || mode === "lite"
? "apps.rawSaving"
? "apps.rawSavingCompact"
: sedimenting === true
? "apps.running"
: sedimenting === false
@@ -179,10 +175,7 @@ export function buildHomeViewModel(input: {
}
: state === "ready" || state === "stale"
? {
valueKey:
plane === "raw"
? ("apps.rawCheckedEmpty" as const)
: ("apps.memoryCheckedEmpty" as const),
valueKey: "apps.recentEmptyCompact" as const,
detailKey: "apps.checkedRecent" as const,
}
: {
-1
View File
@@ -76,7 +76,6 @@
text-overflow: ellipsis;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
+53 -9
View File
@@ -66,7 +66,6 @@
text-overflow: ellipsis;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
@@ -229,7 +228,8 @@
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-choice-list {
max-width: 760px;
width: 100%;
max-width: none;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -482,7 +482,8 @@
text-wrap: pretty;
}
.r6-settings .connection-fallback .custom-harness-name {
max-width: 560px;
width: 100%;
max-width: none;
gap: 8px;
margin: 0 0 14px;
color: #4f6373;
@@ -596,6 +597,12 @@
background: #fff;
}
.r6-settings .r6-model-lane {
min-height: 108px;
}
.r6-settings .r6-model-lane[data-expanded="true"] {
background: #f8fafb;
}
.r6-settings .r6-model-lane-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -639,7 +646,7 @@
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
@@ -690,8 +697,9 @@
line-height: 28px;
}
.r6-settings .r6-model-owner {
padding-top: 24px;
padding: 24px 20px;
border-top: 1px solid #d8e2ea;
background: #f8fafb;
}
.r6-settings .r6-model-owner[hidden] {
display: none;
@@ -847,7 +855,6 @@
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
@@ -866,6 +873,39 @@
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-option-label {
display: block;
margin: 0;
}
.model-option-menu-panel {
padding: 6px;
border-color: var(--r6-control-edge, #c0ccd5);
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.model-option-menu-panel [role="option"] {
min-height: 40px;
padding: 8px 10px;
color: #304757;
font-size: 14px;
line-height: 22px;
}
.model-option-menu-panel [role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"]:hover:not(:disabled) {
color: #304757;
background: #e9f0f4;
}
.model-option-menu-panel [role="option"]:active:not(:disabled) {
background: #d4e2ea;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
@@ -884,7 +924,8 @@
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
.r6-settings .model-advanced label,
.r6-settings .model-advanced .model-form-field {
display: flex;
flex-direction: column;
gap: 8px;
@@ -988,14 +1029,17 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
.r6-settings .r6-model-lane-header {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
justify-self: start;
}
.r6-settings .r6-model-owner {
padding: 20px 16px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
-1
View File
@@ -97,7 +97,6 @@
border-color: transparent;
}
.selected-source-filter .om-option-menu-button::after {
top: 18px;
right: 4px;
}
.selected-source-filter details {
@@ -9,6 +9,7 @@ export const applicationsChineseMessages = {
"apps.memories": "已沉淀记忆",
"apps.rawConversations": "原始对话",
"apps.rawSaving": "正在保存原始对话",
"apps.rawSavingCompact": "正在保存",
"apps.rawSavingStatus": "保存状态",
"apps.rawSavingDetail": "原始对话持续保存,不需要生成模型",
"apps.rawNoExcerpt": "这条对话没有可显示的用户原文",
@@ -17,6 +18,7 @@ export const applicationsChineseMessages = {
"apps.rawEmpty": "还没有保存的原始对话",
"apps.rawCheckedEmpty": "已核对,暂无可显示的原始对话更新",
"apps.memoryCheckedEmpty": "已核对,暂无可显示的记忆更新",
"apps.recentEmptyCompact": "暂无更新",
"apps.rawWaiting": "原始对话保存后会显示在这里",
"apps.checkedRecent": "已核对当前内容",
"apps.currentMode": "当前模式",
@@ -149,6 +151,7 @@ export const applicationsSpanishMessages: Record<
"apps.memories": "Memorias guardadas",
"apps.rawConversations": "Conversaciones originales",
"apps.rawSaving": "Guardando",
"apps.rawSavingCompact": "Guardando",
"apps.rawSavingStatus": "Estado de guardado",
"apps.rawSavingDetail":
"Las conversaciones originales se guardan sin un modelo generativo",
@@ -160,6 +163,7 @@ export const applicationsSpanishMessages: Record<
"Comprobado: no hay actualizaciones de conversaciones para mostrar",
"apps.memoryCheckedEmpty":
"Comprobado: no hay actualizaciones de memorias para mostrar",
"apps.recentEmptyCompact": "Sin novedades",
"apps.rawWaiting":
"Las conversaciones originales aparecerán aquí cuando se guarden",
"apps.checkedRecent": "Contenido actual comprobado",
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。",
"已经处理完的旧对话不会补做;原始对话全部保留。",
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
"知识图谱",
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
"已有记忆会在后台加入知识图谱,期间仍可使用。",
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"每天自动整合会停止,已经生成的数据不会删除。",
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。",
] as const;
@@ -1345,6 +1345,7 @@ const baseEnglishMessages = {
"apps.memories": "Saved memories",
"apps.rawConversations": "Original conversations",
"apps.rawSaving": "Saving",
"apps.rawSavingCompact": "Saving",
"apps.rawSavingStatus": "Saving status",
"apps.rawSavingDetail":
"Original conversations are saved without a generation model",
@@ -1355,6 +1356,7 @@ const baseEnglishMessages = {
"apps.rawCheckedEmpty":
"Checked: no original-conversation updates to display",
"apps.memoryCheckedEmpty": "Checked: no memory updates to display",
"apps.recentEmptyCompact": "No updates",
"apps.rawWaiting": "Original conversations will appear here when saved",
"apps.checkedRecent": "Current content checked",
"apps.currentMode": "Current mode",
@@ -24,6 +24,8 @@ const ENGLISH_EQUIVALENT_ALLOWED = new Set<MessageKey>([
const keys = Object.keys(chineseMessages) as MessageKey[];
const rawSavingStatusKey = "apps.rawSavingStatus" satisfies MessageKey;
const rawSavingCompactKey = "apps.rawSavingCompact" satisfies MessageKey;
const recentEmptyCompactKey = "apps.recentEmptyCompact" satisfies MessageKey;
const rawSavingStatusTranslations = {
en: "Saving status",
@@ -39,6 +41,23 @@ const rawSavingStatusTranslations = {
id: "Status penyimpanan",
} as const satisfies Record<(typeof SUPPORTED_LOCALES)[number], string>;
const compactHomeTranslations = {
en: ["Saving", "No updates"],
"zh-Hans": ["正在保存", "暂无更新"],
"zh-Hant": ["儲存中", "暫無更新"],
ja: ["保存中", "更新なし"],
es: ["Guardando", "Sin novedades"],
de: ["Wird gespeichert", "Keine Updates"],
pt: ["Salvando", "Sem atualizações"],
fr: ["Enregistrement", "Aucune mise à jour"],
ko: ["저장 중", "업데이트 없음"],
ru: ["Сохранение", "Нет обновлений"],
id: ["Menyimpan", "Belum ada pembaruan"],
} as const satisfies Record<
(typeof SUPPORTED_LOCALES)[number],
readonly [string, string]
>;
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
const catalog = guiMessages[locale];
@@ -100,6 +119,14 @@ describe("applications catalog", () => {
rawSavingStatusTranslations[locale],
);
});
it("keeps large home metric copy compact in all 11 locales", () => {
for (const locale of SUPPORTED_LOCALES) {
const [saving, empty] = compactHomeTranslations[locale];
expect(guiMessages[locale][rawSavingCompactKey]).toBe(saving);
expect(guiMessages[locale][recentEmptyCompactKey]).toBe(empty);
}
});
});
describe("embedding switch catalog", () => {
+12 -1
View File
@@ -47,7 +47,7 @@ const modeChineseMessages = {
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
"每天自动整合会停止,已经生成的数据不会删除。":
"自动整合会停止,已有数据保留。",
"每天自动整合会停止;已生成的整合结果继续保留。",
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
"已有高级记忆将在后台加入知识图谱。",
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
@@ -97,6 +97,7 @@ const modeChineseMessages = {
模型调用与费用: "模型调用与费用",
搜索范围: "搜索范围",
已有数据: "已有数据",
知识图谱: "知识图谱",
切换后: "切换后",
"之后只保存原始对话,不再生成新记忆。":
"之后只保存原始对话,不再生成新记忆。",
@@ -295,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
模型调用与费用: "Model calls and cost",
搜索范围: "Search scope",
已有数据: "Existing data",
知识图谱: "Knowledge graph",
切换后: "After switching",
"之后只保存原始对话,不再生成新记忆。":
"New conversations are saved as original text only; no new memories are created.",
@@ -498,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
模型调用与费用: "Llamadas al modelo y coste",
搜索范围: "Ámbito de búsqueda",
已有数据: "Datos existentes",
知识图谱: "Grafo de conocimiento",
切换后: "Después del cambio",
"之后只保存原始对话,不再生成新记忆。":
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
@@ -693,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
模型调用与费用: "模型呼叫與費用",
搜索范围: "搜尋範圍",
已有数据: "現有資料",
知识图谱: "知識圖譜",
切换后: "切換後",
"之后只保存原始对话,不再生成新记忆。":
"之後只儲存原始對話,不再產生新記憶。",
@@ -843,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
模型调用与费用: "モデル呼び出しと料金",
搜索范围: "検索範囲",
已有数据: "既存データ",
知识图谱: "ナレッジグラフ",
切换后: "切り替え後",
"之后只保存原始对话,不再生成新记忆。":
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
@@ -1043,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
模型调用与费用: "Modellaufrufe und Kosten",
搜索范围: "Suchbereich",
已有数据: "Vorhandene Daten",
知识图谱: "Wissensgraph",
切换后: "Nach dem Wechsel",
"之后只保存原始对话,不再生成新记忆。":
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
@@ -1246,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
模型调用与费用: "Chamadas ao modelo e custos",
搜索范围: "Âmbito da pesquisa",
已有数据: "Dados existentes",
知识图谱: "Grafo de conhecimento",
切换后: "Após a mudança",
"之后只保存原始对话,不再生成新记忆。":
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
@@ -1451,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
模型调用与费用: "Appels au modèle et coût",
搜索范围: "Périmètre de recherche",
已有数据: "Données existantes",
知识图谱: "Graphe de connaissances",
切换后: "Après le changement",
"之后只保存原始对话,不再生成新记忆。":
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
@@ -1651,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
模型调用与费用: "모델 호출 및 비용",
搜索范围: "검색 범위",
已有数据: "기존 데이터",
知识图谱: "지식 그래프",
切换后: "전환 후",
"之后只保存原始对话,不再生成新记忆。":
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
@@ -1850,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
模型调用与费用: "Вызовы модели и расходы",
搜索范围: "Область поиска",
已有数据: "Существующие данные",
知识图谱: "Граф знаний",
切换后: "После переключения",
"之后只保存原始对话,不再生成新记忆。":
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
@@ -2052,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
模型调用与费用: "Panggilan model dan biaya",
搜索范围: "Cakupan pencarian",
已有数据: "Data yang ada",
知识图谱: "Graf pengetahuan",
切换后: "Setelah beralih",
"之后只保存原始对话,不再生成新记忆。":
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
@@ -1544,6 +1544,7 @@ export const germanMessages: Record<MessageKey, string> = {
"apps.memories": "Gespeicherte Erinnerungen",
"apps.rawConversations": "Originalunterhaltungen",
"apps.rawSaving": "Wird gespeichert",
"apps.rawSavingCompact": "Wird gespeichert",
"apps.rawSavingStatus": "Speicherstatus",
"apps.rawSavingDetail":
"Originalunterhaltungen werden ohne generatives Modell gespeichert",
@@ -1555,6 +1556,7 @@ export const germanMessages: Record<MessageKey, string> = {
"Geprüft: keine anzeigbaren Aktualisierungen der Originalunterhaltungen",
"apps.memoryCheckedEmpty":
"Geprüft: keine anzeigbaren Erinnerungsaktualisierungen",
"apps.recentEmptyCompact": "Keine Updates",
"apps.rawWaiting": "Gespeicherte Originalunterhaltungen erscheinen hier",
"apps.checkedRecent": "Aktueller Inhalt geprüft",
"apps.currentMode": "Aktueller Modus",
@@ -1565,6 +1565,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"apps.memories": "Mémoires enregistrées",
"apps.rawConversations": "Conversations d’origine",
"apps.rawSaving": "Enregistrement",
"apps.rawSavingCompact": "Enregistrement",
"apps.rawSavingStatus": "État de l’enregistrement",
"apps.rawSavingDetail":
"Les conversations d’origine sont enregistrées sans modèle génératif",
@@ -1576,6 +1577,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"Vérifié : aucune mise à jour de conversation à afficher",
"apps.memoryCheckedEmpty":
"Vérifié : aucune mise à jour de mémoire à afficher",
"apps.recentEmptyCompact": "Aucune mise à jour",
"apps.rawWaiting":
"Les conversations d’origine enregistrées apparaîtront ici",
"apps.checkedRecent": "Contenu actuel vérifié",
@@ -1519,6 +1519,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"apps.memories": "Memori tersimpan",
"apps.rawConversations": "Percakapan asli",
"apps.rawSaving": "Menyimpan",
"apps.rawSavingCompact": "Menyimpan",
"apps.rawSavingStatus": "Status penyimpanan",
"apps.rawSavingDetail": "Percakapan asli disimpan tanpa model generatif",
"apps.rawNoExcerpt": "Tidak ada kutipan pengguna yang dapat ditampilkan",
@@ -1529,6 +1530,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Diperiksa: tidak ada pembaruan percakapan untuk ditampilkan",
"apps.memoryCheckedEmpty":
"Diperiksa: tidak ada pembaruan memori untuk ditampilkan",
"apps.recentEmptyCompact": "Belum ada pembaruan",
"apps.rawWaiting": "Percakapan asli yang disimpan akan muncul di sini",
"apps.checkedRecent": "Konten saat ini telah diperiksa",
"apps.currentMode": "Mode saat ini",
@@ -1516,6 +1516,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.memories": "保存済みの記憶",
"apps.rawConversations": "元の会話",
"apps.rawSaving": "保存中",
"apps.rawSavingCompact": "保存中",
"apps.rawSavingStatus": "保存状態",
"apps.rawSavingDetail": "生成モデルを使わず元の会話を保存します",
"apps.rawNoExcerpt": "表示できるユーザーの原文がありません",
@@ -1524,6 +1525,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "保存済みの元の会話はまだありません",
"apps.rawCheckedEmpty": "確認済み:表示できる元の会話の更新はありません",
"apps.memoryCheckedEmpty": "確認済み:表示できる記憶の更新はありません",
"apps.recentEmptyCompact": "更新なし",
"apps.rawWaiting": "保存された元の会話がここに表示されます",
"apps.checkedRecent": "現在の内容を確認済み",
"apps.currentMode": "現在のモード",
@@ -1480,6 +1480,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.memories": "저장된 기억",
"apps.rawConversations": "원본 대화",
"apps.rawSaving": "저장 중",
"apps.rawSavingCompact": "저장 중",
"apps.rawSavingStatus": "저장 상태",
"apps.rawSavingDetail": "생성 모델 없이 원본 대화를 저장합니다",
"apps.rawNoExcerpt": "표시할 수 있는 사용자 원문이 없습니다",
@@ -1488,6 +1489,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "저장된 원본 대화가 아직 없습니다",
"apps.rawCheckedEmpty": "확인됨: 표시할 원본 대화 업데이트가 없습니다",
"apps.memoryCheckedEmpty": "확인됨: 표시할 메모리 업데이트가 없습니다",
"apps.recentEmptyCompact": "업데이트 없음",
"apps.rawWaiting": "저장된 원본 대화가 여기에 표시됩니다",
"apps.checkedRecent": "현재 내용 확인됨",
"apps.currentMode": "현재 모드",
@@ -1541,6 +1541,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"apps.memories": "Memórias salvas",
"apps.rawConversations": "Conversas originais",
"apps.rawSaving": "Salvando",
"apps.rawSavingCompact": "Salvando",
"apps.rawSavingStatus": "Status de salvamento",
"apps.rawSavingDetail":
"As conversas originais são salvas sem um modelo generativo",
@@ -1552,6 +1553,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Verificado: não há atualizações de conversas para mostrar",
"apps.memoryCheckedEmpty":
"Verificado: não há atualizações de memórias para mostrar",
"apps.recentEmptyCompact": "Sem atualizações",
"apps.rawWaiting": "As conversas originais salvas aparecerão aqui",
"apps.checkedRecent": "Conteúdo atual verificado",
"apps.currentMode": "Modo atual",
@@ -1524,6 +1524,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.memories": "Сохранённые записи памяти",
"apps.rawConversations": "Исходные разговоры",
"apps.rawSaving": "Сохранение",
"apps.rawSavingCompact": "Сохранение",
"apps.rawSavingStatus": "Статус сохранения",
"apps.rawSavingDetail":
"Исходные разговоры сохраняются без генеративной модели",
@@ -1534,6 +1535,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.rawCheckedEmpty":
"Проверено: нет обновлений исходных диалогов для показа",
"apps.memoryCheckedEmpty": "Проверено: нет обновлений памяти для показа",
"apps.recentEmptyCompact": "Нет обновлений",
"apps.rawWaiting": "Сохранённые исходные диалоги появятся здесь",
"apps.checkedRecent": "Текущее содержимое проверено",
"apps.currentMode": "Текущий режим",
@@ -1388,6 +1388,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.memories": "已建立的記憶",
"apps.rawConversations": "原始對話",
"apps.rawSaving": "儲存中",
"apps.rawSavingCompact": "儲存中",
"apps.rawSavingStatus": "儲存狀態",
"apps.rawSavingDetail": "原始對話會持續儲存,不需要生成模型",
"apps.rawNoExcerpt": "這段對話沒有可顯示的使用者原文",
@@ -1396,6 +1397,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "還沒有儲存的原始對話",
"apps.rawCheckedEmpty": "已核對,暫無可顯示的原始對話更新",
"apps.memoryCheckedEmpty": "已核對,暫無可顯示的記憶更新",
"apps.recentEmptyCompact": "暫無更新",
"apps.rawWaiting": "原始對話儲存後會顯示在這裡",
"apps.checkedRecent": "已核對目前內容",
"apps.currentMode": "目前模式",
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
});
it("isolates the old-Core matrix in one helper", () => {
it.each([
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
[
"lite",
"ultra",
[
"earlier_raw_not_extracted",
"lite_search_stops",
"existing_memories_added_to_graph",
],
],
["pro", "lite", ["higher_mode_memories_hidden"]],
["pro", "ultra", ["existing_memories_added_to_graph"]],
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
] as const)(
"keeps the old-Core %s to %s effects aligned with the plan route",
(current, target, effects) => {
expect(
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
).toEqual(effects);
},
);
it("keeps the old-Core model blocker and target capabilities", () => {
expect(
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
).toMatchObject({
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
graph_enabled: true,
},
blockers: ["active_model_required"],
effects: expect.arrayContaining([
"earlier_raw_not_extracted",
"existing_memories_added_to_graph",
]),
});
});
});
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
options: { hasActiveModel: boolean },
): MemoryModePlan {
const effects: MemoryModeChange["effects"] = [];
if (target === "lite") {
effects.push("higher_mode_memories_hidden");
if (current === "ultra") effects.push("evolve_stopped");
} else if (current === "lite") {
const rank: Record<MemoryPipelineMode, number> = {
lite: 0,
pro: 1,
ultra: 2,
};
if (current !== target && current === "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
}
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
if (current !== target && current === "ultra") effects.push("evolve_stopped");
if (target === "ultra" && current !== "ultra")
effects.push("existing_memories_added_to_graph");
return {