Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9737431cf8 | ||
|
|
a8156dd410 | ||
|
|
30f796ddc4 | ||
|
|
d2f1acc84a | ||
|
|
2e22399e2e | ||
|
|
fdb62be235 | ||
|
|
4c2844531d | ||
|
|
d23ebf9bd8 | ||
|
|
7a80287f4b |
No files matched your search
+1
-1
@@ -105,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-04
|
||||
|
||||
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 45, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||
(build 44),GitHub 仓库为
|
||||
(build 45),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
@@ -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(
|
||||
@@ -6948,9 +6772,8 @@ async def _recall_raw_sources(
|
||||
) -> frozenset[str] | None:
|
||||
"""The raw sources this caller may read: the `/context` raw-lane rule.
|
||||
|
||||
Recall's own grant does not cover original conversations. A host reads
|
||||
them only with `om_conversations` and its granted, unrevoked sources; a
|
||||
trusted local caller reads every source.
|
||||
`om_conversations` plus `all-apps` reads every connected, unrevoked source.
|
||||
A trusted local caller reads every source without an external grant.
|
||||
"""
|
||||
if access is None:
|
||||
return None
|
||||
|
||||
@@ -11,14 +11,14 @@ from one_memory.context_models import ContextSearchRequest
|
||||
from one_memory.external_memory_access import ExternalMemoryAccess
|
||||
from one_memory.memory_server.store_binding import store_for
|
||||
|
||||
#: The revoked-source query, per engine.
|
||||
#: The connected-source query, per engine.
|
||||
#:
|
||||
#: Identical text today because it uses no placeholders and reads by position,
|
||||
#: which both engines support. Kept as a named constant so that adding a
|
||||
#: parameter later forces the `?`/`%s` difference to be considered here rather
|
||||
#: than being introduced silently at the call site.
|
||||
REVOKED_SOURCES_SQL = (
|
||||
"SELECT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NOT NULL"
|
||||
CONNECTED_SOURCES_SQL = (
|
||||
"SELECT DISTINCT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,16 +49,10 @@ def resolve_context_authority(
|
||||
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
|
||||
)
|
||||
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
|
||||
declared_sources = frozenset(
|
||||
scope.removeprefix("source:") for scope in access.scopes if scope.startswith("source:")
|
||||
)
|
||||
raw_allowed = (
|
||||
raw_requested
|
||||
and "om_conversations" in access.tools
|
||||
and "all-apps" in access.scopes
|
||||
and bool(declared_sources)
|
||||
raw_requested and "om_conversations" in access.tools and "all-apps" in access.scopes
|
||||
)
|
||||
if explicit is not None and (not raw_allowed or not explicit <= declared_sources):
|
||||
if explicit is not None and not raw_allowed:
|
||||
raise HTTPException(403, "external_context_scope_denied")
|
||||
|
||||
raw_sources: frozenset[str] = frozenset()
|
||||
@@ -70,10 +64,10 @@ def resolve_context_authority(
|
||||
# the same code runs on both engines without a branch per engine.
|
||||
if hasattr(db, "set_progress_handler"):
|
||||
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
|
||||
revoked = frozenset(str(row[0]) for row in db.execute(REVOKED_SOURCES_SQL))
|
||||
connected = frozenset(str(row[0]) for row in db.execute(CONNECTED_SOURCES_SQL))
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError
|
||||
raw_sources = declared_sources - revoked
|
||||
raw_sources = connected
|
||||
if explicit is not None:
|
||||
if not explicit <= raw_sources:
|
||||
raise HTTPException(403, "external_context_scope_denied")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authenticated SDK context search route, without generation or reindex side effects."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
@@ -36,6 +37,8 @@ from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
from one_memory.user_scope import owns
|
||||
|
||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||
SDK_CONTEXT_CONTENT_LIMIT = 20_000
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
|
||||
#: schema/working floors describe memory lanes; the raw lane is always the
|
||||
@@ -90,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def bounded_context_content(content: str) -> tuple[str, bool]:
|
||||
"""Fit SDK text into its wire contract before Pydantic validates it."""
|
||||
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
|
||||
return content, False
|
||||
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
|
||||
|
||||
|
||||
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
||||
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
||||
@@ -170,34 +180,39 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
)
|
||||
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||
if not turns:
|
||||
content, truncated = bounded_context_content(hit.content)
|
||||
primary.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# The embedding is the batch's: the hit is shown by the turns
|
||||
# that contain the query, or else by its closest turn, each
|
||||
# with the hit's score.
|
||||
shown = [
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=turn.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
shown: list[SdkRawContextItem] = []
|
||||
for turn in turns_for_hit(turns, value.query):
|
||||
content, truncated = bounded_context_content(turn.content)
|
||||
shown.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
for turn in turns_for_hit(turns, value.query)
|
||||
]
|
||||
primary.append(shown[0])
|
||||
siblings.extend(shown[1:])
|
||||
chosen = primary[: value.limit]
|
||||
@@ -250,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
or not owns(memory.user_id, value.user_id)
|
||||
):
|
||||
continue
|
||||
content, truncated = bounded_context_content(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
|
||||
else memory.content
|
||||
)
|
||||
items.append(
|
||||
SdkMemoryContextItem(
|
||||
memory_id=memory.memory_id,
|
||||
content=(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted
|
||||
and hit.formatting_complete
|
||||
and hit.formatted_content
|
||||
else memory.content
|
||||
),
|
||||
content=content,
|
||||
layer=memory.layer,
|
||||
status=memory.status,
|
||||
source=memory.source,
|
||||
@@ -269,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
return tuple(items)
|
||||
@@ -318,10 +333,45 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if isinstance(lane, EmbeddingUnavailableError):
|
||||
# No lexical fallback: the query could not be embedded.
|
||||
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
||||
requested_lanes = {
|
||||
"raw": raw_items,
|
||||
"memory": memory_items,
|
||||
}
|
||||
failed_lanes = {
|
||||
name: lane
|
||||
for name, lane in requested_lanes.items()
|
||||
if name in value.kinds and isinstance(lane, Exception)
|
||||
}
|
||||
for name, error in failed_lanes.items():
|
||||
logger.warning(
|
||||
"sdk_context_lane_failed lane=%s error_class=%s",
|
||||
name,
|
||||
type(error).__name__,
|
||||
)
|
||||
all_requested_failed = len(failed_lanes) == len(value.kinds)
|
||||
partial = bool(failed_lanes)
|
||||
raw_candidates = (
|
||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||
)
|
||||
if access is not None and raw_candidates:
|
||||
try:
|
||||
current = await asyncio.to_thread(
|
||||
resolve_context_authority,
|
||||
value,
|
||||
access,
|
||||
database,
|
||||
deadline=started + value.timeout_ms / 1000,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(503, "context_authority_unavailable") from None
|
||||
current_sources = current.raw_sources or frozenset()
|
||||
raw_candidates = tuple(
|
||||
item
|
||||
for item in raw_candidates
|
||||
if item.kind == "raw" and item.source in current_sources
|
||||
)
|
||||
memory_candidates = (
|
||||
()
|
||||
if isinstance(memory_items, Exception)
|
||||
@@ -347,11 +397,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if len(content) > remaining:
|
||||
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
||||
truncated = True
|
||||
truncated = truncated or item.truncated
|
||||
selected.append(item)
|
||||
used += len(item.content)
|
||||
final = SdkContextSearchResponse(
|
||||
request_id=str(uuid4()),
|
||||
state=("partial" if partial else "grounded" if selected else "empty"),
|
||||
state=(
|
||||
"unavailable"
|
||||
if all_requested_failed
|
||||
else "partial"
|
||||
if partial
|
||||
else "grounded"
|
||||
if selected
|
||||
else "empty"
|
||||
),
|
||||
items=tuple(selected),
|
||||
used_characters=used,
|
||||
truncated=truncated,
|
||||
|
||||
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
|
||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||
|
||||
None (no language set) sets nothing: the SDK detects zh/en as it always
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a
|
||||
feature, the language never touches `vector_store`, so the collection
|
||||
identity never depends on it.
|
||||
"""
|
||||
if language is not None:
|
||||
config.language = language
|
||||
|
||||
@@ -99,9 +99,7 @@ class HySdkMemoryRecord(BaseModel):
|
||||
created_at = gmt_created
|
||||
updated_at = _epoch(raw.get("updated_at"))
|
||||
modified_at = _epoch(raw.get("gmt_modified"))
|
||||
sort_updated_at = (
|
||||
modified_at if modified_at is not None else updated_at
|
||||
)
|
||||
sort_updated_at = modified_at if modified_at is not None else updated_at
|
||||
return cls(
|
||||
memory_id=str(raw["memory_id"]),
|
||||
content=str(raw["content"]),
|
||||
|
||||
@@ -226,9 +226,9 @@ class JsonlSourceAdapter:
|
||||
self.header_gate = header_gate
|
||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||
self._history_listing_cache: SourceListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, SourceListing
|
||||
] | None = None
|
||||
self._probe_listing_cache: (
|
||||
tuple[dict[str, SourceCursor], int | None, SourceListing] | None
|
||||
) = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
|
||||
@@ -176,9 +176,9 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant = grant
|
||||
self.source_version = source_version
|
||||
self._history_listing_cache: _CursorListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, _CursorListing
|
||||
] | None = None
|
||||
self._probe_listing_cache: (
|
||||
tuple[dict[str, SourceCursor], int | None, _CursorListing] | None
|
||||
) = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
@@ -218,12 +218,7 @@ class CursorUnifiedSourceAdapter:
|
||||
options.max_scan_targets,
|
||||
listing,
|
||||
)
|
||||
return bool(
|
||||
listing.transcripts
|
||||
or listing.stores
|
||||
or listing.legacy
|
||||
or listing.failures
|
||||
)
|
||||
return bool(listing.transcripts or listing.stores or listing.legacy or listing.failures)
|
||||
|
||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||
|
||||
@@ -15,9 +15,7 @@ class HyLifecycle:
|
||||
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
|
||||
other.hidden
|
||||
)
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(tuple(sorted(self.hidden.items())))
|
||||
@@ -27,9 +25,5 @@ class HyLifecycle:
|
||||
|
||||
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
old
|
||||
for old, replacements in self.hidden.items()
|
||||
if memory_id in replacements
|
||||
)
|
||||
sorted(old for old, replacements in self.hidden.items() if memory_id in replacements)
|
||||
)
|
||||
@@ -11,6 +11,7 @@ instead: plain retrieval over what was imported, the same lane
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
@@ -46,6 +47,50 @@ def recall_total_timeout_seconds(timeout_ms: int) -> float:
|
||||
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
||||
|
||||
|
||||
def _bounded_raw_excerpt(content: str, query: str, limit: int) -> str:
|
||||
"""Fit one raw hit without discarding the highest-ranked candidate.
|
||||
|
||||
Prefer the literal query, then its longest matching word. A semantic hit
|
||||
with no lexical anchor keeps its beginning: that is also the part the
|
||||
embedding service preserves when an input exceeds its own token limit.
|
||||
"""
|
||||
if len(content) <= limit:
|
||||
return content
|
||||
if limit <= 2:
|
||||
return content[:limit]
|
||||
|
||||
anchor = 0
|
||||
needle = query.strip()
|
||||
if needle:
|
||||
exact = re.search(re.escape(needle), content, re.IGNORECASE)
|
||||
if exact is not None:
|
||||
anchor = exact.start()
|
||||
else:
|
||||
terms = sorted(re.findall(r"\w+", needle), key=len, reverse=True)
|
||||
match = next(
|
||||
(
|
||||
found
|
||||
for term in terms
|
||||
if (found := re.search(re.escape(term), content, re.IGNORECASE)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is not None:
|
||||
anchor = match.start()
|
||||
|
||||
payload = limit
|
||||
while True:
|
||||
start = max(0, min(anchor - payload // 3, len(content) - payload))
|
||||
end = min(len(content), start + payload)
|
||||
adjusted = limit - int(start > 0) - int(end < len(content))
|
||||
if adjusted == payload:
|
||||
break
|
||||
payload = adjusted
|
||||
prefix = "…" if start > 0 else ""
|
||||
suffix = "…" if end < len(content) else ""
|
||||
return f"{prefix}{content[start:end]}{suffix}"
|
||||
|
||||
|
||||
async def recall_with_fallback(
|
||||
request: RecallRequest,
|
||||
*,
|
||||
@@ -123,6 +168,9 @@ async def _raw_recall(
|
||||
)
|
||||
except TimeoutError:
|
||||
return receipt(truncated_by=TruncationBudget.TIMEOUT)
|
||||
current_sources = await raw_sources()
|
||||
if sources is not None:
|
||||
sources &= current_sources or frozenset()
|
||||
hits: list[RecallHit] = []
|
||||
used = 0
|
||||
truncated_by: TruncationBudget | None = None
|
||||
@@ -137,13 +185,23 @@ async def _raw_recall(
|
||||
truncated_by=truncated_by,
|
||||
unexamined=len(found) - index,
|
||||
)
|
||||
if used + len(hit.content) > request.character_budget:
|
||||
remaining = request.character_budget - used
|
||||
if remaining <= 0:
|
||||
truncated_by = TruncationBudget.CHARACTER
|
||||
return receipt(
|
||||
tuple(hits),
|
||||
used=used,
|
||||
truncated_by=truncated_by,
|
||||
unexamined=len(found) - index,
|
||||
)
|
||||
content = hit.content
|
||||
if len(content) > remaining:
|
||||
content = _bounded_raw_excerpt(content, request.query, remaining)
|
||||
truncated_by = TruncationBudget.CHARACTER
|
||||
continue
|
||||
hits.append(
|
||||
RecallHit(
|
||||
memory_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
content=content,
|
||||
kind="raw",
|
||||
scopes=(),
|
||||
evidence_ids=(),
|
||||
@@ -158,5 +216,5 @@ async def _raw_recall(
|
||||
session_id=hit.session_id,
|
||||
)
|
||||
)
|
||||
used += len(hit.content)
|
||||
used += len(content)
|
||||
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
|
||||
@@ -288,4 +288,3 @@ def verify_readback(
|
||||
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
|
||||
chat_missing_before_migration=chat_missing_before_migration,
|
||||
)
|
||||
|
||||
@@ -39,8 +39,7 @@ def kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
|
||||
database = kuzu.Database(str(db_path))
|
||||
connection = kuzu.Connection(database)
|
||||
connection.execute(
|
||||
f"CREATE NODE TABLE Memory("
|
||||
f"node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
||||
f"CREATE NODE TABLE Memory(node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
||||
)
|
||||
del connection
|
||||
del database
|
||||
|
||||
@@ -104,9 +104,7 @@ SDK_PRIVATE_ATTRIBUTES = frozenset(
|
||||
}
|
||||
)
|
||||
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
|
||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
|
||||
{"_graph_store", "_loop_thread", "_run"}
|
||||
),
|
||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset({"_graph_store", "_loop_thread", "_run"}),
|
||||
# Startup migration runs while Runtime writers are stopped. It is the one
|
||||
# non-runtime path allowed to manipulate SDK storage directly so that
|
||||
# migration-only mechanics never become public HyMemoryClient methods.
|
||||
@@ -266,9 +264,7 @@ def test_no_sdk_private_implementation_access() -> None:
|
||||
)
|
||||
}
|
||||
if used:
|
||||
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(
|
||||
relative, frozenset()
|
||||
)
|
||||
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(relative, frozenset())
|
||||
if unexpected:
|
||||
offenders.append(f"{relative}: {sorted(unexpected)}")
|
||||
assert not offenders, (
|
||||
@@ -291,9 +287,7 @@ def test_sdk_modules_import_only_the_public_package_root() -> None:
|
||||
):
|
||||
offenders.append(relative)
|
||||
break
|
||||
if isinstance(node, ast.ImportFrom) and (
|
||||
node.module or ""
|
||||
).startswith("hy_memory."):
|
||||
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("hy_memory."):
|
||||
offenders.append(relative)
|
||||
break
|
||||
if (
|
||||
|
||||
@@ -16,6 +16,8 @@ def database(tmp_path):
|
||||
path = tmp_path / "context.sqlite3"
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','cursor',NULL)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','codex',NULL)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
|
||||
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
|
||||
return path
|
||||
@@ -43,16 +45,14 @@ def test_memory_only_never_reads_raw_authority(database, monkeypatch):
|
||||
assert not result.raw_allowed and result.raw_sources == frozenset()
|
||||
|
||||
|
||||
def test_raw_permission_does_not_grant_memory_and_removes_revoked_sources(database):
|
||||
result = resolve(
|
||||
database, access(["om_conversations"], ["all-apps", "source:cursor", "source:revoked"])
|
||||
)
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"cursor"})
|
||||
def test_all_apps_raw_reads_every_connected_source_and_removes_revoked_sources(database):
|
||||
result = resolve(database, access(["om_conversations"], ["all-apps", "source:cursor"]))
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"cursor", "codex"})
|
||||
assert not result.memory_allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scopes", [["source:cursor"], ["all-apps"], []])
|
||||
def test_raw_requires_all_apps_and_source(database, scopes):
|
||||
@pytest.mark.parametrize("scopes", [["source:cursor"], []])
|
||||
def test_raw_requires_all_apps(database, scopes):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
resolve(database, access(["om_conversations"], scopes))
|
||||
assert error.value.status_code == 403
|
||||
@@ -80,9 +80,7 @@ def test_explicit_raw_filter_must_be_currently_authorized(database, source):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
resolve(
|
||||
database,
|
||||
access(
|
||||
["om_recall", "om_conversations"], ["all-apps", "source:cursor", "source:revoked"]
|
||||
),
|
||||
access(["om_recall", "om_conversations"], ["all-apps"]),
|
||||
raw_source_ids=(source,),
|
||||
)
|
||||
assert error.value.status_code == 403
|
||||
@@ -104,10 +102,19 @@ def test_filters_do_not_change_memory_scope(database):
|
||||
assert result.raw_sources == frozenset({"cursor"})
|
||||
|
||||
|
||||
def test_implicit_revoked_raw_path_can_be_reported_beside_allowed_memory(database):
|
||||
def test_explicit_raw_filter_accepts_any_connected_source_under_all_apps(database):
|
||||
result = resolve(
|
||||
database, access(["om_recall", "om_conversations"], ["all-apps", "source:revoked"])
|
||||
database,
|
||||
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
||||
raw_source_ids=("codex",),
|
||||
)
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"codex"})
|
||||
|
||||
|
||||
def test_no_connected_raw_source_can_be_reported_beside_allowed_memory(database):
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute("UPDATE source_grants SET revoked_at='2026-09-20' WHERE user_id='local'")
|
||||
result = resolve(database, access(["om_recall", "om_conversations"], ["all-apps"]))
|
||||
assert result.memory_allowed and not result.raw_allowed
|
||||
|
||||
|
||||
@@ -124,7 +131,7 @@ def test_expired_deadline_does_not_open_database(database, monkeypatch):
|
||||
with pytest.raises(TimeoutError):
|
||||
resolve_context_authority(
|
||||
ContextSearchRequest(query="x"),
|
||||
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
||||
access(["om_conversations"], ["all-apps"]),
|
||||
database,
|
||||
deadline=time.monotonic() - 1,
|
||||
)
|
||||
|
||||
@@ -53,8 +53,8 @@ def test_external_marker_is_not_an_installation_grant(tmp_path):
|
||||
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
|
||||
)
|
||||
assert denied_tool.status_code == 403
|
||||
# om_conversations reaches Core only as the v2 raw lane, and only
|
||||
# with a source grant: all-apps alone opens no raw source.
|
||||
# om_conversations reaches Core only as the v2 raw lane. all-apps
|
||||
# has nothing to open because this fresh home has no connected source.
|
||||
no_raw = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "history", "kinds": ["raw"]},
|
||||
|
||||
@@ -187,9 +187,10 @@ def test_sync_off_or_no_grant_is_rejected_counted_and_stages_nothing(tmp_path) -
|
||||
|
||||
assert _jobs(home.database) == []
|
||||
logs = home.database.parent / "logs"
|
||||
assert json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())[
|
||||
"count"
|
||||
] == 1
|
||||
assert (
|
||||
json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())["count"]
|
||||
== 1
|
||||
)
|
||||
assert (logs / "capture-rejection-codex-grant_missing.json").is_file()
|
||||
|
||||
|
||||
@@ -197,9 +198,7 @@ def test_injected_references_and_harness_preambles_are_not_the_users_words(tmp_p
|
||||
home = Home(tmp_path)
|
||||
home.grant("claude-code")
|
||||
home.grant("codex")
|
||||
reference = (
|
||||
"以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
|
||||
)
|
||||
reference = "以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
|
||||
assert (
|
||||
home.capture(
|
||||
"claude-code",
|
||||
|
||||
@@ -294,9 +294,7 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
|
||||
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
|
||||
live.interval_seconds, 15 * 60
|
||||
)
|
||||
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(live.interval_seconds, 15 * 60)
|
||||
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||
await imports.shutdown()
|
||||
|
||||
|
||||
@@ -1394,12 +1394,7 @@ def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> Non
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root
|
||||
/ "extensions"
|
||||
/ "large-cache"
|
||||
/ "agent-transcripts"
|
||||
/ "ignored"
|
||||
/ "ignored.jsonl",
|
||||
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Without a usable generation model no memory can be distilled, and with no
|
||||
memory in the library a memory search can only come back empty. In both cases
|
||||
recall searches the SDK raw layer and says so with `mode: "raw"`. Original
|
||||
conversations stay under their own grant: recall's grant does not widen it.
|
||||
recall searches the SDK raw layer and says so with `mode: "raw"`. External
|
||||
callers need the conversations tool and all-apps scope; Core resolves that to
|
||||
every connected, unrevoked source.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -18,6 +19,7 @@ from one_memory import api
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.hy_sdk_types import HySdkRawHit
|
||||
from one_memory.imports.sources.registry import RootCandidateKind
|
||||
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
|
||||
from one_memory.recall_fallback import recall_with_fallback
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
@@ -156,6 +158,26 @@ def test_raw_fallback_respects_the_callers_raw_sources():
|
||||
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
|
||||
|
||||
|
||||
def test_raw_fallback_rechecks_sources_after_search_before_releasing_content():
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("r1", "private", source="cursor")],
|
||||
sources=frozenset({"cursor"}),
|
||||
)
|
||||
checks = iter((frozenset({"cursor"}), frozenset()))
|
||||
|
||||
async def sources():
|
||||
return next(checks)
|
||||
|
||||
fakes["raw_sources"] = sources
|
||||
receipt = run(RecallRequest(query="private"), fakes)
|
||||
|
||||
assert receipt.state == RecallState.EMPTY
|
||||
assert receipt.hits == ()
|
||||
|
||||
|
||||
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
|
||||
calls, fakes = _fakes(
|
||||
ready=False,
|
||||
@@ -187,6 +209,43 @@ def test_raw_fallback_keeps_the_count_and_character_budgets():
|
||||
assert by_chars.used_characters == 300
|
||||
|
||||
|
||||
def test_oversized_raw_hit_returns_a_query_anchored_excerpt_instead_of_empty():
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("large", ("x" * 5_000) + "WebGL 黄色节点" + ("y" * 5_000))],
|
||||
)
|
||||
|
||||
receipt = run(
|
||||
RecallRequest(query="WebGL 黄色节点", limit=3, character_budget=300),
|
||||
fakes,
|
||||
)
|
||||
|
||||
assert receipt.state == RecallState.GROUNDED
|
||||
assert len(receipt.hits) == 1
|
||||
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||
assert "WebGL 黄色节点" in receipt.hits[0].content
|
||||
assert receipt.truncated_by.value == "character_budget"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix_length", [0, 100, 5_000, 9_980])
|
||||
def test_raw_excerpt_never_exceeds_its_character_budget(prefix_length):
|
||||
needle = "WebGL"
|
||||
content = ("x" * prefix_length) + needle + ("y" * (10_000 - prefix_length))
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("large", content)],
|
||||
)
|
||||
|
||||
receipt = run(RecallRequest(query=needle, character_budget=300), fakes)
|
||||
|
||||
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||
assert needle in receipt.hits[0].content
|
||||
|
||||
|
||||
def test_the_probe_never_falls_back():
|
||||
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
|
||||
|
||||
@@ -237,6 +296,14 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
headers={"Authorization": "Bearer recall-test"},
|
||||
) as client,
|
||||
):
|
||||
cursor_root = tmp_path / "cursor"
|
||||
cursor_root.mkdir()
|
||||
app.state.source_grant_store.grant(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
root=cursor_root,
|
||||
candidate_kind=RootCandidateKind.DEFAULT,
|
||||
)
|
||||
local = await client.post("/api/v1/recall", json={"query": "数据库"})
|
||||
recall_only = await client.post(
|
||||
"/api/v1/recall",
|
||||
@@ -247,9 +314,18 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
|
||||
},
|
||||
)
|
||||
return local.json(), recall_only.json()
|
||||
cross_app = await client.post(
|
||||
"/api/v1/recall",
|
||||
json={"query": "数据库"},
|
||||
headers={
|
||||
**EXTERNAL,
|
||||
"X-One-Memory-Allowed-Tools": json.dumps(["om_recall", "om_conversations"]),
|
||||
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps", "source:workbuddy"]),
|
||||
},
|
||||
)
|
||||
return local.json(), recall_only.json(), cross_app.json()
|
||||
|
||||
local, recall_only = asyncio.run(scenario())
|
||||
local, recall_only, cross_app = asyncio.run(scenario())
|
||||
|
||||
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
|
||||
assert local["hits"][0]["content_format"] == "raw"
|
||||
@@ -258,7 +334,9 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
assert searched[0] is None
|
||||
# A host granted recall alone may not read original conversations.
|
||||
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
|
||||
assert len(searched) == 1
|
||||
# all-apps covers every connected source, not only the host's source scope.
|
||||
assert cross_app["hits"][0]["source"] == "cursor"
|
||||
assert searched == [None, frozenset({"cursor"})]
|
||||
|
||||
|
||||
def test_the_total_recall_budget_stays_under_the_brokers_wait():
|
||||
|
||||
@@ -469,6 +469,101 @@ def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monk
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(query, **kwargs):
|
||||
sdk._record("search", {"query": query, **kwargs})
|
||||
memories = [
|
||||
{
|
||||
"memory_id": "raw-target",
|
||||
"content": "[user]: lite 模式的 3d 图需要调整",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "target-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_100_000,
|
||||
"score": 0.97,
|
||||
},
|
||||
{
|
||||
"memory_id": "raw-long",
|
||||
"content": f"[user]: {'x' * 20_001}",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "long-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_000_000,
|
||||
"score": 0.9,
|
||||
},
|
||||
]
|
||||
return {
|
||||
"request_id": "long-raw",
|
||||
"memories": memories[1:] if query == "oversized" else memories,
|
||||
}
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "3d",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"limit": 20,
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["state"] == "grounded"
|
||||
assert body["items"][0]["raw_id"] == "raw-target"
|
||||
assert "3d" in body["items"][0]["content"]
|
||||
assert body["items"][1]["raw_id"] == "raw-long"
|
||||
assert body["items"][1]["truncated"] is True
|
||||
assert body["truncated"] is True
|
||||
|
||||
single = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "oversized",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
assert single.json()["items"][0]["truncated"] is True
|
||||
assert single.json()["truncated"] is True
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(_query, **_kwargs):
|
||||
raise ValueError("private conversation text must not reach logs")
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["state"] == "unavailable"
|
||||
assert response.json()["items"] == []
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
@@ -743,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
|
||||
@@ -752,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]: 新消息",
|
||||
@@ -792,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]: 新消息"
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import SecretStr
|
||||
from one_memory import api
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.sources.registry import RootCandidateKind
|
||||
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
||||
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
@@ -96,8 +97,11 @@ class Sdk:
|
||||
|
||||
|
||||
class Handle:
|
||||
def __init__(self, sdk=None):
|
||||
self.sdk = sdk or Sdk()
|
||||
|
||||
async def client(self):
|
||||
return Sdk()
|
||||
return self.sdk
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
@@ -195,3 +199,50 @@ def test_v2_host_calls_are_recorded_and_renderer_calls_are_not(tmp_path, monkeyp
|
||||
assert listed.json()["total"] == 3
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_v2_raw_rechecks_a_source_revoked_during_search(tmp_path):
|
||||
app = application(tmp_path)
|
||||
|
||||
class RevokingRawSdk:
|
||||
def search(self, query, **kwargs):
|
||||
app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
|
||||
return {
|
||||
"memories": {
|
||||
"schema": [],
|
||||
"proactive": [],
|
||||
"normal": [
|
||||
{
|
||||
"memory_id": "raw-1",
|
||||
"content": "must not escape",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "session-1",
|
||||
"source": "cursor",
|
||||
"score": 0.9,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async def scenario():
|
||||
async with app.router.lifespan_context(app), client_for(app, EXTERNAL) as host:
|
||||
root = tmp_path / "cursor"
|
||||
root.mkdir()
|
||||
app.state.source_grant_store.grant(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
root=root,
|
||||
candidate_kind=RootCandidateKind.DEFAULT,
|
||||
)
|
||||
app.state.sdk_client_handle = Handle(RevokingRawSdk())
|
||||
return await host.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "private", "kinds": ["raw"]},
|
||||
)
|
||||
|
||||
response = asyncio.run(scenario())
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "must not escape" not in response.text
|
||||
@@ -266,93 +266,9 @@ export function FirstImportReview({
|
||||
step={2}
|
||||
title={text(title)}
|
||||
footer={
|
||||
<div className="first-import-r6-footer-actions">
|
||||
{changing ? (
|
||||
<R6Button
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
}}
|
||||
>
|
||||
{text("停止后续操作")}
|
||||
</R6Button>
|
||||
) : onChangeJob && (canPause || canResume) ? (
|
||||
<R6Button
|
||||
disabled={busy || (stale && !canPause)}
|
||||
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
{text(canPause ? "暂停导入" : "继续导入")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
recovery.onSkip();
|
||||
}}
|
||||
>
|
||||
{text(
|
||||
recovery.destination === "sources"
|
||||
? "仍进入应用页"
|
||||
: recovery.destination === "settings"
|
||||
? "仍进入设置"
|
||||
: "仍进入工作台",
|
||||
)}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave(recovery.destination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
</R6Button>
|
||||
</>
|
||||
) : (
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="first-import-r6-layout">
|
||||
<section
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<div className="first-import-r6-intro">
|
||||
<h2>{text("正在导入你的会话")}</h2>
|
||||
{importSubmissionState === "checking" ? (
|
||||
<p className="first-import-r6-submission-status" role="status">
|
||||
{t("onboardingImportChecking")}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "后台将持续为你导入,可以放心进入下一步",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{ids.length > 3 && (
|
||||
<p className="first-import-r6-scroll-hint">
|
||||
{t("onboardingMoreImportApps", { count: ids.length - 3 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(error || actionMessage) && (
|
||||
<div className="first-import-r6-feedback">
|
||||
<>
|
||||
<div className="first-import-r6-footer-note">
|
||||
{(error || actionMessage) && (
|
||||
<p
|
||||
role={
|
||||
error?.includes("应用连接") ||
|
||||
@@ -371,8 +287,87 @@ export function FirstImportReview({
|
||||
>
|
||||
{error ? text(error) : actionMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className="first-import-r6-footer-actions">
|
||||
{changing ? (
|
||||
<R6Button
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
}}
|
||||
>
|
||||
{text("停止后续操作")}
|
||||
</R6Button>
|
||||
) : onChangeJob && (canPause || canResume) ? (
|
||||
<R6Button
|
||||
disabled={busy || (stale && !canPause)}
|
||||
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
{text(canPause ? "暂停导入" : "继续导入")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
recovery.onSkip();
|
||||
}}
|
||||
>
|
||||
{text(
|
||||
recovery.destination === "sources"
|
||||
? "仍进入应用页"
|
||||
: recovery.destination === "settings"
|
||||
? "仍进入设置"
|
||||
: "仍进入工作台",
|
||||
)}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave(recovery.destination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
</R6Button>
|
||||
</>
|
||||
) : (
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="first-import-r6-layout">
|
||||
<section
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<p className="first-import-r6-intro">
|
||||
{importSubmissionState === "checking"
|
||||
? t("onboardingImportChecking")
|
||||
: text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||
)}
|
||||
</p>
|
||||
<p className="first-import-r6-subintro">
|
||||
{text(
|
||||
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
|
||||
)}
|
||||
</p>
|
||||
{historyWindowLimited && (
|
||||
<p className="first-import-r6-history-window" role="status">
|
||||
{t("onboardingHistoryWindow")}
|
||||
@@ -384,39 +379,46 @@ export function FirstImportReview({
|
||||
jobs={batch}
|
||||
stale={progressStale}
|
||||
/>
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
>
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
<section
|
||||
className="first-import-r6-feed"
|
||||
aria-label={text("导入说明")}
|
||||
>
|
||||
<h2>{text("正在导入你的会话")}</h2>
|
||||
<p className="first-import-r6-feed-status">
|
||||
{text("后台将持续为你导入,可以放心进入下一步")}
|
||||
</p>
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
<aside
|
||||
className="first-import-r6-options"
|
||||
aria-label={text("连接应用")}
|
||||
>
|
||||
<div className="first-import-r6-options-head">
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
<SelectionTile
|
||||
title={text(
|
||||
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -33,8 +33,10 @@ export function MemoryModeCards({
|
||||
currentBadge,
|
||||
disabled = false,
|
||||
layout = "grid",
|
||||
describedBy,
|
||||
onChange,
|
||||
ultraDetail,
|
||||
ultraDetailBusy = false,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-busy": ariaBusy,
|
||||
}: {
|
||||
@@ -43,8 +45,10 @@ export function MemoryModeCards({
|
||||
currentBadge?: ReactNode;
|
||||
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
|
||||
layout?: "grid" | "rows";
|
||||
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
|
||||
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
|
||||
ultraDetail?: ReactNode;
|
||||
ultraDetailBusy?: boolean;
|
||||
"aria-label": string;
|
||||
"aria-busy"?: boolean;
|
||||
}) {
|
||||
@@ -64,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*/,
|
||||
);
|
||||
@@ -86,7 +87,8 @@ 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}
|
||||
onChange={(event) => onChange(mode.id, event.currentTarget)}
|
||||
@@ -94,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}
|
||||
@@ -115,13 +115,16 @@ export function MemoryModeCards({
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||
<div className="memory-mode-detail">
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<div>{ultraDetail}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||
<div
|
||||
className="memory-mode-detail"
|
||||
aria-busy={ultraDetailBusy || undefined}
|
||||
>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<div>{ultraDetail}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -156,7 +156,7 @@ it("moves from import to the actual third step without completing onboarding", a
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
@@ -240,7 +240,7 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
|
||||
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
}
|
||||
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
|
||||
await selectMode(user, /lite/i);
|
||||
@@ -688,7 +688,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
const receipt = screen.getByRole("button", {
|
||||
name: "模型启用回执已确认",
|
||||
});
|
||||
@@ -1031,6 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "你希望 MemHub 如何形成记忆?",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("Choose how MemHub remembers")).toBeNull();
|
||||
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||
@@ -1043,7 +1049,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await selectMode(user, /lite/i);
|
||||
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
|
||||
await selectMode(user, /pro/i);
|
||||
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "配置模型" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("configures Ultra scheduling before model setup and completes after activation", async () => {
|
||||
@@ -1083,10 +1089,19 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /ultra/i }));
|
||||
expect(screen.getByText("每天自动整合")).toBeTruthy();
|
||||
const ultra = screen.getByRole("radio", { name: /ultra/i });
|
||||
await user.click(ultra);
|
||||
const schedule = screen.getByText("每天自动整合");
|
||||
expect(
|
||||
schedule.closest(".memory-mode-card")?.getAttribute("data-mode"),
|
||||
).toBe("ultra");
|
||||
expect(schedule.closest("label")).toBeNull();
|
||||
expect(ultra.getAttribute("aria-describedby")).toBe(
|
||||
"onboarding-ultra-schedule-description",
|
||||
);
|
||||
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(screen.getByText("后续可在设置页自定义时间或关闭。")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(putOnboardingProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1149,14 +1164,15 @@ describe("OnboardingFlow S1.3", () => {
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
|
||||
expect(screen.queryByText("后续可在设置页自定义时间或关闭。")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -1190,7 +1206,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /ultra/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"自动整合设置未能保存",
|
||||
);
|
||||
@@ -1199,7 +1215,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
|
||||
).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -1207,9 +1223,14 @@ describe("OnboardingFlow S1.3", () => {
|
||||
});
|
||||
|
||||
it("retries a failed Ultra schedule read without losing the selection", async () => {
|
||||
let resolveRetry!: (value: { enabled: boolean; time: string }) => void;
|
||||
vi.mocked(api.getEvolveSchedule)
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
@@ -1225,19 +1246,35 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"记忆模式状态暂时无法读取",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重试" }));
|
||||
const retry = screen.getByRole("button", { name: "重试" });
|
||||
expect(retry.closest(".memory-mode-card")?.getAttribute("data-mode")).toBe(
|
||||
"ultra",
|
||||
);
|
||||
expect(retry.closest("label")).toBeNull();
|
||||
retry.focus();
|
||||
await user.click(retry);
|
||||
expect(screen.getByRole("button", { name: "重试" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
retry.closest(".memory-mode-detail")?.getAttribute("aria-busy"),
|
||||
).toBe("true");
|
||||
expect(document.activeElement).toBe(retry);
|
||||
expect(api.getEvolveSchedule).toHaveBeenCalledTimes(2);
|
||||
await act(async () => resolveRetry({ enabled: true, time: "04:15" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
|
||||
).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
@@ -1430,7 +1467,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
@@ -1484,7 +1521,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
@@ -1492,7 +1529,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
modelActive = false;
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
@@ -1538,7 +1575,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
@@ -1580,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
@@ -1629,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
@@ -1669,7 +1706,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
|
||||
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
const returnedTitle = screen.getByRole("heading", {
|
||||
name: "选择记忆模式",
|
||||
@@ -1699,7 +1736,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
@@ -1718,7 +1755,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
const lateReceipt = screen.getByRole("button", {
|
||||
name: "模型启用回执已确认",
|
||||
});
|
||||
@@ -1767,14 +1804,14 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"模式未能保存",
|
||||
);
|
||||
expect(pro.checked).toBe(true);
|
||||
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "模型步骤边界" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -112,7 +112,7 @@ export function OnboardingFlow({
|
||||
imports: ImportSourcesController;
|
||||
onFinish(destination: OnboardingDestination): void;
|
||||
}) {
|
||||
const { text, t, locale } = useOnboardingTranslation();
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const [sources, setSources] = useState(imports.sources);
|
||||
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
|
||||
if (sourceGeneration.current.value !== imports.sources) {
|
||||
@@ -165,6 +165,7 @@ export function OnboardingFlow({
|
||||
});
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState(false);
|
||||
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||
const [scheduleLoading, setScheduleLoading] = useState(true);
|
||||
const scheduleReadOperation = useRef(0);
|
||||
const [modeFocusToken, setModeFocusToken] = useState(
|
||||
resumedStep === "mode" ? 1 : 0,
|
||||
@@ -430,7 +431,7 @@ export function OnboardingFlow({
|
||||
const refreshUltraSchedule = useCallback(async () => {
|
||||
const operation = ++scheduleReadOperation.current;
|
||||
setScheduleLoaded(false);
|
||||
setScheduleReadFailed(false);
|
||||
setScheduleLoading(true);
|
||||
try {
|
||||
const schedule = await getEvolveSchedule();
|
||||
if (!alive.current || scheduleReadOperation.current !== operation) return;
|
||||
@@ -446,6 +447,9 @@ export function OnboardingFlow({
|
||||
setScheduleLoaded(false);
|
||||
setScheduleReadFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (alive.current && scheduleReadOperation.current === operation)
|
||||
setScheduleLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1260,11 +1264,11 @@ export function OnboardingFlow({
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
: !modeChoice
|
||||
? "继续"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: "配置模型",
|
||||
)}
|
||||
</R6Button>
|
||||
}
|
||||
@@ -1274,18 +1278,21 @@ export function OnboardingFlow({
|
||||
aria-label={text("记忆模式")}
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<h2>
|
||||
{locale === "zh-Hans"
|
||||
? "Choose how MemHub remembers"
|
||||
: text("你希望 MemHub 如何形成记忆?")}
|
||||
</h2>
|
||||
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
|
||||
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||
</div>
|
||||
<MemoryModeCards
|
||||
aria-label={text("记忆模式")}
|
||||
value={modeChoice}
|
||||
layout="rows"
|
||||
describedBy={{
|
||||
ultra:
|
||||
modeChoice === "ultra"
|
||||
? "onboarding-ultra-schedule-description"
|
||||
: undefined,
|
||||
}}
|
||||
disabled={modeSaving || completionBusy}
|
||||
ultraDetailBusy={scheduleLoading}
|
||||
onChange={(mode) => {
|
||||
modeChoiceTouched.current = true;
|
||||
intentRef.current = mode;
|
||||
@@ -1294,30 +1301,51 @@ export function OnboardingFlow({
|
||||
}}
|
||||
ultraDetail={
|
||||
scheduleLoaded ? (
|
||||
<div className="onboarding-r6-ultra-default">
|
||||
<div className="onboarding-r6-ultra-default" role="status">
|
||||
<strong>{text("每天自动整合")}</strong>
|
||||
<small>
|
||||
<small id="onboarding-ultra-schedule-description">
|
||||
{text(
|
||||
ultraSchedule.enabled
|
||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
).replace("{time}", ultraSchedule.time)}
|
||||
{ultraSchedule.enabled ? (
|
||||
<>
|
||||
<br />
|
||||
<span>
|
||||
{text("后续可在设置页自定义时间或关闭。")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</small>
|
||||
</div>
|
||||
) : scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p role="alert">
|
||||
<p
|
||||
id="onboarding-ultra-schedule-description"
|
||||
role="alert"
|
||||
>
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
pending={scheduleLoading}
|
||||
disabled={
|
||||
scheduleLoading || modeSaving || completionBusy
|
||||
}
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{text("正在读取自动整合设置…")}</p>
|
||||
<p
|
||||
id="onboarding-ultra-schedule-description"
|
||||
className="onboarding-r6-schedule-loading"
|
||||
role="status"
|
||||
>
|
||||
{text("正在读取自动整合设置…")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -82,7 +82,7 @@ it("does not reserve an empty eyebrow row", () => {
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
|
||||
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
|
||||
const card = declaration(
|
||||
"r6-import-progress.css",
|
||||
".r6-import-progress-card",
|
||||
@@ -94,13 +94,12 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
|
||||
"first-import-r6.css",
|
||||
".first-import-r6-main > .r6-import-progress",
|
||||
),
|
||||
).toMatch(/flex:\s*0 0 auto/);
|
||||
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
||||
/height:\s*100%/,
|
||||
).toMatch(/flex:\s*0 0 218px/);
|
||||
const layout = declaration("first-import-r6.css", ".first-import-r6-layout");
|
||||
expect(layout).toMatch(
|
||||
/grid-template-columns:\s*minmax\(0,\s*738px\)\s+278px/,
|
||||
);
|
||||
expect(
|
||||
declaration("first-import-r6.css", ".first-import-r6-feedback"),
|
||||
).not.toMatch(/max-height|overflow/);
|
||||
expect(layout).toMatch(/height:\s*560\.2px/);
|
||||
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
||||
/overflow-y:\s*auto/,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("R6 shared controls", () => {
|
||||
</R6Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "保存修改" });
|
||||
expect(button.getAttribute("data-pending-capable")).toBe("true");
|
||||
const reservedSpinner = button.querySelector(".r6-button-spinner");
|
||||
expect(reservedSpinner).toBeTruthy();
|
||||
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
|
||||
|
||||
@@ -28,6 +28,7 @@ export function R6Button({
|
||||
type={type}
|
||||
className={`r6-button ${className}`}
|
||||
data-variant={variant}
|
||||
data-pending-capable={supportsPending || undefined}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ it("lists raw conversations for the selected application", async () => {
|
||||
});
|
||||
|
||||
it("keeps the application filter when searching raw conversations", async () => {
|
||||
post.mockResolvedValue({ data: { items: [] } });
|
||||
post.mockResolvedValue({ data: { state: "empty", items: [] } });
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await searchSdkRaw("release plan", { source: "claude-code", signal });
|
||||
@@ -60,34 +60,39 @@ it("keeps the application filter when searching raw conversations", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
it("pages the independent lightweight raw graph", async () => {
|
||||
it("does not present an unavailable raw search as no matches", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { state: "unavailable", items: [] },
|
||||
});
|
||||
|
||||
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1052,6 +1050,8 @@ export async function searchSdkRaw(
|
||||
},
|
||||
{ signal: options.signal, timeout: 10_000 },
|
||||
);
|
||||
if (response.data?.state !== "grounded" && response.data?.state !== "empty")
|
||||
throw new Error("raw_search_unavailable");
|
||||
const items = response.data?.items;
|
||||
return Array.isArray(items)
|
||||
? items.filter(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
|
||||
.first-import-r6-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
height: 560.2px;
|
||||
min-height: 0;
|
||||
}
|
||||
.first-import-r6-main {
|
||||
min-width: 0;
|
||||
@@ -13,24 +13,19 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.first-import-r6-intro {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.first-import-r6-intro h2 {
|
||||
margin: 0 0 3px;
|
||||
color: #262a2e;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
.first-import-r6-intro p {
|
||||
margin: 0;
|
||||
color: #5a6169;
|
||||
font-size: 13px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.first-import-r6-subintro {
|
||||
margin: 1px 0 20px;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.first-import-r6-history-window {
|
||||
margin: 0 0 12px;
|
||||
margin: -10px 0 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ead7a7;
|
||||
border-radius: 8px;
|
||||
@@ -40,114 +35,13 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="1"] {
|
||||
grid-auto-columns: min(320px, 100%);
|
||||
justify-content: center;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="2"] {
|
||||
grid-auto-columns: min(320px, calc((100% - 16px) / 2));
|
||||
justify-content: center;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="3"] {
|
||||
grid-auto-columns: calc((100% - 32px) / 3);
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="many"] {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: calc((100% - 32px) / 3);
|
||||
justify-content: start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-color: rgb(102 128 145 / 50%) transparent;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
|
||||
min-height: 128px;
|
||||
padding: 16px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
align-content: center;
|
||||
column-gap: 18px;
|
||||
text-align: left;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 4;
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
|
||||
align-self: end;
|
||||
}
|
||||
.first-import-r6-intro .first-import-r6-submission-status {
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #41697d;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.first-import-r6-submission-status::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(88 124 144 / 28%);
|
||||
border-top-color: #587c90;
|
||||
border-radius: 50%;
|
||||
animation: first-import-r6-checking 1.2s linear infinite;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-submission-status) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-submission-status) h2 {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-intro .first-import-r6-scroll-hint {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
color: #6b7e8b;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-scroll-hint) {
|
||||
padding-right: 190px;
|
||||
}
|
||||
@keyframes first-import-r6-checking {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
flex: 0 0 218px;
|
||||
}
|
||||
.first-import-r6-feed {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 20px;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
margin-top: 26px;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -194,14 +88,9 @@
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
padding: 22px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
|
||||
align-items: center;
|
||||
gap: 0 28px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid #ffffffe0;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
@@ -210,13 +99,9 @@
|
||||
box-shadow:
|
||||
inset 0 1px #fff,
|
||||
0 12px 32px rgb(49 75 101 / 4%);
|
||||
overflow: visible;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
align-self: stretch;
|
||||
}
|
||||
.first-import-r6-options-head {
|
||||
min-width: 0;
|
||||
}
|
||||
.first-import-r6-eyebrow {
|
||||
margin: 0 0 14px;
|
||||
@@ -243,12 +128,12 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 104px;
|
||||
margin: 0;
|
||||
padding: 18px 52px 16px 18px;
|
||||
min-height: 124px;
|
||||
margin: 0 0 16px;
|
||||
padding: 18px 16px 12px;
|
||||
border: 1px solid rgb(179 196 209 / 42%);
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 34%);
|
||||
border-radius: 0;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
box-shadow: inset 0 1px 1px #ffffffd9;
|
||||
backdrop-filter: blur(20px);
|
||||
cursor: pointer;
|
||||
@@ -277,8 +162,8 @@
|
||||
.first-import-r6-selection input {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
top: 22px;
|
||||
right: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
@@ -316,11 +201,10 @@
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-top: 7px;
|
||||
margin-top: 8px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.first-import-r6-options-note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
margin: 0 0 12px;
|
||||
@@ -340,37 +224,33 @@
|
||||
.first-import-r6-options details p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.first-import-r6-feedback {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 50%);
|
||||
.first-import-r6-footer-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 64px;
|
||||
overflow: auto;
|
||||
}
|
||||
.first-import-r6-feedback p {
|
||||
.first-import-r6-footer-note p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #63707b;
|
||||
}
|
||||
.first-import-r6-feedback [role="alert"] {
|
||||
.first-import-r6-footer-note [role="alert"] {
|
||||
color: #944242;
|
||||
}
|
||||
.first-import-r6-feedback .first-import-r6-partial-success {
|
||||
.first-import-r6-footer-note .first-import-r6-partial-success {
|
||||
color: #245f50;
|
||||
font-weight: 600;
|
||||
}
|
||||
.first-import-r6-footer-actions {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.first-import-r6-footer-actions > [data-variant="primary"] {
|
||||
min-width: min(160px, 100%);
|
||||
min-width: 210px;
|
||||
}
|
||||
.first-import-r6-footer-actions .r6-button-label {
|
||||
display: inline-flex;
|
||||
@@ -517,28 +397,13 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
@media (max-width: 1150px) {
|
||||
.first-import-r6-layout {
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 859px) {
|
||||
.onboarding-r6:has(.first-import-r6-layout)
|
||||
> :is(.onboarding-r6-titlebar, .onboarding-r6-local-footer) {
|
||||
display: none;
|
||||
}
|
||||
.onboarding-r6:has(.first-import-r6-layout) > .onboarding-r6-stage {
|
||||
min-height: 0;
|
||||
height: 100dvh;
|
||||
padding: 10px 24px 2px;
|
||||
}
|
||||
.onboarding-r6:has(.first-import-r6-layout)
|
||||
> .onboarding-r6-stage
|
||||
> .onboarding-r6-shell {
|
||||
height: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) 278px;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.onboarding-r6-body:has(> .first-import-r6-layout) {
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
@@ -548,8 +413,8 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 859px) {
|
||||
.first-import-r6-options {
|
||||
padding: 18px 20px;
|
||||
overflow: visible;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.first-import-r6-eyebrow {
|
||||
margin-bottom: 4px;
|
||||
@@ -566,16 +431,16 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
.first-import-r6-selection,
|
||||
.first-import-r6-selection + .first-import-r6-selection {
|
||||
min-height: 96px;
|
||||
margin-bottom: 0;
|
||||
padding: 15px 48px 13px 16px;
|
||||
min-height: 86px;
|
||||
margin-bottom: 8px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
.first-import-r6-selection strong {
|
||||
line-height: 24px;
|
||||
}
|
||||
.first-import-r6-selection input {
|
||||
top: 17px;
|
||||
right: 16px;
|
||||
top: 13px;
|
||||
right: 12px;
|
||||
}
|
||||
.first-import-r6-selection span {
|
||||
margin-top: 4px;
|
||||
@@ -596,7 +461,6 @@ dialog.first-import-r6-original[open] {
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-footer-note {
|
||||
@@ -654,10 +518,6 @@ dialog.first-import-r6-original[open] {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.first-import-r6-submission-status::before {
|
||||
animation: none;
|
||||
border-color: #587c90;
|
||||
}
|
||||
.first-import-r6-selection,
|
||||
.first-import-r6-selection input::after {
|
||||
transition: none;
|
||||
|
||||
@@ -50,19 +50,25 @@
|
||||
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
|
||||
}
|
||||
|
||||
.memory-mode-cards--rows
|
||||
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
|
||||
min-height: 166px;
|
||||
}
|
||||
|
||||
.memory-mode-card[data-disabled="true"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.memory-mode-card-choice {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
padding: 18px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
column-gap: 28px;
|
||||
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 24px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -116,7 +122,6 @@
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-mode-current {
|
||||
@@ -171,51 +176,59 @@
|
||||
grid-row: 1 / -1;
|
||||
align-self: stretch;
|
||||
margin-top: 0;
|
||||
padding: 0 42px 0 28px;
|
||||
padding: 0 42px 0 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
border-left: 1px solid #dbe4ea;
|
||||
gap: 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.memory-mode-features > span {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.memory-mode-features > span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: rgb(199 211 220 / 78%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.memory-mode-features i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-top: 7px;
|
||||
flex: 0 0 5px;
|
||||
border-radius: 50%;
|
||||
background: #7f95a4;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.memory-mode-detail {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
align-self: start;
|
||||
margin-top: 6px;
|
||||
padding: 7px 0 0;
|
||||
width: auto;
|
||||
margin: 7px 18px 12px;
|
||||
padding: 10px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
border-top: 1px solid #d4e0e7;
|
||||
gap: 9px;
|
||||
border-top: 1px solid rgb(166 190 179 / 48%);
|
||||
background: transparent;
|
||||
color: #405867;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
animation: memory-mode-detail-in 180ms ease-out both;
|
||||
}
|
||||
|
||||
.memory-mode-detail > svg {
|
||||
margin-top: 1px;
|
||||
color: #527565;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -272,17 +285,31 @@
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
padding: 16px 0 2px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
border-top: 1px solid #dbe4ea;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-features > span {
|
||||
min-height: 34px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-features > span + span {
|
||||
border-top: 1px solid rgb(199 211 220 / 72%);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-features > span::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-detail {
|
||||
width: calc(100% + 40px);
|
||||
margin: auto -20px -20px;
|
||||
padding: 14px 18px;
|
||||
width: auto;
|
||||
margin: 8px 18px 16px;
|
||||
padding: 12px 0 0;
|
||||
display: block;
|
||||
border-radius: 0 0 15px 15px;
|
||||
background: #f1f6f8;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -300,44 +327,52 @@
|
||||
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.memory-mode-cards--rows {
|
||||
min-height: 360px;
|
||||
min-height: 284px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-list {
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card {
|
||||
min-height: 112px;
|
||||
min-height: 88px;
|
||||
}
|
||||
.memory-mode-cards--rows
|
||||
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
|
||||
min-height: 116px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-choice {
|
||||
min-height: 0;
|
||||
padding: 13px 16px;
|
||||
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
|
||||
column-gap: 22px;
|
||||
padding: 11px 16px;
|
||||
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
|
||||
column-gap: 18px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-radio {
|
||||
top: 13px;
|
||||
top: 11px;
|
||||
right: 16px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-title strong {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-description {
|
||||
min-height: 0;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features {
|
||||
margin-top: 0;
|
||||
padding: 0 36px 0 22px;
|
||||
gap: 6px;
|
||||
padding: 0 34px 0 18px;
|
||||
gap: 0;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
line-height: 15px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features > span {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-detail {
|
||||
margin-top: 5px;
|
||||
padding: 5px 0 0;
|
||||
margin: 6px 14px 10px;
|
||||
padding: 8px 0 0;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
@@ -381,15 +416,26 @@
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 16px 0 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
border-top: 1px solid #dbe4ea;
|
||||
border-left: 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features > span {
|
||||
min-height: 34px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features > span + span {
|
||||
border-top: 1px solid rgb(199 211 220 / 72%);
|
||||
border-left: 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features > span::before {
|
||||
display: none;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-detail {
|
||||
width: calc(100% + 40px);
|
||||
margin: 16px -20px -20px;
|
||||
padding: 14px 20px 16px;
|
||||
border-radius: 0 0 15px 15px;
|
||||
background: #f1f6f8;
|
||||
width: auto;
|
||||
margin: 8px 20px 14px;
|
||||
padding: 12px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,9 +467,9 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-detail {
|
||||
width: calc(100% + 32px);
|
||||
margin: auto -16px -16px;
|
||||
padding: 10px 14px;
|
||||
width: auto;
|
||||
margin: 6px 14px 14px;
|
||||
padding: 10px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -588,6 +588,25 @@
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.onboarding-r6-schedule-loading {
|
||||
margin: 0;
|
||||
color: #607482;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.memory-mode-detail .onboarding-r6-schedule-recovery {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.memory-mode-detail .onboarding-r6-schedule-retry {
|
||||
flex: 0 0 auto;
|
||||
min-width: 76px;
|
||||
}
|
||||
.onboarding-r6-evolve-settings {
|
||||
margin-top: 0;
|
||||
padding: 18px 20px 4px;
|
||||
@@ -615,15 +634,12 @@
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
.onboarding-r6-schedule-recovery {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
|
||||
color: #944242;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.onboarding-r6-schedule-recovery p {
|
||||
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-mode-error {
|
||||
@@ -637,7 +653,19 @@
|
||||
@media (min-width: 761px) {
|
||||
.onboarding-r6-mode {
|
||||
min-height: 100%;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.onboarding-r6-mode {
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-mode-intro h2 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
.onboarding-r6-mode-intro p {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
.onboarding-r6-empty {
|
||||
@@ -808,6 +836,13 @@
|
||||
flex-wrap: wrap;
|
||||
flex-basis: auto;
|
||||
}
|
||||
.memory-mode-detail .onboarding-r6-schedule-recovery {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.memory-mode-detail .onboarding-r6-schedule-retry {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.onboarding-r6-shell {
|
||||
|
||||
@@ -190,6 +190,10 @@ button.r6-button[data-variant="primary"] {
|
||||
0 3px 8px -2px rgb(64 87 102 / 12%);
|
||||
}
|
||||
|
||||
button.r6-button[data-pending-capable="true"] {
|
||||
padding-inline: 32px;
|
||||
}
|
||||
|
||||
button.r6-button[data-variant="quiet"] {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
@@ -204,6 +208,10 @@ button.r6-button[data-variant="quiet"] {
|
||||
color: var(--r6-muted);
|
||||
}
|
||||
|
||||
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
button.r6-button:hover:not(:disabled) {
|
||||
background: rgb(231 239 244 / 60%);
|
||||
border-color: #aabcc8;
|
||||
@@ -242,12 +250,16 @@ button.r6-button[data-variant="quiet"]:disabled {
|
||||
}
|
||||
.r6-button-label {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.r6-button-spinner {
|
||||
position: static;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -6px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.6`(build 44)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 45)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -21,9 +21,10 @@ Core without it still validates; any other unknown field still fails closed.
|
||||
- Existing `om_recall` and `om_conversations` grants are composed rather than
|
||||
replaced by a universal context grant. At least one requested lane must be
|
||||
authorized.
|
||||
- Raw access requires `all-apps` plus source grants. Memory access requires an
|
||||
existing memory scope. Explicit unauthorized raw sources are rejected before
|
||||
Core is called.
|
||||
- Raw access requires `om_conversations` plus `all-apps`; Core resolves that
|
||||
scope to every connected, unrevoked source. Memory access requires an existing
|
||||
memory scope. Explicit raw source filters are checked against live connections
|
||||
by Core.
|
||||
- Broker captures installation identity and revision, then re-authenticates
|
||||
before releasing results. Revocation, credential rotation, or scope
|
||||
narrowing invalidates the release.
|
||||
|
||||
@@ -18,9 +18,10 @@ revision or Evidence fields.
|
||||
No caller user/workspace/installation/permission fields. Unknown fields reject.
|
||||
- Envelope remains existing broker identity and existing underlying tools. At
|
||||
least one requested path must be authorized. Raw needs om_conversations plus
|
||||
all-apps and source grants; memory needs om_recall plus an existing all-apps or
|
||||
workspace scope. Source scopes alone never authorize arbitrary memories.
|
||||
- Explicit raw source filter outside current allowed sources is 403. A missing
|
||||
all-apps and reads every connected, unrevoked source; memory needs om_recall
|
||||
plus an existing all-apps or workspace scope. Source scopes alone never
|
||||
authorize arbitrary memories or original conversations.
|
||||
- Explicit raw source filter outside currently connected sources is 403. A missing
|
||||
optional lane is not_authorized without querying it. No legal requested lane
|
||||
means 403. Invalid external envelope/changed installation must reject wholesale.
|
||||
- Local authenticated calls may read the existing local data instance; they do
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 44,
|
||||
"build_number": 45,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -60,20 +60,13 @@ function memory(): SdkContextItem {
|
||||
}
|
||||
|
||||
describe("context response authority", () => {
|
||||
it("accepts a raw item from a source the host was granted", () => {
|
||||
// The write side stores under `source:<id>`, so a read that did not check
|
||||
// the same scope would return rows from sources this host never granted.
|
||||
it("accepts a raw item from any connected app under all-apps", () => {
|
||||
expect(
|
||||
isContextResponseAuthorized(response([raw("codex")]), identity()),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a raw item from a source the host was not granted", () => {
|
||||
// This is the symmetry rule: granted for one source, shown another's rows
|
||||
// is a silent leak rather than an error anyone would notice.
|
||||
expect(
|
||||
isContextResponseAuthorized(response([raw("cursor")]), identity()),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects raw when the conversations tool is missing", () => {
|
||||
@@ -98,15 +91,13 @@ describe("context response authority", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects every item when any one of them is out of scope", () => {
|
||||
// Not "filter the bad one out": a response that mixed in one foreign row
|
||||
// has failed its contract, and passing the rest would hide that.
|
||||
it("accepts raw results spanning connected apps", () => {
|
||||
expect(
|
||||
isContextResponseAuthorized(
|
||||
response([raw("codex"), raw("cursor")]),
|
||||
identity(),
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checks memory items against memory authority, not source scopes", () => {
|
||||
|
||||
@@ -530,15 +530,20 @@ it("rechecks the WorkBuddy installation after Core records delivery", async () =
|
||||
it.each([
|
||||
{ tools: ["om_recall"], scopes: ["all-apps"] },
|
||||
{ tools: ["om_recall"], scopes: [`workspace:v1:${"a".repeat(64)}`] },
|
||||
{ tools: ["om_conversations"], scopes: ["all-apps", "source:codex"] },
|
||||
{ tools: ["om_conversations"], scopes: ["all-apps"] },
|
||||
{
|
||||
tools: ["om_conversations"],
|
||||
scopes: ["all-apps", "source:codex"],
|
||||
body: { query: "original", raw_source_ids: ["cursor"] },
|
||||
},
|
||||
])(
|
||||
"combines only the underlying grants: $tools $scopes",
|
||||
async ({ tools, scopes }) => {
|
||||
async ({ tools, scopes, body }) => {
|
||||
const f = await fixture(tools as HostReadTool[], scopes);
|
||||
expect((await f.post()).status).toBe(200);
|
||||
expect((await f.post(body)).status).toBe(200);
|
||||
expect(f.calls).toHaveLength(1);
|
||||
expect(f.calls[0].url).toBe("/api/v2/context/search");
|
||||
expect(f.calls[0].body).toEqual({ query: "original" });
|
||||
expect(f.calls[0].body).toEqual(body ?? { query: "original" });
|
||||
expect(
|
||||
JSON.parse(f.calls[0].headers["x-one-memory-allowed-tools"] as string),
|
||||
).toEqual(tools);
|
||||
@@ -552,17 +557,11 @@ it.each([
|
||||
{ tools: [], scopes: ["all-apps"], body: { query: "x" } },
|
||||
{ tools: ["om_ask"], scopes: ["all-apps"], body: { query: "x" } },
|
||||
{ tools: ["om_recall"], scopes: ["source:codex"], body: { query: "x" } },
|
||||
{ tools: ["om_conversations"], scopes: ["all-apps"], body: { query: "x" } },
|
||||
{
|
||||
tools: ["om_recall"],
|
||||
scopes: ["all-apps"],
|
||||
body: { query: "x", kinds: ["raw"] },
|
||||
},
|
||||
{
|
||||
tools: ["om_recall", "om_conversations"],
|
||||
scopes: ["all-apps", "source:codex"],
|
||||
body: { query: "x", raw_source_ids: ["cursor"] },
|
||||
},
|
||||
])(
|
||||
"denies invalid requested authority before Core: $tools $body",
|
||||
async ({ tools, scopes, body }) => {
|
||||
@@ -1069,11 +1068,6 @@ it.each([
|
||||
scopes: ["all-apps", "source:codex"],
|
||||
response: receiptFor([memory]),
|
||||
},
|
||||
{
|
||||
tools: ["om_recall", "om_conversations"],
|
||||
scopes: ["all-apps", "source:codex"],
|
||||
response: receiptFor([{ ...raw, source: "cursor" }]),
|
||||
},
|
||||
])(
|
||||
"checks captured identity on Core output: $tools $scopes",
|
||||
async ({ tools, scopes, response }) => {
|
||||
@@ -1099,8 +1093,8 @@ it.each([
|
||||
},
|
||||
{
|
||||
tools: ["om_conversations"],
|
||||
scopes: ["all-apps", "source:codex"],
|
||||
response: receiptFor([raw]),
|
||||
scopes: ["all-apps"],
|
||||
response: receiptFor([{ ...raw, source: "cursor" }]),
|
||||
},
|
||||
])(
|
||||
"preserves correctly authorized single-lane output: $tools $scopes",
|
||||
|
||||
@@ -67,7 +67,7 @@ export function isContextSearchRequest(
|
||||
);
|
||||
}
|
||||
|
||||
/** Composition of existing rights, not a new all-content grant. Core checks live sources. */
|
||||
/** Composition of existing rights. Core checks which connected sources remain live. */
|
||||
export function authorizeContext(
|
||||
identity: VerifiedHostIdentity,
|
||||
request: ContextSearchRequest,
|
||||
@@ -76,16 +76,9 @@ export function authorizeContext(
|
||||
const raw =
|
||||
kinds.includes("raw") &&
|
||||
identity.allowedTools.includes("om_conversations") &&
|
||||
identity.allowedScopes.includes("all-apps") &&
|
||||
identity.allowedScopes.some((s) => s.startsWith("source:"));
|
||||
identity.allowedScopes.includes("all-apps");
|
||||
const memory = kinds.includes("memory") && hasMemoryAuthority(identity);
|
||||
if (
|
||||
request.raw_source_ids &&
|
||||
(!raw ||
|
||||
!request.raw_source_ids.every((s) =>
|
||||
identity.allowedScopes.includes(`source:${s}`),
|
||||
))
|
||||
)
|
||||
if (request.raw_source_ids && !raw)
|
||||
throw new HostGrantError("host_grant_scope_denied");
|
||||
if (!raw && !memory) throw new HostGrantError("host_grant_tool_denied");
|
||||
}
|
||||
@@ -109,8 +102,7 @@ export function isContextResponseAuthorized(
|
||||
item.kind === "memory"
|
||||
? hasMemoryAuthority(identity)
|
||||
: identity.allowedTools.includes("om_conversations") &&
|
||||
identity.allowedScopes.includes("all-apps") &&
|
||||
identity.allowedScopes.includes(`source:${item.source}`),
|
||||
identity.allowedScopes.includes("all-apps"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ function checkedReceipt(value: RecallReceipt, query: string): RecallReceipt {
|
||||
!hit.evidence_ids.every((id) => typeof id === "string"),
|
||||
) ||
|
||||
(value.state === "empty") !== (value.hits.length === 0) ||
|
||||
value.hits.reduce((n, h) => n + h.content.length, 0) > 6000
|
||||
value.hits.reduce((n, h) => n + [...h.content].length, 0) > 6000
|
||||
) {
|
||||
throw new ClaudeAdapterError("invalid_recall_response");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,26 @@ describe("MH-C01 event contract", () => {
|
||||
expect(f.notifySourceChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts astral characters the same way as Core's character budget", async () => {
|
||||
const f = fixture();
|
||||
const content = "😀".repeat(4_000);
|
||||
f.recall.mockResolvedValueOnce({
|
||||
...hit,
|
||||
hits: [{ ...hit.hits[0], content }],
|
||||
used_characters: 4_000,
|
||||
truncated: true,
|
||||
truncated_by: "character_budget",
|
||||
});
|
||||
|
||||
const result = await f.adapter.handle({
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
session_id: "s1",
|
||||
prompt: "previous decision",
|
||||
});
|
||||
|
||||
expect(result.state).toBe("grounded");
|
||||
});
|
||||
|
||||
it("accepts a raw-mode recall and labels its hits as conversation excerpts", async () => {
|
||||
const f = fixture();
|
||||
f.recall.mockResolvedValueOnce({
|
||||
|
||||
@@ -10,6 +10,7 @@ const modeChineseMessages = {
|
||||
选择记忆方式: "选择记忆方式",
|
||||
选择记忆模式: "选择记忆模式",
|
||||
"之后可在设置中更改。": "之后可在设置中更改。",
|
||||
"后续可在设置页自定义时间或关闭。": "后续可在设置页自定义时间或关闭。",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Ultra 可在完成首次设置后到设置页开启。",
|
||||
"使用 Lite 进入": "使用 Lite 进入",
|
||||
@@ -23,7 +24,7 @@ const modeChineseMessages = {
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"当前模型已经启用,可以继续完成首次设置。",
|
||||
继续使用当前模型: "继续使用当前模型",
|
||||
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
|
||||
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成记忆?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
|
||||
记忆模式: "记忆模式",
|
||||
记忆模式与模型: "记忆模式与模型",
|
||||
@@ -61,6 +62,7 @@ const modeChineseMessages = {
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"模式选择未能确认,仍显示上次确认的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||
"自动整合设置暂时无法读取,请重试。": "自动整合设置暂时无法读取,请重试。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
|
||||
@@ -200,6 +202,8 @@ export const modeEnglishMessages: ModeMessages = {
|
||||
选择记忆方式: "Choose how memory works",
|
||||
选择记忆模式: "Choose memory mode",
|
||||
"之后可在设置中更改。": "You can change this later in Settings.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"You can later customize the time or turn this off in Settings.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Ultra can be enabled in Settings after initial setup.",
|
||||
"使用 Lite 进入": "Enter with Lite",
|
||||
@@ -257,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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -404,6 +410,8 @@ export const modeSpanishMessages: ModeMessages = {
|
||||
选择记忆方式: "Elegir cómo funciona la memoria",
|
||||
选择记忆模式: "Elegir el modo de memoria",
|
||||
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Más adelante, puedes personalizar la hora o desactivarlo en Configuración.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
|
||||
"使用 Lite 进入": "Entrar con Lite",
|
||||
@@ -461,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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -607,6 +617,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
选择记忆方式: "選擇記憶方式",
|
||||
选择记忆模式: "選擇記憶模式",
|
||||
"之后可在设置中更改。": "之後可在設定中更改。",
|
||||
"后续可在设置页自定义时间或关闭。": "之後可在設定頁自訂時間或關閉。",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"完成首次設定後,可到設定頁開啟 Ultra。",
|
||||
"使用 Lite 进入": "使用 Lite 進入",
|
||||
@@ -660,6 +671,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"無法確認模式切換,仍顯示上次確認的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
|
||||
"自动整合设置暂时无法读取,请重试。": "暫時無法讀取自動整合設定,請重試。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在檢查或切換向量模型,完成後才能整合記憶。",
|
||||
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
|
||||
@@ -795,6 +807,8 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
选择记忆方式: "記憶方法を選択",
|
||||
选择记忆模式: "記憶モードを選択",
|
||||
"之后可在设置中更改。": "後から設定で変更できます。",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"後から設定画面で時刻を変更したり、無効にしたりできます。",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"初期設定の完了後、設定画面で Ultra を有効にできます。",
|
||||
"使用 Lite 进入": "Lite で開始",
|
||||
@@ -940,6 +954,8 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"自動統合の設定を保存できませんでした。もう一度お試しください。",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"自動統合の設定を読み込めませんでした。もう一度お試しください。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -994,6 +1010,8 @@ export const modeGermanMessages: ModeMessages = {
|
||||
选择记忆方式: "Erinnerungsmodus wählen",
|
||||
选择记忆模式: "Erinnerungsmodus auswählen",
|
||||
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Später können Sie die Uhrzeit in den Einstellungen anpassen oder die Funktion deaktivieren.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
|
||||
"使用 Lite 进入": "Mit Lite starten",
|
||||
@@ -1143,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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1198,6 +1218,8 @@ export const modePortugueseMessages: ModeMessages = {
|
||||
选择记忆方式: "Escolher o modo de memória",
|
||||
选择记忆模式: "Escolher o modo de memória",
|
||||
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Mais tarde, pode personalizar a hora ou desativar esta opção nas Definições.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
|
||||
"使用 Lite 进入": "Entrar com Lite",
|
||||
@@ -1347,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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1404,6 +1428,8 @@ export const modeFrenchMessages: ModeMessages = {
|
||||
选择记忆模式: "Choisir le mode de mémoire",
|
||||
"之后可在设置中更改。":
|
||||
"Vous pourrez le modifier plus tard dans les réglages.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Vous pourrez ensuite personnaliser l’heure ou désactiver cette option dans les réglages.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
|
||||
"使用 Lite 进入": "Entrer avec Lite",
|
||||
@@ -1553,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.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1607,6 +1635,8 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
选择记忆方式: "메모리 방식 선택",
|
||||
选择记忆模式: "메모리 모드 선택",
|
||||
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"나중에 설정에서 시간을 변경하거나 끌 수 있습니다.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
|
||||
"使用 Lite 进入": "Lite로 시작",
|
||||
@@ -1752,6 +1782,8 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"자동 통합 설정을 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -1805,6 +1837,8 @@ export const modeRussianMessages: ModeMessages = {
|
||||
选择记忆方式: "Выберите режим памяти",
|
||||
选择记忆模式: "Выберите режим памяти",
|
||||
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Позже в настройках можно изменить время или отключить эту функцию.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Ultra можно включить в настройках после завершения первоначальной настройки.",
|
||||
"使用 Lite 进入": "Войти с Lite",
|
||||
@@ -1953,6 +1987,8 @@ export const modeRussianMessages: ModeMessages = {
|
||||
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
|
||||
"自动整合设置暂时无法读取,请重试。":
|
||||
"Не удалось загрузить настройки автообъединения. Повторите попытку.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
@@ -2009,6 +2045,8 @@ export const modeIndonesianMessages: ModeMessages = {
|
||||
选择记忆方式: "Pilih cara kerja memori",
|
||||
选择记忆模式: "Pilih mode memori",
|
||||
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
|
||||
"后续可在设置页自定义时间或关闭。":
|
||||
"Nanti, Anda dapat menyesuaikan waktunya atau menonaktifkannya di Pengaturan.",
|
||||
"Ultra 可在完成首次设置后到设置页开启。":
|
||||
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
|
||||
"使用 Lite 进入": "Masuk dengan Lite",
|
||||
@@ -2156,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