Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c9f300e5 | ||
|
|
30f796ddc4 |
No files matched your search
@@ -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, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -653,7 +653,7 @@ class SdkRawPage(BaseModel):
|
||||
|
||||
|
||||
class SdkRawGraphItem(BaseModel):
|
||||
"""Small, non-authoritative projection used to draw the complete raw map."""
|
||||
"""Small, non-authoritative projection used to draw the bounded raw map."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -667,7 +667,7 @@ class SdkRawGraphItem(BaseModel):
|
||||
|
||||
|
||||
class SdkRawGraphPage(BaseModel):
|
||||
"""One page from a complete, bounded-memory raw-turn catalog."""
|
||||
"""One bounded page from the raw-turn catalog."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -675,9 +675,8 @@ class SdkRawGraphPage(BaseModel):
|
||||
items: tuple[SdkRawGraphItem, ...] = ()
|
||||
total: int = Field(ge=0)
|
||||
offset: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
limit: int = Field(ge=1, le=300)
|
||||
has_more: bool = False
|
||||
snapshot_id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class SdkLayerCounts(BaseModel):
|
||||
@@ -4570,55 +4569,6 @@ 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],
|
||||
@@ -4640,95 +4590,6 @@ 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,
|
||||
@@ -5122,14 +4983,13 @@ 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=500, ge=1, le=500),
|
||||
snapshot_id: str | None = Query(default=None, max_length=64),
|
||||
limit: int = Query(default=300, ge=1, le=300),
|
||||
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:
|
||||
"""Complete lightweight raw graph, independent of list pagination."""
|
||||
"""Bounded lightweight raw graph, independent of list pagination."""
|
||||
chosen_statuses = (
|
||||
_library_values(
|
||||
statuses or [],
|
||||
@@ -5141,85 +5001,49 @@ def create_app(
|
||||
sources = split_csv(source)
|
||||
excluded = split_csv(exclude_sources)
|
||||
sessions = split_csv(session_id)
|
||||
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:
|
||||
if offset:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="raw_graph_snapshot_required",
|
||||
detail="raw_graph_is_single_page",
|
||||
)
|
||||
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)),
|
||||
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,
|
||||
)
|
||||
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)
|
||||
page = HySdkRawPage.from_memory_list(
|
||||
value,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
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]
|
||||
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))
|
||||
return SdkRawGraphPage(
|
||||
user_id=user_id,
|
||||
items=selected,
|
||||
total=len(items),
|
||||
offset=offset,
|
||||
total=total,
|
||||
offset=0,
|
||||
limit=limit,
|
||||
has_more=offset + len(selected) < len(items),
|
||||
snapshot_id=snapshot_id,
|
||||
has_more=page.total > len(page.raw) or len(projected) > limit,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
|
||||
@@ -838,8 +838,10 @@ 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
|
||||
@@ -847,35 +849,13 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
|
||||
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
|
||||
]
|
||||
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": 301})).status_code == 422
|
||||
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]: 新消息",
|
||||
@@ -887,18 +867,6 @@ 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]: 新消息"
|
||||
|
||||
@@ -62,30 +62,6 @@ 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();
|
||||
|
||||
@@ -52,7 +52,6 @@ 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";
|
||||
@@ -108,8 +107,6 @@ 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;
|
||||
@@ -1327,23 +1324,6 @@ 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) && (
|
||||
@@ -1374,25 +1354,14 @@ export function MemoryMap(props: Props) {
|
||||
</header>
|
||||
<div className="map-canvas" data-tour-id="memory-map-canvas">
|
||||
{projection.nodes.length && (!props.simplified || !hidden) ? (
|
||||
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}
|
||||
/>
|
||||
)
|
||||
<MemoryGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
|
||||
@@ -34,6 +34,7 @@ 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: /深度记忆/ }));
|
||||
|
||||
@@ -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 } from "./memory-mode-ui";
|
||||
import { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import "./memory-mode-cards.css";
|
||||
|
||||
@@ -68,11 +68,8 @@ export function MemoryModeCards({
|
||||
{MODES.map((mode) => {
|
||||
const selected = value === mode.id;
|
||||
const modeDisabled = unavailable(mode.id);
|
||||
const localizedTitle = text(MODE_COPY[mode.id].title);
|
||||
const title = localizedTitle.replace(
|
||||
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
|
||||
"",
|
||||
);
|
||||
const title = modeDisplayName(text, mode.id);
|
||||
const tier = MODE_TIER[mode.id];
|
||||
const features = text(MODE_COPY[mode.id].description).split(
|
||||
/\s*·\s*/,
|
||||
);
|
||||
@@ -90,7 +87,7 @@ export function MemoryModeCards({
|
||||
type="radio"
|
||||
name={name}
|
||||
value={mode.id}
|
||||
aria-label={localizedTitle}
|
||||
aria-label={`${title} · ${tier}`}
|
||||
aria-describedby={describedBy?.[mode.id]}
|
||||
checked={selected}
|
||||
disabled={modeDisabled}
|
||||
@@ -99,9 +96,7 @@ export function MemoryModeCards({
|
||||
<span className="memory-mode-title">
|
||||
<span className="memory-mode-index">{mode.index}</span>
|
||||
<strong>{title}</strong>
|
||||
<span className="memory-mode-tier">
|
||||
{mode.id.toUpperCase()}
|
||||
</span>
|
||||
<span className="memory-mode-tier">· {tier}</span>
|
||||
{current === mode.id && currentBadge ? (
|
||||
<span className="memory-mode-current">{currentBadge}</span>
|
||||
) : null}
|
||||
|
||||
@@ -75,9 +75,13 @@ async function chooseMode(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
const option = await screen.findByRole("radio", { name });
|
||||
const control = await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
});
|
||||
await user.click(control);
|
||||
const option = await screen.findByRole("option", { name });
|
||||
await user.click(option);
|
||||
return option;
|
||||
return control;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -88,17 +92,43 @@ afterEach(() => {
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("renders all settings modes as one grouped shared choice list", async () => {
|
||||
it("shows the current mode in a compact dropdown", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
const group = await screen.findByRole("radiogroup", {
|
||||
name: "当前模式",
|
||||
const control = await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
});
|
||||
expect(within(group).getAllByRole("radio")).toHaveLength(3);
|
||||
expect(control.textContent).toContain("智能记忆 · Pro");
|
||||
expect(control.textContent).toContain("沉淀高级记忆");
|
||||
expect(
|
||||
within(group).getByText("当前模式").closest("label")?.textContent,
|
||||
).toContain("智能记忆PRO");
|
||||
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();
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
@@ -111,7 +141,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();
|
||||
@@ -121,18 +151,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("radio", { name: /基础记忆(lite)/ }),
|
||||
screen.getByRole("combobox", { name: "记忆模式" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
||||
it("traps modal focus, closes on Escape and restores the mode dropdown", 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));
|
||||
@@ -154,7 +184,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");
|
||||
@@ -185,7 +215,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 前需要先配置并启用生成模型。",
|
||||
);
|
||||
@@ -199,11 +229,29 @@ 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"));
|
||||
@@ -211,7 +259,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);
|
||||
@@ -223,7 +271,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();
|
||||
@@ -239,7 +287,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);
|
||||
@@ -256,7 +304,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);
|
||||
@@ -273,10 +321,10 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
await screen.findByRole("combobox", { name: "记忆模式" });
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await chooseMode(user, /深度记忆 · Ultra/);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -287,6 +335,29 @@ 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()} />);
|
||||
@@ -305,6 +376,26 @@ 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({
|
||||
@@ -443,9 +534,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 pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
|
||||
|
||||
expect((pro as HTMLInputElement).checked).toBe(true);
|
||||
expect(mode.textContent).toContain("智能记忆 · Pro");
|
||||
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
||||
expect(getMemoryMode).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
@@ -459,9 +550,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("radio", {
|
||||
name: /智能记忆(pro)/,
|
||||
})) as HTMLInputElement,
|
||||
await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
}),
|
||||
).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
@@ -474,15 +565,17 @@ it("offers a text recovery action when the mode read fails", async () => {
|
||||
expect(
|
||||
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", false),
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -493,7 +586,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");
|
||||
@@ -514,15 +607,17 @@ 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("radio", { name: /基础记忆(lite)/ }),
|
||||
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
|
||||
).toHaveProperty("disabled", false);
|
||||
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
|
||||
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
|
||||
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
@@ -538,9 +633,10 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
||||
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
|
||||
@@ -23,8 +23,14 @@ import {
|
||||
issueConfirmedModeTicket,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { commitEvolveRun, useEvolveState } from "./evolve-state";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
MODE_TIER,
|
||||
modeDisplayName,
|
||||
} from "./memory-mode-ui";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
@@ -58,28 +64,39 @@ 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;
|
||||
focusEvolveWhenReady.current = false;
|
||||
document.getElementById(`${id}-evolve`)?.focus();
|
||||
const evolve = document.getElementById(`${id}-evolve`);
|
||||
if (evolve) {
|
||||
focusEvolveWhenReady.current = false;
|
||||
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
|
||||
.querySelector<HTMLInputElement>(
|
||||
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
|
||||
)
|
||||
.getElementById(`${id}-evolve`)
|
||||
?.querySelector<HTMLElement>('[role="switch"]')
|
||||
?.focus();
|
||||
}, [busy, pendingMode, state?.mode]);
|
||||
}, [id, schedule, scheduleReadFailed]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
@@ -101,8 +118,12 @@ 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]);
|
||||
@@ -123,7 +144,7 @@ export function MemoryModeSettings({
|
||||
run?.state === "running" ||
|
||||
state?.graph_build.state === "running";
|
||||
|
||||
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
|
||||
async function choose(mode: MemoryPipelineMode) {
|
||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
@@ -133,6 +154,7 @@ export function MemoryModeSettings({
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) {
|
||||
setError("模式切换影响暂时无法读取,请重试。");
|
||||
focusModeWhenReady.current = state.mode;
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +165,7 @@ export function MemoryModeSettings({
|
||||
onConfigureModel(BLOCKER_COPY.active_model_required);
|
||||
return;
|
||||
}
|
||||
dialogOpener.current = opener;
|
||||
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||
setPendingPlan(plan);
|
||||
setPendingMode(mode);
|
||||
}
|
||||
@@ -195,7 +217,7 @@ export function MemoryModeSettings({
|
||||
}
|
||||
|
||||
async function evolveNow() {
|
||||
if (!state || !schedule) return;
|
||||
if (!state) return;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
@@ -203,12 +225,15 @@ export function MemoryModeSettings({
|
||||
const receipt = await startEvolve();
|
||||
commitEvolveRun(
|
||||
receipt,
|
||||
overview ?? {
|
||||
mode: state.mode,
|
||||
schedule,
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
overview ??
|
||||
(schedule
|
||||
? {
|
||||
mode: state.mode,
|
||||
schedule,
|
||||
users: {},
|
||||
running: [],
|
||||
}
|
||||
: undefined),
|
||||
);
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
@@ -234,28 +259,69 @@ export function MemoryModeSettings({
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
>
|
||||
<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 ? (
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={schedule}
|
||||
headingId={`${id}-evolve-title`}
|
||||
@@ -263,63 +329,56 @@ export function MemoryModeSettings({
|
||||
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
|
||||
onTimeChange={(time) => changeSchedule({ time })}
|
||||
/>
|
||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("立即整合一次")}</h3>
|
||||
) : (
|
||||
<>
|
||||
<div className="r6-evolve-heading">
|
||||
<h3 id={`${id}-evolve-title`}>
|
||||
{text("记忆整合(Evolve)")}
|
||||
</h3>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
{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>
|
||||
</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}
|
||||
<R6Button
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pendingMode && pendingPlan ? (
|
||||
<MemoryModeConfirmDialog
|
||||
|
||||
@@ -129,9 +129,6 @@ vi.mock("./MemoryGraph3D", () => ({
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
@@ -323,10 +320,9 @@ function setup() {
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "snapshot-1",
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
@@ -431,7 +427,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
@@ -441,94 +437,92 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the complete raw graph independently and reports its exact total", async () => {
|
||||
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
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",
|
||||
};
|
||||
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,
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
it("restarts the complete graph when its snapshot expires between pages", async () => {
|
||||
it("keeps the last successful Lite graph when a background refresh fails", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
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",
|
||||
});
|
||||
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]} />);
|
||||
|
||||
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(3));
|
||||
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
|
||||
expect.objectContaining({ offset: 0, snapshotId: undefined }),
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "搜索原始对话" }),
|
||||
"发布计划",
|
||||
);
|
||||
expect(screen.getByText("1 / 1")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
|
||||
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
@@ -589,7 +583,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: undefined,
|
||||
cursor: "turn:1:0:1",
|
||||
paging: "cursor",
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -601,7 +595,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: "cursor",
|
||||
cursor: null,
|
||||
paging: "cursor",
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -614,7 +608,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
@@ -837,10 +831,9 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
limit: 500,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "search-total",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkRawGraphItem,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { isMessageKey } from "@one-memory/i18n";
|
||||
@@ -201,11 +200,6 @@ 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), []);
|
||||
@@ -269,16 +263,11 @@ export function MemorySpace({
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||
(sdkGraph.status === "ready" || evolveRunning)
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
|
||||
const visibleGraphProgress =
|
||||
sdkGraphProgress?.scope === sdkGraphRequestedScope
|
||||
? sdkGraphProgress
|
||||
: null;
|
||||
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||
sdkGraph.status === "failed" &&
|
||||
sdkGraph.value === 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.
|
||||
@@ -456,8 +445,6 @@ 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;
|
||||
@@ -469,58 +456,25 @@ export function MemorySpace({
|
||||
);
|
||||
if (cancelled) return;
|
||||
setSdkGraph({ scope, status: "ready", value });
|
||||
setSdkGraphProgress({
|
||||
scope,
|
||||
loaded: value.projected_memories,
|
||||
total: value.total_memories,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
setSdkGraph({
|
||||
scope,
|
||||
status: "ready",
|
||||
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
|
||||
});
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkGraph((current) => ({
|
||||
@@ -547,17 +501,7 @@ export function MemorySpace({
|
||||
sdkMapVisible,
|
||||
sourceKey,
|
||||
]);
|
||||
const displayedSdkPage =
|
||||
lite &&
|
||||
!sdkLibraryQuery.trim() &&
|
||||
sdkPage &&
|
||||
visibleGraphProgress?.total != null
|
||||
? {
|
||||
...sdkPage,
|
||||
total: visibleGraphProgress.total,
|
||||
total_exact: true,
|
||||
}
|
||||
: sdkPage;
|
||||
const displayedSdkPage = sdkPage;
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
? `${t("memory:来源")} ${
|
||||
@@ -1397,8 +1341,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @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();
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
|
||||
export interface OptionMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
lang?: string;
|
||||
@@ -22,6 +23,17 @@ 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,
|
||||
@@ -268,11 +280,11 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{selected?.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{visibleLabel}</span>
|
||||
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
|
||||
<small>{selected.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
visibleLabel
|
||||
optionLabel(visibleLabel, selected?.suffix)
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
@@ -299,6 +311,14 @@ 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={() => {
|
||||
@@ -308,11 +328,13 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{item.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{item.label}</span>
|
||||
<small>{item.description}</small>
|
||||
<span>{optionLabel(item.label, item.suffix)}</span>
|
||||
<small id={`${panelId}-option-${index}-description`}>
|
||||
{item.description}
|
||||
</small>
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
optionLabel(item.label, item.suffix)
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -204,7 +204,13 @@ 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("当前是基础记忆(lite)")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_, element) =>
|
||||
element?.tagName === "STRONG" &&
|
||||
element.textContent === "基础记忆 · Lite",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
|
||||
).toBeTruthy();
|
||||
@@ -243,8 +249,9 @@ 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("radio", { name: /智能记忆(pro)/ }),
|
||||
await screen.findByRole("option", { name: /智能记忆 · Pro/ }),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -394,14 +395,16 @@ export function R6Settings({
|
||||
{currentModel && (
|
||||
<div className="r6-model-mode-status" role="status">
|
||||
<strong>
|
||||
{text(
|
||||
memoryMode === "lite"
|
||||
? "当前是基础记忆(lite)"
|
||||
: memoryMode === "ultra"
|
||||
? "当前是深度记忆(ultra)"
|
||||
: memoryMode === "pro"
|
||||
? "当前是智能记忆(pro)"
|
||||
: "正在读取记忆模式…",
|
||||
{memoryMode ? (
|
||||
<>
|
||||
{modeDisplayName(text, memoryMode)}
|
||||
<span className="r6-model-mode-tier">
|
||||
{" "}
|
||||
· {MODE_TIER[memoryMode]}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
text("正在读取记忆模式…")
|
||||
)}
|
||||
</strong>
|
||||
<p>
|
||||
|
||||
@@ -8,14 +8,19 @@ function declaration(file: string, selector: string): string {
|
||||
}
|
||||
|
||||
it("fills settings controls without widening shared fallback controls", () => {
|
||||
for (const selector of [
|
||||
".r6-memory-mode-settings .memory-mode-cards",
|
||||
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",
|
||||
".r6-settings .connection-fallback .custom-harness-name",
|
||||
]) {
|
||||
const rule = declaration("r6-settings.css", selector);
|
||||
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
||||
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
||||
}
|
||||
);
|
||||
expect(fallback).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
||||
expect(fallback).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
||||
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
|
||||
/(?:^|[\s;])max-width:\s*420px\s*;/,
|
||||
);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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()]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,421 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -68,34 +68,31 @@ it("does not present an unavailable raw search as no matches", async () => {
|
||||
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
|
||||
});
|
||||
|
||||
it("pages the independent lightweight raw graph", async () => {
|
||||
it("requests one Pro-sized raw graph page", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
has_more: true,
|
||||
snapshot_id: "snapshot-1",
|
||||
},
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await getSdkRawGraph({
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshotId: "snapshot-1",
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal,
|
||||
});
|
||||
|
||||
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
|
||||
params: {
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshot_id: "snapshot-1",
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -1019,18 +1019,16 @@ export async function getSdkRaw({
|
||||
export async function getSdkRawGraph({
|
||||
source,
|
||||
offset = 0,
|
||||
limit = 500,
|
||||
snapshotId,
|
||||
limit = 300,
|
||||
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, snapshot_id: snapshotId },
|
||||
params: { source, offset, limit },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-mode-current {
|
||||
|
||||
@@ -21,6 +21,22 @@ 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 前需要先配置并启用生成模型。",
|
||||
@@ -184,7 +200,7 @@ export function MemoryModeConfirmDialog({
|
||||
<h2 id="memory-mode-confirm-title">
|
||||
{text("切换到 {mode}?").replace(
|
||||
"{mode}",
|
||||
text(MODE_COPY[mode].title),
|
||||
`${modeDisplayName(text, mode)} · ${MODE_TIER[mode]}`,
|
||||
)}
|
||||
</h2>
|
||||
<dl className="r6-mode-confirm-effects">
|
||||
|
||||
@@ -227,26 +227,140 @@
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory-mode-settings .memory-mode-cards {
|
||||
.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 {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.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 {
|
||||
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"]
|
||||
.memory-mode-card-choice {
|
||||
min-height: 180px;
|
||||
.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-evolve-settings--in-card {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button::after {
|
||||
right: 22px;
|
||||
}
|
||||
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
|
||||
border-bottom: 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-settings-note {
|
||||
margin: 14px 0 0;
|
||||
@@ -261,11 +375,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -692,6 +813,20 @@
|
||||
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;
|
||||
@@ -727,6 +862,11 @@
|
||||
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);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
|
||||
export const SDK_RAW_PAGE_SIZE = 50;
|
||||
export const SDK_RAW_PAGE_SIZE = 20;
|
||||
|
||||
export function rawRecordId(record: RawMapRecord): string {
|
||||
return record.turn_id || record.raw_id;
|
||||
|
||||
@@ -720,7 +720,7 @@ export interface SdkRawPage {
|
||||
time_filtered?: boolean;
|
||||
}
|
||||
|
||||
/** Lightweight complete-map projection. Raw text remains SDK-owned. */
|
||||
/** Lightweight bounded-map projection. Raw text remains SDK-owned. */
|
||||
export interface SdkRawGraphItem {
|
||||
turn_id: string;
|
||||
raw_id: string;
|
||||
@@ -734,11 +734,12 @@ 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. */
|
||||
|
||||
@@ -62,6 +62,7 @@ const modeChineseMessages = {
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"模式选择未能确认,仍显示上次确认的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||
"自动整合设置暂时无法读取,请重试。": "自动整合设置暂时无法读取,请重试。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
|
||||
@@ -260,6 +261,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -466,6 +469,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -666,6 +671,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"無法確認模式切換,仍顯示上次確認的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
|
||||
"自动整合设置暂时无法读取,请重试。": "暫時無法讀取自動整合設定,請重試。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在檢查或切換向量模型,完成後才能整合記憶。",
|
||||
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
|
||||
@@ -948,6 +954,8 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"自動統合の設定を保存できませんでした。もう一度お試しください。",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"自動統合の設定を読み込めませんでした。もう一度お試しください。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1153,6 +1161,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1359,6 +1369,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1567,6 +1579,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1768,6 +1782,8 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"자동 통합 설정을 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1971,6 +1987,8 @@ export const modeRussianMessages: ModeMessages = {
|
||||
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"Не удалось загрузить настройки автообъединения. Повторите попытку.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -2176,6 +2194,8 @@ 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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
|
||||
Reference in new issue
Block a user