Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfd21e668 | ||
|
|
45dbf46545 | ||
|
|
fac7a43ffb | ||
|
|
6e4b22a363 | ||
|
|
c186e5b2fb | ||
|
|
ad84e5decd | ||
|
|
0c43973fc2 | ||
|
|
ebf16b9399 | ||
|
|
87d70b0eb9 | ||
|
|
603e0843c5 | ||
|
|
80f4231e46 |
No files matched your search
+22
-1
@@ -4,6 +4,27 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub host capture hooks no longer write the database. `one-memory
|
||||
capture <host>` used to open the main SQLite file from its own short-lived
|
||||
process on every hook, re-create the retired `evidence` / FTS tables on an
|
||||
SDK-authority home and write the write queue outside Core's single writer,
|
||||
contending for the file lock with Core and with other hosts' hooks. It now
|
||||
reads only the runtime receipt and token and posts the hook's JSON to the
|
||||
running Core (`POST /api/v1/live-capture/{source}`, 1.5 s budget). Core
|
||||
applies the same gates (grant, sync switch, rejection counting, automated
|
||||
sessions, the Cursor/Claude Code rule; the migration fence stays on the hook
|
||||
side) and stages the turn in-process through the live file-sync path. When
|
||||
Core is not running the turn is dropped silently; nothing falls back to a
|
||||
local write.
|
||||
- MemHub no longer sends one turn to the SDK twice when a host's hook and live
|
||||
file sync both see it. The two lanes gave the same message different
|
||||
identities (the hook carries no workspace and often no message id) and
|
||||
separate assembly rows, so both were written as raws. Hook input for a
|
||||
source that live sync reads from its own files (every hooked host except
|
||||
Qwen Work, while live sync is on) is now left to the file path; only a
|
||||
source without file polling is staged from its hook. Tables an older hook
|
||||
re-created on an SDK home are dropped by the existing startup cleanup.
|
||||
|
||||
## Hy Memory 2.0.14 — 2026-11-04
|
||||
|
||||
- `search()` / `async_search()` take an optional `statuses` for explicit
|
||||
@@ -84,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-04
|
||||
|
||||
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 43, 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 41),GitHub 仓库为
|
||||
(build 43),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
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -652,6 +652,34 @@ class SdkRawPage(BaseModel):
|
||||
time_filtered: bool = False
|
||||
|
||||
|
||||
class SdkRawGraphItem(BaseModel):
|
||||
"""Small, non-authoritative projection used to draw the complete raw map."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
turn_id: str = Field(min_length=1, max_length=96)
|
||||
raw_id: str = Field(min_length=1)
|
||||
label: str = Field(max_length=240)
|
||||
source: str
|
||||
session_id: str
|
||||
memory_at: float | None = None
|
||||
gmt_created: float | None = None
|
||||
|
||||
|
||||
class SdkRawGraphPage(BaseModel):
|
||||
"""One page from a complete, bounded-memory raw-turn catalog."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: str
|
||||
items: tuple[SdkRawGraphItem, ...] = ()
|
||||
total: int = Field(ge=0)
|
||||
offset: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
has_more: bool = False
|
||||
snapshot_id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class SdkLayerCounts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -2206,6 +2234,19 @@ def create_app(
|
||||
from one_memory.embedding_switch_routes import embedding_switch_router
|
||||
|
||||
app.include_router(embedding_switch_router(protected))
|
||||
from one_memory.live_capture import live_capture_router
|
||||
|
||||
def file_polled(request: Request, source_id: str) -> bool:
|
||||
"""Whether live sync reads this source's own files, so the hook's copy is redundant."""
|
||||
registration = getattr(request.app.state, "source_catalog", {}).get(source_id)
|
||||
return (
|
||||
runtime_settings.live_sync_enabled
|
||||
and registration is not None
|
||||
and registration.capabilities.live_sync is CapabilityState.IMPLEMENTED
|
||||
)
|
||||
|
||||
# Host hooks hand their turn over here instead of opening the database.
|
||||
app.include_router(live_capture_router(protected, file_polled=file_polled))
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -4483,6 +4524,164 @@ 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],
|
||||
) -> tuple[SdkRawGraphItem, ...]:
|
||||
items: list[SdkRawGraphItem] = []
|
||||
for record in records:
|
||||
for turn in reversed(raw_display_turns(record)):
|
||||
label = re.sub(r"\s+", " ", turn.content).strip()[:240]
|
||||
items.append(
|
||||
SdkRawGraphItem(
|
||||
turn_id=turn.turn_id,
|
||||
raw_id=record.raw_id,
|
||||
label=label or record.raw_id,
|
||||
source=record.source,
|
||||
session_id=record.session_id,
|
||||
memory_at=record.memory_at,
|
||||
gmt_created=record.gmt_created,
|
||||
)
|
||||
)
|
||||
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",
|
||||
@@ -4620,15 +4819,11 @@ def create_app(
|
||||
offset=raw_offset,
|
||||
)
|
||||
if raw_page.total != observed_total:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
|
||||
if expected_raw.startswith("p"):
|
||||
if raw_offset == 0:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
previous_value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
@@ -4641,13 +4836,11 @@ def create_app(
|
||||
valid_anchor = (
|
||||
previous_page.total == raw_page.total
|
||||
and bool(previous_page.raw)
|
||||
and raw_anchor(previous_page.raw[0])
|
||||
== expected_raw[1:]
|
||||
and raw_anchor(previous_page.raw[0]) == expected_raw[1:]
|
||||
)
|
||||
else:
|
||||
valid_anchor = (
|
||||
bool(raw_page.raw)
|
||||
and raw_anchor(raw_page.raw[0]) == expected_raw
|
||||
bool(raw_page.raw) and raw_anchor(raw_page.raw[0]) == expected_raw
|
||||
)
|
||||
if expected_raw and not valid_anchor:
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
@@ -4655,9 +4848,7 @@ def create_app(
|
||||
next_cursor: str | None = None
|
||||
for record_index, record in enumerate(raw_page.raw):
|
||||
turns = raw_display_turns(record)
|
||||
ordered_turns = (
|
||||
tuple(reversed(turns)) if order == "desc" else turns
|
||||
)
|
||||
ordered_turns = tuple(reversed(turns)) if order == "desc" else turns
|
||||
start = turn_offset if record_index == 0 else 0
|
||||
for index in range(start, len(ordered_turns)):
|
||||
selected_turns.append((record, ordered_turns[index]))
|
||||
@@ -4692,14 +4883,9 @@ def create_app(
|
||||
break
|
||||
if len(selected_turns) >= limit:
|
||||
break
|
||||
if (
|
||||
next_cursor is None
|
||||
and raw_offset + len(raw_page.raw) < raw_page.total
|
||||
):
|
||||
if next_cursor is None and raw_offset + len(raw_page.raw) < raw_page.total:
|
||||
if not raw_page.raw:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="raw_cursor_stale"
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||
next_raw = raw_offset + len(raw_page.raw)
|
||||
previous_anchor = raw_anchor(raw_page.raw[-1])
|
||||
next_cursor = (
|
||||
@@ -4708,10 +4894,7 @@ def create_app(
|
||||
)
|
||||
return SdkRawPage(
|
||||
user_id=user_id,
|
||||
items=tuple(
|
||||
SdkRawItem.of(record, turn)
|
||||
for record, turn in selected_turns
|
||||
),
|
||||
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected_turns),
|
||||
# Cursor consumers do not use a precise total. This lower
|
||||
# bound preserves the legacy numeric field without forcing
|
||||
# a full scan merely to count variable-size turn batches.
|
||||
@@ -4885,6 +5068,114 @@ def create_app(
|
||||
time_filtered=time_filtered,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v2/raw/graph",
|
||||
response_model=SdkRawGraphPage,
|
||||
dependencies=strict,
|
||||
)
|
||||
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),
|
||||
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."""
|
||||
chosen_statuses = (
|
||||
_library_values(
|
||||
statuses or [],
|
||||
MEMORY_STATUSES,
|
||||
"memory_status_unknown",
|
||||
)
|
||||
or raw_list_filters()["statuses"]
|
||||
)
|
||||
sources = split_csv(source)
|
||||
excluded = split_csv(exclude_sources)
|
||||
sessions = split_csv(session_id)
|
||||
snapshot_scope: tuple[object, ...] = (
|
||||
user_id,
|
||||
tuple(sorted(chosen_statuses)),
|
||||
tuple(sorted(sources)),
|
||||
tuple(sorted(excluded)),
|
||||
tuple(sorted(sessions)),
|
||||
)
|
||||
if offset and snapshot_id is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="raw_graph_snapshot_required",
|
||||
)
|
||||
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)),
|
||||
)
|
||||
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)
|
||||
)
|
||||
snapshot_id = uuid4().hex
|
||||
raw_graph_snapshots[snapshot_id] = (
|
||||
time.monotonic(),
|
||||
snapshot_scope,
|
||||
items,
|
||||
graph_cache_size(items),
|
||||
)
|
||||
prune_raw_graph_state()
|
||||
if snapshot_id not in raw_graph_snapshots:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="raw_graph_snapshot_too_large",
|
||||
)
|
||||
if snapshot_id is None:
|
||||
raise RuntimeError("raw_graph_snapshot_missing")
|
||||
selected = items[offset : offset + limit]
|
||||
return SdkRawGraphPage(
|
||||
user_id=user_id,
|
||||
items=selected,
|
||||
total=len(items),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=offset + len(selected) < len(items),
|
||||
snapshot_id=snapshot_id,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v2/memories/ask",
|
||||
response_model=SdkMemoryAnswer,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The hook side of live capture: hand the turn to the running Core, nothing more.
|
||||
|
||||
A host runs `one-memory capture <source>` as a short-lived process on every
|
||||
hook, often several at once. That process used to open the main database
|
||||
itself and write Evidence and the write queue from outside Core's single
|
||||
writer. It now never opens the database: it reads the hook's JSON, posts it to
|
||||
the running Core over loopback with the runtime token, and exits. Core applies
|
||||
every gate and stages the turn in-process (`live_capture.capture_hook`).
|
||||
|
||||
Anything that goes wrong — Core not running, a stale receipt, a slow answer —
|
||||
drops the turn silently. There is no local fallback write: file-backed sources
|
||||
are read again by live sync from their persisted cursor once Core is back.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
CAPTURE_SOURCES = frozenset(
|
||||
{
|
||||
"workbuddy",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"cursor",
|
||||
"pi",
|
||||
"qwen-code",
|
||||
"qwenwork",
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
}
|
||||
)
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
#: Core's in-process intake for a hook's payload; see `live_capture`.
|
||||
CAPTURE_ROUTE = "/api/v1/live-capture/{source_id}"
|
||||
#: Whole request, connect to answer. Pi and OpenCode wait for the hook
|
||||
#: synchronously (3 s, interpreter start included), so this stays well under.
|
||||
HANDOFF_TIMEOUT_SECONDS = 1.5
|
||||
#: A hook carries one turn, or one session's visible text at most.
|
||||
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def data_directory() -> Path:
|
||||
from one_memory.config import load_settings
|
||||
|
||||
return load_settings().data_dir
|
||||
|
||||
|
||||
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
"""Host hooks must stay silent. A bad payload or an absent Core does not fail the turn."""
|
||||
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
try:
|
||||
data_dir = data_directory()
|
||||
if sdk_migration_capture_fenced(data_dir):
|
||||
return 0
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except Exception:
|
||||
return 0
|
||||
if not isinstance(payload, dict):
|
||||
return 0
|
||||
try:
|
||||
hand_to_core(source_id, payload, data_dir)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def hand_to_core(
|
||||
source_id: str,
|
||||
payload: dict[str, object],
|
||||
data_dir: Path,
|
||||
*,
|
||||
timeout: float = HANDOFF_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""Post the payload to the Core named by this home's receipt. True when Core took it.
|
||||
|
||||
Only the receipt, the runtime token and a loopback socket are touched; the
|
||||
main database is never opened from here.
|
||||
"""
|
||||
from one_memory.runtime_client import core_endpoint
|
||||
|
||||
endpoint = core_endpoint(data_dir)
|
||||
if endpoint is None:
|
||||
return False
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
if len(body) > MAX_PAYLOAD_BYTES:
|
||||
return False
|
||||
from one_memory.http_client import create_client
|
||||
|
||||
with create_client(timeout=timeout) as client:
|
||||
response = client.post(
|
||||
f"http://{endpoint.host}:{endpoint.port}{CAPTURE_ROUTE.format(source_id=source_id)}",
|
||||
content=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {endpoint.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return response.status_code == 202
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
|
||||
|
||||
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
|
||||
put back as it was before the upgrade and takes no writes until the next
|
||||
migration attempt, which replaces the fence and lifts it on success.
|
||||
"""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if isinstance(document, dict) and document.get("frozen") is True:
|
||||
return True
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
@@ -119,7 +119,7 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int:
|
||||
if command == "sdk-migration-status":
|
||||
return sdk_migration_status(options, stream)
|
||||
if command == "capture" and len(options) == 1:
|
||||
from one_memory.live_capture import capture_stdio
|
||||
from one_memory.capture_handoff import capture_stdio
|
||||
|
||||
return capture_stdio(options[0], sys.stdin, stream)
|
||||
if command not in {"status", "doctor"} or options not in ([], ["--json"]):
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
"""Write visible hook text into Evidence. Tool output stays out."""
|
||||
"""Core's intake for host hooks: visible text only, staged under Core's writer.
|
||||
|
||||
The hook process (`capture_handoff`) never opens the database; it posts the
|
||||
hook's JSON to `POST /api/v1/live-capture/{source}` on the running Core, which
|
||||
calls `capture_hook` here. Tool output stays out.
|
||||
|
||||
A source that live sync already reads from its own files is left to that
|
||||
path: the hook and the file name the same message differently (the hook has
|
||||
no workspace and often no message id), so staging both sent one turn to the
|
||||
SDK twice. Only a source no file poller follows is staged from the hook.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TextIO, cast
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.params import Depends
|
||||
from pydantic import JsonValue
|
||||
|
||||
from one_memory.capture_handoff import (
|
||||
CAPTURE_ROUTE,
|
||||
CAPTURE_SOURCES,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
SDK_MIGRATION_CAPTURE_FENCE,
|
||||
sdk_migration_capture_fenced,
|
||||
)
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.derived_echo import PREFACE, strip_injected_memory
|
||||
from one_memory.imports.access import opaque_workspace_id, safe_project_label
|
||||
@@ -19,6 +39,7 @@ from one_memory.imports.models import (
|
||||
EvidenceRecord,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.imports.sources.claude_code import parse_claude_code_record
|
||||
from one_memory.imports.sources.codex import HARNESS_PREFIXES, parse_codex_record
|
||||
@@ -38,23 +59,21 @@ from one_memory.imports.sources.parsing import (
|
||||
)
|
||||
from one_memory.imports.sources.pi import _is_automated_session
|
||||
from one_memory.imports.sources.workbuddy import _is_scheduled_automation, parse_workbuddy_record
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
||||
|
||||
__all__ = [
|
||||
"CAPTURE_SOURCES",
|
||||
"SDK_MIGRATION_CAPTURE_FENCE",
|
||||
"CaptureOutcome",
|
||||
"CaptureRejection",
|
||||
"capture_hook",
|
||||
"capture_rejection",
|
||||
"live_capture_router",
|
||||
"record_capture_rejection",
|
||||
"sdk_migration_capture_fenced",
|
||||
]
|
||||
|
||||
CAPTURE_SOURCES = frozenset(
|
||||
{
|
||||
"workbuddy",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"cursor",
|
||||
"pi",
|
||||
"qwen-code",
|
||||
"qwenwork",
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
}
|
||||
)
|
||||
_UNKNOWN_USER = frozenset({"hermes", "opencode", "openclaw"})
|
||||
_USER_FIELDS = {
|
||||
"UserPromptSubmit": "prompt",
|
||||
@@ -75,112 +94,130 @@ _FILE_FALLBACK = {
|
||||
}
|
||||
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
||||
_TEXT_BLOCKS = frozenset({"text"})
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
|
||||
|
||||
def database_path() -> Path:
|
||||
from one_memory.config import load_settings
|
||||
class CaptureOutcome(StrEnum):
|
||||
"""What Core did with one hook payload. Never the payload itself."""
|
||||
|
||||
return load_settings().data_dir / "one-memory.sqlite3"
|
||||
STAGED = "staged"
|
||||
#: Live sync reads this source's own files; that path owns the turn.
|
||||
FILE_POLLED = "file_polled"
|
||||
REJECTED = "rejected"
|
||||
IGNORED = "ignored"
|
||||
|
||||
|
||||
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
"""Host hooks must stay silent. A bad payload does not fail the turn."""
|
||||
def capture_hook(
|
||||
source_id: str,
|
||||
payload: object,
|
||||
database: Path,
|
||||
*,
|
||||
queue: DurableWriteQueue,
|
||||
file_polled: Callable[[str], bool] = lambda _source_id: False,
|
||||
) -> CaptureOutcome:
|
||||
"""Gate one hook payload and stage its visible lines, inside Core.
|
||||
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
database = database_path()
|
||||
if sdk_migration_capture_fenced(database.parent):
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
try:
|
||||
capture_hook(source_id, payload, database)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
|
||||
|
||||
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
|
||||
put back as it was before the upgrade and takes no writes until the next
|
||||
migration attempt, which replaces the fence and lifts it on success.
|
||||
Runs on SDK authority through Core's single writer: the lines become
|
||||
write-job input exactly as live file sync stages them
|
||||
(`materialize_write_input` -> `stage_sdk_input` -> `finalize_sdk_file`,
|
||||
live priority). No Evidence table is involved.
|
||||
"""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if isinstance(document, dict) and document.get("frozen") is True:
|
||||
return True
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def capture_hook(source_id: str, payload: object, database: Path) -> bool:
|
||||
if source_id not in CAPTURE_SOURCES or not isinstance(payload, dict):
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
# Cursor also runs ~/.claude/settings.json hooks; its own hook already files the turn.
|
||||
if source_id != "cursor" and "cursor_version" in payload:
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
if not database.is_file():
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
rejection = capture_rejection(database, source_id)
|
||||
if rejection is not None:
|
||||
# Silent, and the reason is counted rather than printed: a hook that
|
||||
# spoke would put this into the host's turn as text.
|
||||
record_capture_rejection(database, source_id, rejection)
|
||||
return False
|
||||
return CaptureOutcome.REJECTED
|
||||
if source_id == "pi" and _is_automated_session(first_string(payload.get("cwd"))):
|
||||
return False
|
||||
lines = _messages(source_id, payload)
|
||||
if lines:
|
||||
conversation_id = _conversation(payload)
|
||||
store = EvidenceStore(database)
|
||||
wrote = False
|
||||
for role, written_by, verbatim, message_id in lines:
|
||||
target = conversation_id
|
||||
if target is None:
|
||||
continue
|
||||
result = store.put(
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
conversation_id=target,
|
||||
message_id=message_id,
|
||||
source_ordinal=None if message_id else "hook-visible",
|
||||
role=role,
|
||||
written_by=written_by,
|
||||
created_at=datetime.now(UTC),
|
||||
verbatim=verbatim,
|
||||
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
_submit_live_memory(
|
||||
store, result.record, changed=result.was_created or result.was_revised
|
||||
)
|
||||
wrote = True
|
||||
return wrote
|
||||
return _capture_transcript(source_id, payload, database)
|
||||
return CaptureOutcome.IGNORED
|
||||
if file_polled(source_id):
|
||||
# The same turn reaches the SDK once, from the file: the two lanes give
|
||||
# one message different identities, so staging both wrote it twice.
|
||||
return CaptureOutcome.FILE_POLLED
|
||||
inputs = _hook_inputs(source_id, payload) or _capture_transcript(source_id, payload, database)
|
||||
if not inputs:
|
||||
return CaptureOutcome.IGNORED
|
||||
observed = datetime.now(UTC)
|
||||
conversations: dict[str, list[EvidenceRecord]] = {}
|
||||
for value in inputs:
|
||||
conversations.setdefault(value.conversation_id, []).append(
|
||||
materialize_write_input(value, observed_at=observed)
|
||||
)
|
||||
for conversation_id, records in conversations.items():
|
||||
# The turn rule decides what is written (`turn_batches`): a turn is
|
||||
# complete when the next user message arrives, batches are 5 turns or
|
||||
# 8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||
queue.assemble_live_records(tuple(records), file_key=f"hook:{conversation_id}")
|
||||
return CaptureOutcome.STAGED
|
||||
|
||||
|
||||
def _sync_open(database: Path, source_id: str) -> bool:
|
||||
return capture_rejection(database, source_id) is None
|
||||
def _hook_inputs(source_id: str, payload: dict[str, object]) -> list[EvidenceInput]:
|
||||
conversation_id = _conversation(payload)
|
||||
if conversation_id is None:
|
||||
return []
|
||||
observed = datetime.now(UTC)
|
||||
return [
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
source_ordinal=None if message_id else "hook-visible",
|
||||
role=role,
|
||||
written_by=written_by,
|
||||
created_at=observed,
|
||||
verbatim=verbatim,
|
||||
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
|
||||
)
|
||||
for role, written_by, verbatim, message_id in _messages(source_id, payload)
|
||||
]
|
||||
|
||||
|
||||
def live_capture_router(
|
||||
dependencies: list[Depends],
|
||||
*,
|
||||
file_polled: Callable[[Request, str], bool],
|
||||
) -> APIRouter:
|
||||
"""`POST /api/v1/live-capture/{source_id}`: a hook's payload, handed over by the hook.
|
||||
|
||||
202 whatever the gates decide, so a hook has nothing to print. The answer
|
||||
names the outcome only, never any of the payload.
|
||||
"""
|
||||
router = APIRouter(dependencies=dependencies)
|
||||
|
||||
@router.post(CAPTURE_ROUTE, status_code=202)
|
||||
async def live_capture(source_id: str, request: Request) -> dict[str, str]:
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
raise HTTPException(status_code=404, detail="capture_source_not_found")
|
||||
body = await request.body()
|
||||
if len(body) > MAX_PAYLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="capture_payload_too_large")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeError) as error:
|
||||
raise HTTPException(status_code=422, detail="capture_payload_invalid") from error
|
||||
state = request.app.state
|
||||
queue = getattr(state, "memory_write_queue", None)
|
||||
if getattr(state, "memory_authority", None) is not MemoryAuthority.SDK or queue is None:
|
||||
# Only an SDK-authority home is served; nothing here may bring the
|
||||
# retired Evidence tables back for any other.
|
||||
return {"outcome": CaptureOutcome.IGNORED.value}
|
||||
outcome = await asyncio.to_thread(
|
||||
capture_hook,
|
||||
source_id,
|
||||
payload,
|
||||
queue.database_path,
|
||||
queue=queue,
|
||||
file_polled=lambda source: file_polled(request, source),
|
||||
)
|
||||
return {"outcome": outcome.value}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
class CaptureRejection(StrEnum):
|
||||
@@ -255,22 +292,6 @@ def record_capture_rejection(
|
||||
return
|
||||
|
||||
|
||||
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
|
||||
"""Add the record to its conversation's live assembling row.
|
||||
|
||||
The turn rule decides what is written (`turn_batches`): a turn is
|
||||
complete when the next user message arrives, batches are 5 turns or
|
||||
8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||
"""
|
||||
if not changed or not _sync_open(store.database_path, record.source_id):
|
||||
return
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
|
||||
DurableWriteQueue(store.database_path, evidence=store).assemble_live_records(
|
||||
(record,), file_key=f"hook:{record.conversation_id}"
|
||||
)
|
||||
|
||||
|
||||
def _conversation(payload: dict[str, object]) -> str | None:
|
||||
return first_string(
|
||||
cast(JsonValue | None, payload.get("session_id")),
|
||||
@@ -470,34 +491,35 @@ def _opencode_messages(
|
||||
return lines
|
||||
|
||||
|
||||
def _capture_transcript(source_id: str, payload: dict[str, object], database: Path) -> bool:
|
||||
def _capture_transcript(
|
||||
source_id: str, payload: dict[str, object], database: Path
|
||||
) -> list[EvidenceInput]:
|
||||
"""Stop without in-memory text reads the granted transcript, then the same parser."""
|
||||
|
||||
event = payload.get("hook_event_name") or payload.get("hookEventName")
|
||||
parser = _FILE_FALLBACK.get(source_id)
|
||||
transcript = payload.get("transcript_path")
|
||||
if event not in _FALLBACK_EVENTS or parser is None or not isinstance(transcript, str):
|
||||
return False
|
||||
return []
|
||||
root = _grant_root(database, source_id)
|
||||
if root is None:
|
||||
return False
|
||||
return []
|
||||
try:
|
||||
granted = root.resolve()
|
||||
target = Path(transcript).resolve()
|
||||
except OSError:
|
||||
return False
|
||||
return []
|
||||
if target != granted and granted not in target.parents:
|
||||
return False
|
||||
return []
|
||||
if not target.is_file():
|
||||
return False
|
||||
return []
|
||||
_wait_stable(target)
|
||||
try:
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
return []
|
||||
relative = target.relative_to(granted).as_posix()
|
||||
store = EvidenceStore(database)
|
||||
wrote = False
|
||||
inputs: list[EvidenceInput] = []
|
||||
for index, line in enumerate(raw.splitlines()):
|
||||
if not line.strip():
|
||||
continue
|
||||
@@ -514,7 +536,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
|
||||
if not isinstance(parsed, ParsedMessage):
|
||||
continue
|
||||
for row_index, row in enumerate(parsed.rows()):
|
||||
result = store.put(
|
||||
inputs.append(
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
workspace=opaque_workspace_id(source_id, row.workspace),
|
||||
@@ -533,11 +555,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
_submit_live_memory(
|
||||
store, result.record, changed=result.was_created or result.was_revised
|
||||
)
|
||||
wrote = True
|
||||
return wrote
|
||||
return inputs
|
||||
|
||||
|
||||
def _grant_root(database: Path, source_id: str) -> Path | None:
|
||||
|
||||
@@ -113,6 +113,37 @@ def inspect_runtime(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoreEndpoint:
|
||||
host: Literal["127.0.0.1"]
|
||||
port: int
|
||||
token: str
|
||||
|
||||
|
||||
def core_endpoint(
|
||||
data_dir: Path,
|
||||
*,
|
||||
alive: Callable[[int], bool] = process_is_alive,
|
||||
) -> CoreEndpoint | None:
|
||||
"""Where this home's Core listens and the token it accepts, from files alone.
|
||||
|
||||
The receipt and token checks `inspect_runtime` makes, without its identity
|
||||
round trip: a caller that only has to hand something over quickly lets the
|
||||
authenticated request itself be the proof. None when no live Core is named.
|
||||
"""
|
||||
try:
|
||||
raw = (data_dir / "runtime.json").read_text(encoding="utf-8")
|
||||
receipt = _receipt(json.loads(raw))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if receipt is None or not alive(receipt.pid):
|
||||
return None
|
||||
token = _runtime_token(data_dir)
|
||||
if token is None:
|
||||
return None
|
||||
return CoreEndpoint(host=receipt.host, port=receipt.port, token=token)
|
||||
|
||||
|
||||
def _receipt(value: object) -> RuntimeReceipt | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
|
||||
| `test_jsonl_snapshot_tail.py` | `contract` | Bounded EOF handling on synthetic files only, never original host history. |
|
||||
| `test_library_search_window.py` | `unit` | The library search window store and the unified record/keyword parsing. |
|
||||
| `test_live_capture.py` | `integration` | Visible hook text lands in Evidence before the transcript file is flushed. |
|
||||
| `test_live_capture.py` | `integration` | Hooks hand visible text to Core, which stages it once, without Evidence. |
|
||||
| `test_live_sync.py` | `contract` | 覆盖 live sync preempts a reader batch before it emits work、live sync cuts in front of a running history import、live sync does not resume a history import the user paused 等 24 个场景。 |
|
||||
| `test_live_sync_file_incremental.py` | `contract` | 覆盖 unchanged file metadata avoids a job and content read、changed file content is read once by the import job、workbuddy first live poll baselines history then imports only append 等 6 个场景。 |
|
||||
| `test_live_sync_job_coalescing.py` | `contract` | A poll that finds nothing must not leave an import job behind. |
|
||||
|
||||
@@ -29,7 +29,6 @@ from one_memory.imports.sources.registry import (
|
||||
build_source_catalog,
|
||||
)
|
||||
from one_memory.imports.supervisor import ImportSupervisor
|
||||
from one_memory.live_capture import capture_hook
|
||||
|
||||
FREEZE_THRESHOLD_SECONDS = 1.0
|
||||
|
||||
@@ -228,31 +227,17 @@ async def _run(
|
||||
|
||||
hook_text = "realtime hook while history import is running"
|
||||
hook_started = time.monotonic()
|
||||
result["hook_accepted"] = await asyncio.to_thread(
|
||||
capture_hook,
|
||||
"workbuddy",
|
||||
{
|
||||
# A hook hands its turn to Core over loopback; it no longer writes the
|
||||
# database itself. What is measured is that hand-off under import load.
|
||||
handed = await client.post(
|
||||
"/api/v1/live-capture/workbuddy",
|
||||
json={
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
"session_id": "pressure-live-session",
|
||||
"prompt": hook_text,
|
||||
},
|
||||
app.state.evidence_store.database_path,
|
||||
)
|
||||
hook_deadline = hook_started + 10
|
||||
hook_stored = False
|
||||
while time.monotonic() < hook_deadline:
|
||||
with sqlite3.connect(app.state.evidence_store.database_path) as connection:
|
||||
hook_stored = (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM evidence WHERE source_id='workbuddy' AND verbatim=?",
|
||||
(hook_text,),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
if hook_stored:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
result["hook_stored"] = hook_stored
|
||||
result["hook_accepted"] = handed.status_code == 202
|
||||
result["hook_visible_ms"] = round((time.monotonic() - hook_started) * 1000, 1)
|
||||
|
||||
during, during_unavailable, sampled_terminal = await _measure_recall_until(
|
||||
|
||||
@@ -131,3 +131,24 @@ def test_run_as_the_entry_it_ends_itself_while_still_importing(tmp_path) -> None
|
||||
_reap([process])
|
||||
assert process.returncode == -signal.SIGALRM
|
||||
assert time.monotonic() - started < 10
|
||||
|
||||
|
||||
def test_with_no_core_a_real_capture_exits_at_once_and_writes_nothing(tmp_path) -> None:
|
||||
# The process a host actually starts: no Core is running for this home,
|
||||
# so the turn is dropped. Nothing may fall back to writing the database.
|
||||
data_dir = tmp_path / "data"
|
||||
env = {
|
||||
key: value for key, value in os.environ.items() if key != "ONE_MEMORY_CAPTURE_DEADLINE_S"
|
||||
}
|
||||
env.update(HOME=str(tmp_path), ONE_MEMORY_DATA_DIR=str(data_dir))
|
||||
payload = b'{"hook_event_name": "UserPromptSubmit", "session_id": "s", "prompt": "hi"}'
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "one_memory.cli", "capture", "workbuddy"],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert (result.returncode, result.stdout, result.stderr) == (0, b"", b"")
|
||||
assert not (data_dir / "one-memory.sqlite3").exists()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -743,6 +743,44 @@ 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 = await client.get("/api/v2/raw/graph", params={"limit": 2})
|
||||
assert graph.status_code == 200, graph.text
|
||||
graph_body = graph.json()
|
||||
assert graph_body["total"] == 3
|
||||
assert graph_body["has_more"] is True
|
||||
assert [item["label"] for item in graph_body["items"]] == [
|
||||
"[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": 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]: 新消息",
|
||||
@@ -754,6 +792,21 @@ 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]: 新消息"
|
||||
continued = await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
|
||||
@@ -293,6 +293,22 @@ def _retry_succeeds(home: Path, monkeypatch) -> None:
|
||||
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
|
||||
|
||||
|
||||
def test_evidence_an_older_capture_hook_recreated_is_dropped_on_the_next_start(home):
|
||||
# Builds through 1.0.6 let the hook process open `EvidenceStore`, which
|
||||
# rebuilt the retired tables on an SDK home. Nothing reads them there, and
|
||||
# the next startup migration's leftovers cleanup removes them again.
|
||||
assert _run(home).state == "cutover"
|
||||
database = home / "one-memory.sqlite3"
|
||||
EvidenceStore(database)
|
||||
assert "evidence" in legacy_tables_present(database)
|
||||
|
||||
result = _run(home)
|
||||
|
||||
assert result.state == "complete"
|
||||
assert {"evidence", "evidence_search"} <= set(result.dropped_tables)
|
||||
assert legacy_tables_present(database) == ()
|
||||
|
||||
|
||||
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@ def test_workbuddy_recall_remains_available_during_100k_import(tmp_path, monkeyp
|
||||
|
||||
assert result["import_status"] == "completed"
|
||||
assert result["stored"] == 100_000
|
||||
assert result["evidence_rows"] == 100_001
|
||||
assert result["evidence_rows"] == 100_000
|
||||
assert result["hook_accepted"] is True
|
||||
assert result["hook_stored"] is True
|
||||
assert result["hook_visible_ms"] < 10_000
|
||||
assert result["recall_sampling_terminal"] is True
|
||||
assert result["during_unavailable"] == 0
|
||||
|
||||
@@ -371,16 +371,14 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "一键接入应用" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。",
|
||||
screen.queryByText(
|
||||
/点击底部按钮后|选择草稿|按各应用回执|本页任务控制尚未接通/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
||||
).toBeNull();
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
@@ -398,11 +396,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/已保存的对话还在/, {
|
||||
selector: ".first-import-r6-footer-note p",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -439,9 +433,7 @@ it.each(["stale", "revoked", "missing"])(
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
if (kind === "missing")
|
||||
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
|
||||
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
|
||||
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
||||
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
||||
expect(
|
||||
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toHaveLength(1);
|
||||
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toBeNull();
|
||||
view.update({
|
||||
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
||||
jobs: [{ ...job, stored: 20 }],
|
||||
|
||||
@@ -153,26 +153,6 @@ export function FirstImportReview({
|
||||
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
||||
batch.every((job) => job.status === "completed") &&
|
||||
!Object.keys(failedSources).length;
|
||||
const allPaused =
|
||||
batch.length > 0 && batch.every((job) => job.status === "paused");
|
||||
const importing =
|
||||
!stale &&
|
||||
batch.some(
|
||||
(job) =>
|
||||
["pending", "running"].includes(job.status) &&
|
||||
sources.some(
|
||||
(source) => source.source_id === job.source_id && source.authorized,
|
||||
),
|
||||
);
|
||||
const displayImporting =
|
||||
importing ||
|
||||
(!stale &&
|
||||
!allCompleted &&
|
||||
!allPaused &&
|
||||
batch.length === 0 &&
|
||||
ids.some((id) =>
|
||||
sources.some((source) => source.source_id === id && source.authorized),
|
||||
));
|
||||
const title = "对话正在汇入,记忆从这里开始";
|
||||
const hostEligible = importHostEligible(sources, ids);
|
||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||
@@ -282,24 +262,83 @@ export function FirstImportReview({
|
||||
<R6ColdStartFrame
|
||||
step={2}
|
||||
title={text(title)}
|
||||
eyebrow={text("导入进行中 · 顺手连接应用")}
|
||||
footer={
|
||||
<>
|
||||
<div className="first-import-r6-footer-note">
|
||||
<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>
|
||||
<p>
|
||||
{text(
|
||||
allCompleted
|
||||
? "对话已保存,记忆尚待生成。"
|
||||
: displayImporting
|
||||
? "导入在后台继续,无需等待。"
|
||||
: stale
|
||||
? "正在重新核对来源;已保存数量仍按任务回执显示。"
|
||||
: allPaused
|
||||
? "本次历史导入已暂停,已保存的对话还在。"
|
||||
: "导入等待处理,已保存的对话还在。",
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "后台将持续为你导入,可以放心进入下一步",
|
||||
)}
|
||||
</p>
|
||||
{(error || actionMessage) && (
|
||||
</div>
|
||||
{(error || actionMessage) && (
|
||||
<div className="first-import-r6-feedback">
|
||||
<p
|
||||
role={
|
||||
error?.includes("应用连接") ||
|
||||
@@ -318,83 +357,8 @@ export function FirstImportReview({
|
||||
>
|
||||
{error ? text(error) : actionMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="first-import-r6-footer-actions">
|
||||
{changing ? (
|
||||
<R6Button
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
}}
|
||||
>
|
||||
{text("停止后续操作")}
|
||||
</R6Button>
|
||||
) : onChangeJob && (canPause || canResume) ? (
|
||||
<R6Button
|
||||
disabled={busy || (stale && !canPause)}
|
||||
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
{text(canPause ? "暂停导入" : "继续导入")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
recovery.onSkip();
|
||||
}}
|
||||
>
|
||||
{text(
|
||||
recovery.destination === "sources"
|
||||
? "仍进入应用页"
|
||||
: recovery.destination === "settings"
|
||||
? "仍进入设置"
|
||||
: "仍进入工作台",
|
||||
)}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave(recovery.destination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
</R6Button>
|
||||
</>
|
||||
) : (
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="first-import-r6-layout">
|
||||
<section
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<p className="first-import-r6-intro">
|
||||
{text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||
)}
|
||||
</p>
|
||||
<p className="first-import-r6-subintro">
|
||||
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{historyWindowLimited && (
|
||||
<p className="first-import-r6-history-window" role="status">
|
||||
{t("onboardingHistoryWindow")}
|
||||
@@ -406,46 +370,39 @@ export function FirstImportReview({
|
||||
jobs={batch}
|
||||
stale={progressStale}
|
||||
/>
|
||||
<section
|
||||
className="first-import-r6-feed"
|
||||
aria-label={text("导入说明")}
|
||||
>
|
||||
<h2>{text("正在导入你的会话")}</h2>
|
||||
<p className="first-import-r6-feed-status">
|
||||
{text("后台将持续为你导入,可以放心进入下一步")}
|
||||
</p>
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
>
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
>
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
<aside
|
||||
className="first-import-r6-options"
|
||||
aria-label={text("连接应用")}
|
||||
>
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
<div className="first-import-r6-options-head">
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
</div>
|
||||
<SelectionTile
|
||||
title={text(
|
||||
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
|
||||
@@ -481,13 +438,11 @@ export function FirstImportReview({
|
||||
{text(connectionNote)}
|
||||
</p>
|
||||
)}
|
||||
<p className="first-import-r6-options-note">
|
||||
{text(
|
||||
draftReady
|
||||
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
|
||||
: "暂时无法连接应用,当前不会安装连接。",
|
||||
)}
|
||||
</p>
|
||||
{!draftReady && (
|
||||
<p className="first-import-r6-options-note">
|
||||
{text("暂时无法连接应用,当前不会安装连接。")}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</R6ColdStartFrame>
|
||||
|
||||
@@ -62,6 +62,30 @@ vi.mock("./MemoryGraph3D", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: ({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: {
|
||||
graph: MemoryGraphResponse;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) => (
|
||||
<div data-testid="canvas" data-selected={selectedNodeId}>
|
||||
{graph.nodes.map((node) => (
|
||||
<button
|
||||
key={node.node_id}
|
||||
type="button"
|
||||
aria-label={`图中选择 ${node.label}`}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
@@ -491,6 +515,7 @@ function Host({
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
raw = false,
|
||||
}: {
|
||||
simplified?: boolean;
|
||||
date?: string | null;
|
||||
@@ -509,6 +534,7 @@ function Host({
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
raw?: boolean;
|
||||
} = {}) {
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
@@ -518,6 +544,7 @@ function Host({
|
||||
return (
|
||||
<MemoryMap
|
||||
simplified={simplified}
|
||||
contentKind={raw ? "raw" : "memory"}
|
||||
libraryView={libraryView}
|
||||
hidden={hidden}
|
||||
overlayBlocked={overlayBlocked}
|
||||
@@ -587,6 +614,61 @@ function Host({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("windows raw rows and automatically continues near the bottom", () => {
|
||||
const items = Array.from({ length: 150 }, (_, index) => ({
|
||||
...memories[0],
|
||||
memory_id: `raw-${index}`,
|
||||
content: `原始会话 ${index}`,
|
||||
}));
|
||||
const onMore = vi.fn();
|
||||
const sourceIds = Object.fromEntries(
|
||||
items.map((item) => [item.memory_id, ["cursor"]]),
|
||||
);
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
raw
|
||||
items={items}
|
||||
libraryView={{
|
||||
query: "",
|
||||
source: "",
|
||||
page: {
|
||||
items,
|
||||
total: 1_600,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
source_ids_by_memory: sourceIds,
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
stale: false,
|
||||
cursorPaging: true,
|
||||
hasMore: true,
|
||||
onQuery: vi.fn(),
|
||||
onSource: vi.fn(),
|
||||
resetPage: vi.fn(),
|
||||
onPage: vi.fn(),
|
||||
onMore,
|
||||
onRetry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const list = screen.getByRole("list", { name: "原始对话" });
|
||||
Object.defineProperties(list, {
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollHeight: { configurable: true, value: 17_400 },
|
||||
scrollTop: { configurable: true, writable: true, value: 16_500 },
|
||||
});
|
||||
fireEvent.scroll(list);
|
||||
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelectorAll("[data-memory-id]").length).toBeLessThan(30);
|
||||
expect(screen.getByRole("button", { name: "加载更多" }).classList).toContain(
|
||||
"map-load-more",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not present an unverified empty page as zero memories", () => {
|
||||
const page = {
|
||||
items: [],
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { GuiLocale } from "@one-memory/i18n";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
} from "./memory-map-model";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { RawConversationGraph3D } from "./RawConversationGraph3D";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SelectedRawDetail } from "./SelectedRawDetail";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
@@ -106,6 +108,8 @@ type Props = {
|
||||
/** The graph for the current source/query scope has not arrived yet. */
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
graphLoaded?: number;
|
||||
graphTotal?: number;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
@@ -179,6 +183,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
||||
working: "工作",
|
||||
coding: "编码",
|
||||
};
|
||||
const RAW_ROW_HEIGHT = 116;
|
||||
const RAW_LIST_OVERSCAN = 6;
|
||||
|
||||
export function MemoryMap(props: Props) {
|
||||
const { locale, text, count, plain } = useMemoryLanguage();
|
||||
@@ -257,6 +263,10 @@ export function MemoryMap(props: Props) {
|
||||
const pane = useRef<HTMLElement>(null);
|
||||
const memoryList = useRef<HTMLUListElement>(null);
|
||||
const inspectorBody = useRef<HTMLDivElement>(null);
|
||||
const [rawListWindow, setRawListWindow] = useState({
|
||||
start: 0,
|
||||
end: 20,
|
||||
});
|
||||
const openedDefault = useRef(false);
|
||||
const scrollSelection = useRef(false);
|
||||
const controlledSource = library
|
||||
@@ -342,6 +352,33 @@ export function MemoryMap(props: Props) {
|
||||
),
|
||||
[active, group, search, serverPaged, axis, groupKey],
|
||||
);
|
||||
const rawWindowed = raw && !!library?.cursorPaging && filtered.length > 100;
|
||||
const visibleMemories = rawWindowed
|
||||
? filtered.slice(rawListWindow.start, rawListWindow.end)
|
||||
: filtered;
|
||||
const rawTopSpace = rawWindowed ? rawListWindow.start * RAW_ROW_HEIGHT : 0;
|
||||
const rawBottomSpace = rawWindowed
|
||||
? Math.max(0, filtered.length - rawListWindow.end) * RAW_ROW_HEIGHT
|
||||
: 0;
|
||||
const updateRawListWindow = useCallback(
|
||||
(element: HTMLUListElement) => {
|
||||
if (!rawWindowed) return;
|
||||
const first = Math.max(
|
||||
0,
|
||||
Math.floor(element.scrollTop / RAW_ROW_HEIGHT) - RAW_LIST_OVERSCAN,
|
||||
);
|
||||
const visible = Math.ceil(element.clientHeight / RAW_ROW_HEIGHT);
|
||||
setRawListWindow({
|
||||
start: first,
|
||||
end: Math.min(filtered.length, first + visible + RAW_LIST_OVERSCAN * 2),
|
||||
});
|
||||
},
|
||||
[filtered.length, rawWindowed],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (rawWindowed && memoryList.current)
|
||||
updateRawListWindow(memoryList.current);
|
||||
}, [rawWindowed, updateRawListWindow]);
|
||||
const projection = useMemo(() => {
|
||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||
// previous scope's graph would show the wrong source.
|
||||
@@ -357,7 +394,7 @@ export function MemoryMap(props: Props) {
|
||||
// left-hand list window. Projecting the graph back down to the current
|
||||
// 20-row page made "All applications" look like whichever source happened
|
||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||
if (serverPaged && !library?.query) {
|
||||
if (serverPaged && (!library?.query || raw)) {
|
||||
return (
|
||||
baseGraph ??
|
||||
projectMap(
|
||||
@@ -409,6 +446,7 @@ export function MemoryMap(props: Props) {
|
||||
search,
|
||||
sources,
|
||||
serverPaged,
|
||||
raw,
|
||||
library?.query,
|
||||
library?.graphLoading,
|
||||
library?.graphFailed,
|
||||
@@ -1026,11 +1064,42 @@ export function MemoryMap(props: Props) {
|
||||
)}
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
className={`map-memory-list${raw ? " raw" : ""}`}
|
||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
updateRawListWindow(element);
|
||||
if (
|
||||
library?.cursorPaging &&
|
||||
library.hasMore &&
|
||||
!pageTransitioning &&
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <
|
||||
element.clientHeight
|
||||
)
|
||||
library.onMore?.();
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
const element = event.currentTarget;
|
||||
if (
|
||||
event.deltaY > 0 &&
|
||||
library?.cursorPaging &&
|
||||
library.hasMore &&
|
||||
!pageTransitioning &&
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <
|
||||
8
|
||||
)
|
||||
library.onMore?.();
|
||||
}}
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
{rawTopSpace ? (
|
||||
<li
|
||||
className="map-memory-spacer"
|
||||
style={{ height: rawTopSpace }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{visibleMemories.map((m) => (
|
||||
<li key={m.memory_id} data-memory-id={m.memory_id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1086,6 +1155,13 @@ export function MemoryMap(props: Props) {
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{rawBottomSpace ? (
|
||||
<li
|
||||
className="map-memory-spacer"
|
||||
style={{ height: rawBottomSpace }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</ul>
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
@@ -1136,12 +1212,24 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
{library.cursorPaging ? (
|
||||
library.hasMore ? (
|
||||
<R6Button
|
||||
<button
|
||||
type="button"
|
||||
className="map-load-more"
|
||||
disabled={pageTransitioning}
|
||||
aria-label={
|
||||
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||
}
|
||||
title={
|
||||
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||
}
|
||||
onClick={() => library.onMore?.()}
|
||||
>
|
||||
{pageTransitioning ? text("正在读取…") : text("加载更多")}
|
||||
</R6Button>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className={pageTransitioning ? "loading" : undefined}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span>{text("已加载全部")}</span>
|
||||
)
|
||||
@@ -1235,19 +1323,38 @@ export function MemoryMap(props: Props) {
|
||||
<h2>
|
||||
{props.simplified
|
||||
? raw
|
||||
? t("memory:原始对话")
|
||||
? text("来源归属")
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
{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) && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{compact && (activeId || selectedSource) && !inspectorOpen && (
|
||||
<button
|
||||
@@ -1267,14 +1374,25 @@ export function MemoryMap(props: Props) {
|
||||
</header>
|
||||
<div className="map-canvas" data-tour-id="memory-map-canvas">
|
||||
{projection.nodes.length && (!props.simplified || !hidden) ? (
|
||||
<MemoryGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
raw ? (
|
||||
<RawConversationGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
) : (
|
||||
<MemoryGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { MemoryPipelineMode } from "@one-memory/contracts";
|
||||
import { Check } from "lucide-react";
|
||||
import { type ReactNode, useId } from "react";
|
||||
import { MODE_COPY } from "./memory-mode-ui";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import "./memory-mode-cards.css";
|
||||
|
||||
const MODES: Array<{
|
||||
id: MemoryPipelineMode;
|
||||
index: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "lite",
|
||||
index: "01",
|
||||
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
index: "02",
|
||||
description: "使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
},
|
||||
{
|
||||
id: "ultra",
|
||||
index: "03",
|
||||
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
|
||||
},
|
||||
];
|
||||
|
||||
export function MemoryModeCards({
|
||||
value,
|
||||
current,
|
||||
currentBadge,
|
||||
disabled = false,
|
||||
onChange,
|
||||
ultraDetail,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-busy": ariaBusy,
|
||||
}: {
|
||||
value: MemoryPipelineMode | null;
|
||||
current?: MemoryPipelineMode;
|
||||
currentBadge?: ReactNode;
|
||||
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
|
||||
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
|
||||
ultraDetail?: ReactNode;
|
||||
"aria-label": string;
|
||||
"aria-busy"?: boolean;
|
||||
}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const name = useId();
|
||||
const unavailable = (mode: MemoryPipelineMode) =>
|
||||
typeof disabled === "function" ? disabled(mode) : disabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="memory-mode-cards"
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy || undefined}
|
||||
>
|
||||
{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 features = text(MODE_COPY[mode.id].description).split(/\s*·\s*/);
|
||||
return (
|
||||
<div
|
||||
key={mode.id}
|
||||
className="memory-mode-card"
|
||||
data-mode={mode.id}
|
||||
data-selected={selected || undefined}
|
||||
data-current={current === mode.id || undefined}
|
||||
data-disabled={modeDisabled || undefined}
|
||||
>
|
||||
<label className="memory-mode-card-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={mode.id}
|
||||
aria-label={localizedTitle}
|
||||
checked={selected}
|
||||
disabled={modeDisabled}
|
||||
onChange={(event) => onChange(mode.id, event.currentTarget)}
|
||||
/>
|
||||
<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>
|
||||
{current === mode.id && currentBadge ? (
|
||||
<span className="memory-mode-current">{currentBadge}</span>
|
||||
) : null}
|
||||
<span className="memory-mode-radio" aria-hidden="true">
|
||||
{selected ? <Check size={13} /> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="memory-mode-description">
|
||||
{text(mode.description)}
|
||||
</span>
|
||||
<span className="memory-mode-features">
|
||||
{features.map((feature) => (
|
||||
<span key={feature}>
|
||||
<i aria-hidden="true" />
|
||||
{text(feature)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||
<div className="memory-mode-detail">{ultraDetail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ it("renders all settings modes as one grouped shared choice list", async () => {
|
||||
expect(within(group).getAllByRole("radio")).toHaveLength(3);
|
||||
expect(
|
||||
within(group).getByText("当前模式").closest("label")?.textContent,
|
||||
).toContain("智能记忆(pro)");
|
||||
).toContain("智能记忆PRO");
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
|
||||
@@ -25,12 +25,8 @@ import {
|
||||
commitConfirmedMemoryMode,
|
||||
issueConfirmedModeTicket,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
} from "./memory-mode-ui";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
@@ -237,16 +233,11 @@ export function MemoryModeSettings({
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
<MemoryModeCards
|
||||
aria-label={text("当前模式")}
|
||||
aria-busy={state === null}
|
||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
mode,
|
||||
title: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
}))}
|
||||
current={state?.mode}
|
||||
selected={state?.mode ?? null}
|
||||
value={state?.mode ?? null}
|
||||
currentBadge={text("当前模式")}
|
||||
disabled={(mode) =>
|
||||
!state ||
|
||||
@@ -256,6 +247,50 @@ export function MemoryModeSettings({
|
||||
(mode !== "lite" && !modelStateKnown)
|
||||
}
|
||||
onChange={(mode, control) => void choose(mode, control)}
|
||||
ultraDetail={
|
||||
state?.mode === "ultra" && schedule ? (
|
||||
<section
|
||||
id={`${id}-evolve`}
|
||||
className="r6-evolve-settings r6-evolve-settings--in-card"
|
||||
aria-labelledby={`${id}-evolve-title`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={schedule}
|
||||
headingId={`${id}-evolve-title`}
|
||||
disabled={busy || maintenance}
|
||||
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>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
@@ -285,49 +320,6 @@ export function MemoryModeSettings({
|
||||
)
|
||||
) : null}
|
||||
|
||||
{state?.mode === "ultra" && schedule ? (
|
||||
<section
|
||||
id={`${id}-evolve`}
|
||||
className="r6-evolve-settings"
|
||||
aria-labelledby={`${id}-evolve-title`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={schedule}
|
||||
headingId={`${id}-evolve-title`}
|
||||
disabled={busy || maintenance}
|
||||
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>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{pendingMode && pendingPlan ? (
|
||||
<MemoryModeConfirmDialog
|
||||
mode={pendingMode}
|
||||
|
||||
@@ -77,6 +77,7 @@ const { reads } = vi.hoisted(() => ({
|
||||
processing: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
rawGraph: vi.fn(),
|
||||
rawSearch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -87,6 +88,7 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getMemoryProcessing: reads.processing,
|
||||
getModelProfiles: reads.profiles,
|
||||
getSdkRaw: reads.raw,
|
||||
getSdkRawGraph: reads.rawGraph,
|
||||
searchSdkRaw: reads.rawSearch,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
@@ -119,6 +121,9 @@ vi.mock("./MemoryAddComposer", () => ({
|
||||
vi.mock("./MemoryGraph3D", () => ({
|
||||
MemoryGraph3D: () => <div data-testid="graph-owner" />,
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
@@ -208,6 +213,25 @@ function setup() {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: [
|
||||
{
|
||||
turn_id: "raw-1",
|
||||
raw_id: "raw-1",
|
||||
label: "原始对话内容",
|
||||
source: "cursor",
|
||||
session_id: "session-1",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "snapshot-1",
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
@@ -289,6 +313,11 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
||||
expect(screen.getByText("1 条原始对话")).toBeTruthy();
|
||||
expect(screen.queryByText("找到 1 条")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "来源归属" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||
).toBe(false);
|
||||
@@ -306,7 +335,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
@@ -316,6 +345,96 @@ 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 () => {
|
||||
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",
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("restarts the complete graph when its snapshot expires between pages", 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",
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(3));
|
||||
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
|
||||
expect.objectContaining({ offset: 0, snapshotId: undefined }),
|
||||
);
|
||||
expect(screen.getByText("1 / 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
@@ -363,15 +482,18 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
const graphCalls = reads.rawGraph.mock.calls.length;
|
||||
await user.click(screen.getByRole("button", { name: "加载更多" }));
|
||||
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
|
||||
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
expect(reads.rawGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
cursor: "turn:1:0:1",
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -383,7 +505,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: "cursor",
|
||||
cursor: null,
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -396,7 +518,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
@@ -615,6 +737,15 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
truncated: true,
|
||||
},
|
||||
]);
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "search-total",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
@@ -625,6 +756,7 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||
|
||||
expect(await screen.findByText("完整问题")).toBeTruthy();
|
||||
expect(screen.getByText("找到 1 条")).toBeTruthy();
|
||||
expect(screen.getByText("完整回答")).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(".map-dialogue-reading")?.textContent,
|
||||
|
||||
@@ -289,7 +289,7 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
|
||||
expect(await screen.findByText(/566/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the previous source graph until the all-applications graph settles", async () => {
|
||||
it("loads each source graph independently from list pagination and search", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
@@ -415,17 +415,13 @@ it("hides the previous source graph until the all-applications graph settles", a
|
||||
);
|
||||
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("search deploy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("4");
|
||||
expect(screen.getByTestId("map-memories").textContent).toBe("");
|
||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
|
||||
await act(async () => releaseSearch(searchPage));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkRawGraphItem,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { isMessageKey } from "@one-memory/i18n";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
getSdkRaw,
|
||||
getSdkRawGraph,
|
||||
manualExtract,
|
||||
searchSdkRaw,
|
||||
setExtractionWindow,
|
||||
@@ -60,9 +62,12 @@ import {
|
||||
import {
|
||||
type RawMapRecord,
|
||||
rawDetail,
|
||||
rawGraphProjection,
|
||||
rawGraphRecord,
|
||||
rawLibraryPage,
|
||||
rawRecordId,
|
||||
rawSearchPage,
|
||||
SDK_RAW_PAGE_SIZE,
|
||||
} from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
@@ -165,6 +170,8 @@ export function MemorySpace({
|
||||
source.grant_revision,
|
||||
]),
|
||||
);
|
||||
const sourcesRef = useRef(sources);
|
||||
sourcesRef.current = sources;
|
||||
const management = simplified && displayedMode === "library";
|
||||
useEffect(() => {
|
||||
viewActive.current = active;
|
||||
@@ -188,6 +195,11 @@ export function MemorySpace({
|
||||
status: "ready" | "failed";
|
||||
value: MemoryGraphResponse | null;
|
||||
} | null>(null);
|
||||
const [sdkGraphProgress, setSdkGraphProgress] = useState<{
|
||||
scope: string;
|
||||
loaded: number;
|
||||
total: number | null;
|
||||
} | null>(null);
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
@@ -239,18 +251,21 @@ export function MemorySpace({
|
||||
sdkLibrary.settledQuery.trim() === requestedQuery &&
|
||||
sdkLibrary.page?.offset === sdkLibraryOffset;
|
||||
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
const sdkGraphRequestedScope = JSON.stringify([
|
||||
pipelineMode,
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
||||
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "ready"
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
||||
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
|
||||
const visibleGraphProgress =
|
||||
sdkGraphProgress?.scope === sdkGraphRequestedScope
|
||||
? sdkGraphProgress
|
||||
: null;
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -295,61 +310,37 @@ export function MemorySpace({
|
||||
rawRecords.current = next;
|
||||
rawRecordsScope.current = windowKey;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
? await Promise.all([
|
||||
query
|
||||
? searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: 0,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
paging: "cursor",
|
||||
cursor: sdkRawCursor,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
}),
|
||||
Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
}),
|
||||
])
|
||||
: await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(sdkLibraryPage),
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
const page = lite
|
||||
? query
|
||||
? await searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: await getSdkRaw({
|
||||
source,
|
||||
offset: 0,
|
||||
limit: SDK_RAW_PAGE_SIZE,
|
||||
paging: "cursor",
|
||||
cursor: sdkRawCursor,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
})
|
||||
: await getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(sdkLibraryPage);
|
||||
if (cancelled) return;
|
||||
if (!lite && sdkSearchWindow.current.key === windowKey)
|
||||
sdkSearchWindow.current = {
|
||||
@@ -405,7 +396,6 @@ export function MemorySpace({
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch (reason) {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
if (
|
||||
@@ -443,6 +433,114 @@ export function MemorySpace({
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
const rawGraphRecords = useRef(new Map<string, RawMapRecord>());
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
void sdkMapRevision;
|
||||
void sourceKey;
|
||||
const scope = sdkGraphRequestedScope;
|
||||
const source = sdkLibrarySource || undefined;
|
||||
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;
|
||||
try {
|
||||
if (!lite) {
|
||||
const value = await getSdkMemoryGraph(
|
||||
{ source, offset: 0, limit: 300 },
|
||||
request.signal,
|
||||
);
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkGraph({ scope, status: "failed", value: null });
|
||||
} finally {
|
||||
reading = false;
|
||||
}
|
||||
};
|
||||
void readGraph();
|
||||
const timer = setInterval(() => void readGraph(), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
request.abort();
|
||||
};
|
||||
}, [
|
||||
lite,
|
||||
sdkGraphRequestedScope,
|
||||
sdkLibrarySource,
|
||||
sdkMapRevision,
|
||||
sdkMapVisible,
|
||||
sourceKey,
|
||||
]);
|
||||
const displayedSdkPage =
|
||||
lite &&
|
||||
!sdkLibraryQuery.trim() &&
|
||||
sdkPage &&
|
||||
visibleGraphProgress?.total != null
|
||||
? {
|
||||
...sdkPage,
|
||||
total: visibleGraphProgress.total,
|
||||
total_exact: true,
|
||||
}
|
||||
: sdkPage;
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
? `${t("memory:来源")} ${
|
||||
@@ -572,10 +670,13 @@ export function MemorySpace({
|
||||
}, [active, onArrivalChange]);
|
||||
const requestIds = useRef(new Map<string, string>());
|
||||
const detailEpoch = useRef(0);
|
||||
const rawDetailController = useRef<AbortController | null>(null);
|
||||
const answerEpoch = useRef(0);
|
||||
const answerMemories = useRef<AskMemory[]>([]);
|
||||
const closeDetail = useCallback(() => {
|
||||
detailEpoch.current++;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
setDetailLoad(null);
|
||||
@@ -650,6 +751,8 @@ export function MemorySpace({
|
||||
cancelled = true;
|
||||
stopProcessing();
|
||||
detailEpoch.current++;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
answerEpoch.current++;
|
||||
deleteAnswer.current?.(false);
|
||||
deleteAnswer.current = null;
|
||||
@@ -761,11 +864,15 @@ export function MemorySpace({
|
||||
async function open(memoryId: string) {
|
||||
if (simplified && (!viewActive.current || stale.current)) return;
|
||||
const epoch = ++detailEpoch.current;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
if (lite) {
|
||||
let record = rawRecords.current.get(memoryId);
|
||||
let record =
|
||||
rawRecords.current.get(memoryId) ??
|
||||
rawGraphRecords.current.get(memoryId);
|
||||
if (!record) {
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
@@ -776,6 +883,8 @@ export function MemorySpace({
|
||||
}
|
||||
if ("kind" in record) {
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
const request = new AbortController();
|
||||
rawDetailController.current = request;
|
||||
try {
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
@@ -784,6 +893,7 @@ export function MemorySpace({
|
||||
sessionId: record.session_id || undefined,
|
||||
offset,
|
||||
limit: 500,
|
||||
signal: request.signal,
|
||||
});
|
||||
const full = page.items.find(
|
||||
(item) => rawRecordId(item) === memoryId,
|
||||
@@ -798,15 +908,18 @@ export function MemorySpace({
|
||||
throw new Error("raw_not_found");
|
||||
}
|
||||
} catch {
|
||||
if (epoch === detailEpoch.current)
|
||||
if (epoch === detailEpoch.current) {
|
||||
rawDetailController.current = null;
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
status: "failed",
|
||||
error: t("sdk.raw.loadFailed"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (epoch !== detailEpoch.current) return;
|
||||
rawDetailController.current = null;
|
||||
}
|
||||
setSelectedRaw(record);
|
||||
setDetail(rawDetail(record));
|
||||
@@ -961,13 +1074,12 @@ export function MemorySpace({
|
||||
? pipelineMode === null
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? sdkPage.total_exact === false
|
||||
? displayedSdkPage
|
||||
? displayedSdkPage.total_exact === false
|
||||
? t("memory:原始对话")
|
||||
: `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
: sdkLibrary.settledQuery.trim()
|
||||
? tn("sdk.raw.found", displayedSdkPage.total)
|
||||
: tn("sdk.raw.total", displayedSdkPage.total)
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
@@ -1258,11 +1370,13 @@ export function MemorySpace({
|
||||
? {
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkPage,
|
||||
page: displayedSdkPage,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
graphLoaded: visibleGraphProgress?.loaded,
|
||||
graphTotal: visibleGraphProgress?.total ?? undefined,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
@@ -1290,7 +1404,7 @@ export function MemorySpace({
|
||||
displayedMode !== "map" ||
|
||||
(simplified && pipelineMode === null)
|
||||
}
|
||||
memories={sdkPage?.items ?? []}
|
||||
memories={displayedSdkPage?.items ?? []}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
|
||||
@@ -23,20 +23,20 @@ vi.mock("./api", () => ({
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({
|
||||
planMemoryMode: vi.fn().mockImplementation(async (target) => ({
|
||||
current: "lite",
|
||||
target: "pro",
|
||||
effective_target: "pro",
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {
|
||||
content_plane: "memory",
|
||||
generation_required: true,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
content_plane: target === "lite" ? "raw" : "memory",
|
||||
generation_required: target !== "lite",
|
||||
graph_enabled: target === "ultra",
|
||||
evolve_available: target === "ultra",
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: ["active_model_required"],
|
||||
}),
|
||||
blockers: target === "lite" ? [] : ["active_model_required"],
|
||||
})),
|
||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -156,16 +156,12 @@ 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(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -184,13 +180,10 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -210,13 +203,10 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
@@ -302,7 +292,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "开始导入" }),
|
||||
);
|
||||
const lateSources = [source("workbuddy"), source("codex")];
|
||||
rendered.rerender(view({ ...imports, sources: lateSources }));
|
||||
expect(checkbox("codex").checked).toBe(false);
|
||||
|
||||
@@ -211,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
||||
).toBeTruthy();
|
||||
@@ -289,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("region", {
|
||||
name: "Progreso de importación de las aplicaciones",
|
||||
@@ -324,16 +324,15 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeTruthy();
|
||||
screen.queryByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("中文"));
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(navigation.textContent).toContain("连接应用");
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(draft).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -425,6 +424,12 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
new Error("fixture-offline"),
|
||||
);
|
||||
const imports = controller();
|
||||
imports.sources = [];
|
||||
vi.mocked(imports.refresh).mockResolvedValue({
|
||||
sources: [],
|
||||
jobs: [],
|
||||
health: null as never,
|
||||
});
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<LanguageProvider>
|
||||
@@ -434,16 +439,13 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
).toHaveProperty("disabled", false),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Confirmar selección" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||
|
||||
@@ -347,13 +347,12 @@ it("keeps the single-source submit frozen when another source arrives during fin
|
||||
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
||||
await ready();
|
||||
await waitFor(() => expect(count()).toBe("0"));
|
||||
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -398,15 +397,8 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
"accepted-job:completed",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByLabelText("保留任务").textContent).toBe(
|
||||
"accepted-job:completed",
|
||||
);
|
||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
|
||||
@@ -125,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "首次设置进度" })
|
||||
@@ -133,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
).toContain("导入历史");
|
||||
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeTruthy();
|
||||
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
||||
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
||||
expect(
|
||||
@@ -349,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
|
||||
expect(
|
||||
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
await screen.findByText("已受理 workbuddy");
|
||||
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
||||
"workbuddy",
|
||||
@@ -373,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
|
||||
done = resolve;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
view.unmount();
|
||||
await act(async () =>
|
||||
done({
|
||||
|
||||
@@ -219,7 +219,7 @@ function imports(
|
||||
|
||||
const READ_CONSENT = "允许读取以上来源的本次历史";
|
||||
const START = "开始导入";
|
||||
const SKIP = "稍后添加,先进入";
|
||||
const SKIP = "选择记忆模式";
|
||||
async function openApps(_user?: ReturnType<typeof userEvent.setup>) {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -251,7 +251,6 @@ async function selectMode(
|
||||
name: RegExp,
|
||||
) {
|
||||
await user.click(screen.getByRole("radio", { name }));
|
||||
await user.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
}
|
||||
function choice(name = "Claude Code") {
|
||||
return screen.getByRole<HTMLInputElement>("checkbox", {
|
||||
@@ -430,6 +429,89 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the import review open when a late connection result is unknown", async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectImport!: (reason?: unknown) => void;
|
||||
const controller = imports({
|
||||
connectForOnboarding: vi.fn(
|
||||
() =>
|
||||
new Promise<
|
||||
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
|
||||
>((_resolve, reject) => {
|
||||
rejectImport = reject;
|
||||
}),
|
||||
),
|
||||
});
|
||||
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "对话正在汇入,记忆从这里开始",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => rejectImport(new Error("response lost")));
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "对话正在汇入,记忆从这里开始",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns to source review when Core definitely refuses the connection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports({
|
||||
connectForOnboarding: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("source rejected"), {
|
||||
definiteRefusal: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "把熟悉的背景,带到这里",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"本次没有来源获准导入",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "对话正在汇入,记忆从这里开始",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("opens model setup immediately and preserves it across a late import failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectImport!: (reason?: unknown) => void;
|
||||
@@ -470,15 +552,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts Lite once while the import submission is still in flight", async () => {
|
||||
@@ -954,9 +1033,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
|
||||
"pro",
|
||||
);
|
||||
expect(screen.queryByText("推荐")).toBeNull();
|
||||
const modeStep = screen
|
||||
.getByRole("navigation", { name: "首次设置进度" })
|
||||
.querySelector('[aria-current="step"]');
|
||||
@@ -1006,24 +1083,8 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /ultra/i }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "选择深度记忆(ultra)?",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole("switch", { name: "每天自动整合" })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "执行时间,03:00" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("每天自动整合")).toBeTruthy();
|
||||
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(putOnboardingProgress).toHaveBeenCalledWith(
|
||||
@@ -1092,11 +1153,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("switch", { name: "每天自动整合" })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
@@ -1174,9 +1231,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重试" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "执行时间,04:15" }),
|
||||
).toBeTruthy(),
|
||||
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
|
||||
@@ -1208,7 +1263,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
const view = render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
@@ -1219,7 +1274,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
view.unmount();
|
||||
await act(async () =>
|
||||
resolvePlan({
|
||||
current: "pro",
|
||||
@@ -1238,11 +1293,6 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "对话正在汇入,记忆从这里开始",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists Lite mode before resumable progress and completion", async () => {
|
||||
@@ -1758,7 +1808,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
it("skipping after a selection does not grant, install, sync or connect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
@@ -1767,7 +1817,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
await choose(user);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1823,6 +1873,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||
expect(choice()).toHaveProperty("disabled", false);
|
||||
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1847,6 +1898,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1929,9 +1981,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||
expect(choice()).toHaveProperty("checked", false);
|
||||
expect(screen.getByRole("button", { name: START })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2688,10 +2740,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await waitFor(() =>
|
||||
expect(api.getSourceConnection).toHaveBeenCalledExactlyOnceWith(
|
||||
requestId,
|
||||
),
|
||||
expect(api.getSourceConnection).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(api.getSourceConnection).toHaveBeenLastCalledWith(requestId);
|
||||
expect(api.connectSources).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
|
||||
vi.mocked(api.getSourceConnection).mockResolvedValueOnce({
|
||||
@@ -2706,7 +2757,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await continueImport(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(api.connectSources).toHaveBeenCalledOnce();
|
||||
expect(api.getSourceConnection).toHaveBeenCalledTimes(2);
|
||||
expect(api.getSourceConnection).toHaveBeenCalledTimes(3);
|
||||
expect(api.createImportJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2738,10 +2789,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在开始导入…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(controller.refresh).toHaveBeenCalledTimes(2);
|
||||
await act(async () => resolve());
|
||||
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
|
||||
@@ -3084,7 +3134,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
it("lets the user leave after completion cannot be persisted without reconnecting", async () => {
|
||||
const user = userEvent.setup();
|
||||
completeOnboarding.mockRejectedValue(new Error("disk unavailable"));
|
||||
const controller = imports();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
@@ -3093,6 +3143,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
|
||||
@@ -2,8 +2,6 @@ import type {
|
||||
ConnectSourcesResponse,
|
||||
EvolveSchedule,
|
||||
ImportJob,
|
||||
MemoryModePlan,
|
||||
MemoryPipelineMode,
|
||||
OnboardingModeIntent,
|
||||
OnboardingState,
|
||||
SourceResponse,
|
||||
@@ -49,19 +47,17 @@ import {
|
||||
hostCapabilities,
|
||||
hostWriteReason,
|
||||
} from "./host-state";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
} from "./memory-mode-ui";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
import { BLOCKER_COPY } from "./memory-mode-ui";
|
||||
import { OnboardingModelController } from "./OnboardingModelController";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
import {
|
||||
type ImportSourcesController,
|
||||
isDefiniteAccessRefusal,
|
||||
} from "./use-import-sources";
|
||||
import { isDefiniteRefusal } from "./write-outcome";
|
||||
import "./cold-start.css";
|
||||
|
||||
@@ -115,7 +111,7 @@ export function OnboardingFlow({
|
||||
imports: ImportSourcesController;
|
||||
onFinish(destination: OnboardingDestination): void;
|
||||
}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const { text, t, locale } = useOnboardingTranslation();
|
||||
const [sources, setSources] = useState(imports.sources);
|
||||
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
|
||||
if (sourceGeneration.current.value !== imports.sources) {
|
||||
@@ -159,14 +155,7 @@ export function OnboardingFlow({
|
||||
);
|
||||
const modeSaving = modePhase !== "idle";
|
||||
const [modeError, setModeError] = useState<string | null>(null);
|
||||
const [knownMode, setKnownMode] = useState<MemoryPipelineMode>("pro");
|
||||
const [pendingMode, setPendingMode] = useState<OnboardingMemoryMode | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
|
||||
const [modePlanBusy, setModePlanBusy] = useState(false);
|
||||
const modeDialogOpener = useRef<HTMLElement | null>(null);
|
||||
const modePlanOperation = useRef(0);
|
||||
const [knownMode, setKnownMode] = useState<OnboardingMemoryMode>("pro");
|
||||
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
@@ -704,10 +693,6 @@ export function OnboardingFlow({
|
||||
const requestModeChoice = useCallback(
|
||||
(destination: OnboardingDestination) => {
|
||||
if (finished.current) return;
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
if (setupStepRef.current === "model")
|
||||
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
|
||||
else {
|
||||
@@ -724,45 +709,19 @@ export function OnboardingFlow({
|
||||
[leaveModelStep, modeChoice],
|
||||
);
|
||||
|
||||
const requestModeSelection = useCallback(
|
||||
async (mode: OnboardingMemoryMode, opener: HTMLElement) => {
|
||||
if (modePlanBusy || modeSaving || completionBusy) return;
|
||||
const operation = ++modePlanOperation.current;
|
||||
setModePlanBusy(true);
|
||||
setModeError(null);
|
||||
const loadModePlan = useCallback(
|
||||
async (target: OnboardingMemoryMode) => {
|
||||
try {
|
||||
let plan: MemoryModePlan;
|
||||
try {
|
||||
plan = await planMemoryMode(mode);
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) throw reason;
|
||||
const profiles = await getModelProfiles();
|
||||
plan = legacyMemoryModePlan(knownMode, mode, {
|
||||
hasActiveModel: profiles.some((profile) => profile.is_active),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!alive.current ||
|
||||
modePlanOperation.current !== operation ||
|
||||
setupStepRef.current !== "mode"
|
||||
)
|
||||
return;
|
||||
modeDialogOpener.current = opener;
|
||||
setPendingMode(mode);
|
||||
setPendingPlan(plan);
|
||||
} catch {
|
||||
if (
|
||||
alive.current &&
|
||||
modePlanOperation.current === operation &&
|
||||
setupStepRef.current === "mode"
|
||||
)
|
||||
setModeError("模式切换影响暂时无法读取,请重试。");
|
||||
} finally {
|
||||
if (alive.current && modePlanOperation.current === operation)
|
||||
setModePlanBusy(false);
|
||||
return await planMemoryMode(target);
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) throw reason;
|
||||
const profiles = await getModelProfiles();
|
||||
return legacyMemoryModePlan(knownMode, target, {
|
||||
hasActiveModel: profiles.some((profile) => profile.is_active),
|
||||
});
|
||||
}
|
||||
},
|
||||
[completionBusy, knownMode, modePlanBusy, modeSaving],
|
||||
[knownMode],
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
@@ -777,7 +736,7 @@ export function OnboardingFlow({
|
||||
if (modeChoice !== "lite") {
|
||||
const target = modeChoice;
|
||||
const [plan, profiles] = await Promise.all([
|
||||
planMemoryMode(target),
|
||||
loadModePlan(target),
|
||||
getModelProfiles(),
|
||||
]);
|
||||
if (!ownsModeOperation()) return;
|
||||
@@ -856,6 +815,14 @@ export function OnboardingFlow({
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === "lite" &&
|
||||
setupStepRef.current === "mode";
|
||||
const plan = await loadModePlan("lite");
|
||||
if (!liteStillCurrent()) return;
|
||||
if (plan.blockers.length > 0) {
|
||||
setModeError(
|
||||
plan.blockers.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const changed = await queueModeWrite("lite", liteStillCurrent);
|
||||
if (!liteStillCurrent()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
@@ -890,6 +857,7 @@ export function OnboardingFlow({
|
||||
modeSaving,
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
loadModePlan,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
@@ -947,7 +915,7 @@ export function OnboardingFlow({
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
const plan = await planMemoryMode(target);
|
||||
const plan = await loadModePlan(target);
|
||||
if (!ownsConfirmation()) return;
|
||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||
const changed = await queueModeWrite(target, ownsConfirmation);
|
||||
@@ -994,6 +962,7 @@ export function OnboardingFlow({
|
||||
finishOnce,
|
||||
leave,
|
||||
leaveModelStep,
|
||||
loadModePlan,
|
||||
modeDestination,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
@@ -1133,11 +1102,14 @@ export function OnboardingFlow({
|
||||
setAccepted(result);
|
||||
setStartingImport(null);
|
||||
setError(null);
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
if (alive.current) {
|
||||
setStartingImport(null);
|
||||
const refused = isDefiniteAccessRefusal(reason);
|
||||
if (refused) setStartingImport(null);
|
||||
setError(
|
||||
"开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
refused
|
||||
? "本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。"
|
||||
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -1148,6 +1120,8 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
const count = selected.length;
|
||||
const continueWithoutImport =
|
||||
busy === null && locationCheck !== "checking" && count === 0;
|
||||
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
||||
// detection must read 未完成 even when sources are listed — otherwise the
|
||||
// number branch answered first and the summary claimed a count while the
|
||||
@@ -1247,166 +1221,94 @@ export function OnboardingFlow({
|
||||
/>
|
||||
</div>
|
||||
{setupStep === "mode" && (
|
||||
<>
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || completionBusy}
|
||||
onClick={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
setModeError(null);
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "review";
|
||||
setSetupStep("review");
|
||||
void queueProgressWrite(() =>
|
||||
patchOnboardingProgress({
|
||||
mode_intent: modeChoice,
|
||||
setup_step: "review",
|
||||
}),
|
||||
).catch((reason) => {
|
||||
if (
|
||||
!isCompletedProgressConflict(reason) &&
|
||||
alive.current
|
||||
)
|
||||
setModeError(
|
||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving || modePlanBusy}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
completionBusy ||
|
||||
modePlanBusy ||
|
||||
(modeChoice === "ultra" && !scheduleLoaded)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: modeChoice === "ultra"
|
||||
? "继续"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
completionBusy ||
|
||||
(modeChoice === "ultra" && !scheduleLoaded)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<p id="onboarding-memory-mode-title">
|
||||
{text("之后可在设置中更改。")}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
aria-label={text("记忆模式")}
|
||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
mode,
|
||||
title: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
}))}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || completionBusy || modePlanBusy}
|
||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
||||
onChange={(mode, control) =>
|
||||
void requestModeSelection(
|
||||
mode as OnboardingMemoryMode,
|
||||
control,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{modeChoice === "ultra" && scheduleLoaded ? (
|
||||
<section
|
||||
className="r6-evolve-settings onboarding-r6-evolve-settings"
|
||||
aria-label={text("记忆整合(Evolve)")}
|
||||
>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={ultraSchedule}
|
||||
disabled={modeSaving || !scheduleLoaded}
|
||||
onEnabledChange={(enabled) =>
|
||||
setUltraSchedule((current) => ({
|
||||
...current,
|
||||
enabled,
|
||||
}))
|
||||
}
|
||||
onTimeChange={async (time) => {
|
||||
setUltraSchedule((current) => ({ ...current, time }));
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{modeChoice === "ultra" && scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p role="alert">
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
{text(
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
{pendingMode && pendingPlan ? (
|
||||
<MemoryModeConfirmDialog
|
||||
mode={pendingMode}
|
||||
plan={pendingPlan}
|
||||
busy={modePlanBusy}
|
||||
opener={modeDialogOpener.current}
|
||||
allowedBlockers={["active_model_required"]}
|
||||
onCancel={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
</R6Button>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-label={text("记忆模式")}
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<h2>
|
||||
{locale === "zh-Hans"
|
||||
? "Choose how MemHub remembers"
|
||||
: text("你希望 MemHub 如何形成记忆?")}
|
||||
</h2>
|
||||
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||
</div>
|
||||
<MemoryModeCards
|
||||
aria-label={text("记忆模式")}
|
||||
value={modeChoice}
|
||||
disabled={modeSaving || completionBusy}
|
||||
onChange={(mode) => {
|
||||
modeChoiceTouched.current = true;
|
||||
intentRef.current = pendingMode;
|
||||
setModeChoice(pendingMode);
|
||||
intentRef.current = mode;
|
||||
setModeChoice(mode);
|
||||
setModeError(null);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
}}
|
||||
ultraDetail={
|
||||
scheduleLoaded ? (
|
||||
<div className="onboarding-r6-ultra-default">
|
||||
<strong>{text("每天自动整合")}</strong>
|
||||
<small>
|
||||
{text(
|
||||
ultraSchedule.enabled
|
||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
).replace("{time}", ultraSchedule.time)}
|
||||
</small>
|
||||
</div>
|
||||
) : scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p role="alert">
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{text("正在读取自动整合设置…")}</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
)}
|
||||
{visitedModel && (
|
||||
<div hidden={setupStep !== "model"}>
|
||||
@@ -1502,16 +1404,8 @@ export function OnboardingFlow({
|
||||
<R6ColdStartFrame
|
||||
step={1}
|
||||
title={text("把熟悉的背景,带到这里")}
|
||||
eyebrow={text("你的记忆,从这里接上")}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={locked}
|
||||
onClick={() => requestModeChoice("sources")}
|
||||
>
|
||||
{text("稍后添加,先进入")}
|
||||
</R6Button>
|
||||
{error && (
|
||||
<p className="onboarding-r6-error" role="alert">
|
||||
{error && text(error)}
|
||||
@@ -1520,10 +1414,20 @@ export function OnboardingFlow({
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
disabled={locked || count === 0}
|
||||
onClick={() => void startImport()}
|
||||
disabled={locked}
|
||||
onClick={() =>
|
||||
continueWithoutImport
|
||||
? requestModeChoice("sources")
|
||||
: void startImport()
|
||||
}
|
||||
>
|
||||
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
|
||||
{text(
|
||||
continueWithoutImport
|
||||
? "选择记忆模式"
|
||||
: busy === "submitting"
|
||||
? "正在开始导入…"
|
||||
: "开始导入",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
@@ -1533,12 +1437,6 @@ export function OnboardingFlow({
|
||||
className="onboarding-r6-apps"
|
||||
aria-labelledby="cold-start-phase"
|
||||
>
|
||||
<div className="onboarding-r6-intro">
|
||||
<p>
|
||||
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
|
||||
</p>
|
||||
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
|
||||
</div>
|
||||
<div className="onboarding-r6-list-heading">
|
||||
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
||||
{text("本机 AI 应用")}
|
||||
|
||||
@@ -48,6 +48,40 @@ it("sizes the import action to its label and wraps footer content", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses content-driven shell, discovery and mode geometry", () => {
|
||||
const stage = declaration("onboarding-r6.css", ".onboarding-r6-stage");
|
||||
const shell = declaration("onboarding-r6.css", ".onboarding-r6-shell");
|
||||
const discovery = declaration(
|
||||
"onboarding-r6.css",
|
||||
".onboarding-r6-discovery",
|
||||
);
|
||||
const heading = declaration(
|
||||
"onboarding-r6.css",
|
||||
".onboarding-r6-list-heading",
|
||||
);
|
||||
const mode = declaration("onboarding-r6.css", ".onboarding-r6-mode");
|
||||
|
||||
expect(stage).toMatch(/min-height:\s*0/);
|
||||
expect(stage).not.toMatch(/min-height:\s*852px/);
|
||||
expect(shell).toMatch(/height:\s*100%/);
|
||||
expect(shell).not.toMatch(/(?:^|;)\s*height:\s*820px/);
|
||||
expect(discovery).toMatch(/height:\s*100%/);
|
||||
expect(heading).toMatch(/position:\s*static/);
|
||||
expect(mode).toMatch(/width:\s*100%/);
|
||||
});
|
||||
|
||||
it("does not reserve an empty eyebrow row", () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<R6ColdStartFrame step={1} title="Fixture" footer={null}>
|
||||
<p>Fixture body</p>
|
||||
</R6ColdStartFrame>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Fixture" })).toBeTruthy();
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
|
||||
const card = declaration(
|
||||
"r6-import-progress.css",
|
||||
@@ -62,10 +96,10 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
|
||||
),
|
||||
).toMatch(/flex:\s*0 0 auto/);
|
||||
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
||||
/height:\s*auto/,
|
||||
/height:\s*100%/,
|
||||
);
|
||||
expect(
|
||||
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
|
||||
declaration("first-import-r6.css", ".first-import-r6-feedback"),
|
||||
).not.toMatch(/max-height|overflow/);
|
||||
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
||||
/overflow-y:\s*auto/,
|
||||
|
||||
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
|
||||
}: {
|
||||
step: 1 | 2 | 3;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
eyebrow?: string | null;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
focusToken?: number;
|
||||
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
|
||||
<section className="onboarding-r6-shell" data-step={step}>
|
||||
<header className="onboarding-r6-head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
{eyebrow ? <p>{eyebrow}</p> : null}
|
||||
<h1
|
||||
ref={titleRef}
|
||||
tabIndex={focusToken === undefined ? undefined : -1}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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-choice-list",
|
||||
".r6-memory-mode-settings .memory-mode-cards",
|
||||
".r6-settings .connection-fallback .custom-harness-name",
|
||||
]) {
|
||||
const rule = declaration("r6-settings.css", selector);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
MemoryGraphEdge,
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRawClusterLayout } from "./RawConversationGraph3D";
|
||||
|
||||
function makeRawGraph(count: number, sourceCount = 4): MemoryGraphResponse {
|
||||
const sources: MemoryGraphNode[] = Array.from(
|
||||
{ length: sourceCount },
|
||||
(_, index) => ({
|
||||
node_id: `source:s-${index}`,
|
||||
node_type: "source",
|
||||
memory_id: null,
|
||||
source_id: `s-${index}`,
|
||||
label: `Source ${index}`,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
}),
|
||||
);
|
||||
const memories: MemoryGraphNode[] = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => ({
|
||||
node_id: `memory:m-${index}`,
|
||||
node_type: "memory",
|
||||
memory_id: `m-${index}`,
|
||||
source_id: null,
|
||||
label: `Turn ${index}`,
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
confidence: null,
|
||||
}),
|
||||
);
|
||||
const edges: MemoryGraphEdge[] = memories.map((memory, index) => ({
|
||||
edge_id: `edge-${index}`,
|
||||
source_node_id: sources[index % sourceCount].node_id,
|
||||
target_node_id: memory.node_id,
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
weight: null,
|
||||
}));
|
||||
return {
|
||||
total_memories: count,
|
||||
projected_memories: count,
|
||||
hy_graph_available: false,
|
||||
nodes: [...sources, ...memories],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRawClusterLayout", () => {
|
||||
it.each([1_600, 5_000])(
|
||||
"positions every node in bounded linear time at %i turns",
|
||||
(count) => {
|
||||
const graph = makeRawGraph(count);
|
||||
const started = performance.now();
|
||||
const positions = createRawClusterLayout(graph);
|
||||
const elapsed = performance.now() - started;
|
||||
|
||||
expect(positions.size).toBe(graph.nodes.length);
|
||||
expect(elapsed).toBeLessThan(1_000);
|
||||
for (const position of positions.values()) {
|
||||
expect(Number.isFinite(position.x)).toBe(true);
|
||||
expect(Number.isFinite(position.y)).toBe(true);
|
||||
expect(Number.isFinite(position.z)).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("is deterministic", () => {
|
||||
const graph = makeRawGraph(80);
|
||||
const first = createRawClusterLayout(graph);
|
||||
const second = createRawClusterLayout(graph);
|
||||
expect([...first].map(([id, value]) => [id, value.toArray()])).toEqual(
|
||||
[...second].map(([id, value]) => [id, value.toArray()]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import type {
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { Focus, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { useMemoryLanguage } from "./memory-language";
|
||||
|
||||
const GRAPH_CANVAS = 0xedf1f5;
|
||||
const MEMORY_COLOR = new THREE.Color(0x4e6478);
|
||||
const SELECTED_COLOR = new THREE.Color(0x294355);
|
||||
|
||||
/** O(n) deterministic source clusters for the complete lite raw map. */
|
||||
export function createRawClusterLayout(
|
||||
graph: MemoryGraphResponse,
|
||||
): Map<string, THREE.Vector3> {
|
||||
const positions = new Map<string, THREE.Vector3>();
|
||||
const sources = graph.nodes.filter((node) => node.node_type === "source");
|
||||
const sourceIds = new Set(sources.map((node) => node.node_id));
|
||||
const sourceByMemory = new Map<string, string>();
|
||||
for (const edge of graph.edges) {
|
||||
const sourceId = sourceIds.has(edge.source_node_id)
|
||||
? edge.source_node_id
|
||||
: sourceIds.has(edge.target_node_id)
|
||||
? edge.target_node_id
|
||||
: null;
|
||||
if (!sourceId) continue;
|
||||
const memoryId =
|
||||
edge.source_node_id === sourceId
|
||||
? edge.target_node_id
|
||||
: edge.source_node_id;
|
||||
sourceByMemory.set(memoryId, sourceId);
|
||||
}
|
||||
const fallbackSource = sources[0]?.node_id ?? "source:unknown";
|
||||
const groups = new Map<string, MemoryGraphNode[]>();
|
||||
for (const node of graph.nodes) {
|
||||
if (node.node_type !== "memory") continue;
|
||||
const sourceId = sourceByMemory.get(node.node_id) ?? fallbackSource;
|
||||
const group = groups.get(sourceId) ?? [];
|
||||
group.push(node);
|
||||
groups.set(sourceId, group);
|
||||
}
|
||||
const clusterRing = Math.max(3.5, sources.length * 1.2);
|
||||
sources.forEach((source, sourceIndex) => {
|
||||
const angle = (sourceIndex / Math.max(1, sources.length)) * Math.PI * 2;
|
||||
const center = new THREE.Vector3(
|
||||
Math.cos(angle) * clusterRing,
|
||||
((sourceIndex % 3) - 1) * 1.4,
|
||||
Math.sin(angle) * clusterRing,
|
||||
);
|
||||
positions.set(source.node_id, center);
|
||||
const memories = groups.get(source.node_id) ?? [];
|
||||
memories.forEach((memory, index) => {
|
||||
const direction = seededDirection(memory.node_id);
|
||||
const radius = 0.9 + Math.cbrt(index + 1) * 0.42;
|
||||
positions.set(
|
||||
memory.node_id,
|
||||
center.clone().addScaledVector(direction, radius),
|
||||
);
|
||||
});
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function RawConversationGraph3D({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: {
|
||||
graph: MemoryGraphResponse;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const selectRef = useRef(onSelectNode);
|
||||
const selectedRef = useRef(selectedNodeId);
|
||||
const graphRef = useRef(graph);
|
||||
const applySelection = useRef<(() => void) | null>(null);
|
||||
const actions = useRef<{
|
||||
zoom(scale: number): void;
|
||||
reset(): void;
|
||||
} | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [hovered, setHovered] = useState<{
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
selectRef.current = onSelectNode;
|
||||
graphRef.current = graph;
|
||||
const identity = useMemo(
|
||||
() =>
|
||||
`${graph.nodes
|
||||
.map((node) => `${node.node_id}\0${node.label}`)
|
||||
.join("\n")}\n${graph.edges
|
||||
.map(
|
||||
(edge) =>
|
||||
`${edge.edge_id}\0${edge.source_node_id}\0${edge.target_node_id}`,
|
||||
)
|
||||
.join("\n")}`,
|
||||
[graph],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedNodeId;
|
||||
applySelection.current?.();
|
||||
}, [selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void identity;
|
||||
const graph = graphRef.current;
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
setAvailable(true);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
element.appendChild(renderer.domElement);
|
||||
renderer.domElement.setAttribute("aria-hidden", "true");
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(GRAPH_CANVAS, 0.025);
|
||||
scene.add(new THREE.AmbientLight(0xf2f6fa, 1.8));
|
||||
const directional = new THREE.DirectionalLight(0xe3edf5, 2);
|
||||
directional.position.set(8, 10, 12);
|
||||
scene.add(directional);
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 240);
|
||||
const defaultDistance = Math.max(13, Math.sqrt(graph.nodes.length) * 0.55);
|
||||
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = false;
|
||||
controls.minDistance = 4;
|
||||
controls.maxDistance = 140;
|
||||
|
||||
const positions = createRawClusterLayout(graph);
|
||||
const nodeById = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const memories = graph.nodes.filter((node) => node.node_type === "memory");
|
||||
const sources = graph.nodes.filter((node) => node.node_type === "source");
|
||||
const memoryGeometry = new THREE.SphereGeometry(0.14, 10, 8);
|
||||
const memoryMaterial = new THREE.MeshStandardMaterial({
|
||||
color: MEMORY_COLOR,
|
||||
roughness: 0.78,
|
||||
metalness: 0.04,
|
||||
vertexColors: true,
|
||||
});
|
||||
const instances = new THREE.InstancedMesh(
|
||||
memoryGeometry,
|
||||
memoryMaterial,
|
||||
Math.max(1, memories.length),
|
||||
);
|
||||
instances.count = memories.length;
|
||||
const matrix = new THREE.Matrix4();
|
||||
memories.forEach((node, index) => {
|
||||
matrix.setPosition(positions.get(node.node_id) ?? new THREE.Vector3());
|
||||
instances.setMatrixAt(index, matrix);
|
||||
instances.setColorAt(index, MEMORY_COLOR);
|
||||
});
|
||||
instances.instanceMatrix.needsUpdate = true;
|
||||
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
|
||||
scene.add(instances);
|
||||
|
||||
const sourceGeometry = new THREE.SphereGeometry(0.3, 16, 12);
|
||||
const sourceMeshes = sources.map((node) => {
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0x2f6b57,
|
||||
roughness: 0.65,
|
||||
});
|
||||
const mesh = new THREE.Mesh(sourceGeometry, material);
|
||||
mesh.position.copy(positions.get(node.node_id) ?? new THREE.Vector3());
|
||||
mesh.userData.nodeId = node.node_id;
|
||||
scene.add(mesh);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
const edgePositions = new Float32Array(graph.edges.length * 6);
|
||||
graph.edges.forEach((edge, index) => {
|
||||
const start = positions.get(edge.source_node_id);
|
||||
const end = positions.get(edge.target_node_id);
|
||||
if (!start || !end) return;
|
||||
edgePositions.set(
|
||||
[start.x, start.y, start.z, end.x, end.y, end.z],
|
||||
index * 6,
|
||||
);
|
||||
});
|
||||
const edgeGeometry = new THREE.BufferGeometry();
|
||||
edgeGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(edgePositions, 3),
|
||||
);
|
||||
const edgeMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x91a99c,
|
||||
transparent: true,
|
||||
opacity: 0.24,
|
||||
});
|
||||
scene.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));
|
||||
|
||||
const render = () => renderer.render(scene, camera);
|
||||
const reset = () => {
|
||||
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
render();
|
||||
};
|
||||
actions.current = {
|
||||
zoom(scale) {
|
||||
const offset = camera.position.clone().sub(controls.target);
|
||||
offset.setLength(
|
||||
Math.max(
|
||||
controls.minDistance,
|
||||
Math.min(controls.maxDistance, offset.length() * scale),
|
||||
),
|
||||
);
|
||||
camera.position.copy(controls.target).add(offset);
|
||||
controls.update();
|
||||
render();
|
||||
},
|
||||
reset,
|
||||
};
|
||||
applySelection.current = () => {
|
||||
memories.forEach((node, index) => {
|
||||
instances.setColorAt(
|
||||
index,
|
||||
node.node_id === selectedRef.current ? SELECTED_COLOR : MEMORY_COLOR,
|
||||
);
|
||||
});
|
||||
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
|
||||
for (const mesh of sourceMeshes) {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
material.color.setHex(
|
||||
mesh.userData.nodeId === selectedRef.current ? 0x294355 : 0x2f6b57,
|
||||
);
|
||||
}
|
||||
render();
|
||||
};
|
||||
applySelection.current();
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(1, element.clientWidth);
|
||||
const height = Math.max(1, element.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
render();
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
controls.addEventListener("change", render);
|
||||
resize();
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
let lastRaycast = 0;
|
||||
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
|
||||
null;
|
||||
const hit = (event: PointerEvent) => {
|
||||
const bounds = renderer.domElement.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const match = raycaster.intersectObjects(
|
||||
[instances, ...sourceMeshes],
|
||||
false,
|
||||
)[0];
|
||||
if (!match) return null;
|
||||
if (match.object === instances && match.instanceId !== undefined)
|
||||
return memories[match.instanceId] ?? null;
|
||||
return nodeById.get(String(match.object.userData.nodeId)) ?? null;
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (pressed)
|
||||
pressed.moved ||=
|
||||
Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 5;
|
||||
const now = performance.now();
|
||||
if (now - lastRaycast < 50 || pressed) return;
|
||||
lastRaycast = now;
|
||||
const node = hit(event);
|
||||
renderer.domElement.style.cursor = node ? "pointer" : "grab";
|
||||
setHovered(
|
||||
node
|
||||
? {
|
||||
label: node.label,
|
||||
x: event.clientX - element.getBoundingClientRect().left,
|
||||
y: event.clientY - element.getBoundingClientRect().top,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
};
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
pressed =
|
||||
event.button === 0
|
||||
? {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id: event.pointerId,
|
||||
moved: false,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const select = (event: PointerEvent) => {
|
||||
const start = pressed;
|
||||
pressed = null;
|
||||
if (!start || start.id !== event.pointerId || start.moved) return;
|
||||
const node = hit(event);
|
||||
if (node) selectRef.current(node);
|
||||
};
|
||||
const leave = () => {
|
||||
pressed = null;
|
||||
setHovered(null);
|
||||
};
|
||||
renderer.domElement.addEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.addEventListener("pointermove", move);
|
||||
renderer.domElement.addEventListener("pointerup", select);
|
||||
renderer.domElement.addEventListener("pointerleave", leave);
|
||||
|
||||
return () => {
|
||||
applySelection.current = null;
|
||||
actions.current = null;
|
||||
observer.disconnect();
|
||||
controls.removeEventListener("change", render);
|
||||
controls.dispose();
|
||||
renderer.domElement.removeEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.removeEventListener("pointermove", move);
|
||||
renderer.domElement.removeEventListener("pointerup", select);
|
||||
renderer.domElement.removeEventListener("pointerleave", leave);
|
||||
memoryGeometry.dispose();
|
||||
memoryMaterial.dispose();
|
||||
sourceGeometry.dispose();
|
||||
for (const mesh of sourceMeshes)
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
edgeGeometry.dispose();
|
||||
edgeMaterial.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, [identity]);
|
||||
|
||||
return (
|
||||
<div className="memory-graph-canvas" ref={host}>
|
||||
{!available ? (
|
||||
<div className="memory-graph-fallback">
|
||||
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="memory-graph-controls"
|
||||
role="toolbar"
|
||||
aria-label={text("图谱工具")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("放大图谱")}
|
||||
onClick={() => actions.current?.zoom(0.8)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("缩小图谱")}
|
||||
onClick={() => actions.current?.zoom(1.25)}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("重置图谱视角")}
|
||||
onClick={() => actions.current?.reset()}
|
||||
>
|
||||
<Focus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hovered ? (
|
||||
<div
|
||||
className="memory-graph-tooltip"
|
||||
style={{ left: hovered.x + 12, top: Math.max(0, hovered.y - 40) }}
|
||||
>
|
||||
{hovered.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function seededDirection(value: string): THREE.Vector3 {
|
||||
const u = seededUnit(value, 0);
|
||||
const v = seededUnit(value, 1);
|
||||
const theta = u * Math.PI * 2;
|
||||
const z = v * 2 - 1;
|
||||
const radius = Math.sqrt(Math.max(0, 1 - z * z));
|
||||
return new THREE.Vector3(
|
||||
Math.cos(theta) * radius,
|
||||
z,
|
||||
Math.sin(theta) * radius,
|
||||
);
|
||||
}
|
||||
|
||||
function seededUnit(value: string, salt: number): number {
|
||||
let hash = 2166136261 + salt;
|
||||
for (const character of value) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 4294967295;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ it("shows every role and preserves a long original message in the detail pane",
|
||||
messages: [
|
||||
{ role: "user", content: long },
|
||||
{ role: "assistant", content: "已收到" },
|
||||
{ role: "tool", content: "3 tests passed" },
|
||||
{ role: "unknown", content: "来源角色缺失" },
|
||||
],
|
||||
}}
|
||||
sources={[]}
|
||||
@@ -38,6 +40,10 @@ it("shows every role and preserves a long original message in the detail pane",
|
||||
|
||||
expect(screen.getByText("你")).toBeTruthy();
|
||||
expect(screen.getByText("助手")).toBeTruthy();
|
||||
expect(screen.getByText("工具")).toBeTruthy();
|
||||
expect(screen.getByText("作者待核对")).toBeTruthy();
|
||||
expect(screen.getByText(long)).toHaveProperty("textContent", long);
|
||||
expect(screen.getByText("已收到")).toBeTruthy();
|
||||
expect(screen.queryByText("原始对话")).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "原始对话" })).toBeTruthy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
|
||||
if (role === "user") return t("selectedMemory.author.user");
|
||||
if (role === "assistant" || role === "agent")
|
||||
return t("selectedMemory.author.agent");
|
||||
if (role === "tool") return t("selectedMemory.author.tool");
|
||||
return t("selectedMemory.author.unknown");
|
||||
}
|
||||
|
||||
@@ -46,8 +47,7 @@ export function SelectedRawDetail({
|
||||
record.source || t("selectedMemory.sourceUnknown"),
|
||||
);
|
||||
return (
|
||||
<article className="map-dialogue-reading">
|
||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
||||
<article className="map-dialogue-reading" aria-label={t("memory:原始对话")}>
|
||||
<ul className="map-transcript">
|
||||
{messages.map((message) => (
|
||||
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { getSdkRaw, searchSdkRaw } from "./api";
|
||||
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -59,3 +59,36 @@ it("keeps the application filter when searching raw conversations", async () =>
|
||||
{ signal, timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("pages the independent lightweight raw graph", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
has_more: true,
|
||||
snapshot_id: "snapshot-1",
|
||||
},
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await getSdkRawGraph({
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshotId: "snapshot-1",
|
||||
signal,
|
||||
});
|
||||
|
||||
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
|
||||
params: {
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshot_id: "snapshot-1",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
type SdkMemoryStatus,
|
||||
type SdkMemoryVariant,
|
||||
type SdkRawContextItem,
|
||||
type SdkRawGraphPage,
|
||||
type SdkRawPage,
|
||||
type SourceDirectoryCandidate,
|
||||
type SourceImportReceipt,
|
||||
@@ -1015,6 +1016,26 @@ export async function getSdkRaw({
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSdkRawGraph({
|
||||
source,
|
||||
offset = 0,
|
||||
limit = 500,
|
||||
snapshotId,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
snapshotId?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawGraphPage> {
|
||||
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
|
||||
params: { source, offset, limit, snapshot_id: snapshotId },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function searchSdkRaw(
|
||||
query: string,
|
||||
options: { source?: string; signal?: AbortSignal } = {},
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
min-height: 129px;
|
||||
}
|
||||
.r6-home-metrics > div {
|
||||
display: grid;
|
||||
grid-template-rows: 20px 72px minmax(20px, auto);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
padding: 4px 24px;
|
||||
border-right: 1px solid #d5dfe8;
|
||||
@@ -89,12 +92,16 @@
|
||||
.r6-home-metrics p,
|
||||
.r6-home-metrics small {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #63707b;
|
||||
margin: 0;
|
||||
}
|
||||
.r6-home-metrics small {
|
||||
display: block;
|
||||
}
|
||||
.r6-home-figure {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -102,12 +109,10 @@
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding-bottom: 10px;
|
||||
color: #405e75;
|
||||
}
|
||||
/* Holds the digit slot while the first summary read is in flight, so the 48px
|
||||
type does not spend that frame rendering a word. Height matches the strong
|
||||
below (48px x 1.3). */
|
||||
type does not spend that frame rendering a word. */
|
||||
.r6-home-figure-pending {
|
||||
display: block;
|
||||
width: 92px;
|
||||
@@ -134,22 +139,22 @@
|
||||
animation: none;
|
||||
}
|
||||
.r6-home-metrics strong {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
line-height: 1;
|
||||
color: #405e75;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.r6-home-metrics strong.r6-home-word {
|
||||
font-size: 32px;
|
||||
padding: 14px 0;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-home-metrics strong.r6-home-time {
|
||||
font-size: 34px;
|
||||
line-height: 1.85;
|
||||
padding: 8px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.r6-home-activity {
|
||||
margin-top: auto;
|
||||
@@ -281,7 +286,10 @@
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-home-activity small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-home-runtime {
|
||||
|
||||
@@ -791,6 +791,44 @@ button.rail-button.active::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-pagination:has(.map-load-more) {
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
border-top: 0;
|
||||
}
|
||||
.map-load-more {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 50%;
|
||||
color: var(--tx3);
|
||||
background: var(--surface);
|
||||
transition:
|
||||
color var(--motion-fast) var(--motion-enter),
|
||||
background-color var(--motion-fast) var(--motion-enter),
|
||||
transform var(--motion-fast) var(--motion-enter);
|
||||
}
|
||||
.map-load-more:hover:not(:disabled) {
|
||||
color: var(--ac-strong);
|
||||
background: var(--ac-bg);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.map-load-more:focus-visible {
|
||||
outline: 2px solid var(--ac);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.map-load-more svg.loading {
|
||||
animation: map-load-more-pulse 800ms ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes map-load-more-pulse {
|
||||
to {
|
||||
transform: translateY(4px);
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
@@ -829,6 +867,16 @@ button.rail-button.active::before {
|
||||
.map-memory-list > li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.map-memory-list.raw > li:not(.map-memory-spacer) {
|
||||
height: 112px;
|
||||
}
|
||||
.map-memory-list.raw .map-memory-item {
|
||||
height: 112px;
|
||||
}
|
||||
.map-memory-list > .map-memory-spacer {
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-memory-item {
|
||||
display: grid;
|
||||
grid-template-columns: 5px minmax(0, 1fr);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
|
||||
.first-import-r6-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
}
|
||||
@@ -13,19 +13,23 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.first-import-r6-intro {
|
||||
margin: 0;
|
||||
color: #5a6169;
|
||||
font-size: 13px;
|
||||
line-height: 25px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.first-import-r6-subintro {
|
||||
margin: 1px 0 20px;
|
||||
.first-import-r6-intro h2 {
|
||||
margin: 0 0 3px;
|
||||
color: #262a2e;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
.first-import-r6-intro p {
|
||||
margin: 0;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.first-import-r6-history-window {
|
||||
margin: -10px 0 12px;
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ead7a7;
|
||||
border-radius: 8px;
|
||||
@@ -36,10 +40,105 @@
|
||||
}
|
||||
.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"] {
|
||||
width: min(680px, 100%);
|
||||
grid-auto-columns: 100%;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="2"] {
|
||||
width: min(720px, 100%);
|
||||
grid-auto-columns: calc((100% - 16px) / 2);
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
|
||||
min-height: 112px;
|
||||
padding: 12px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
align-content: center;
|
||||
column-gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 4;
|
||||
margin: 0;
|
||||
}
|
||||
.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-main > .r6-import-progress[data-count="many"] {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: unset;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(4)):not(
|
||||
:has(> :nth-child(5))
|
||||
) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(5)):not(
|
||||
:has(> :nth-child(6))
|
||||
) {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(6)):not(
|
||||
:has(> :nth-child(7))
|
||||
) {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
|
||||
.r6-import-progress-card {
|
||||
min-height: 112px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
|
||||
.r6-import-progress-orbit {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
|
||||
:is(h2, p, small) {
|
||||
grid-column: 1;
|
||||
align-self: auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.first-import-r6-main
|
||||
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
|
||||
.r6-import-progress-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.first-import-r6-feed {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 26px;
|
||||
margin-top: 20px;
|
||||
overflow: visible;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
@@ -87,9 +186,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
min-height: 132px;
|
||||
padding: 22px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
|
||||
align-items: center;
|
||||
gap: 0 28px;
|
||||
border: 1px solid #ffffffe0;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
@@ -98,9 +202,13 @@
|
||||
box-shadow:
|
||||
inset 0 1px #fff,
|
||||
0 12px 32px rgb(49 75 101 / 4%);
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
align-self: stretch;
|
||||
}
|
||||
.first-import-r6-options-head {
|
||||
min-width: 0;
|
||||
}
|
||||
.first-import-r6-eyebrow {
|
||||
margin: 0 0 14px;
|
||||
@@ -127,12 +235,12 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 124px;
|
||||
margin: 0 0 16px;
|
||||
padding: 18px 16px 12px;
|
||||
min-height: 104px;
|
||||
margin: 0;
|
||||
padding: 18px 52px 16px 18px;
|
||||
border: 1px solid rgb(179 196 209 / 42%);
|
||||
border-radius: 0;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 34%);
|
||||
box-shadow: inset 0 1px 1px #ffffffd9;
|
||||
backdrop-filter: blur(20px);
|
||||
cursor: pointer;
|
||||
@@ -161,8 +269,8 @@
|
||||
.first-import-r6-selection input {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 18px;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
@@ -200,10 +308,11 @@
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-top: 8px;
|
||||
margin-top: 7px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.first-import-r6-options-note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
margin: 0 0 12px;
|
||||
@@ -223,20 +332,22 @@
|
||||
.first-import-r6-options details p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.first-import-r6-footer-note {
|
||||
flex: 1 1 240px;
|
||||
min-width: 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 p {
|
||||
.first-import-r6-feedback p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #63707b;
|
||||
}
|
||||
.first-import-r6-footer-note [role="alert"] {
|
||||
.first-import-r6-feedback [role="alert"] {
|
||||
color: #944242;
|
||||
}
|
||||
.first-import-r6-footer-note .first-import-r6-partial-success {
|
||||
.first-import-r6-feedback .first-import-r6-partial-success {
|
||||
color: #245f50;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -248,6 +359,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.first-import-r6-footer-actions > [data-variant="primary"] {
|
||||
min-width: min(160px, 100%);
|
||||
@@ -397,8 +509,7 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
@media (max-width: 1150px) {
|
||||
.first-import-r6-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 278px;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
@@ -408,12 +519,12 @@ dialog.first-import-r6-original[open] {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.first-import-r6-layout {
|
||||
height: auto;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 859px) {
|
||||
.first-import-r6-options {
|
||||
padding: 14px;
|
||||
padding: 18px 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-eyebrow {
|
||||
@@ -431,16 +542,16 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
.first-import-r6-selection,
|
||||
.first-import-r6-selection + .first-import-r6-selection {
|
||||
min-height: 86px;
|
||||
margin-bottom: 8px;
|
||||
padding: 9px 12px;
|
||||
min-height: 96px;
|
||||
margin-bottom: 0;
|
||||
padding: 15px 48px 13px 16px;
|
||||
}
|
||||
.first-import-r6-selection strong {
|
||||
line-height: 24px;
|
||||
}
|
||||
.first-import-r6-selection input {
|
||||
top: 13px;
|
||||
right: 12px;
|
||||
top: 17px;
|
||||
right: 16px;
|
||||
}
|
||||
.first-import-r6-selection span {
|
||||
margin-top: 4px;
|
||||
@@ -461,6 +572,7 @@ dialog.first-import-r6-original[open] {
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-footer-note {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
.memory-mode-cards {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.memory-mode-card {
|
||||
min-width: 0;
|
||||
min-height: 286px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c7d3dc;
|
||||
border-radius: 15px;
|
||||
background: rgb(255 255 255 / 66%);
|
||||
color: #304757;
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 82%);
|
||||
transition:
|
||||
border-color 140ms ease-out,
|
||||
background-color 140ms ease-out,
|
||||
box-shadow 140ms ease-out,
|
||||
transform 140ms ease-out;
|
||||
}
|
||||
|
||||
.memory-mode-card:has(.memory-mode-card-choice:hover):not(
|
||||
[data-disabled="true"]
|
||||
) {
|
||||
border-color: #91a7b6;
|
||||
background: rgb(255 255 255 / 82%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.memory-mode-card[data-selected="true"] {
|
||||
border: 2px solid #6f9183;
|
||||
background: #fbfdfc;
|
||||
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
|
||||
}
|
||||
|
||||
.memory-mode-card[data-disabled="true"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.memory-mode-card-choice {
|
||||
min-height: 284px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-mode-card[data-disabled="true"] .memory-mode-card-choice {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.memory-mode-card-choice > input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.memory-mode-card:has(input:focus-visible) {
|
||||
outline: 2px solid var(--r6-focus, #557c93);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.memory-mode-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-mode-index {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ok, #2f6b57);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.memory-mode-title strong {
|
||||
min-width: 0;
|
||||
color: #263947;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.memory-mode-tier {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ok, #2f6b57);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-mode-current {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
color: #526a60;
|
||||
background: #edf4f0;
|
||||
font-size: 10px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.memory-mode-radio {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
margin-left: auto;
|
||||
flex: 0 0 21px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #8da0ae;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 82%);
|
||||
}
|
||||
|
||||
.memory-mode-card[data-selected="true"] .memory-mode-radio {
|
||||
border-color: var(--ok, #2f6b57);
|
||||
background: var(--ok, #2f6b57);
|
||||
}
|
||||
|
||||
.memory-mode-radio svg {
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.memory-mode-description {
|
||||
min-height: 50px;
|
||||
margin-top: 10px;
|
||||
color: #526674;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.memory-mode-features {
|
||||
margin-top: auto;
|
||||
padding: 16px 0 2px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #dbe4ea;
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.memory-mode-features > span {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-mode-features i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-top: 7px;
|
||||
flex: 0 0 5px;
|
||||
border-radius: 50%;
|
||||
background: #7f95a4;
|
||||
}
|
||||
|
||||
.memory-mode-detail {
|
||||
margin-top: auto;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid #d4e0e7;
|
||||
background: #f1f6f8;
|
||||
color: #405867;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
animation: memory-mode-detail-in 180ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes memory-mode-detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 681px) and (max-height: 959px) {
|
||||
.memory-mode-cards {
|
||||
gap: 14px;
|
||||
}
|
||||
.memory-mode-card {
|
||||
min-height: 194px;
|
||||
}
|
||||
.memory-mode-card-choice {
|
||||
min-height: 194px;
|
||||
padding: 16px;
|
||||
}
|
||||
.memory-mode-title strong {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.memory-mode-description {
|
||||
min-height: 36px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.memory-mode-features {
|
||||
padding-top: 10px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.memory-mode-detail {
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.memory-mode-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.memory-mode-card,
|
||||
.memory-mode-card-choice {
|
||||
min-height: 0;
|
||||
}
|
||||
.memory-mode-description {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.memory-mode-card,
|
||||
.memory-mode-detail {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -594,7 +594,7 @@
|
||||
}
|
||||
.onboarding-model-layout {
|
||||
display: block;
|
||||
width: min(760px, 100%);
|
||||
width: min(880px, 100%);
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -603,11 +603,13 @@
|
||||
}
|
||||
.onboarding-model-group {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 20px;
|
||||
padding: 24px 26px;
|
||||
border: 1px solid #d8e2e8;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
background: rgb(255 255 255 / 72%);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 88%);
|
||||
}
|
||||
.onboarding-model-provider,
|
||||
.onboarding-model-key {
|
||||
@@ -639,6 +641,7 @@
|
||||
line-height: 19px;
|
||||
}
|
||||
.onboarding-model-default-summary {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 0;
|
||||
@@ -656,10 +659,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.onboarding-model-disclosure {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.generation-technical-disclosure {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid #e4eaee;
|
||||
padding-top: 4px;
|
||||
}
|
||||
@@ -750,8 +755,27 @@
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.onboarding-model-r6 .onboarding-r6-body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.onboarding-model-layout,
|
||||
.onboarding-model-form {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-model-form {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.onboarding-model-group {
|
||||
gap: 14px 18px;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.onboarding-model-layout--single-column-600 .onboarding-model-group {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -115,14 +115,16 @@
|
||||
}
|
||||
.onboarding-r6-stage {
|
||||
flex: 1;
|
||||
min-height: 852px;
|
||||
padding: 32px 24px 2px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
}
|
||||
.onboarding-r6-shell {
|
||||
min-height: 0;
|
||||
width: 1120px;
|
||||
max-width: 100%;
|
||||
height: 820px;
|
||||
height: 100%;
|
||||
max-height: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -137,7 +139,7 @@
|
||||
inset 0 -1px 0 #adc0d22e;
|
||||
}
|
||||
.onboarding-r6-head {
|
||||
min-height: 123.7px;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -195,6 +197,7 @@
|
||||
background: #ffffff38;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
border-radius: 0 0 25px 25px;
|
||||
}
|
||||
@@ -222,29 +225,39 @@
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.onboarding-r6-body:has(> .onboarding-r6-mode) {
|
||||
padding-block: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.onboarding-r6-mode {
|
||||
width: 100%;
|
||||
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-discovery {
|
||||
height: 560.2px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
}
|
||||
.onboarding-r6-apps {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.onboarding-r6-intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 24.05px;
|
||||
color: #5a6169;
|
||||
}
|
||||
.onboarding-r6-intro p {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-list-heading {
|
||||
position: absolute;
|
||||
inset: 96px 0 auto;
|
||||
position: static;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -277,8 +290,10 @@
|
||||
cursor: default;
|
||||
}
|
||||
.onboarding-r6-list-area {
|
||||
position: absolute;
|
||||
inset: 142px 0 auto;
|
||||
position: static;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
||||
color: #4e6478;
|
||||
@@ -288,7 +303,9 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
height: 330px;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -451,44 +468,6 @@
|
||||
content: "↓";
|
||||
margin-right: 6px;
|
||||
}
|
||||
.onboarding-r6-import {
|
||||
position: absolute;
|
||||
top: 498px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #6a7988;
|
||||
}
|
||||
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
|
||||
markup keeps the sentence first so it stays the accessible label order for
|
||||
the control that follows. Reordering visually only, rather than moving the
|
||||
DOM, keeps that reading order intact. */
|
||||
.onboarding-r6-import > p {
|
||||
order: 2;
|
||||
}
|
||||
.onboarding-r6-import > .history-import,
|
||||
.onboarding-r6-import > .history-import-trigger {
|
||||
order: 1;
|
||||
}
|
||||
.onboarding-r6-import > p {
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-import .history-import-trigger {
|
||||
font: inherit;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4e6478;
|
||||
padding: 5px 11px;
|
||||
min-height: 30px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.onboarding-r6-import .history-import-trigger > svg {
|
||||
display: none;
|
||||
}
|
||||
.onboarding-r6-summary {
|
||||
border-radius: 22px;
|
||||
border: 1px solid #ffffffe0;
|
||||
@@ -500,6 +479,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-summary p {
|
||||
margin: 0;
|
||||
@@ -572,45 +553,40 @@
|
||||
border: 0;
|
||||
}
|
||||
.onboarding-r6-mode {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.onboarding-r6-mode-intro {
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
}
|
||||
.onboarding-r6-mode-intro h2 {
|
||||
margin: 0;
|
||||
color: #263947;
|
||||
font-size: 22px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.onboarding-r6-mode-intro p {
|
||||
margin: 0;
|
||||
margin: 4px 0 0;
|
||||
color: #637381;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.onboarding-r6-mode .memory-mode-choice-list {
|
||||
background: rgb(255 255 255 / 72%);
|
||||
.onboarding-r6-ultra-default {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.onboarding-r6-mode .memory-mode-choice {
|
||||
min-height: 82px;
|
||||
padding: 15px 18px;
|
||||
}
|
||||
.onboarding-r6-mode .memory-mode-choice-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
.onboarding-r6-mode .memory-mode-choice-copy strong {
|
||||
font-size: 16px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.onboarding-r6-mode .memory-mode-choice-copy small {
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.onboarding-r6-mode-ultra-note {
|
||||
margin: -6px 0 0;
|
||||
color: #637381;
|
||||
.onboarding-r6-ultra-default strong {
|
||||
color: #304757;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.onboarding-r6-ultra-default small {
|
||||
color: #607482;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.onboarding-r6-evolve-settings {
|
||||
margin-top: 0;
|
||||
@@ -801,17 +777,11 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.onboarding-r6-apps {
|
||||
min-height: 590px;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-list-heading {
|
||||
top: 110px;
|
||||
}
|
||||
.onboarding-r6-list-area {
|
||||
top: 154px;
|
||||
}
|
||||
.onboarding-r6-import {
|
||||
top: 552px;
|
||||
flex-wrap: wrap;
|
||||
.onboarding-r6-list {
|
||||
height: min(330px, 45vh);
|
||||
flex: none;
|
||||
}
|
||||
.onboarding-r6-row > label {
|
||||
padding: 14px 10px;
|
||||
@@ -853,20 +823,7 @@
|
||||
}
|
||||
.onboarding-r6-discovery > .onboarding-r6-apps {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto 30px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-apps > .onboarding-r6-list-heading,
|
||||
.onboarding-r6-apps > .onboarding-r6-list-area,
|
||||
.onboarding-r6-apps > .onboarding-r6-import {
|
||||
position: static;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-apps > .onboarding-r6-list-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
.onboarding-r6-list-area > .onboarding-r6-list {
|
||||
min-height: 0;
|
||||
|
||||
@@ -227,10 +227,27 @@
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory-mode-settings .memory-mode-choice-list {
|
||||
.r6-memory-mode-settings .memory-mode-cards {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.r6-memory-mode-settings
|
||||
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.r6-memory-mode-settings
|
||||
.memory-mode-card[data-mode="ultra"][data-selected="true"]
|
||||
.memory-mode-card-choice {
|
||||
min-height: 180px;
|
||||
}
|
||||
.r6-evolve-settings--in-card {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.r6-settings-note {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryDetail,
|
||||
MemoryGraphResponse,
|
||||
MemoryLibraryPage,
|
||||
SdkRawContextItem,
|
||||
SdkRawGraphItem,
|
||||
SdkRawPage,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
|
||||
export const SDK_RAW_PAGE_SIZE = 50;
|
||||
|
||||
export function rawRecordId(record: RawMapRecord): string {
|
||||
return record.turn_id || record.raw_id;
|
||||
@@ -124,6 +128,78 @@ export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
|
||||
return pageFor(items, items.length, 0, Math.max(items.length, 1));
|
||||
}
|
||||
|
||||
export function rawGraphRecord(item: SdkRawGraphItem): SdkRawContextItem {
|
||||
return {
|
||||
kind: "raw",
|
||||
raw_id: item.raw_id,
|
||||
turn_id: item.turn_id,
|
||||
content: item.label,
|
||||
source: item.source,
|
||||
session_id: item.session_id,
|
||||
memory_at:
|
||||
typeof item.memory_at === "number"
|
||||
? new Date(item.memory_at * 1_000).toISOString()
|
||||
: null,
|
||||
score: 0,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function rawGraphProjection(
|
||||
items: SdkRawGraphItem[],
|
||||
total: number,
|
||||
sources: SourceResponse[],
|
||||
): MemoryGraphResponse {
|
||||
const labels = new Map(
|
||||
sources.map((source) => [source.source_id, source.display_name]),
|
||||
);
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const sourceIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
const sourceId = item.source || "default";
|
||||
if (!sourceIds.has(sourceId)) {
|
||||
sourceIds.add(sourceId);
|
||||
nodes.push({
|
||||
node_id: `source:${sourceId}`,
|
||||
node_type: "source" as const,
|
||||
memory_id: null,
|
||||
source_id: sourceId,
|
||||
label: labels.get(sourceId) ?? sourceId,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
const nodeId = `memory:${item.turn_id}`;
|
||||
nodes.push({
|
||||
node_id: nodeId,
|
||||
node_type: "memory" as const,
|
||||
memory_id: item.turn_id,
|
||||
source_id: null,
|
||||
label: item.label,
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
confidence: null,
|
||||
});
|
||||
edges.push({
|
||||
edge_id: `source:${sourceId}->${nodeId}`,
|
||||
source_node_id: `source:${sourceId}`,
|
||||
target_node_id: nodeId,
|
||||
relation: "evidence_source",
|
||||
origin: "provenance" as const,
|
||||
weight: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
total_memories: total,
|
||||
projected_memories: items.length,
|
||||
hy_graph_available: false,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
export function rawDetail(record: RawMapRecord): MemoryDetail {
|
||||
const memory = rawMapMemory(record);
|
||||
return {
|
||||
|
||||
@@ -129,7 +129,7 @@ function ColdIntentHarness() {
|
||||
}
|
||||
|
||||
describe("useImportSources", () => {
|
||||
it("queries an uncertain cold-start intent before trying another write", async () => {
|
||||
it("recovers an uncertain cold-start write from its durable receipt immediately", async () => {
|
||||
connectSources.mockRejectedValueOnce(new Error("response lost"));
|
||||
getSourceConnection.mockImplementation(async (requestId: string) => ({
|
||||
request_id: requestId,
|
||||
@@ -142,11 +142,11 @@ describe("useImportSources", () => {
|
||||
const button = screen.getByRole("button", { name: "start cold import" });
|
||||
await waitFor(() => expect(button).toHaveProperty("disabled", false));
|
||||
await userEvent.click(button);
|
||||
await userEvent.click(button);
|
||||
expect(connectSources).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(getSourceConnection).toHaveBeenCalledOnce());
|
||||
const sent = connectSources.mock.calls[0][2];
|
||||
expect(sent.sourceRoots).toEqual({ cursor: "root:v2:fixture" });
|
||||
expect(getSourceConnection).toHaveBeenCalledWith(sent.requestId);
|
||||
expect(connectSources).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("translates a source error code without exposing a generic HTTP failure", async () => {
|
||||
const reason = Object.assign(
|
||||
@@ -434,6 +434,7 @@ describe("useImportSources", () => {
|
||||
expect(stored).not.toContain("authorized");
|
||||
expect(stored).not.toContain("~/.fixture");
|
||||
first.unmount();
|
||||
getSourceConnection.mockClear();
|
||||
getHealth.mockResolvedValue(health(instance));
|
||||
getSourceConnection.mockResolvedValue(receipt(requestId, ["ok"]));
|
||||
const next = await controller();
|
||||
@@ -639,7 +640,9 @@ describe("useImportSources", () => {
|
||||
let currentSource = { ...source("ok"), grant_revision: 1 };
|
||||
const saved = new Map<string, ConnectSourcesResponse>();
|
||||
getSources.mockImplementation(async () => [currentSource]);
|
||||
getSourceConnection.mockImplementation(async (id: string) => saved.get(id));
|
||||
getSourceConnection
|
||||
.mockRejectedValueOnce(new Error("receipt temporarily offline"))
|
||||
.mockImplementation(async (id: string) => saved.get(id));
|
||||
connectSources.mockImplementation(async (ids: string[], _, options) => {
|
||||
saved.set(options.requestId, receipt(options.requestId, ids));
|
||||
currentSource = { ...currentSource, authorized: true, grant_revision: 2 };
|
||||
@@ -854,6 +857,7 @@ describe("useImportSources", () => {
|
||||
|
||||
it("recovers an uncertain failed-only retry after remount without replaying accepted sources", async () => {
|
||||
const saved = new Map<string, ConnectSourcesResponse>();
|
||||
let loseRetryReceiptOnce = true;
|
||||
connectSources.mockImplementation(async (ids: string[], _, options) => {
|
||||
const response =
|
||||
ids.length === 2
|
||||
@@ -863,7 +867,17 @@ describe("useImportSources", () => {
|
||||
if (ids.length === 1) throw new Error("retry response lost");
|
||||
return response;
|
||||
});
|
||||
getSourceConnection.mockImplementation(async (id: string) => saved.get(id));
|
||||
getSourceConnection.mockImplementation(async (id: string) => {
|
||||
const known = saved.get(id);
|
||||
if (
|
||||
loseRetryReceiptOnce &&
|
||||
known?.accepted_source_ids.includes("failed")
|
||||
) {
|
||||
loseRetryReceiptOnce = false;
|
||||
throw new Error("retry receipt temporarily offline");
|
||||
}
|
||||
return known;
|
||||
});
|
||||
const first = await controller();
|
||||
const snapshot = [source("ok"), source("failed")];
|
||||
await act(async () => {
|
||||
|
||||
@@ -159,39 +159,53 @@ export function useImportSources(_options: { automaticSync?: boolean } = {}) {
|
||||
});
|
||||
}
|
||||
submitted = true;
|
||||
const result = await connectSourceSet([
|
||||
[...ids],
|
||||
true,
|
||||
{
|
||||
fullRescan: checked.onboarding,
|
||||
onboarding: checked.onboarding,
|
||||
enableSync: true,
|
||||
requestId: id,
|
||||
...(traceId ? { traceId } : {}),
|
||||
...(checked.importUploadId === undefined
|
||||
? {}
|
||||
: { importUploadId: checked.importUploadId }),
|
||||
sourceRoots: Object.fromEntries(
|
||||
checked.sources
|
||||
.filter((source) => ids.includes(source.source_id))
|
||||
.map((source) => [source.source_id, source.candidate_root_id]),
|
||||
),
|
||||
...(checked.sources.some(
|
||||
(source) => (source.candidate_revision ?? 0) > 0,
|
||||
)
|
||||
? {
|
||||
sourceCandidateRevisions: Object.fromEntries(
|
||||
checked.sources
|
||||
.filter((source) => ids.includes(source.source_id))
|
||||
.map((source) => [
|
||||
source.source_id,
|
||||
source.candidate_revision ?? 0,
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]);
|
||||
let result: ConnectSourcesResponse;
|
||||
try {
|
||||
result = await connectSourceSet([
|
||||
[...ids],
|
||||
true,
|
||||
{
|
||||
fullRescan: checked.onboarding,
|
||||
onboarding: checked.onboarding,
|
||||
enableSync: true,
|
||||
requestId: id,
|
||||
...(traceId ? { traceId } : {}),
|
||||
...(checked.importUploadId === undefined
|
||||
? {}
|
||||
: { importUploadId: checked.importUploadId }),
|
||||
sourceRoots: Object.fromEntries(
|
||||
checked.sources
|
||||
.filter((source) => ids.includes(source.source_id))
|
||||
.map((source) => [source.source_id, source.candidate_root_id]),
|
||||
),
|
||||
...(checked.sources.some(
|
||||
(source) => (source.candidate_revision ?? 0) > 0,
|
||||
)
|
||||
? {
|
||||
sourceCandidateRevisions: Object.fromEntries(
|
||||
checked.sources
|
||||
.filter((source) => ids.includes(source.source_id))
|
||||
.map((source) => [
|
||||
source.source_id,
|
||||
source.candidate_revision ?? 0,
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]);
|
||||
} catch (reason) {
|
||||
// Core persists the idempotent receipt before it finishes activating
|
||||
// every source lane. A large cold start can therefore commit and keep
|
||||
// working after the POST's 30s transport deadline. Read the receipt
|
||||
// before surfacing an unknown result; never replay the write here.
|
||||
if (isDefiniteAccessRefusal(reason)) throw reason;
|
||||
try {
|
||||
result = await getSourceConnection(id);
|
||||
} catch {
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
assertConnectionReceipt(result, id, ids);
|
||||
liveRequests.current.set(id, {
|
||||
instanceId: fresh.instance_id,
|
||||
|
||||
@@ -139,6 +139,14 @@ This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
- 旧用户迁移:由 App 在可见的启动 loading 阶段统一执行;此时 Core 尚未对外提供产品读写。所有已发布版本都先经 SDK 写入,Governance/Evidence 只是镜像,所以迁移**不复制任何 memory 或 raw**:创建并校验完整 bundle、让写入队列脱离旧表、把旧版命名的 SDK 向量集合接回当前名字、用 Runtime 同样配置的公开 SDK client 按 ID 回读核对,通过后才提交 marker、删除旧表。由一个 Core 命令 `sdk-migrate` 完成;Desktop 启动与 CLI `memhub install`/`upgrade`(macOS、Linux)在启动新 Runtime 前调用它,普通用户不需要另跑命令。App 不得在 migration `deferred` 状态继续启动 Runtime。
|
||||
- 迁移期间宿主 capture hook 由 Desktop 活跃 PID marker 静默挡住,不再追加
|
||||
Evidence;cutover 后先从宿主 History checkpoint 补齐遗漏 turn,再恢复 live sync。
|
||||
- 宿主 capture hook(`one-memory capture <host>`)是短命的独立进程,**不打开主库**:
|
||||
它只读 runtime receipt 与 runtime token,把 hook JSON 经 loopback
|
||||
`POST /api/v1/live-capture/{source}` 交给正在运行的 Core,超时或 Core 未运行即静默丢弃,
|
||||
不回退本地写;迁移 fence 仍在 hook 侧检查。Core 在进程内套用其余的门(grant、sync、
|
||||
拒绝计数、自动化会话、cursor/claude 去重),再按 live file sync 的同一路径
|
||||
(`materialize_write_input` → `stage_sdk_input` → `finalize_sdk_file`,live 优先级)
|
||||
经单一 writer 入队。live sync 已读取其宿主文件的来源,hook 内容交给文件路径,
|
||||
同一 turn 只进 SDK 一次;只有没有文件轮询的来源才从 hook 入队。
|
||||
- Matter 若未来重新开发,需以新的 SDK-native 合同重新设计,不继承当前占位 schema。
|
||||
|
||||
Pinned SDK 版本见发布锁文件 / `MEMORY.md`(随发布更新)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.6`(build 41)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 43)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 41,
|
||||
"build_number": 43,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -720,6 +720,27 @@ export interface SdkRawPage {
|
||||
time_filtered?: boolean;
|
||||
}
|
||||
|
||||
/** Lightweight complete-map projection. Raw text remains SDK-owned. */
|
||||
export interface SdkRawGraphItem {
|
||||
turn_id: string;
|
||||
raw_id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
session_id: string;
|
||||
memory_at: number | null;
|
||||
gmt_created: number | null;
|
||||
}
|
||||
|
||||
export interface SdkRawGraphPage {
|
||||
user_id: string;
|
||||
items: SdkRawGraphItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
snapshot_id: string;
|
||||
}
|
||||
|
||||
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
|
||||
export interface SdkLayerCounts {
|
||||
/** The user whose memories were counted. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/** Register `capture` on the hosts whose one-click install can own a hook file.
|
||||
*
|
||||
* The command is the packaged core binary. It reads the hook's JSON on stdin
|
||||
* and writes visible text into Evidence. Stdout stays empty, so the host does
|
||||
* not treat the capture as another instruction.
|
||||
* and hands it to the running Core over loopback; it never opens the
|
||||
* database, and drops the turn when Core is not running. Stdout stays empty,
|
||||
* so the host does not treat the capture as another instruction.
|
||||
*
|
||||
* WorkBuddy's command lives in its plugin tree. Hermes and OpenClaw deliver
|
||||
* the turn through a single memory-provider slot; those are not written here,
|
||||
|
||||
@@ -229,6 +229,12 @@ describe("English catalog", () => {
|
||||
});
|
||||
|
||||
it("uses CLDR English singular and plural forms", () => {
|
||||
expect(translateCount("en", "sdk.raw.total", 1, { count: 1 })).toBe(
|
||||
"1 original conversation",
|
||||
);
|
||||
expect(translateCount("en", "sdk.raw.total", 2, { count: 2 })).toBe(
|
||||
"2 original conversations",
|
||||
);
|
||||
expect(
|
||||
translateCount("en", "calls.headline.found", 1, {
|
||||
host: "Cursor",
|
||||
|
||||
@@ -73,6 +73,8 @@ export const currentChineseMessages = {
|
||||
"sdk.raw.searchFailed": "原始对话搜索失败,请重试。",
|
||||
"sdk.raw.recent": "最近原始对话",
|
||||
"sdk.raw.empty": "还没有原始对话。连接来源后,对话会显示在这里。",
|
||||
"sdk.raw.total": "{count} 条原始对话",
|
||||
"sdk.raw.total.one": "{count} 条原始对话",
|
||||
"sdk.raw.found": "找到 {count} 条",
|
||||
"sdk.raw.found.one": "找到 {count} 条",
|
||||
|
||||
@@ -134,6 +136,7 @@ export const currentChineseMessages = {
|
||||
"selectedMemory.save": "保存修改",
|
||||
"selectedMemory.author.user": "你",
|
||||
"selectedMemory.author.agent": "助手",
|
||||
"selectedMemory.author.tool": "工具",
|
||||
"selectedMemory.sourceUnavailable": "来源权限已变化或原文不可用。",
|
||||
"selectedMemory.related": "对应记忆",
|
||||
"selectedMemory.viewMemory": "查看记忆",
|
||||
@@ -222,6 +225,8 @@ export const currentEnglishMessages: Record<
|
||||
"sdk.raw.recent": "Recent original conversations",
|
||||
"sdk.raw.empty":
|
||||
"No original conversations yet. Connect a source to see conversations here.",
|
||||
"sdk.raw.total": "{count} original conversations",
|
||||
"sdk.raw.total.one": "{count} original conversation",
|
||||
"sdk.raw.found": "{count} results",
|
||||
"sdk.raw.found.one": "{count} result",
|
||||
|
||||
@@ -289,6 +294,7 @@ export const currentEnglishMessages: Record<
|
||||
"selectedMemory.save": "Save changes",
|
||||
"selectedMemory.author.user": "You",
|
||||
"selectedMemory.author.agent": "Assistant",
|
||||
"selectedMemory.author.tool": "Tool",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Source permissions changed or the original text is unavailable.",
|
||||
"selectedMemory.related": "Related memory",
|
||||
@@ -384,6 +390,8 @@ export const currentSpanishMessages: Record<
|
||||
"sdk.raw.recent": "Conversaciones originales recientes",
|
||||
"sdk.raw.empty":
|
||||
"Todavía no hay conversaciones originales. Conecta una fuente para verlas aquí.",
|
||||
"sdk.raw.total": "{count} conversaciones originales",
|
||||
"sdk.raw.total.one": "{count} conversación original",
|
||||
"sdk.raw.found": "{count} resultados",
|
||||
"sdk.raw.found.one": "{count} resultado",
|
||||
|
||||
@@ -452,6 +460,7 @@ export const currentSpanishMessages: Record<
|
||||
"selectedMemory.save": "Guardar cambios",
|
||||
"selectedMemory.author.user": "Tú",
|
||||
"selectedMemory.author.agent": "Asistente",
|
||||
"selectedMemory.author.tool": "Herramienta",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Los permisos del origen cambiaron o el texto no está disponible.",
|
||||
"selectedMemory.related": "Memoria correspondiente",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "Letzte Originalunterhaltungen",
|
||||
"sdk.raw.empty":
|
||||
"Noch keine Originalunterhaltungen. Verbinde eine Quelle, damit sie hier erscheinen.",
|
||||
"sdk.raw.total": "{count} Originalunterhaltungen",
|
||||
"sdk.raw.total.one": "{count} Originalunterhaltung",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Der Basisspeicher ist aktiv. Neue Unterhaltungen werden als Originale gespeichert und erzeugen keine erweiterten Erinnerungen. Vorhandene erweiterte Erinnerungen bleiben gespeichert, werden aber nicht angezeigt oder abgerufen.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -158,6 +160,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "Speichern",
|
||||
"selectedMemory.author.user": "Sie",
|
||||
"selectedMemory.author.agent": "Assistent",
|
||||
"selectedMemory.author.tool": "Tool",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Quellberechtigungen haben sich geändert oder der Originaltext ist nicht verfügbar.",
|
||||
"selectedMemory.related": "Zugehörige Erinnerung",
|
||||
|
||||
@@ -87,6 +87,8 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "Conversations originales récentes",
|
||||
"sdk.raw.empty":
|
||||
"Aucune conversation originale pour le moment. Connectez une source pour les afficher ici.",
|
||||
"sdk.raw.total": "{count} conversations originales",
|
||||
"sdk.raw.total.one": "{count} conversation originale",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"La mémoire de base est active. Les nouvelles conversations sont conservées telles quelles et ne créent pas de mémoires avancées. Les mémoires avancées existantes restent enregistrées, mais ne sont ni affichées ni rappelées.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -162,6 +164,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "Enregistrer",
|
||||
"selectedMemory.author.user": "Vous",
|
||||
"selectedMemory.author.agent": "Assistant",
|
||||
"selectedMemory.author.tool": "Outil",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Les autorisations de la source ont changé ou le texte original est indisponible.",
|
||||
"selectedMemory.related": "Mémoire associée",
|
||||
|
||||
@@ -81,6 +81,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "Percakapan asli terbaru",
|
||||
"sdk.raw.empty":
|
||||
"Belum ada percakapan asli. Hubungkan sumber agar percakapan tampil di sini.",
|
||||
"sdk.raw.total": "{count} percakapan asli",
|
||||
"sdk.raw.total.one": "{count} percakapan asli",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Memori dasar aktif. Percakapan baru disimpan sebagai catatan asli dan tidak membuat memori lanjutan. Memori lanjutan yang ada tetap tersimpan, tetapi tidak ditampilkan atau digunakan untuk recall.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -151,6 +153,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "Simpan perubahan",
|
||||
"selectedMemory.author.user": "Anda",
|
||||
"selectedMemory.author.agent": "Asisten",
|
||||
"selectedMemory.author.tool": "Alat",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Izin sumber berubah atau teks asli tidak tersedia.",
|
||||
"selectedMemory.related": "Memori terkait",
|
||||
|
||||
@@ -83,6 +83,8 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "最近の元の会話",
|
||||
"sdk.raw.empty":
|
||||
"元の会話はまだありません。ソースを接続すると、ここに表示されます。",
|
||||
"sdk.raw.total": "元の会話 {count} 件",
|
||||
"sdk.raw.total.one": "元の会話 {count} 件",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"基本メモリを使用中です。新しい会話は原文として保存され、高度なメモリは生成されません。既存の高度なメモリは保持されますが、現在は表示も呼び出しもされません。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -152,6 +154,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "変更を保存",
|
||||
"selectedMemory.author.user": "あなた",
|
||||
"selectedMemory.author.agent": "アシスタント",
|
||||
"selectedMemory.author.tool": "ツール",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"ソースの権限が変更されたか、原文を利用できません。",
|
||||
"selectedMemory.related": "関連する記憶",
|
||||
|
||||
@@ -80,6 +80,8 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "최근 원본 대화",
|
||||
"sdk.raw.empty":
|
||||
"아직 원본 대화가 없습니다. 소스를 연결하면 여기에 표시됩니다.",
|
||||
"sdk.raw.total": "원본 대화 {count}개",
|
||||
"sdk.raw.total.one": "원본 대화 {count}개",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"기본 메모리를 사용 중입니다. 새 대화는 원본 기록으로 저장되며 고급 메모리를 만들지 않습니다. 기존 고급 메모리는 보존되지만 현재 표시되거나 회상에 사용되지 않습니다.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -149,6 +151,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "변경 사항 저장",
|
||||
"selectedMemory.author.user": "나",
|
||||
"selectedMemory.author.agent": "어시스턴트",
|
||||
"selectedMemory.author.tool": "도구",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"출처 권한이 변경되었거나 원문을 사용할 수 없습니다.",
|
||||
"selectedMemory.related": "관련 기억",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "Conversas originais recentes",
|
||||
"sdk.raw.empty":
|
||||
"Ainda não há conversas originais. Ligue uma origem para as ver aqui.",
|
||||
"sdk.raw.total": "{count} conversas originais",
|
||||
"sdk.raw.total.one": "{count} conversa original",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"A memória básica está ativa. As novas conversas são guardadas como registos originais e não criam memórias avançadas. As memórias avançadas existentes continuam guardadas, mas não são mostradas nem usadas na recuperação.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -159,6 +161,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "Salvar alterações",
|
||||
"selectedMemory.author.user": "Você",
|
||||
"selectedMemory.author.agent": "Assistente",
|
||||
"selectedMemory.author.tool": "Ferramenta",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"As permissões da fonte mudaram ou o texto original não está disponível.",
|
||||
"selectedMemory.related": "Memória relacionada",
|
||||
|
||||
@@ -83,6 +83,8 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.recent": "Недавние исходные диалоги",
|
||||
"sdk.raw.empty":
|
||||
"Исходных диалогов пока нет. Подключите источник, и они появятся здесь.",
|
||||
"sdk.raw.total": "Исходных диалогов: {count}",
|
||||
"sdk.raw.total.one": "{count} исходный диалог",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Включена базовая память. Новые диалоги сохраняются в исходном виде и не создают расширенные воспоминания. Существующие расширенные воспоминания сохраняются, но не показываются и не используются при поиске.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -155,6 +157,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "Сохранить изменения",
|
||||
"selectedMemory.author.user": "Вы",
|
||||
"selectedMemory.author.agent": "Ассистент",
|
||||
"selectedMemory.author.tool": "Инструмент",
|
||||
"selectedMemory.sourceUnavailable":
|
||||
"Разрешения источника изменились, или исходный текст недоступен.",
|
||||
"selectedMemory.related": "Связанная запись памяти",
|
||||
|
||||
@@ -74,6 +74,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed": "原始對話搜尋失敗,請重試。",
|
||||
"sdk.raw.recent": "最近原始對話",
|
||||
"sdk.raw.empty": "還沒有原始對話。連接來源後,對話會顯示在這裡。",
|
||||
"sdk.raw.total": "{count} 則原始對話",
|
||||
"sdk.raw.total.one": "{count} 則原始對話",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"目前使用基礎記憶。新對話會保留為原始記錄,不會產生進階記憶;過去的進階記憶仍會保留,但目前不會顯示或參與記憶召回。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
@@ -139,6 +141,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"selectedMemory.save": "儲存修改",
|
||||
"selectedMemory.author.user": "你",
|
||||
"selectedMemory.author.agent": "助理",
|
||||
"selectedMemory.author.tool": "工具",
|
||||
"selectedMemory.sourceUnavailable": "來源權限已變化或原文不可用。",
|
||||
"selectedMemory.related": "對應記憶",
|
||||
"selectedMemory.viewMemory": "檢視記憶",
|
||||
|
||||
Reference in new issue
Block a user