Compare commits

..
Author SHA1 Message Date
kristinheandCursor fcd2b739b6 style(memhub): format Core sources with Ruff
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:51:53 +08:00
39 changed files with 1605 additions and 1255 deletions

No files matched your search

+1 -1
View File
@@ -105,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-04
Build 45, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 45),GitHub 仓库为
(build 44),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+216 -40
View File
@@ -21,7 +21,7 @@ from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID
from uuid import UUID, uuid4
from fastapi import (
Depends,
@@ -653,7 +653,7 @@ class SdkRawPage(BaseModel):
class SdkRawGraphItem(BaseModel):
"""Small, non-authoritative projection used to draw the bounded raw map."""
"""Small, non-authoritative projection used to draw the complete raw map."""
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -667,7 +667,7 @@ class SdkRawGraphItem(BaseModel):
class SdkRawGraphPage(BaseModel):
"""One bounded page from the raw-turn catalog."""
"""One page from a complete, bounded-memory raw-turn catalog."""
model_config = ConfigDict(extra="forbid")
@@ -675,8 +675,9 @@ class SdkRawGraphPage(BaseModel):
items: tuple[SdkRawGraphItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=300)
limit: int = Field(ge=1, le=500)
has_more: bool = False
snapshot_id: str = Field(min_length=1, max_length=64)
class SdkLayerCounts(BaseModel):
@@ -4569,6 +4570,55 @@ def create_app(
],
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
raw_graph_cache: dict[
tuple[object, ...],
tuple[
float,
int,
tuple[tuple[str, str | None], ...],
tuple[SdkRawGraphItem, ...],
int,
],
] = {}
raw_graph_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
raw_graph_snapshots: dict[
str,
tuple[float, tuple[object, ...], tuple[SdkRawGraphItem, ...], int],
] = {}
raw_graph_cache_ttl = 300.0
raw_graph_snapshot_ttl = 120.0
raw_graph_cache_budget = 8 * 1024 * 1024
def graph_cache_size(items: Iterable[SdkRawGraphItem]) -> int:
return sum(
128
+ len(item.turn_id.encode())
+ len(item.raw_id.encode())
+ len(item.label.encode())
+ len(item.source.encode())
+ len(item.session_id.encode())
for item in items
)
def prune_raw_graph_state() -> None:
now = time.monotonic()
for key, cached in tuple(raw_graph_cache.items()):
if now - cached[0] > raw_graph_cache_ttl:
raw_graph_cache.pop(key, None)
for snapshot_id, snapshot in tuple(raw_graph_snapshots.items()):
if now - snapshot[0] > raw_graph_snapshot_ttl:
raw_graph_snapshots.pop(snapshot_id, None)
while raw_graph_cache and (
len(raw_graph_cache) > 8
or sum(cached[4] for cached in raw_graph_cache.values()) > raw_graph_cache_budget
):
raw_graph_cache.pop(next(iter(raw_graph_cache)))
while raw_graph_snapshots and (
len(raw_graph_snapshots) > 8
or sum(snapshot[3] for snapshot in raw_graph_snapshots.values())
> raw_graph_cache_budget
):
raw_graph_snapshots.pop(next(iter(raw_graph_snapshots)))
def graph_items(
records: Iterable[HySdkRawRecord],
@@ -4590,6 +4640,95 @@ def create_app(
)
return tuple(items)
async def raw_graph_catalog(
client: object,
*,
query: dict[str, object],
cache_key: tuple[object, ...],
) -> tuple[SdkRawGraphItem, ...]:
"""Build a complete raw-turn map without retaining raw bodies.
The SDK remains the sole raw authority. This cache contains only stable
ids and short labels and can be dropped at any time. A verified first
page makes one catalog snapshot atomic from the reader's perspective.
"""
prune_raw_graph_state()
if len(raw_graph_cache_locks) > 16:
for stale_key, stale_lock in tuple(raw_graph_cache_locks.items()):
if stale_key not in raw_graph_cache and not stale_lock.locked():
raw_graph_cache_locks.pop(stale_key, None)
lock = raw_graph_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(value, limit=500, offset=0)
signature = tuple((record.raw_id, record.status) for record in first_page.raw)
cached = raw_graph_cache.get(cache_key)
if cached is not None and cached[1] == first_page.total and cached[2] == signature:
return cached[3]
added = (
None
if cached is None
else raw_prefix_added(
cached[2],
signature,
cached[1],
first_page.total,
)
)
if cached is not None and added is not None:
items = graph_items(first_page.raw[:added]) + cached[3]
else:
items = graph_items(first_page.raw)
raw_offset = len(first_page.raw)
snapshot_changed = False
while first_page.raw and raw_offset < first_page.total:
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=500,
)
page = HySdkRawPage.from_memory_list(value, limit=500, offset=raw_offset)
if (
page.total != first_page.total
or not page.raw
or raw_offset + len(page.raw) > first_page.total
):
snapshot_changed = True
break
items += graph_items(page.raw)
raw_offset += len(page.raw)
if snapshot_changed or raw_offset != first_page.total:
continue
verify_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
verify = HySdkRawPage.from_memory_list(verify_value, limit=500, offset=0)
verify_signature = tuple((record.raw_id, record.status) for record in verify.raw)
if verify.total != first_page.total or verify_signature != signature:
continue
raw_graph_cache.pop(cache_key, None)
raw_graph_cache[cache_key] = (
time.monotonic(),
first_page.total,
signature,
items,
graph_cache_size(items),
)
prune_raw_graph_state()
return items
raise RuntimeError("hy_memory_raw_snapshot_busy")
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4983,13 +5122,14 @@ def create_app(
async def sdk_raw_graph(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=300, ge=1, le=300),
limit: int = Query(default=500, ge=1, le=500),
snapshot_id: str | None = Query(default=None, max_length=64),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
statuses: list[str] | None = Query(default=None), # noqa: B008
) -> SdkRawGraphPage:
"""Bounded lightweight raw graph, independent of list pagination."""
"""Complete lightweight raw graph, independent of list pagination."""
chosen_statuses = (
_library_values(
statuses or [],
@@ -5001,49 +5141,85 @@ def create_app(
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
if offset:
snapshot_scope: tuple[object, ...] = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if offset and snapshot_id is None:
raise HTTPException(
status_code=422,
detail="raw_graph_is_single_page",
detail="raw_graph_snapshot_required",
)
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": "desc",
}
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=limit,
prune_raw_graph_state()
if snapshot_id is not None:
snapshot = raw_graph_snapshots.get(snapshot_id)
if snapshot is None or snapshot[1] != snapshot_scope:
raise HTTPException(
status_code=409,
detail="raw_graph_snapshot_stale",
)
items = snapshot[2]
else:
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"order": "desc",
}
cache_key: tuple[object, ...] = (
user_id,
tuple(sorted(chosen_statuses)),
)
page = HySdkRawPage.from_memory_list(
value,
limit=limit,
offset=0,
try:
client = await _sdk_client_handle(app).client()
catalog = await raw_graph_catalog(
client,
query=query,
cache_key=cache_key,
)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
source_set = set(sources)
excluded_set = set(excluded)
session_set = set(sessions)
items = tuple(
item
for item in catalog
if (not source_set or item.source in source_set)
and item.source not in excluded_set
and (not session_set or item.session_id in session_set)
)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
projected = graph_items(page.raw)
selected = projected[:limit]
total = max(page.total, len(projected))
snapshot_id = uuid4().hex
raw_graph_snapshots[snapshot_id] = (
time.monotonic(),
snapshot_scope,
items,
graph_cache_size(items),
)
prune_raw_graph_state()
if snapshot_id not in raw_graph_snapshots:
raise HTTPException(
status_code=413,
detail="raw_graph_snapshot_too_large",
)
if snapshot_id is None:
raise RuntimeError("raw_graph_snapshot_missing")
selected = items[offset : offset + limit]
return SdkRawGraphPage(
user_id=user_id,
items=selected,
total=total,
offset=0,
total=len(items),
offset=offset,
limit=limit,
has_more=page.total > len(page.raw) or len(projected) > limit,
has_more=offset + len(selected) < len(items),
snapshot_id=snapshot_id,
)
@app.post(
+35 -3
View File
@@ -838,10 +838,8 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
graph_calls = len(sdk.all("list_memories"))
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
assert graph.status_code == 200, graph.text
assert len(sdk.all("list_memories")) == graph_calls + 1
graph_body = graph.json()
assert graph_body["total"] == 3
assert graph_body["has_more"] is True
@@ -849,13 +847,35 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
]
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
graph_page_2 = (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": graph_body["snapshot_id"],
},
)
).json()
assert graph_page_2["total"] == 3
assert graph_page_2["has_more"] is False
assert graph_page_2["items"][0]["turn_id"]
assert (
await client.get(
"/api/v2/raw/graph",
params={"limit": 2, "offset": 2},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": "missing",
},
)
).status_code == 409
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
@@ -867,6 +887,18 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
}
RAW_ROWS["local"].insert(0, inserted)
try:
stable_graph_page = (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": graph_body["snapshot_id"],
},
)
).json()
assert stable_graph_page["total"] == 3
assert stable_graph_page["items"][0]["label"] == "[user]: 你好"
updated_graph = (await client.get("/api/v2/raw/graph")).json()
assert updated_graph["total"] == 4
assert updated_graph["items"][0]["label"] == "[user]: 新消息"
+116 -118
View File
@@ -266,9 +266,93 @@ export function FirstImportReview({
step={2}
title={text(title)}
footer={
<>
<div className="first-import-r6-footer-note">
{(error || actionMessage) && (
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
{importSubmissionState === "checking" ? (
<p className="first-import-r6-submission-status" role="status">
{t("onboardingImportChecking")}
</p>
) : (
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
)}
{ids.length > 3 && (
<p className="first-import-r6-scroll-hint">
{t("onboardingMoreImportApps", { count: ids.length - 3 })}
</p>
)}
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
<p
role={
error?.includes("应用连接") ||
@@ -287,87 +371,8 @@ export function FirstImportReview({
>
{error ? text(error) : actionMessage}
</p>
)}
</div>
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
</>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{importSubmissionState === "checking"
? t("onboardingImportChecking")
: text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
</p>
</div>
)}
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
{t("onboardingHistoryWindow")}
@@ -379,46 +384,39 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<p className="first-import-r6-feed-status">
{text("后台将持续为你导入,可以放心进入下一步")}
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
<div className="first-import-r6-options-head">
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
</div>
<SelectionTile
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
+24
View File
@@ -62,6 +62,30 @@ vi.mock("./MemoryGraph3D", () => ({
</div>
),
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: ({
graph,
selectedNodeId,
onSelectNode,
}: {
graph: MemoryGraphResponse;
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) => (
<div data-testid="canvas" data-selected={selectedNodeId}>
{graph.nodes.map((node) => (
<button
key={node.node_id}
type="button"
aria-label={`图中选择 ${node.label}`}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
</div>
),
}));
afterEach(() => {
cleanup();
vi.resetAllMocks();
+39 -8
View File
@@ -52,6 +52,7 @@ import {
} from "./memory-map-model";
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { RawConversationGraph3D } from "./RawConversationGraph3D";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SelectedRawDetail } from "./SelectedRawDetail";
import { sourceNameOr } from "./source-label";
@@ -107,6 +108,8 @@ type Props = {
/** The graph for the current source/query scope has not arrived yet. */
graphLoading?: boolean;
graphFailed?: boolean;
graphLoaded?: number;
graphTotal?: number;
stale: boolean;
settledSource?: string;
settledQuery?: string;
@@ -1324,6 +1327,23 @@ export function MemoryMap(props: Props) {
: text("记忆关联")
: label}
</h2>
{props.simplified && raw && library?.graphTotal !== undefined ? (
<p className="selected-graph-subtitle" role="status">
{library.graphLoading
? `${text("正在读取记忆地图…")} ${new Intl.NumberFormat(
locale,
).format(
library.graphLoaded ?? 0,
)} / ${new Intl.NumberFormat(locale).format(
library.graphTotal,
)}`
: `${new Intl.NumberFormat(locale).format(
library.graphLoaded ?? 0,
)} / ${new Intl.NumberFormat(locale).format(
library.graphTotal,
)}`}
</p>
) : null}
{props.simplified &&
(!raw || !projection.nodes.length) &&
!(raw && library?.graphLoading) && (
@@ -1354,14 +1374,25 @@ export function MemoryMap(props: Props) {
</header>
<div className="map-canvas" data-tour-id="memory-map-canvas">
{projection.nodes.length && (!props.simplified || !hidden) ? (
<MemoryGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
raw ? (
<RawConversationGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
) : (
<MemoryGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
)
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
@@ -34,7 +34,6 @@ it("shows every mode feature and reveals Ultra scheduling after selection", asyn
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.getByText("· Pro").className).toContain("memory-mode-tier");
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
+16 -19
View File
@@ -1,7 +1,7 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import "./memory-mode-cards.css";
@@ -33,10 +33,8 @@ export function MemoryModeCards({
currentBadge,
disabled = false,
layout = "grid",
describedBy,
onChange,
ultraDetail,
ultraDetailBusy = false,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
@@ -45,10 +43,8 @@ export function MemoryModeCards({
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
layout?: "grid" | "rows";
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
ultraDetailBusy?: boolean;
"aria-label": string;
"aria-busy"?: boolean;
}) {
@@ -68,8 +64,11 @@ export function MemoryModeCards({
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const title = modeDisplayName(text, mode.id);
const tier = MODE_TIER[mode.id];
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
@@ -87,8 +86,7 @@ export function MemoryModeCards({
type="radio"
name={name}
value={mode.id}
aria-label={`${title} · ${tier}`}
aria-describedby={describedBy?.[mode.id]}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
@@ -96,7 +94,9 @@ export function MemoryModeCards({
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">· {tier}</span>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
@@ -115,16 +115,13 @@ export function MemoryModeCards({
</span>
))}
</span>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div
className="memory-mode-detail"
aria-busy={ultraDetailBusy || undefined}
>
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</div>
);
})}
+39 -135
View File
@@ -75,13 +75,9 @@ async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
await user.click(control);
const option = await screen.findByRole("option", { name });
const option = await screen.findByRole("radio", { name });
await user.click(option);
return control;
return option;
}
afterEach(() => {
@@ -92,43 +88,17 @@ afterEach(() => {
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("shows the current mode in a compact dropdown", async () => {
it("renders all settings modes as one grouped shared choice list", async () => {
prepare("pro");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const control = await screen.findByRole("combobox", {
name: "记忆模式",
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
});
expect(control.textContent).toContain("智能记忆 · Pro");
expect(control.textContent).toContain("沉淀高级记忆");
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
control.querySelector(".om-option-menu-suffix")?.textContent,
).toContain("· Pro");
expect(control.textContent).not.toContain("(");
expect(screen.queryByText("当前模式")).toBeNull();
expect(screen.queryByRole("radiogroup")).toBeNull();
await user.click(control);
expect(screen.getAllByRole("option")).toHaveLength(3);
});
it("shows the effective Ultra mode and disables selection when forced by the environment", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
configured_mode: "pro",
forced_by_env: true,
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
expect(control.textContent).toContain("深度记忆 · Ultra");
expect(control).toHaveProperty("disabled", true);
expect(screen.getByRole("switch", { name: "每天自动整合" })).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆PRO");
});
it("confirms the data effects before switching to lite", async () => {
@@ -141,7 +111,7 @@ it("confirms the data effects before switching to lite", async () => {
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
await chooseMode(user, /基础记忆(lite)/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -151,18 +121,18 @@ it("confirms the data effects before switching to lite", async () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("combobox", { name: "记忆模式" }),
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode dropdown", async () => {
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 lite = await chooseMode(user, /基础记忆(lite)/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
@@ -184,7 +154,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
await chooseMode(user, /基础记忆(lite)/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
@@ -215,7 +185,7 @@ it("routes to model setup instead of switching pro without a model", async () =>
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆 · Pro/);
await chooseMode(user, /智能记忆(pro)/);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
@@ -229,29 +199,11 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
await chooseMode(user, /基础记忆(lite)/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("restores focus to the mode dropdown when planning fails", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
expect(
await screen.findByText("模式选择影响暂时无法读取,请重试。"),
).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("combobox", { name: "记忆模式" }),
),
);
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
@@ -259,7 +211,7 @@ it("keeps downgrade effects when an old Core falls back from ultra to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆 · Pro/);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -271,7 +223,7 @@ it("explains model cost, search scope and the next step before switching to ultr
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆 · Ultra/);
await chooseMode(user, /深度记忆(ultra)/);
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -287,7 +239,7 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆 · Pro/);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -304,7 +256,7 @@ it("does not show automatic consolidation when switching from lite to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆 · Pro/);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -321,10 +273,10 @@ it("focuses the Evolve settings after switching to ultra", async () => {
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("combobox", { name: "记忆模式" });
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆 · Ultra/);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
@@ -335,29 +287,6 @@ it("focuses the Evolve settings after switching to ultra", async () => {
).toBeTruthy();
});
it("focuses the recoverable Evolve section when Ultra schedule loading fails", async () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValue(new Error("offline"));
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("combobox", { name: "记忆模式" });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(screen.getByText("自动整合设置暂时无法读取,请重试。")).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
@@ -376,26 +305,6 @@ it("shows the default nightly evolve controls in ultra", async () => {
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("keeps the immediate action visible and recovers when the schedule read fails", async () => {
prepare("ultra");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("自动整合设置暂时无法读取,请重试。"),
).toBeTruthy();
expect(screen.queryByRole("switch", { name: "每天自动整合" })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "重新读取" }));
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await waitFor(() => expect(document.activeElement).toBe(automatic));
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -534,9 +443,9 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
expect(mode.textContent).toContain("智能记忆 · Pro");
expect((pro as HTMLInputElement).checked).toBe(true);
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
@@ -550,9 +459,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByRole("combobox", {
name: "记忆模式",
}),
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
).toHaveProperty("disabled", false);
});
@@ -565,17 +474,15 @@ it("offers a text recovery action when the mode read fails", async () => {
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
false,
),
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
);
});
@@ -586,7 +493,7 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆 · Ultra/);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
@@ -607,17 +514,15 @@ it("keeps lite available while model status is unknown", async () => {
/>,
);
const user = userEvent.setup();
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
expect(
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
).toHaveProperty("disabled", true);
});
@@ -633,10 +538,9 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
+93 -152
View File
@@ -23,14 +23,8 @@ import {
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
MODE_TIER,
modeDisplayName,
} from "./memory-mode-ui";
import { OptionMenu } from "./OptionMenu";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -64,39 +58,28 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const focusScheduleWhenReady = useRef(false);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
return;
const evolve = document.getElementById(`${id}-evolve`);
if (evolve) {
focusEvolveWhenReady.current = false;
evolve.focus();
}
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.getElementById(`${id}-select`)?.focus();
}, [busy, id, pendingMode, state?.mode]);
useEffect(() => {
if (!focusScheduleWhenReady.current || scheduleReadFailed || !schedule)
return;
focusScheduleWhenReady.current = false;
document
.getElementById(`${id}-evolve`)
?.querySelector<HTMLElement>('[role="switch"]')
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [id, schedule, scheduleReadFailed]);
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
@@ -118,12 +101,8 @@ export function MemoryModeSettings({
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled") {
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
setScheduleReadFailed(false);
} else {
setScheduleReadFailed(true);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -144,7 +123,7 @@ export function MemoryModeSettings({
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode) {
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
setError("");
setBusy(true);
@@ -154,7 +133,6 @@ export function MemoryModeSettings({
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
focusModeWhenReady.current = state.mode;
setBusy(false);
return;
}
@@ -165,7 +143,7 @@ export function MemoryModeSettings({
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
dialogOpener.current = document.getElementById(`${id}-select`);
dialogOpener.current = opener;
setPendingPlan(plan);
setPendingMode(mode);
}
@@ -217,7 +195,7 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state) return;
if (!state || !schedule) return;
refreshVersion.current++;
setBusy(true);
setError("");
@@ -225,15 +203,12 @@ export function MemoryModeSettings({
const receipt = await startEvolve();
commitEvolveRun(
receipt,
overview ??
(schedule
? {
mode: state.mode,
schedule,
users: {},
running: [],
}
: undefined),
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
);
} catch (reason) {
const code = apiErrorCode(reason);
@@ -259,69 +234,28 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<div
className="r6-mode-control"
data-expanded={state?.mode === "ultra" || undefined}
>
<div className="r6-mode-setting-row" aria-busy={state === null}>
<OptionMenu
id={`${id}-select`}
ariaLabel={text("记忆模式")}
value={state?.mode ?? ""}
disabled={
!state || busy || lifecycleBusy || Boolean(state?.forced_by_env)
}
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
value: mode,
label: modeDisplayName(text, mode),
suffix: MODE_TIER[mode],
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")
void choose(value);
}}
/>
</div>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
{schedule ? (
<MemoryModeCards
aria-label={text("当前模式")}
aria-busy={state === null}
current={state?.mode}
value={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)}
ultraDetail={
state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings r6-evolve-settings--in-card"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
@@ -329,56 +263,63 @@ export function MemoryModeSettings({
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
) : (
<>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>
{text("记忆整合(Evolve)")}
</h3>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
{scheduleReadFailed ? (
<R6StateRecovery
message={text("自动整合设置暂时无法读取,请重试。")}
onRetry={() => {
focusScheduleWhenReady.current = true;
void refresh();
}}
/>
) : null}
</>
)}
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
</div>
</section>
) : null
}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
+85 -78
View File
@@ -129,6 +129,9 @@ vi.mock("./MemoryGraph3D", () => ({
/>
),
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
}));
afterEach(() => {
cleanup();
resetClient();
@@ -320,9 +323,10 @@ function setup() {
},
],
total: 1,
limit: 300,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "snapshot-1",
});
reads.rawSearch.mockResolvedValue([]);
}
@@ -427,7 +431,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect.objectContaining({
source: undefined,
offset: 0,
limit: 20,
limit: 50,
signal: expect.any(AbortSignal),
}),
);
@@ -437,92 +441,94 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
it("loads the complete raw graph independently and reports its exact total", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: Array.from({ length: 300 }, (_, index) => ({
turn_id: `turn-${index}`,
raw_id: `raw-${index}`,
label: `原始会话 ${index}`,
source: "cursor",
session_id: `session-${index}`,
memory_at: null,
gmt_created: 1_761_000_000,
})),
total: 1_600,
limit: 300,
offset: 0,
has_more: true,
reads.rawGraph.mockImplementation(async ({ offset = 0 }) => {
const total = 1_600;
const count = Math.min(500, total - offset);
return {
user_id: "local",
items: Array.from({ length: count }, (_, index) => {
const absolute = offset + index;
return {
turn_id: `turn-${absolute}`,
raw_id: `raw-${absolute}`,
label: `原始会话 ${absolute}`,
source: "cursor",
session_id: `session-${absolute}`,
memory_at: null,
gmt_created: 1_761_000_000,
};
}),
total,
limit: 500,
offset,
has_more: offset + count < total,
snapshot_id: "snapshot-1600",
};
});
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledWith({
source: undefined,
offset: 0,
limit: 300,
signal: expect.any(AbortSignal),
});
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
"1,600 条原始对话",
);
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
expect(await screen.findByText("1,600 条原始对话")).toBeTruthy();
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(4));
expect(reads.rawGraph.mock.calls.map(([request]) => request.offset)).toEqual([
0, 500, 1_000, 1_500,
]);
expect(
reads.rawGraph.mock.calls
.slice(1)
.every(([request]) => request.snapshotId === "snapshot-1600"),
).toBe(true);
expect(screen.getByText("1,600 / 1,600")).toBeTruthy();
});
it("keeps the last successful Lite graph when a background refresh fails", async () => {
it("restarts the complete graph when its snapshot expires between pages", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
const source = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[source]} />);
const item = {
turn_id: "turn-1",
raw_id: "raw-1",
label: "原始会话",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
};
reads.rawGraph
.mockResolvedValueOnce({
user_id: "local",
items: [item],
total: 2,
limit: 500,
offset: 0,
has_more: true,
snapshot_id: "expired",
})
.mockRejectedValueOnce(
Object.assign(new Error("stale"), {
isAxiosError: true,
response: { data: { detail: "raw_graph_snapshot_stale" } },
}),
)
.mockResolvedValueOnce({
user_id: "local",
items: [item],
total: 1,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "fresh",
});
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
);
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
view.rerender(
<MemorySpace
simplified
sources={[
source,
{
...source,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
});
it("does not request the raw graph for a keyword search", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawSearch.mockResolvedValue([]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
await user.type(
screen.getByRole("textbox", { name: "搜索原始对话" }),
"发布计划",
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(3));
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
expect.objectContaining({ offset: 0, snapshotId: undefined }),
);
await user.click(screen.getByRole("button", { name: "搜索" }));
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
expect(screen.getByText("1 / 1")).toBeTruthy();
});
it("pages and filters the raw conversation data plane in lite", async () => {
@@ -583,7 +589,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
limit: 50,
}),
);
@@ -595,7 +601,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
limit: 50,
}),
);
@@ -608,7 +614,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
limit: 50,
paging: "cursor",
cursor: null,
}),
@@ -831,9 +837,10 @@ it("opens a search hit with the complete role transcript", async () => {
user_id: "local",
items: [],
total: 1_600,
limit: 300,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "search-total",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
+79 -21
View File
@@ -9,6 +9,7 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkRawGraphItem,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
@@ -200,6 +201,11 @@ export function MemorySpace({
status: "ready" | "failed";
value: MemoryGraphResponse | null;
} | null>(null);
const [sdkGraphProgress, setSdkGraphProgress] = useState<{
scope: string;
loaded: number;
total: number | null;
} | null>(null);
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
@@ -263,11 +269,16 @@ export function MemorySpace({
]);
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
const sdkGraphFailed =
sdkGraph?.scope === sdkGraphRequestedScope &&
sdkGraph.status === "failed" &&
sdkGraph.value === null;
(sdkGraph.status === "ready" || evolveRunning)
? sdkGraph.value
: null;
const sdkGraphFailed =
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
const visibleGraphProgress =
sdkGraphProgress?.scope === sdkGraphRequestedScope
? sdkGraphProgress
: null;
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -445,6 +456,8 @@ export function MemorySpace({
const request = new AbortController();
let cancelled = false;
let reading = false;
rawGraphRecords.current.clear();
setSdkGraphProgress({ scope, loaded: 0, total: null });
const readGraph = async () => {
if (reading) return;
reading = true;
@@ -456,25 +469,58 @@ export function MemorySpace({
);
if (cancelled) return;
setSdkGraph({ scope, status: "ready", value });
setSdkGraphProgress({
scope,
loaded: value.projected_memories,
total: value.total_memories,
});
return;
}
const page = await getSdkRawGraph({
source,
offset: 0,
limit: 300,
signal: request.signal,
});
if (cancelled) return;
rawGraphRecords.current.clear();
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
for (let attempt = 0; attempt < 2; attempt += 1) {
const items: SdkRawGraphItem[] = [];
let offset = 0;
let total = 0;
let snapshotId: string | undefined;
rawGraphRecords.current.clear();
try {
while (!cancelled) {
const page = await getSdkRawGraph({
source,
offset,
limit: 500,
snapshotId,
signal: request.signal,
});
if (cancelled) return;
snapshotId = page.snapshot_id;
total = page.total;
items.push(...page.items);
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
}
setSdkGraphProgress({ scope, loaded: items.length, total });
if (!page.has_more || page.items.length === 0) break;
offset += page.items.length;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
} catch (reason) {
if (
attempt === 0 &&
apiErrorCode(reason) === "raw_graph_snapshot_stale"
)
continue;
throw reason;
}
if (cancelled) return;
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(items, total, sourcesRef.current),
});
break;
}
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
});
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph((current) => ({
@@ -501,7 +547,17 @@ export function MemorySpace({
sdkMapVisible,
sourceKey,
]);
const displayedSdkPage = sdkPage;
const displayedSdkPage =
lite &&
!sdkLibraryQuery.trim() &&
sdkPage &&
visibleGraphProgress?.total != null
? {
...sdkPage,
total: visibleGraphProgress.total,
total_exact: true,
}
: sdkPage;
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
@@ -1341,6 +1397,8 @@ export function MemorySpace({
failed: sdkLibrary.failed && !evolveRunning,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed && !evolveRunning,
graphLoaded: visibleGraphProgress?.loaded,
graphTotal: visibleGraphProgress?.total ?? undefined,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -156,7 +156,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();
+25 -62
View File
@@ -240,7 +240,7 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await selectMode(user, /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 selectMode(user, /lite/i);
@@ -688,7 +688,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1031,12 +1031,6 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(
screen.getByRole("heading", {
name: "你希望 MemHub 如何形成记忆?",
}),
).toBeTruthy();
expect(screen.queryByText("Choose how MemHub remembers")).toBeNull();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
@@ -1049,7 +1043,7 @@ describe("OnboardingFlow S1.3", () => {
await selectMode(user, /lite/i);
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await selectMode(user, /pro/i);
expect(screen.getByRole("button", { name: "配置模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("configures Ultra scheduling before model setup and completes after activation", async () => {
@@ -1089,19 +1083,10 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const ultra = screen.getByRole("radio", { name: /ultra/i });
await user.click(ultra);
const schedule = screen.getByText("每天自动整合");
expect(
schedule.closest(".memory-mode-card")?.getAttribute("data-mode"),
).toBe("ultra");
expect(schedule.closest("label")).toBeNull();
expect(ultra.getAttribute("aria-describedby")).toBe(
"onboarding-ultra-schedule-description",
);
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(screen.getByText("每天自动整合")).toBeTruthy();
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
expect(screen.getByText("后续可在设置页自定义时间或关闭。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1164,15 +1149,14 @@ describe("OnboardingFlow S1.3", () => {
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
),
);
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
expect(screen.queryByText("后续可在设置页自定义时间或关闭。")).toBeNull();
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1206,7 +1190,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"自动整合设置未能保存",
);
@@ -1215,7 +1199,7 @@ describe("OnboardingFlow S1.3", () => {
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1223,14 +1207,9 @@ describe("OnboardingFlow S1.3", () => {
});
it("retries a failed Ultra schedule read without losing the selection", async () => {
let resolveRetry!: (value: { enabled: boolean; time: string }) => void;
vi.mocked(api.getEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockReturnValueOnce(
new Promise((resolve) => {
resolveRetry = resolve;
}),
);
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
const user = userEvent.setup();
render(
<OnboardingFlow
@@ -1246,35 +1225,19 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("alert").textContent).toContain(
"记忆模式状态暂时无法读取",
);
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
const retry = screen.getByRole("button", { name: "重试" });
expect(retry.closest(".memory-mode-card")?.getAttribute("data-mode")).toBe(
"ultra",
);
expect(retry.closest("label")).toBeNull();
retry.focus();
await user.click(retry);
expect(screen.getByRole("button", { name: "重试" })).toHaveProperty(
"disabled",
true,
);
expect(
retry.closest(".memory-mode-detail")?.getAttribute("aria-busy"),
).toBe("true");
expect(document.activeElement).toBe(retry);
expect(api.getEvolveSchedule).toHaveBeenCalledTimes(2);
await act(async () => resolveRetry({ enabled: true, time: "04:15" }));
await user.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() =>
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
);
@@ -1467,7 +1430,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1521,7 +1484,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1529,7 +1492,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: "返回导入" }));
modelActive = false;
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1575,7 +1538,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1617,7 +1580,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1666,7 +1629,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1706,7 +1669,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
@@ -1736,7 +1699,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
@@ -1755,7 +1718,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1804,14 +1767,14 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "配置模型" }));
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: "配置模型" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
+16 -44
View File
@@ -112,7 +112,7 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t } = useOnboardingTranslation();
const { text, t, locale } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -165,7 +165,6 @@ export function OnboardingFlow({
});
const [scheduleLoaded, setScheduleLoaded] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const [scheduleLoading, setScheduleLoading] = useState(true);
const scheduleReadOperation = useRef(0);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
@@ -431,7 +430,7 @@ export function OnboardingFlow({
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleLoading(true);
setScheduleReadFailed(false);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
@@ -447,9 +446,6 @@ export function OnboardingFlow({
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
} finally {
if (alive.current && scheduleReadOperation.current === operation)
setScheduleLoading(false);
}
}, []);
@@ -1264,11 +1260,11 @@ export function OnboardingFlow({
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: !modeChoice
? "继续"
: modeChoice === "lite"
? "使用 Lite 进入"
: "配置模型",
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
}
@@ -1278,21 +1274,18 @@ export function OnboardingFlow({
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
<h2>
{locale === "zh-Hans"
? "Choose how MemHub remembers"
: text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
layout="rows"
describedBy={{
ultra:
modeChoice === "ultra"
? "onboarding-ultra-schedule-description"
: undefined,
}}
disabled={modeSaving || completionBusy}
ultraDetailBusy={scheduleLoading}
onChange={(mode) => {
modeChoiceTouched.current = true;
intentRef.current = mode;
@@ -1301,51 +1294,30 @@ export function OnboardingFlow({
}}
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default" role="status">
<div className="onboarding-r6-ultra-default">
<strong>{text("每天自动整合")}</strong>
<small id="onboarding-ultra-schedule-description">
<small>
{text(
ultraSchedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", ultraSchedule.time)}
{ultraSchedule.enabled ? (
<>
<br />
<span>
{text("后续可在设置页自定义时间或关闭。")}
</span>
</>
) : null}
</small>
</div>
) : scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p
id="onboarding-ultra-schedule-description"
role="alert"
>
<p role="alert">
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
pending={scheduleLoading}
disabled={
scheduleLoading || modeSaving || completionBusy
}
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : (
<p
id="onboarding-ultra-schedule-description"
className="onboarding-r6-schedule-loading"
role="status"
>
{text("正在读取自动整合设置…")}
</p>
<p>{text("正在读取自动整合设置…")}</p>
)
}
/>
-70
View File
@@ -1,70 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
afterEach(cleanup);
it("shows a suffix without requiring description copy", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{ value: "lite", label: "基础记忆", suffix: "Lite" },
{ value: "pro", label: "智能记忆", suffix: "Pro" },
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
const control = screen.getByRole("combobox", { name: "记忆模式" });
expect(control.textContent).toContain("智能记忆 · Pro");
await user.click(control);
expect(screen.getByRole("option", { name: "基础记忆 · Lite" })).toBeTruthy();
});
it("keeps option descriptions available to assistive technology", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{
value: "lite",
label: "基础记忆",
description: "保存原始会话",
},
{
value: "pro",
label: "智能记忆",
suffix: "Pro",
description: "调用模型整理记忆",
},
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
await user.click(screen.getByRole("combobox", { name: "记忆模式" }));
const option = screen.getByRole("option", { name: "智能记忆 · Pro" });
const descriptionId = option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
"调用模型整理记忆",
);
expect(
screen
.getByRole("option", { name: /基础记忆.*保存原始会话/ })
.getAttribute("aria-describedby"),
).toBeNull();
});
+5 -27
View File
@@ -13,7 +13,6 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
suffix?: string;
description?: string;
disabled?: boolean;
lang?: string;
@@ -23,17 +22,6 @@ const VIEWPORT_MARGIN = 12;
const PANEL_GAP = 4;
const MAX_PANEL_HEIGHT = 280;
function optionLabel(label: string, suffix?: string) {
return suffix ? (
<>
{label}
<span className="om-option-menu-suffix"> · {suffix}</span>
</>
) : (
label
);
}
export function optionMenuPosition(
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
panelHeight: number,
@@ -280,11 +268,11 @@ export function OptionMenu(props: {
>
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
<span>{visibleLabel}</span>
<small>{selected.description}</small>
</span>
) : (
optionLabel(visibleLabel, selected?.suffix)
visibleLabel
)}
</button>
{open &&
@@ -311,14 +299,6 @@ export function OptionMenu(props: {
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
aria-label={
item.suffix ? `${item.label} · ${item.suffix}` : undefined
}
aria-describedby={
item.description && item.suffix
? `${panelId}-option-${index}-description`
: undefined
}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
@@ -328,13 +308,11 @@ export function OptionMenu(props: {
>
{item.description ? (
<span className="om-option-menu-copy">
<span>{optionLabel(item.label, item.suffix)}</span>
<small id={`${panelId}-option-${index}-description`}>
{item.description}
</small>
<span>{item.label}</span>
<small>{item.description}</small>
</span>
) : (
optionLabel(item.label, item.suffix)
item.label
)}
</button>
))}
@@ -82,7 +82,7 @@ it("does not reserve an empty eyebrow row", () => {
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
});
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
@@ -94,12 +94,13 @@ it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
"first-import-r6.css",
".first-import-r6-main > .r6-import-progress",
),
).toMatch(/flex:\s*0 0 218px/);
const layout = declaration("first-import-r6.css", ".first-import-r6-layout");
expect(layout).toMatch(
/grid-template-columns:\s*minmax\(0,\s*738px\)\s+278px/,
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*100%/,
);
expect(layout).toMatch(/height:\s*560\.2px/);
expect(
declaration("first-import-r6.css", ".first-import-r6-feedback"),
).not.toMatch(/max-height|overflow/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
);
-1
View File
@@ -25,7 +25,6 @@ describe("R6 shared controls", () => {
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
expect(button.getAttribute("data-pending-capable")).toBe("true");
const reservedSpinner = button.querySelector(".r6-button-spinner");
expect(reservedSpinner).toBeTruthy();
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
-1
View File
@@ -28,7 +28,6 @@ export function R6Button({
type={type}
className={`r6-button ${className}`}
data-variant={variant}
data-pending-capable={supportsPending || undefined}
disabled={disabled || busy}
aria-busy={busy || undefined}
>
+2 -9
View File
@@ -204,13 +204,7 @@ it("uses one sidebar hierarchy and explains when a saved model is inactive in li
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(
await screen.findByText(
(_, element) =>
element?.tagName === "STRONG" &&
element.textContent === "基础记忆 · Lite",
),
).toBeTruthy();
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
expect(
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
).toBeTruthy();
@@ -249,9 +243,8 @@ it("carries a missing-model explanation into model configuration and moves focus
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
await user.click(
await screen.findByRole("option", { name: /智能记忆 · Pro/ }),
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
);
expect(
+8 -11
View File
@@ -18,7 +18,6 @@ import {
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -395,16 +394,14 @@ export function R6Settings({
{currentModel && (
<div className="r6-model-mode-status" role="status">
<strong>
{memoryMode ? (
<>
{modeDisplayName(text, memoryMode)}
<span className="r6-model-mode-tier">
{" "}
· {MODE_TIER[memoryMode]}
</span>
</>
) : (
text("正在读取记忆模式…")
{text(
memoryMode === "lite"
? "当前是基础记忆(lite)"
: memoryMode === "ultra"
? "当前是深度记忆(ultra)"
: memoryMode === "pro"
? "当前是智能记忆(pro)"
: "正在读取记忆模式…",
)}
</strong>
<p>
+7 -12
View File
@@ -8,19 +8,14 @@ function declaration(file: string, selector: string): string {
}
it("fills settings controls without widening shared fallback controls", () => {
const modeMenu = declaration(
"r6-settings.css",
".r6-settings .r6-mode-option-menu",
);
expect(modeMenu).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(modeMenu).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
const fallback = declaration(
"r6-settings.css",
for (const selector of [
".r6-memory-mode-settings .memory-mode-cards",
".r6-settings .connection-fallback .custom-harness-name",
);
expect(fallback).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(fallback).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
]) {
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*;/,
);
@@ -0,0 +1,80 @@
import type {
MemoryGraphEdge,
MemoryGraphNode,
MemoryGraphResponse,
} from "@one-memory/contracts";
import { describe, expect, it } from "vitest";
import { createRawClusterLayout } from "./RawConversationGraph3D";
function makeRawGraph(count: number, sourceCount = 4): MemoryGraphResponse {
const sources: MemoryGraphNode[] = Array.from(
{ length: sourceCount },
(_, index) => ({
node_id: `source:s-${index}`,
node_type: "source",
memory_id: null,
source_id: `s-${index}`,
label: `Source ${index}`,
kind: null,
status: null,
confidence: null,
}),
);
const memories: MemoryGraphNode[] = Array.from(
{ length: count },
(_, index) => ({
node_id: `memory:m-${index}`,
node_type: "memory",
memory_id: `m-${index}`,
source_id: null,
label: `Turn ${index}`,
kind: "chat",
status: "confirmed",
confidence: null,
}),
);
const edges: MemoryGraphEdge[] = memories.map((memory, index) => ({
edge_id: `edge-${index}`,
source_node_id: sources[index % sourceCount].node_id,
target_node_id: memory.node_id,
relation: "evidence_source",
origin: "provenance",
weight: null,
}));
return {
total_memories: count,
projected_memories: count,
hy_graph_available: false,
nodes: [...sources, ...memories],
edges,
};
}
describe("createRawClusterLayout", () => {
it.each([1_600, 5_000])(
"positions every node in bounded linear time at %i turns",
(count) => {
const graph = makeRawGraph(count);
const started = performance.now();
const positions = createRawClusterLayout(graph);
const elapsed = performance.now() - started;
expect(positions.size).toBe(graph.nodes.length);
expect(elapsed).toBeLessThan(1_000);
for (const position of positions.values()) {
expect(Number.isFinite(position.x)).toBe(true);
expect(Number.isFinite(position.y)).toBe(true);
expect(Number.isFinite(position.z)).toBe(true);
}
},
);
it("is deterministic", () => {
const graph = makeRawGraph(80);
const first = createRawClusterLayout(graph);
const second = createRawClusterLayout(graph);
expect([...first].map(([id, value]) => [id, value.toArray()])).toEqual(
[...second].map(([id, value]) => [id, value.toArray()]),
);
});
});
@@ -0,0 +1,421 @@
import type {
MemoryGraphNode,
MemoryGraphResponse,
} from "@one-memory/contracts";
import { Focus, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { useMemoryLanguage } from "./memory-language";
const GRAPH_CANVAS = 0xedf1f5;
const MEMORY_COLOR = new THREE.Color(0x4e6478);
const SELECTED_COLOR = new THREE.Color(0x294355);
/** O(n) deterministic source clusters for the complete lite raw map. */
export function createRawClusterLayout(
graph: MemoryGraphResponse,
): Map<string, THREE.Vector3> {
const positions = new Map<string, THREE.Vector3>();
const sources = graph.nodes.filter((node) => node.node_type === "source");
const sourceIds = new Set(sources.map((node) => node.node_id));
const sourceByMemory = new Map<string, string>();
for (const edge of graph.edges) {
const sourceId = sourceIds.has(edge.source_node_id)
? edge.source_node_id
: sourceIds.has(edge.target_node_id)
? edge.target_node_id
: null;
if (!sourceId) continue;
const memoryId =
edge.source_node_id === sourceId
? edge.target_node_id
: edge.source_node_id;
sourceByMemory.set(memoryId, sourceId);
}
const fallbackSource = sources[0]?.node_id ?? "source:unknown";
const groups = new Map<string, MemoryGraphNode[]>();
for (const node of graph.nodes) {
if (node.node_type !== "memory") continue;
const sourceId = sourceByMemory.get(node.node_id) ?? fallbackSource;
const group = groups.get(sourceId) ?? [];
group.push(node);
groups.set(sourceId, group);
}
const clusterRing = Math.max(3.5, sources.length * 1.2);
sources.forEach((source, sourceIndex) => {
const angle = (sourceIndex / Math.max(1, sources.length)) * Math.PI * 2;
const center = new THREE.Vector3(
Math.cos(angle) * clusterRing,
((sourceIndex % 3) - 1) * 1.4,
Math.sin(angle) * clusterRing,
);
positions.set(source.node_id, center);
const memories = groups.get(source.node_id) ?? [];
memories.forEach((memory, index) => {
const direction = seededDirection(memory.node_id);
const radius = 0.9 + Math.cbrt(index + 1) * 0.42;
positions.set(
memory.node_id,
center.clone().addScaledVector(direction, radius),
);
});
});
return positions;
}
export function RawConversationGraph3D({
graph,
selectedNodeId,
onSelectNode,
}: {
graph: MemoryGraphResponse;
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const selectedRef = useRef(selectedNodeId);
const graphRef = useRef(graph);
const applySelection = useRef<(() => void) | null>(null);
const actions = useRef<{
zoom(scale: number): void;
reset(): void;
} | null>(null);
const [available, setAvailable] = useState(true);
const [hovered, setHovered] = useState<{
label: string;
x: number;
y: number;
} | null>(null);
selectRef.current = onSelectNode;
graphRef.current = graph;
const identity = useMemo(
() =>
`${graph.nodes
.map((node) => `${node.node_id}\0${node.label}`)
.join("\n")}\n${graph.edges
.map(
(edge) =>
`${edge.edge_id}\0${edge.source_node_id}\0${edge.target_node_id}`,
)
.join("\n")}`,
[graph],
);
useEffect(() => {
selectedRef.current = selectedNodeId;
applySelection.current?.();
}, [selectedNodeId]);
useEffect(() => {
void identity;
const graph = graphRef.current;
const element = host.current;
if (!element) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch {
setAvailable(false);
return;
}
setAvailable(true);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000, 0);
element.appendChild(renderer.domElement);
renderer.domElement.setAttribute("aria-hidden", "true");
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(GRAPH_CANVAS, 0.025);
scene.add(new THREE.AmbientLight(0xf2f6fa, 1.8));
const directional = new THREE.DirectionalLight(0xe3edf5, 2);
directional.position.set(8, 10, 12);
scene.add(directional);
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 240);
const defaultDistance = Math.max(13, Math.sqrt(graph.nodes.length) * 0.55);
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
controls.minDistance = 4;
controls.maxDistance = 140;
const positions = createRawClusterLayout(graph);
const nodeById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const memories = graph.nodes.filter((node) => node.node_type === "memory");
const sources = graph.nodes.filter((node) => node.node_type === "source");
const memoryGeometry = new THREE.SphereGeometry(0.14, 10, 8);
const memoryMaterial = new THREE.MeshStandardMaterial({
color: MEMORY_COLOR,
roughness: 0.78,
metalness: 0.04,
vertexColors: true,
});
const instances = new THREE.InstancedMesh(
memoryGeometry,
memoryMaterial,
Math.max(1, memories.length),
);
instances.count = memories.length;
const matrix = new THREE.Matrix4();
memories.forEach((node, index) => {
matrix.setPosition(positions.get(node.node_id) ?? new THREE.Vector3());
instances.setMatrixAt(index, matrix);
instances.setColorAt(index, MEMORY_COLOR);
});
instances.instanceMatrix.needsUpdate = true;
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
scene.add(instances);
const sourceGeometry = new THREE.SphereGeometry(0.3, 16, 12);
const sourceMeshes = sources.map((node) => {
const material = new THREE.MeshStandardMaterial({
color: 0x2f6b57,
roughness: 0.65,
});
const mesh = new THREE.Mesh(sourceGeometry, material);
mesh.position.copy(positions.get(node.node_id) ?? new THREE.Vector3());
mesh.userData.nodeId = node.node_id;
scene.add(mesh);
return mesh;
});
const edgePositions = new Float32Array(graph.edges.length * 6);
graph.edges.forEach((edge, index) => {
const start = positions.get(edge.source_node_id);
const end = positions.get(edge.target_node_id);
if (!start || !end) return;
edgePositions.set(
[start.x, start.y, start.z, end.x, end.y, end.z],
index * 6,
);
});
const edgeGeometry = new THREE.BufferGeometry();
edgeGeometry.setAttribute(
"position",
new THREE.BufferAttribute(edgePositions, 3),
);
const edgeMaterial = new THREE.LineBasicMaterial({
color: 0x91a99c,
transparent: true,
opacity: 0.24,
});
scene.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));
const render = () => renderer.render(scene, camera);
const reset = () => {
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
controls.target.set(0, 0, 0);
controls.update();
render();
};
actions.current = {
zoom(scale) {
const offset = camera.position.clone().sub(controls.target);
offset.setLength(
Math.max(
controls.minDistance,
Math.min(controls.maxDistance, offset.length() * scale),
),
);
camera.position.copy(controls.target).add(offset);
controls.update();
render();
},
reset,
};
applySelection.current = () => {
memories.forEach((node, index) => {
instances.setColorAt(
index,
node.node_id === selectedRef.current ? SELECTED_COLOR : MEMORY_COLOR,
);
});
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
for (const mesh of sourceMeshes) {
const material = mesh.material as THREE.MeshStandardMaterial;
material.color.setHex(
mesh.userData.nodeId === selectedRef.current ? 0x294355 : 0x2f6b57,
);
}
render();
};
applySelection.current();
const resize = () => {
const width = Math.max(1, element.clientWidth);
const height = Math.max(1, element.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
render();
};
const observer = new ResizeObserver(resize);
observer.observe(element);
controls.addEventListener("change", render);
resize();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let lastRaycast = 0;
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
const hit = (event: PointerEvent) => {
const bounds = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const match = raycaster.intersectObjects(
[instances, ...sourceMeshes],
false,
)[0];
if (!match) return null;
if (match.object === instances && match.instanceId !== undefined)
return memories[match.instanceId] ?? null;
return nodeById.get(String(match.object.userData.nodeId)) ?? null;
};
const move = (event: PointerEvent) => {
if (pressed)
pressed.moved ||=
Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 5;
const now = performance.now();
if (now - lastRaycast < 50 || pressed) return;
lastRaycast = now;
const node = hit(event);
renderer.domElement.style.cursor = node ? "pointer" : "grab";
setHovered(
node
? {
label: node.label,
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
: null,
);
};
const pointerDown = (event: PointerEvent) => {
pressed =
event.button === 0
? {
x: event.clientX,
y: event.clientY,
id: event.pointerId,
moved: false,
}
: null;
};
const select = (event: PointerEvent) => {
const start = pressed;
pressed = null;
if (!start || start.id !== event.pointerId || start.moved) return;
const node = hit(event);
if (node) selectRef.current(node);
};
const leave = () => {
pressed = null;
setHovered(null);
};
renderer.domElement.addEventListener("pointerdown", pointerDown);
renderer.domElement.addEventListener("pointermove", move);
renderer.domElement.addEventListener("pointerup", select);
renderer.domElement.addEventListener("pointerleave", leave);
return () => {
applySelection.current = null;
actions.current = null;
observer.disconnect();
controls.removeEventListener("change", render);
controls.dispose();
renderer.domElement.removeEventListener("pointerdown", pointerDown);
renderer.domElement.removeEventListener("pointermove", move);
renderer.domElement.removeEventListener("pointerup", select);
renderer.domElement.removeEventListener("pointerleave", leave);
memoryGeometry.dispose();
memoryMaterial.dispose();
sourceGeometry.dispose();
for (const mesh of sourceMeshes)
(mesh.material as THREE.Material).dispose();
edgeGeometry.dispose();
edgeMaterial.dispose();
renderer.dispose();
renderer.domElement.remove();
};
}, [identity]);
return (
<div className="memory-graph-canvas" ref={host}>
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
</div>
) : (
<div
className="memory-graph-controls"
role="toolbar"
aria-label={text("图谱工具")}
>
<button
type="button"
className="secondary"
aria-label={text("放大图谱")}
onClick={() => actions.current?.zoom(0.8)}
>
<Plus size={16} />
</button>
<button
type="button"
className="secondary"
aria-label={text("缩小图谱")}
onClick={() => actions.current?.zoom(1.25)}
>
<Minus size={16} />
</button>
<button
type="button"
className="secondary"
aria-label={text("重置图谱视角")}
onClick={() => actions.current?.reset()}
>
<Focus size={16} />
</button>
</div>
)}
{hovered ? (
<div
className="memory-graph-tooltip"
style={{ left: hovered.x + 12, top: Math.max(0, hovered.y - 40) }}
>
{hovered.label}
</div>
) : null}
</div>
);
}
function seededDirection(value: string): THREE.Vector3 {
const u = seededUnit(value, 0);
const v = seededUnit(value, 1);
const theta = u * Math.PI * 2;
const z = v * 2 - 1;
const radius = Math.sqrt(Math.max(0, 1 - z * z));
return new THREE.Vector3(
Math.cos(theta) * radius,
z,
Math.sin(theta) * radius,
);
}
function seededUnit(value: string, salt: number): number {
let hash = 2166136261 + salt;
for (const character of value) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
+10 -7
View File
@@ -68,31 +68,34 @@ it("does not present an unavailable raw search as no matches", async () => {
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
});
it("requests one Pro-sized raw graph page", async () => {
it("pages the independent lightweight raw graph", async () => {
get.mockResolvedValue({
data: {
user_id: "local",
items: [],
total: 1_600,
offset: 0,
limit: 300,
offset: 500,
limit: 500,
has_more: true,
snapshot_id: "snapshot-1",
},
});
const signal = new AbortController().signal;
await getSdkRawGraph({
source: "cursor",
offset: 0,
limit: 300,
offset: 500,
limit: 500,
snapshotId: "snapshot-1",
signal,
});
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
params: {
source: "cursor",
offset: 0,
limit: 300,
offset: 500,
limit: 500,
snapshot_id: "snapshot-1",
},
signal,
});
+4 -2
View File
@@ -1019,16 +1019,18 @@ export async function getSdkRaw({
export async function getSdkRawGraph({
source,
offset = 0,
limit = 300,
limit = 500,
snapshotId,
signal,
}: {
source?: string;
offset?: number;
limit?: number;
snapshotId?: string;
signal?: AbortSignal;
} = {}): Promise<SdkRawGraphPage> {
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
params: { source, offset, limit },
params: { source, offset, limit, snapshot_id: snapshotId },
signal,
});
return response.data;
+189 -49
View File
@@ -1,10 +1,10 @@
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
.first-import-r6-layout {
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
height: 560.2px;
min-height: 0;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100%;
height: auto;
}
.first-import-r6-main {
min-width: 0;
@@ -13,19 +13,24 @@
flex-direction: column;
}
.first-import-r6-intro {
margin: 0;
color: #5a6169;
font-size: 13px;
line-height: 25px;
position: relative;
margin: 0 0 8px;
}
.first-import-r6-subintro {
margin: 1px 0 20px;
.first-import-r6-intro h2 {
margin: 0 0 3px;
color: #262a2e;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.first-import-r6-intro p {
margin: 0;
color: #63707b;
font-size: 12px;
line-height: 24px;
line-height: 20px;
}
.first-import-r6-history-window {
margin: -10px 0 12px;
margin: 0 0 12px;
padding: 8px 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
@@ -35,13 +40,114 @@
line-height: 20px;
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 218px;
flex: 0 0 auto;
width: 100%;
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
grid-auto-columns: min(320px, 100%);
justify-content: center;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
grid-auto-columns: min(320px, calc((100% - 16px) / 2));
justify-content: center;
}
.first-import-r6-main > .r6-import-progress[data-count="3"] {
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: center;
overflow-x: hidden;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: column;
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: start;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-color: rgb(102 128 145 / 50%) transparent;
}
.first-import-r6-main > .r6-import-progress:focus-visible {
outline: 2px solid #557c93;
outline-offset: 3px;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 128px;
padding: 16px 20px;
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 18px;
text-align: left;
scroll-snap-align: start;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 86px;
height: 86px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-icon {
width: 54px;
height: 54px;
border-radius: 14px;
}
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
grid-column: 2;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
align-self: end;
}
.first-import-r6-intro .first-import-r6-submission-status {
min-height: 26px;
display: flex;
align-items: center;
gap: 7px;
color: #41697d;
font-weight: 500;
white-space: nowrap;
}
.first-import-r6-submission-status::before {
content: "";
flex: 0 0 auto;
width: 12px;
height: 12px;
border: 1.5px solid rgb(88 124 144 / 28%);
border-top-color: #587c90;
border-radius: 50%;
animation: first-import-r6-checking 1.2s linear infinite;
}
.first-import-r6-intro:has(.first-import-r6-submission-status) {
display: flex;
align-items: center;
gap: 12px;
}
.first-import-r6-intro:has(.first-import-r6-submission-status) h2 {
flex: 0 0 auto;
margin: 0;
}
.first-import-r6-intro .first-import-r6-scroll-hint {
position: absolute;
right: 0;
bottom: 0;
color: #6b7e8b;
font-size: 11px;
line-height: 20px;
white-space: nowrap;
}
.first-import-r6-intro:has(.first-import-r6-scroll-hint) {
padding-right: 190px;
}
@keyframes first-import-r6-checking {
to {
transform: rotate(360deg);
}
}
.first-import-r6-feed {
min-height: 0;
flex: 1;
margin-top: 26px;
overflow: auto;
flex: 0 0 auto;
margin-top: 20px;
overflow: visible;
scrollbar-width: thin;
overscroll-behavior: contain;
}
@@ -88,9 +194,14 @@
margin: 0;
}
.first-import-r6-options {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
padding: 24px;
min-height: 132px;
padding: 22px 24px;
display: grid;
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
align-items: center;
gap: 0 28px;
border: 1px solid #ffffffe0;
border-radius: 22px;
background:
@@ -99,9 +210,13 @@
box-shadow:
inset 0 1px #fff,
0 12px 32px rgb(49 75 101 / 4%);
overflow: auto;
overflow: visible;
scrollbar-width: thin;
overscroll-behavior: contain;
align-self: stretch;
}
.first-import-r6-options-head {
min-width: 0;
}
.first-import-r6-eyebrow {
margin: 0 0 14px;
@@ -128,12 +243,12 @@
position: relative;
display: block;
width: 100%;
min-height: 124px;
margin: 0 0 16px;
padding: 18px 16px 12px;
min-height: 104px;
margin: 0;
padding: 18px 52px 16px 18px;
border: 1px solid rgb(179 196 209 / 42%);
border-radius: 0;
background: rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(255 255 255 / 34%);
box-shadow: inset 0 1px 1px #ffffffd9;
backdrop-filter: blur(20px);
cursor: pointer;
@@ -162,8 +277,8 @@
.first-import-r6-selection input {
appearance: none;
position: absolute;
top: 22px;
right: 18px;
top: 20px;
right: 20px;
width: 16px;
height: 16px;
min-height: 16px;
@@ -201,10 +316,11 @@
display: block;
font-size: 12px;
line-height: 20px;
margin-top: 8px;
margin-top: 7px;
color: #5e6e7a;
}
.first-import-r6-options-note {
grid-column: 1 / -1;
font-size: 11px;
line-height: 22px;
margin: 0 0 12px;
@@ -224,33 +340,37 @@
.first-import-r6-options details p {
margin: 6px 0;
}
.first-import-r6-footer-note {
flex: 1;
min-width: 0;
max-height: 64px;
overflow: auto;
.first-import-r6-feedback {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 50%);
}
.first-import-r6-footer-note p {
.first-import-r6-feedback p {
margin: 0;
font-size: 12px;
line-height: 20px;
color: #63707b;
}
.first-import-r6-footer-note [role="alert"] {
.first-import-r6-feedback [role="alert"] {
color: #944242;
}
.first-import-r6-footer-note .first-import-r6-partial-success {
.first-import-r6-feedback .first-import-r6-partial-success {
color: #245f50;
font-weight: 600;
}
.first-import-r6-footer-actions {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 16px;
gap: 12px;
align-items: center;
margin-left: auto;
}
.first-import-r6-footer-actions > [data-variant="primary"] {
min-width: 210px;
min-width: min(160px, 100%);
}
.first-import-r6-footer-actions .r6-button-label {
display: inline-flex;
@@ -397,13 +517,28 @@ dialog.first-import-r6-original[open] {
}
@media (max-width: 1150px) {
.first-import-r6-layout {
grid-template-columns: minmax(0, 1fr) 278px;
gap: 24px;
gap: 18px;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.onboarding-r6:has(.first-import-r6-layout)
> :is(.onboarding-r6-titlebar, .onboarding-r6-local-footer) {
display: none;
}
.onboarding-r6:has(.first-import-r6-layout) > .onboarding-r6-stage {
min-height: 0;
height: 100dvh;
padding: 10px 24px 2px;
}
.onboarding-r6:has(.first-import-r6-layout)
> .onboarding-r6-stage
> .onboarding-r6-shell {
height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow: hidden;
overflow-y: auto;
padding-top: 16px;
padding-bottom: 16px;
}
@@ -413,8 +548,8 @@ dialog.first-import-r6-original[open] {
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 14px;
overflow: hidden;
padding: 18px 20px;
overflow: visible;
}
.first-import-r6-eyebrow {
margin-bottom: 4px;
@@ -431,16 +566,16 @@ dialog.first-import-r6-original[open] {
}
.first-import-r6-selection,
.first-import-r6-selection + .first-import-r6-selection {
min-height: 86px;
margin-bottom: 8px;
padding: 9px 12px;
min-height: 96px;
margin-bottom: 0;
padding: 15px 48px 13px 16px;
}
.first-import-r6-selection strong {
line-height: 24px;
}
.first-import-r6-selection input {
top: 13px;
right: 12px;
top: 17px;
right: 16px;
}
.first-import-r6-selection span {
margin-top: 4px;
@@ -461,6 +596,7 @@ dialog.first-import-r6-original[open] {
overflow: visible;
}
.first-import-r6-options {
display: block;
overflow: visible;
}
.first-import-r6-footer-note {
@@ -518,6 +654,10 @@ dialog.first-import-r6-original[open] {
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.first-import-r6-submission-status::before {
animation: none;
border-color: #587c90;
}
.first-import-r6-selection,
.first-import-r6-selection input::after {
transition: none;
+57 -103
View File
@@ -50,25 +50,19 @@
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 166px;
}
.memory-mode-card[data-disabled="true"] {
opacity: 0.55;
}
.memory-mode-card-choice {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: auto;
height: 100%;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
grid-template-rows: auto auto;
column-gap: 24px;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
grid-template-rows: auto auto minmax(0, 1fr);
column-gap: 28px;
align-items: center;
cursor: pointer;
}
@@ -122,6 +116,7 @@
font-size: 11px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.memory-mode-current {
@@ -176,59 +171,51 @@
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 24px;
padding: 0 42px 0 28px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
align-content: center;
gap: 0;
gap: 8px;
border-left: 1px solid #dbe4ea;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
}
.memory-mode-features > span {
position: relative;
display: flex;
align-items: center;
min-width: 0;
min-height: 40px;
padding: 0 16px;
overflow-wrap: break-word;
hyphens: auto;
}
.memory-mode-features > span::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 22px;
background: rgb(199 211 220 / 78%);
transform: translateY(-50%);
align-items: flex-start;
gap: 8px;
}
.memory-mode-features i {
display: none;
width: 5px;
height: 5px;
margin-top: 7px;
flex: 0 0 5px;
border-radius: 50%;
background: #7f95a4;
}
.memory-mode-detail {
width: auto;
margin: 7px 18px 12px;
padding: 10px 0 0;
grid-column: 1;
grid-row: 3;
align-self: start;
margin-top: 6px;
padding: 7px 0 0;
display: flex;
align-items: flex-start;
gap: 9px;
border-top: 1px solid rgb(166 190 179 / 48%);
gap: 7px;
border-top: 1px solid #d4e0e7;
background: transparent;
color: #405867;
font-size: 12px;
line-height: 18px;
font-size: 11px;
line-height: 17px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
color: #527565;
margin-top: 1px;
flex: 0 0 auto;
}
@@ -285,31 +272,17 @@
width: 100%;
margin-top: auto;
padding: 16px 0 2px;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--grid .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 8px 18px 16px;
padding: 12px 0 0;
width: calc(100% + 40px);
margin: auto -20px -20px;
padding: 14px 18px;
display: block;
background: transparent;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
font-size: 12px;
line-height: 20px;
}
@@ -327,52 +300,44 @@
@media (min-width: 761px) and (max-height: 959px) {
.memory-mode-cards--rows {
min-height: 284px;
min-height: 360px;
}
.memory-mode-cards--rows .memory-mode-card-list {
gap: 10px;
gap: 12px;
}
.memory-mode-cards--rows .memory-mode-card {
min-height: 88px;
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 116px;
min-height: 112px;
}
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 11px 16px;
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
column-gap: 18px;
padding: 13px 16px;
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
column-gap: 22px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 11px;
top: 13px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
font-size: 16px;
line-height: 22px;
font-size: 17px;
line-height: 25px;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 2px;
font-size: 11px;
line-height: 16px;
margin-top: 4px;
font-size: 12px;
line-height: 18px;
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 34px 0 18px;
gap: 0;
padding: 0 36px 0 22px;
gap: 6px;
font-size: 11px;
line-height: 15px;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 32px;
padding: 0 10px;
line-height: 17px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin: 6px 14px 10px;
padding: 8px 0 0;
margin-top: 5px;
padding: 5px 0 0;
gap: 6px;
font-size: 11px;
line-height: 16px;
@@ -416,26 +381,15 @@
width: 100%;
margin-top: 16px;
padding: 16px 0 0;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--rows .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--rows .memory-mode-detail {
width: auto;
margin: 8px 20px 14px;
padding: 12px 0 0;
background: transparent;
width: calc(100% + 40px);
margin: 16px -20px -20px;
padding: 14px 20px 16px;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
}
}
@@ -467,9 +421,9 @@
line-height: 18px;
}
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 6px 14px 14px;
padding: 10px 0 0;
width: calc(100% + 32px);
margin: auto -16px -16px;
padding: 10px 14px;
font-size: 11px;
line-height: 18px;
}
+1 -17
View File
@@ -21,22 +21,6 @@ export const MODE_COPY: Record<
},
};
export const MODE_TIER: Record<MemoryPipelineMode, string> = {
lite: "Lite",
pro: "Pro",
ultra: "Ultra",
};
export function modeDisplayName(
text: (value: string) => string,
mode: MemoryPipelineMode,
) {
return text(MODE_COPY[mode].title)
.replace(/\s*[((](?:lite|pro|ultra)[))]\s*/i, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
@@ -200,7 +184,7 @@ export function MemoryModeConfirmDialog({
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
`${modeDisplayName(text, mode)} · ${MODE_TIER[mode]}`,
text(MODE_COPY[mode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
+7 -42
View File
@@ -588,25 +588,6 @@
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-schedule-loading {
margin: 0;
color: #607482;
font-size: 12px;
line-height: 20px;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
flex: 0 0 auto;
min-width: 76px;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
@@ -634,12 +615,15 @@
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
.onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
color: #944242;
font-size: 12px;
font-size: 13px;
line-height: 20px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
.onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
@@ -653,19 +637,7 @@
@media (min-width: 761px) {
.onboarding-r6-mode {
min-height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-mode {
gap: 12px;
}
.onboarding-r6-mode-intro h2 {
font-size: 20px;
line-height: 28px;
}
.onboarding-r6-mode-intro p {
font-size: 12px;
line-height: 20px;
gap: 24px;
}
}
.onboarding-r6-empty {
@@ -836,13 +808,6 @@
flex-wrap: wrap;
flex-basis: auto;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
flex-direction: column;
align-items: stretch;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
align-self: flex-start;
}
}
@media (prefers-reduced-transparency: reduce) {
.onboarding-r6-shell {
+1 -13
View File
@@ -190,10 +190,6 @@ button.r6-button[data-variant="primary"] {
0 3px 8px -2px rgb(64 87 102 / 12%);
}
button.r6-button[data-pending-capable="true"] {
padding-inline: 32px;
}
button.r6-button[data-variant="quiet"] {
height: 36px;
min-height: 36px;
@@ -208,10 +204,6 @@ button.r6-button[data-variant="quiet"] {
color: var(--r6-muted);
}
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
padding-inline: 24px;
}
button.r6-button:hover:not(:disabled) {
background: rgb(231 239 244 / 60%);
border-color: #aabcc8;
@@ -250,16 +242,12 @@ button.r6-button[data-variant="quiet"]:disabled {
}
.r6-button-label {
min-width: 0;
text-align: center;
}
.r6-button-spinner {
position: absolute;
top: 50%;
right: 12px;
position: static;
flex: 0 0 auto;
width: 12px;
height: 12px;
margin-top: -6px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
+14 -154
View File
@@ -227,140 +227,26 @@
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-mode-setting-row {
width: 100%;
margin-top: 4px;
}
.r6-mode-control[data-expanded="true"] {
border: 1px solid #cbd6de;
border-radius: 10px;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 14px rgb(48 71 87 / 5%);
}
.r6-mode-control[data-expanded="true"] .r6-mode-setting-row {
margin-top: 0;
border-bottom: 1px solid #dbe4ea;
}
.r6-settings
.r6-mode-control[data-expanded="true"]
.r6-mode-option-menu
.om-option-menu-button {
border: 0;
border-radius: 9px 9px 0 0;
box-shadow: inset 0 1px 0 #fff;
}
.r6-settings .r6-mode-option-menu {
.r6-memory-mode-settings .memory-mode-cards {
width: 100%;
max-width: none;
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button {
height: auto;
min-height: 72px;
padding: 12px 64px 12px 18px;
border-color: #cbd6de;
border-radius: 10px;
background: #f8fafb;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 90%),
0 1px 2px rgb(48 71 87 / 4%);
white-space: normal;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
grid-column: 1 / -1;
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::before {
content: "";
position: absolute;
top: 12px;
right: 52px;
bottom: 12px;
width: 1px;
background: #dbe4ea;
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"]
.memory-mode-card-choice {
min-height: 180px;
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::after {
right: 22px;
.r6-evolve-settings--in-card {
margin: 0;
padding: 0;
border-top: 0;
}
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button:hover:not(:disabled) {
border-color: #a8bac5;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 12px rgb(48 71 87 / 7%);
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
background: #fff;
box-shadow:
inset 0 1px 0 #fff,
0 0 0 3px rgb(85 124 147 / 10%);
}
.r6-mode-option-menu .om-option-menu-copy,
.r6-mode-option-menu-panel .om-option-menu-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.r6-mode-option-menu .om-option-menu-copy > span,
.r6-mode-option-menu-panel .om-option-menu-copy > span {
color: #304757;
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
}
.r6-mode-option-menu .om-option-menu-suffix,
.r6-mode-option-menu-panel .om-option-menu-suffix {
color: var(--ok, #2f6b57);
font-size: 12px;
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-mode-option-menu .om-option-menu-copy > small,
.r6-mode-option-menu-panel .om-option-menu-copy > small {
color: #526674;
font-size: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.01em;
overflow-wrap: anywhere;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel {
padding: 8px;
border-color: #cbd6de;
border-radius: 12px;
box-shadow: 0 14px 32px rgb(48 71 87 / 12%);
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"] {
min-height: 64px;
padding: 10px 12px;
border-radius: 8px;
white-space: normal;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"] {
background: #e8eff3;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][data-active="true"] {
background: #f0f4f6;
box-shadow: inset 2px 0 #607b8e;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"][data-active="true"] {
background: #e8eff3;
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
border-bottom: 0;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -375,18 +261,11 @@
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid #d8e2ea;
animation: r6-evolve-settings-in 180ms ease-out both;
}
.r6-evolve-settings:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 4px;
}
@keyframes r6-evolve-settings-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.r6-evolve-heading {
margin-bottom: 8px;
}
@@ -813,20 +692,6 @@
line-height: 22px;
margin: 8px 0 0;
}
.r6-mode-control[data-expanded="true"] > .r6-settings-note,
.r6-mode-control[data-expanded="true"] > .r6-settings-error {
margin-right: 18px;
margin-left: 18px;
}
.r6-mode-control[data-expanded="true"] > .r6-evolve-settings {
margin: 18px 18px 0;
padding-top: 20px;
}
.r6-mode-control[data-expanded="true"]
> .r6-evolve-settings
.r6-evolve-setting--manual {
border-bottom: 0;
}
.r6-settings .r6-settings-read-status {
color: #63707b;
font-size: 13px;
@@ -862,11 +727,6 @@
font-weight: 600;
line-height: 22px;
}
.r6-settings .r6-model-mode-tier {
color: var(--ok, #2f6b57);
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-settings .r6-model-mode-status p {
margin: 2px 0 0;
color: var(--r6-muted);
+1 -1
View File
@@ -11,7 +11,7 @@ import type {
} from "@one-memory/contracts";
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export const SDK_RAW_PAGE_SIZE = 20;
export const SDK_RAW_PAGE_SIZE = 50;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 45)的发行目标如下:
MemHub `v1.0.6`(build 44)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 45,
"build_number": 44,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+2 -3
View File
@@ -720,7 +720,7 @@ export interface SdkRawPage {
time_filtered?: boolean;
}
/** Lightweight bounded-map projection. Raw text remains SDK-owned. */
/** Lightweight complete-map projection. Raw text remains SDK-owned. */
export interface SdkRawGraphItem {
turn_id: string;
raw_id: string;
@@ -734,12 +734,11 @@ export interface SdkRawGraphItem {
export interface SdkRawGraphPage {
user_id: string;
items: SdkRawGraphItem[];
/** SDK-reported matching raw scope; `items` remains capped by `limit`. */
total: number;
limit: number;
offset: number;
/** More matching turns exist, but the product map does not auto-page them. */
has_more: boolean;
snapshot_id: string;
}
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
+1 -41
View File
@@ -10,7 +10,6 @@ const modeChineseMessages = {
选择记忆方式: "选择记忆方式",
选择记忆模式: "选择记忆模式",
"之后可在设置中更改。": "之后可在设置中更改。",
"后续可在设置页自定义时间或关闭。": "后续可在设置页自定义时间或关闭。",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra 可在完成首次设置后到设置页开启。",
"使用 Lite 进入": "使用 Lite 进入",
@@ -24,7 +23,7 @@ const modeChineseMessages = {
"当前模型已经启用,可以继续完成首次设置。":
"当前模型已经启用,可以继续完成首次设置。",
继续使用当前模型: "继续使用当前模型",
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成记忆?",
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
记忆模式: "记忆模式",
记忆模式与模型: "记忆模式与模型",
@@ -62,7 +61,6 @@ const modeChineseMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"模式选择未能确认,仍显示上次确认的模式。",
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
"自动整合设置暂时无法读取,请重试。": "自动整合设置暂时无法读取,请重试。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在检查或切换向量模型,完成后才能整合记忆。",
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
@@ -202,8 +200,6 @@ export const modeEnglishMessages: ModeMessages = {
选择记忆方式: "Choose how memory works",
选择记忆模式: "Choose memory mode",
"之后可在设置中更改。": "You can change this later in Settings.",
"后续可在设置页自定义时间或关闭。":
"You can later customize the time or turn this off in Settings.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra can be enabled in Settings after initial setup.",
"使用 Lite 进入": "Enter with Lite",
@@ -261,8 +257,6 @@ export const modeEnglishMessages: ModeMessages = {
"The mode change could not be confirmed. The last confirmed mode is still shown.",
"自动整合设置未能保存,请重试。":
"Could not save automatic consolidation settings. Please retry.",
"自动整合设置暂时无法读取,请重试。":
"Could not load automatic consolidation settings. Please retry.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"The embedding model is being checked or changed. Wait for it to finish before consolidating memories.",
"未能开始整合记忆,请重试。":
@@ -410,8 +404,6 @@ export const modeSpanishMessages: ModeMessages = {
选择记忆方式: "Elegir cómo funciona la memoria",
选择记忆模式: "Elegir el modo de memoria",
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
"后续可在设置页自定义时间或关闭。":
"Más adelante, puedes personalizar la hora o desactivarlo en Configuración.",
"Ultra 可在完成首次设置后到设置页开启。":
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
"使用 Lite 进入": "Entrar con Lite",
@@ -469,8 +461,6 @@ export const modeSpanishMessages: ModeMessages = {
"No se pudo confirmar el cambio. Se sigue mostrando el último modo confirmado.",
"自动整合设置未能保存,请重试。":
"No se pudo guardar la consolidación automática. Reinténtalo.",
"自动整合设置暂时无法读取,请重试。":
"No se pudo cargar la configuración de consolidación automática. Reinténtalo.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Se está comprobando o cambiando el modelo de embeddings. Espera para consolidar memorias.",
"未能开始整合记忆,请重试。":
@@ -617,7 +607,6 @@ export const modeTraditionalChineseMessages: ModeMessages = {
选择记忆方式: "選擇記憶方式",
选择记忆模式: "選擇記憶模式",
"之后可在设置中更改。": "之後可在設定中更改。",
"后续可在设置页自定义时间或关闭。": "之後可在設定頁自訂時間或關閉。",
"Ultra 可在完成首次设置后到设置页开启。":
"完成首次設定後,可到設定頁開啟 Ultra。",
"使用 Lite 进入": "使用 Lite 進入",
@@ -671,7 +660,6 @@ export const modeTraditionalChineseMessages: ModeMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"無法確認模式切換,仍顯示上次確認的模式。",
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
"自动整合设置暂时无法读取,请重试。": "暫時無法讀取自動整合設定,請重試。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在檢查或切換向量模型,完成後才能整合記憶。",
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
@@ -807,8 +795,6 @@ export const modeJapaneseMessages: ModeMessages = {
选择记忆方式: "記憶方法を選択",
选择记忆模式: "記憶モードを選択",
"之后可在设置中更改。": "後から設定で変更できます。",
"后续可在设置页自定义时间或关闭。":
"後から設定画面で時刻を変更したり、無効にしたりできます。",
"Ultra 可在完成首次设置后到设置页开启。":
"初期設定の完了後、設定画面で Ultra を有効にできます。",
"使用 Lite 进入": "Lite で開始",
@@ -954,8 +940,6 @@ export const modeJapaneseMessages: ModeMessages = {
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
"自动整合设置未能保存,请重试。":
"自動統合の設定を保存できませんでした。もう一度お試しください。",
"自动整合设置暂时无法读取,请重试。":
"自動統合の設定を読み込めませんでした。もう一度お試しください。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
"未能开始整合记忆,请重试。":
@@ -1010,8 +994,6 @@ export const modeGermanMessages: ModeMessages = {
选择记忆方式: "Erinnerungsmodus wählen",
选择记忆模式: "Erinnerungsmodus auswählen",
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
"后续可在设置页自定义时间或关闭。":
"Später können Sie die Uhrzeit in den Einstellungen anpassen oder die Funktion deaktivieren.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
"使用 Lite 进入": "Mit Lite starten",
@@ -1161,8 +1143,6 @@ export const modeGermanMessages: ModeMessages = {
"Der Moduswechsel konnte nicht bestätigt werden. Der zuletzt bestätigte Modus wird weiterhin angezeigt.",
"自动整合设置未能保存,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht gespeichert werden. Bitte erneut versuchen.",
"自动整合设置暂时无法读取,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht geladen werden. Bitte erneut versuchen.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Das Embedding-Modell wird geprüft oder gewechselt. Danach können Erinnerungen konsolidiert werden.",
"未能开始整合记忆,请重试。":
@@ -1218,8 +1198,6 @@ export const modePortugueseMessages: ModeMessages = {
选择记忆方式: "Escolher o modo de memória",
选择记忆模式: "Escolher o modo de memória",
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
"后续可在设置页自定义时间或关闭。":
"Mais tarde, pode personalizar a hora ou desativar esta opção nas Definições.",
"Ultra 可在完成首次设置后到设置页开启。":
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
"使用 Lite 进入": "Entrar com Lite",
@@ -1369,8 +1347,6 @@ export const modePortugueseMessages: ModeMessages = {
"Não foi possível confirmar a mudança. Continua a ser mostrado o último modo confirmado.",
"自动整合设置未能保存,请重试。":
"Não foi possível guardar a consolidação automática. Tente novamente.",
"自动整合设置暂时无法读取,请重试。":
"Não foi possível carregar as configurações de consolidação automática. Tente novamente.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para consolidar memórias.",
"未能开始整合记忆,请重试。":
@@ -1428,8 +1404,6 @@ export const modeFrenchMessages: ModeMessages = {
选择记忆模式: "Choisir le mode de mémoire",
"之后可在设置中更改。":
"Vous pourrez le modifier plus tard dans les réglages.",
"后续可在设置页自定义时间或关闭。":
"Vous pourrez ensuite personnaliser l’heure ou désactiver cette option dans les réglages.",
"Ultra 可在完成首次设置后到设置页开启。":
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
"使用 Lite 进入": "Entrer avec Lite",
@@ -1579,8 +1553,6 @@ export const modeFrenchMessages: ModeMessages = {
"Le changement n’a pas pu être confirmé. Le dernier mode confirmé reste affiché.",
"自动整合设置未能保存,请重试。":
"Impossible d’enregistrer la consolidation automatique. Réessayez.",
"自动整合设置暂时无法读取,请重试。":
"Impossible de charger les paramètres de consolidation automatique. Réessayez.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de consolider les mémoires.",
"未能开始整合记忆,请重试。":
@@ -1635,8 +1607,6 @@ export const modeKoreanMessages: ModeMessages = {
选择记忆方式: "메모리 방식 선택",
选择记忆模式: "메모리 모드 선택",
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
"后续可在设置页自定义时间或关闭。":
"나중에 설정에서 시간을 변경하거나 끌 수 있습니다.",
"Ultra 可在完成首次设置后到设置页开启。":
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
"使用 Lite 进入": "Lite로 시작",
@@ -1782,8 +1752,6 @@ export const modeKoreanMessages: ModeMessages = {
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
"自动整合设置未能保存,请重试。":
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
"自动整合设置暂时无法读取,请重试。":
"자동 통합 설정을 불러오지 못했습니다. 다시 시도하세요.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
"未能开始整合记忆,请重试。":
@@ -1837,8 +1805,6 @@ export const modeRussianMessages: ModeMessages = {
选择记忆方式: "Выберите режим памяти",
选择记忆模式: "Выберите режим памяти",
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
"后续可在设置页自定义时间或关闭。":
"Позже в настройках можно изменить время или отключить эту функцию.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra можно включить в настройках после завершения первоначальной настройки.",
"使用 Lite 进入": "Войти с Lite",
@@ -1987,8 +1953,6 @@ export const modeRussianMessages: ModeMessages = {
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
"自动整合设置未能保存,请重试。":
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
"自动整合设置暂时无法读取,请重试。":
"Не удалось загрузить настройки автообъединения. Повторите попытку.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
"未能开始整合记忆,请重试。":
@@ -2045,8 +2009,6 @@ export const modeIndonesianMessages: ModeMessages = {
选择记忆方式: "Pilih cara kerja memori",
选择记忆模式: "Pilih mode memori",
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
"后续可在设置页自定义时间或关闭。":
"Nanti, Anda dapat menyesuaikan waktunya atau menonaktifkannya di Pengaturan.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
"使用 Lite 进入": "Masuk dengan Lite",
@@ -2194,8 +2156,6 @@ export const modeIndonesianMessages: ModeMessages = {
"Perubahan mode tidak dapat dikonfirmasi. Mode terakhir yang dikonfirmasi tetap ditampilkan.",
"自动整合设置未能保存,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat disimpan. Coba lagi.",
"自动整合设置暂时无法读取,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat dimuat. Coba lagi.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengonsolidasikan memori.",
"未能开始整合记忆,请重试。":