Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f441eaaa | ||
|
|
9004977163 | ||
|
|
7f0c8198d9 | ||
|
|
25349b4cab | ||
|
|
5a42ce5861 | ||
|
|
b7731ac834 | ||
|
|
9560327ee2 | ||
|
|
f445ad69c4 | ||
|
|
45dbf46545 | ||
|
|
fac7a43ffb | ||
|
|
6e4b22a363 | ||
|
|
c186e5b2fb | ||
|
|
ad84e5decd | ||
|
|
0c43973fc2 | ||
|
|
ebf16b9399 | ||
|
|
87d70b0eb9 |
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 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
|
||||
+1
-1
@@ -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 44),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")
|
||||
|
||||
@@ -1758,6 +1786,7 @@ def create_app(
|
||||
else None
|
||||
)
|
||||
app.state.live_sync_watch_lock = asyncio.Lock()
|
||||
app.state.source_activation_tasks = {}
|
||||
app.state.live_sync_supervisor.set_watched_sources(
|
||||
watch_roots if watching and runtime_settings.live_sync_enabled else ()
|
||||
)
|
||||
@@ -2093,6 +2122,11 @@ def create_app(
|
||||
app.state.memory_governance_store.wait_content_candidate_backfill,
|
||||
5.0,
|
||||
)
|
||||
source_activation_tasks = tuple(app.state.source_activation_tasks.values())
|
||||
for task in source_activation_tasks:
|
||||
task.cancel()
|
||||
if source_activation_tasks:
|
||||
await asyncio.gather(*source_activation_tasks, return_exceptions=True)
|
||||
if app.state.live_sync_watch is not None:
|
||||
await app.state.live_sync_watch.shutdown()
|
||||
await app.state.live_sync_supervisor.shutdown()
|
||||
@@ -2206,6 +2240,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,
|
||||
@@ -3222,6 +3269,69 @@ def create_app(
|
||||
"""`POST /api/v1/import-jobs` on one user's lane (Hermes only for another user)."""
|
||||
return await create_job_on(request.source_id, request.operation, request.user_id)
|
||||
|
||||
async def _activate_connected_sources(
|
||||
request_id: str,
|
||||
source_ids: tuple[str, ...],
|
||||
*,
|
||||
enable_sync: bool,
|
||||
) -> None:
|
||||
"""Apply one committed connection intent without holding its HTTP response."""
|
||||
try:
|
||||
for source_id in source_ids:
|
||||
if enable_sync:
|
||||
await _live_sync(app).initialize_source(
|
||||
source_id,
|
||||
baseline_missing_files=True,
|
||||
)
|
||||
else:
|
||||
await _live_sync(app).stop_source(source_id)
|
||||
if enable_sync:
|
||||
for source_id in source_ids:
|
||||
await _activate_source_resume(source_id)
|
||||
await _refresh_live_watch(app)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
diagnostic_event(
|
||||
level="error",
|
||||
component="source.connection",
|
||||
event="source.connection.activation_failed",
|
||||
outcome="failed",
|
||||
**diagnostic_exception(error),
|
||||
details={"request_id": request_id, "source_ids": list(source_ids)},
|
||||
)
|
||||
raise
|
||||
|
||||
def _schedule_source_activation(
|
||||
request_id: str,
|
||||
source_ids: tuple[str, ...],
|
||||
*,
|
||||
enable_sync: bool,
|
||||
) -> None:
|
||||
tasks = app.state.source_activation_tasks
|
||||
existing = tasks.get(request_id)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
task = watched_task(
|
||||
asyncio.get_running_loop(),
|
||||
lambda: _activate_connected_sources(
|
||||
request_id,
|
||||
source_ids,
|
||||
enable_sync=enable_sync,
|
||||
),
|
||||
component="source.connection",
|
||||
name=f"source-connection-{request_id}",
|
||||
)
|
||||
if task is None:
|
||||
return
|
||||
tasks[request_id] = task
|
||||
|
||||
def forget(completed: asyncio.Future[None]) -> None:
|
||||
if tasks.get(request_id) is completed:
|
||||
tasks.pop(request_id, None)
|
||||
|
||||
task.add_done_callback(forget)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/sources/connect",
|
||||
response_model=ConnectSourcesResponse,
|
||||
@@ -3311,44 +3421,21 @@ def create_app(
|
||||
if grant is not None:
|
||||
_supervisor(app).register_adapter(registration.create_adapter(grant))
|
||||
_detection(app).pop(job.source_id, None)
|
||||
if request.enable_sync:
|
||||
await _prepare_source_resume(job.source_id)
|
||||
else:
|
||||
await _live_sync(app).stop_source(job.source_id)
|
||||
configured.append(job.source_id)
|
||||
if configured:
|
||||
previous = {
|
||||
source_id: _config_store(app).sync_enabled(source_id) for source_id in configured
|
||||
}
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_config_store(app).set_sync_many,
|
||||
{source_id: request.enable_sync for source_id in configured},
|
||||
)
|
||||
except BaseException:
|
||||
for source_id in configured:
|
||||
if request.enable_sync:
|
||||
await _live_sync(app).stop_source(source_id)
|
||||
elif previous[source_id]:
|
||||
await _prepare_source_resume(source_id)
|
||||
await _activate_source_resume(source_id)
|
||||
await _refresh_live_watch(app)
|
||||
raise
|
||||
try:
|
||||
if request.enable_sync:
|
||||
for source_id in configured:
|
||||
await _activate_source_resume(source_id)
|
||||
await _refresh_live_watch(app)
|
||||
except BaseException:
|
||||
await asyncio.to_thread(_config_store(app).set_sync_many, previous)
|
||||
for source_id in configured:
|
||||
if request.enable_sync:
|
||||
await _live_sync(app).stop_source(source_id)
|
||||
elif previous[source_id]:
|
||||
await _prepare_source_resume(source_id)
|
||||
await _activate_source_resume(source_id)
|
||||
await _refresh_live_watch(app)
|
||||
raise
|
||||
await asyncio.to_thread(
|
||||
_config_store(app).set_sync_many,
|
||||
{source_id: request.enable_sync for source_id in configured},
|
||||
)
|
||||
if request.enable_sync:
|
||||
for job in result.jobs:
|
||||
if job.source_id in configured and job.status is ImportStatus.PENDING:
|
||||
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
|
||||
_schedule_source_activation(
|
||||
request.request_id,
|
||||
tuple(configured),
|
||||
enable_sync=request.enable_sync,
|
||||
)
|
||||
return ConnectSourcesResponse(**result.model_dump())
|
||||
|
||||
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
|
||||
@@ -4483,6 +4570,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 +4865,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 +4882,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 +4894,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 +4929,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 +4940,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 +5114,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"]):
|
||||
|
||||
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
|
||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||
|
||||
None (no language set) sets nothing: the SDK detects zh/en as it always
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a
|
||||
feature, the language never touches `vector_store`, so the collection
|
||||
identity never depends on it.
|
||||
"""
|
||||
if language is not None:
|
||||
config.language = language
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,6 +197,82 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_connection_returns_committed_receipt_before_live_activation_finishes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
|
||||
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
root = home / ".claude/projects"
|
||||
root.mkdir(parents=True)
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=home,
|
||||
api_token=SecretStr("test"),
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
source_catalog=build_source_catalog(home=home, environment={}),
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer test"},
|
||||
) as client,
|
||||
):
|
||||
found = await client.post("/api/v1/sources/claude-code/location")
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
original = app.state.live_sync_supervisor.initialize_source
|
||||
|
||||
async def blocked_initialize(source_id, *, baseline_missing_files):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original(
|
||||
source_id,
|
||||
baseline_missing_files=baseline_missing_files,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
app.state.live_sync_supervisor,
|
||||
"initialize_source",
|
||||
blocked_initialize,
|
||||
)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["claude-code"],
|
||||
"request_id": "fast-receipt",
|
||||
"source_roots": {
|
||||
"claude-code": found.json()["root_id"],
|
||||
},
|
||||
"onboarding": True,
|
||||
"full_rescan": True,
|
||||
"enable_sync": True,
|
||||
},
|
||||
),
|
||||
timeout=1,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["request_id"] == "fast-receipt"
|
||||
assert app.state.config_store.sync_enabled("claude-code")
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
assert not release.is_set()
|
||||
finally:
|
||||
release.set()
|
||||
tasks = tuple(app.state.source_activation_tasks.values())
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("turn_count", "age_days", "limited"),
|
||||
[
|
||||
|
||||
@@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t
|
||||
expect(summary).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
const { summary, projection, processing, sedimentation, history, liveSync } =
|
||||
vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
projection: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
summary,
|
||||
projection,
|
||||
processing,
|
||||
sedimentation,
|
||||
history,
|
||||
liveSync,
|
||||
evolve,
|
||||
} = vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
projection: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
evolve: vi.fn(),
|
||||
}));
|
||||
vi.mock("./call-history-client", async (original) => ({
|
||||
...(await original<typeof import("./call-history-client")>()),
|
||||
getCallHistory: history,
|
||||
@@ -105,6 +113,7 @@ vi.mock("./api", () => ({
|
||||
getSdkRaw: vi.fn(),
|
||||
getLiveSyncHealth: liveSync,
|
||||
getMemoryProcessing: processing,
|
||||
getEvolveOverview: evolve,
|
||||
getSedimentation: sedimentation,
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
@@ -146,6 +155,12 @@ beforeEach(() => {
|
||||
history
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true, recording_error: false, items: [] });
|
||||
evolve.mockReset().mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
imports.refresh = vi.fn();
|
||||
summary.mockReset().mockResolvedValue({
|
||||
memory_count: 601,
|
||||
@@ -940,6 +955,39 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the home snapshot visible during Evolve and refreshes when it ends", async () => {
|
||||
evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [
|
||||
{
|
||||
run_id: "run-1",
|
||||
user_id: "local",
|
||||
state: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(mountHome());
|
||||
|
||||
expect(await screen.findByText("601")).toBeTruthy();
|
||||
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
|
||||
const readsBeforeCompletion = projection.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(["evolve", "local"], {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(projection.mock.calls.length).toBeGreaterThan(readsBeforeCompletion),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
retryAutoReconcile,
|
||||
useAutoConnect,
|
||||
} from "./connection-reconcile";
|
||||
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
sourceConnectionIncomplete,
|
||||
useRecallHosts,
|
||||
} from "./host-state";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
@@ -209,6 +211,10 @@ export function ApplicationsHome({
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const { text } = useOnboardingTranslation();
|
||||
const evolveState = useEvolveState(active);
|
||||
const evolveRunning = localEvolveRunning(evolveState.data);
|
||||
const evolveUncertain = evolveState.isError && evolveRunning;
|
||||
const hostQuery = useRecallHosts({ poll: active });
|
||||
const hostBridge = hostBridgeAvailable();
|
||||
const hosts = hostQuery.data ?? [];
|
||||
@@ -313,6 +319,12 @@ export function ApplicationsHome({
|
||||
const [expanded, setExpanded] = useState(retained.expanded);
|
||||
const [phase, setPhase] = useState("idle");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const previousEvolveRunning = useRef(evolveRunning);
|
||||
useEffect(() => {
|
||||
const previous = previousEvolveRunning.current;
|
||||
previousEvolveRunning.current = evolveRunning;
|
||||
if (previous && !evolveRunning && active) setRevision((value) => value + 1);
|
||||
}, [active, evolveRunning]);
|
||||
const confirmedModeSeen = useRef(confirmedMode);
|
||||
const expandButton = useRef<HTMLButtonElement>(null);
|
||||
const backButton = useRef<HTMLButtonElement>(null);
|
||||
@@ -953,6 +965,11 @@ export function ApplicationsHome({
|
||||
<header>
|
||||
<h2>{t("apps.overview")}</h2>
|
||||
</header>
|
||||
{evolveRunning ? (
|
||||
<p className="r6-evolve-status" role="status">
|
||||
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="r6-home-metrics">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>{t(viewModel.countLabel)}</p>
|
||||
|
||||
@@ -371,13 +371,11 @@ 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.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||
|
||||
@@ -46,6 +46,7 @@ export function defaultHostSourceIds(
|
||||
export type ImportConnectionDraft = {
|
||||
hostSourceIds: readonly string[];
|
||||
};
|
||||
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
|
||||
export interface FirstImportReviewProps {
|
||||
sourceIds: readonly string[];
|
||||
sources: SourceResponse[];
|
||||
@@ -54,6 +55,7 @@ export interface FirstImportReviewProps {
|
||||
acceptedJobIds?: readonly string[];
|
||||
failedSources?: Record<string, string>;
|
||||
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
|
||||
importSubmissionState?: ImportSubmissionState;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
connectionNote?: string;
|
||||
@@ -78,6 +80,7 @@ export function FirstImportReview({
|
||||
acceptedJobIds,
|
||||
failedSources = {},
|
||||
sourceSnapshot,
|
||||
importSubmissionState = "confirmed",
|
||||
busy = false,
|
||||
error,
|
||||
connectionNote,
|
||||
@@ -148,6 +151,11 @@ export function FirstImportReview({
|
||||
const canResume = batch.some((job) =>
|
||||
["paused", "interrupted", "failed"].includes(job.status),
|
||||
);
|
||||
const allCompleted =
|
||||
batch.length > 0 &&
|
||||
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
||||
batch.every((job) => job.status === "completed") &&
|
||||
!Object.keys(failedSources).length;
|
||||
const title = "对话正在汇入,记忆从这里开始";
|
||||
const hostEligible = importHostEligible(sources, ids);
|
||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||
@@ -322,6 +330,27 @@ export function FirstImportReview({
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<div className="first-import-r6-intro">
|
||||
<h2>{text("正在导入你的会话")}</h2>
|
||||
{importSubmissionState === "checking" ? (
|
||||
<p className="first-import-r6-submission-status" role="status">
|
||||
{t("onboardingImportChecking")}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "后台将持续为你导入,可以放心进入下一步",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{ids.length > 3 && (
|
||||
<p className="first-import-r6-scroll-hint">
|
||||
{t("onboardingMoreImportApps", { count: ids.length - 3 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(error || actionMessage) && (
|
||||
<div className="first-import-r6-feedback">
|
||||
<p
|
||||
@@ -355,46 +384,39 @@ export function FirstImportReview({
|
||||
jobs={batch}
|
||||
stale={progressStale}
|
||||
/>
|
||||
<section
|
||||
className="first-import-r6-feed"
|
||||
aria-label={text("导入说明")}
|
||||
>
|
||||
<h2>{text("正在导入你的会话")}</h2>
|
||||
<p className="first-import-r6-feed-status">
|
||||
{text("后台将持续为你导入,可以放心进入下一步")}
|
||||
</p>
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
{Object.keys(failedSources).length > 0 && (
|
||||
<section
|
||||
className="first-import-r6-failures"
|
||||
aria-label={text("未开始的导入")}
|
||||
>
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
>
|
||||
{Object.keys(failedSources).map((id) => (
|
||||
<p key={id}>
|
||||
{sources.find((source) => source.source_id === id)
|
||||
?.display_name ?? id}{" "}
|
||||
{text("未能开始导入,其他已接受任务不受影响。")}
|
||||
</p>
|
||||
))}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => leave("sources")}
|
||||
>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
{text("查看未导入的应用")}
|
||||
</R6Button>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
<aside
|
||||
className="first-import-r6-options"
|
||||
aria-label={text("连接应用")}
|
||||
>
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
<div className="first-import-r6-options-head">
|
||||
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
|
||||
<h2>{text("连接你的应用")}</h2>
|
||||
<p className="first-import-r6-options-intro">
|
||||
{text("适用于本次选择且支持的应用。")}
|
||||
</p>
|
||||
</div>
|
||||
<SelectionTile
|
||||
title={text(
|
||||
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
|
||||
@@ -430,13 +452,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,70 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LanguageProvider } from "./gui-language";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const view = (value: "lite" | "pro" | "ultra") => (
|
||||
<LanguageProvider>
|
||||
<MemoryModeCards
|
||||
aria-label="记忆模式"
|
||||
value={value}
|
||||
layout="rows"
|
||||
onChange={onChange}
|
||||
ultraDetail={
|
||||
<div>
|
||||
<strong>每天自动整合</strong>
|
||||
<small>默认 03:00,可在设置中修改。</small>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</LanguageProvider>
|
||||
);
|
||||
const { rerender } = render(view("pro"));
|
||||
|
||||
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByText("不调用生成模型")).toBeTruthy();
|
||||
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
|
||||
expect(screen.getByText("增强关联召回")).toBeTruthy();
|
||||
expect(screen.queryByText("每天自动整合")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
|
||||
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
|
||||
|
||||
rerender(view("ultra"));
|
||||
expect(screen.getByText("每天自动整合")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves per-mode disabled behavior", () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<MemoryModeCards
|
||||
aria-label="记忆模式"
|
||||
value="lite"
|
||||
disabled={(mode) => mode !== "lite"}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { MemoryPipelineMode } from "@one-memory/contracts";
|
||||
import { Check, Clock3 } from "lucide-react";
|
||||
import { type ReactNode, useId } from "react";
|
||||
import { MODE_COPY } from "./memory-mode-ui";
|
||||
import { 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,
|
||||
layout = "grid",
|
||||
onChange,
|
||||
ultraDetail,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-busy": ariaBusy,
|
||||
}: {
|
||||
value: MemoryPipelineMode | null;
|
||||
current?: MemoryPipelineMode;
|
||||
currentBadge?: ReactNode;
|
||||
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
|
||||
layout?: "grid" | "rows";
|
||||
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 memory-mode-cards--${layout}`}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy || undefined}
|
||||
>
|
||||
<div className="memory-mode-card-list">
|
||||
{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>
|
||||
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||
<div className="memory-mode-detail">
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<div>{ultraDetail}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
import { resetClient } from "./query-client";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryMode: vi.fn(),
|
||||
@@ -81,6 +82,7 @@ async function chooseMode(
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(apiErrorCode).mockReturnValue(null);
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
|
||||
@@ -96,7 +98,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 () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
EvolveOverview,
|
||||
EvolveRunReceipt,
|
||||
EvolveSchedule,
|
||||
MemoryModeChange,
|
||||
MemoryModePlan,
|
||||
@@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
@@ -25,12 +22,9 @@ import {
|
||||
commitConfirmedMemoryMode,
|
||||
issueConfirmedModeTicket,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
} from "./memory-mode-ui";
|
||||
import { commitEvolveRun, useEvolveState } from "./evolve-state";
|
||||
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";
|
||||
@@ -51,7 +45,11 @@ export function MemoryModeSettings({
|
||||
const id = useId();
|
||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
|
||||
const [overview, setOverview] = useState<EvolveOverview | null>(null);
|
||||
const evolveState = useEvolveState();
|
||||
const overview = evolveState.data ?? null;
|
||||
const run =
|
||||
overview?.running.find((candidate) => candidate.user_id === "local") ??
|
||||
null;
|
||||
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
@@ -60,7 +58,6 @@ export function MemoryModeSettings({
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [readFailed, setReadFailed] = useState(false);
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const refreshVersion = useRef(0);
|
||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||
const focusEvolveWhenReady = useRef(false);
|
||||
@@ -87,11 +84,10 @@ export function MemoryModeSettings({
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
const modeTicket = issueConfirmedModeTicket("settings");
|
||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
||||
const [modeResult, scheduleResult, processingResult] =
|
||||
await Promise.allSettled([
|
||||
getMemoryMode(),
|
||||
getEvolveSchedule(),
|
||||
getEvolveOverview(),
|
||||
getMemoryProcessing(),
|
||||
]);
|
||||
if (version !== refreshVersion.current) return;
|
||||
@@ -107,10 +103,6 @@ export function MemoryModeSettings({
|
||||
}
|
||||
if (scheduleResult.status === "fulfilled")
|
||||
setScheduleState(scheduleResult.value);
|
||||
if (overviewResult.status === "fulfilled") {
|
||||
setOverview(overviewResult.value);
|
||||
setRun(overviewResult.value.running[0] ?? null);
|
||||
}
|
||||
if (processingResult.status === "fulfilled")
|
||||
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
||||
}, [onModeChange]);
|
||||
@@ -120,14 +112,11 @@ export function MemoryModeSettings({
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const running =
|
||||
maintenance ||
|
||||
state?.graph_build.state === "running" ||
|
||||
run?.state === "running";
|
||||
const running = maintenance || state?.graph_build.state === "running";
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
|
||||
}, [maintenance, refresh, state?.graph_build.state]);
|
||||
|
||||
const lifecycleBusy =
|
||||
maintenance ||
|
||||
@@ -206,13 +195,21 @@ export function MemoryModeSettings({
|
||||
}
|
||||
|
||||
async function evolveNow() {
|
||||
if (!state || !schedule) return;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const receipt = await startEvolve();
|
||||
setRun(receipt);
|
||||
await refresh();
|
||||
commitEvolveRun(
|
||||
receipt,
|
||||
overview ?? {
|
||||
mode: state.mode,
|
||||
schedule,
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
);
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
@@ -237,16 +234,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 +248,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 +321,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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { MemorySpace } from "./MemorySpace";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
|
||||
it("translates memory filters and detail affordances live without refetching", async () => {
|
||||
setup();
|
||||
@@ -77,7 +78,9 @@ const { reads } = vi.hoisted(() => ({
|
||||
processing: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
rawGraph: vi.fn(),
|
||||
rawSearch: vi.fn(),
|
||||
evolve: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
@@ -87,7 +90,9 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getMemoryProcessing: reads.processing,
|
||||
getModelProfiles: reads.profiles,
|
||||
getSdkRaw: reads.raw,
|
||||
getSdkRawGraph: reads.rawGraph,
|
||||
searchSdkRaw: reads.rawSearch,
|
||||
getEvolveOverview: reads.evolve,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryAddComposer", () => ({
|
||||
@@ -117,10 +122,19 @@ vi.mock("./MemoryAddComposer", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("./MemoryGraph3D", () => ({
|
||||
MemoryGraph3D: () => <div data-testid="graph-owner" />,
|
||||
MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => (
|
||||
<div
|
||||
data-testid="graph-owner"
|
||||
data-graph-nodes={graph?.nodes?.length ?? 0}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -154,7 +168,94 @@ it("translates the model-required prompt without configuring a model", async ()
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("keeps the memory snapshot visible during Evolve and refreshes when it ends", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("ultra"));
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [
|
||||
{
|
||||
run_id: "run-1",
|
||||
user_id: "local",
|
||||
state: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect((await screen.findAllByText("已保存的记忆")).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
|
||||
const readsBeforeCompletion = reads.sdkLibrary.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(["evolve", "local"], {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(
|
||||
readsBeforeCompletion,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the last successful graph when its Evolve refresh fails", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("ultra"));
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [{ run_id: "run-1", user_id: "local", state: "running" }],
|
||||
});
|
||||
const firstSource = {
|
||||
source_id: "cursor",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
sync_enabled: true,
|
||||
imported_messages: 1,
|
||||
imported_conversations: 1,
|
||||
} as SourceResponse;
|
||||
const view = render(<MemorySpace simplified sources={[firstSource]} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"),
|
||||
);
|
||||
reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock"));
|
||||
|
||||
view.rerender(
|
||||
<MemorySpace
|
||||
simplified
|
||||
sources={[
|
||||
firstSource,
|
||||
{
|
||||
...firstSource,
|
||||
source_id: "codex",
|
||||
display_name: "Codex",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(reads.sdkGraph).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1");
|
||||
});
|
||||
|
||||
function setup() {
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
const memories = [
|
||||
{
|
||||
memory_id: "m1",
|
||||
@@ -208,6 +309,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 +409,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 +431,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
@@ -316,6 +441,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 +578,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 +601,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 +614,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
@@ -615,6 +833,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 +852,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,15 +39,18 @@ import {
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
getSdkRaw,
|
||||
getSdkRawGraph,
|
||||
manualExtract,
|
||||
searchSdkRaw,
|
||||
setExtractionWindow,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||
import { GraphWidthPrompt } from "./GraphWidthPrompt";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
|
||||
import { MemoryMap } from "./MemoryMap";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
||||
import { SdkRawHistory } from "./SdkRawHistory";
|
||||
@@ -60,9 +64,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";
|
||||
@@ -114,6 +121,10 @@ export function MemorySpace({
|
||||
onFocusMemoryConsumed?(): void;
|
||||
}) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const { text } = useOnboardingTranslation();
|
||||
const evolveState = useEvolveState(active || preload);
|
||||
const evolveRunning = localEvolveRunning(evolveState.data);
|
||||
const evolveUncertain = evolveState.isError && evolveRunning;
|
||||
const memoryText = (text: string) => {
|
||||
const key = `memory:${text}`;
|
||||
return isMessageKey(key) ? t(key) : text;
|
||||
@@ -165,6 +176,8 @@ export function MemorySpace({
|
||||
source.grant_revision,
|
||||
]),
|
||||
);
|
||||
const sourcesRef = useRef(sources);
|
||||
sourcesRef.current = sources;
|
||||
const management = simplified && displayedMode === "library";
|
||||
useEffect(() => {
|
||||
viewActive.current = active;
|
||||
@@ -188,9 +201,20 @@ 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), []);
|
||||
const previousEvolveRunning = useRef(evolveRunning);
|
||||
useEffect(() => {
|
||||
const previous = previousEvolveRunning.current;
|
||||
previousEvolveRunning.current = evolveRunning;
|
||||
if (previous && !evolveRunning) bumpSdkMap();
|
||||
}, [bumpSdkMap, evolveRunning]);
|
||||
const [sdkLibrary, setSdkLibrary] = useState<{
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
@@ -239,18 +263,22 @@ 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" || evolveRunning)
|
||||
? 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 +323,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 +409,6 @@ export function MemorySpace({
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch (reason) {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
if (
|
||||
@@ -443,6 +446,118 @@ 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((current) => ({
|
||||
scope,
|
||||
status: "failed",
|
||||
value: current?.scope === scope ? current.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 +687,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 +768,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 +881,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 +900,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 +910,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 +925,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 +1091,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:正在读取…")
|
||||
@@ -1075,6 +1204,11 @@ export function MemorySpace({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{evolveRunning ? (
|
||||
<p className="memory-evolve-status" role="status">
|
||||
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||
</p>
|
||||
) : null}
|
||||
{processing && (!simplified || managementOpen) ? (
|
||||
<details className="memory-processing-details">
|
||||
<summary>
|
||||
@@ -1258,11 +1392,13 @@ export function MemorySpace({
|
||||
? {
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkPage,
|
||||
page: displayedSdkPage,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
failed: sdkLibrary.failed && !evolveRunning,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
graphFailed: sdkGraphFailed && !evolveRunning,
|
||||
graphLoaded: visibleGraphProgress?.loaded,
|
||||
graphTotal: visibleGraphProgress?.total ?? undefined,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
@@ -1290,7 +1426,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,9 +156,6 @@ 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();
|
||||
@@ -187,9 +184,6 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
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"),
|
||||
@@ -213,9 +207,6 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
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();
|
||||
|
||||
@@ -329,7 +329,6 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
||||
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("连接应用");
|
||||
@@ -447,9 +446,6 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
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" }),
|
||||
|
||||
@@ -353,7 +353,6 @@ it("empty discovery shows zero and allows explicit entry without a connection re
|
||||
);
|
||||
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"),
|
||||
@@ -400,7 +399,6 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
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"),
|
||||
|
||||
@@ -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,90 @@ 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.queryByRole("alert")).toBeNull();
|
||||
expect(
|
||||
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
|
||||
).toBeTruthy();
|
||||
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,11 +553,11 @@ describe("OnboardingFlow S1.3", () => {
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -948,7 +1031,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
|
||||
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||
expect(screen.queryByText("推荐")).toBeNull();
|
||||
@@ -1001,24 +1084,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(
|
||||
@@ -1087,11 +1154,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(() =>
|
||||
@@ -1169,9 +1232,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,
|
||||
@@ -2630,7 +2691,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an unknown connection visible and reconciles its receipt without a duplicate POST", async () => {
|
||||
it("keeps an unknown connection visible without reporting an error or duplicating the POST", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFinish = vi.fn();
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
@@ -2646,6 +2707,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
vi.mocked(api.getSourceConnection).mockRejectedValueOnce(
|
||||
new Error("receipt offline"),
|
||||
);
|
||||
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
|
||||
function Harness() {
|
||||
const controller = useImportSources({ automaticSync: false });
|
||||
return controller.loading ? (
|
||||
@@ -2663,9 +2725,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await screen.findByRole("alert");
|
||||
await screen.findByText("正在核对导入结果,已保存的选择不会丢失");
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(api.connectSources).toHaveBeenCalledOnce();
|
||||
const requestId = vi.mocked(api.connectSources).mock.calls[0][2]?.requestId;
|
||||
@@ -2678,27 +2740,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await waitFor(() =>
|
||||
expect(api.getSourceConnection).toHaveBeenCalledExactlyOnceWith(
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
expect(api.connectSources).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
|
||||
vi.mocked(api.getSourceConnection).mockResolvedValueOnce({
|
||||
request_id: requestId,
|
||||
accepted_source_ids: ["claude"],
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
});
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await continueImport(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(api.connectSources).toHaveBeenCalledOnce();
|
||||
expect(api.getSourceConnection).toHaveBeenCalledTimes(2);
|
||||
expect(api.getSourceConnection).toHaveBeenCalledOnce();
|
||||
expect(api.getSourceConnection).toHaveBeenCalledWith(requestId);
|
||||
expect(api.createImportJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type {
|
||||
ConnectSourcesResponse,
|
||||
EvolveSchedule,
|
||||
ImportJob,
|
||||
MemoryModePlan,
|
||||
MemoryPipelineMode,
|
||||
OnboardingModeIntent,
|
||||
OnboardingState,
|
||||
SourceResponse,
|
||||
@@ -43,25 +41,24 @@ import {
|
||||
defaultHostSourceIds,
|
||||
FirstImportReview,
|
||||
type ImportConnectionDraft,
|
||||
type ImportSubmissionState,
|
||||
} from "./FirstImportReview";
|
||||
import {
|
||||
enableHostConnection,
|
||||
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 +112,7 @@ export function OnboardingFlow({
|
||||
imports: ImportSourcesController;
|
||||
onFinish(destination: OnboardingDestination): void;
|
||||
}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const { text, t, locale } = useOnboardingTranslation();
|
||||
const [sources, setSources] = useState(imports.sources);
|
||||
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
|
||||
if (sourceGeneration.current.value !== imports.sources) {
|
||||
@@ -138,6 +135,8 @@ export function OnboardingFlow({
|
||||
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
||||
null,
|
||||
);
|
||||
const [importSubmissionState, setImportSubmissionState] =
|
||||
useState<ImportSubmissionState>("confirmed");
|
||||
const resumedStep =
|
||||
state.mode_intent &&
|
||||
state.mode_intent !== "lite" &&
|
||||
@@ -159,14 +158,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 +696,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 +712,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 +739,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 +818,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 +860,7 @@ export function OnboardingFlow({
|
||||
modeSaving,
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
loadModePlan,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
@@ -947,7 +918,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 +965,7 @@ export function OnboardingFlow({
|
||||
finishOnce,
|
||||
leave,
|
||||
leaveModelStep,
|
||||
loadModePlan,
|
||||
modeDestination,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
@@ -1089,6 +1061,7 @@ export function OnboardingFlow({
|
||||
|
||||
async function submitImport(approved: SourceResponse[]) {
|
||||
sourceFlight.current = true;
|
||||
setImportSubmissionState("checking");
|
||||
if (window.oneMemory) setStartingImport(approved);
|
||||
const version = scopeVersion.current;
|
||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||
@@ -1100,8 +1073,11 @@ export function OnboardingFlow({
|
||||
try {
|
||||
current = (await imports.refresh()).sources;
|
||||
} catch {
|
||||
if (alive.current)
|
||||
if (alive.current) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!alive.current) return;
|
||||
@@ -1124,6 +1100,7 @@ export function OnboardingFlow({
|
||||
setItemErrors(result.failed_sources);
|
||||
if (result.accepted_source_ids.length === 0) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError(
|
||||
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||
);
|
||||
@@ -1132,13 +1109,25 @@ export function OnboardingFlow({
|
||||
// Keep the accepted intent and job IDs while the user inspects early originals.
|
||||
setAccepted(result);
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("confirmed");
|
||||
setError(null);
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
if (alive.current) {
|
||||
setStartingImport(null);
|
||||
setError(
|
||||
"开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
);
|
||||
const refused = isDefiniteAccessRefusal(reason);
|
||||
if (refused) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError(
|
||||
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||
);
|
||||
} else {
|
||||
setImportSubmissionState("checking");
|
||||
setError(
|
||||
window.oneMemory
|
||||
? null
|
||||
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connectSent.current = false;
|
||||
@@ -1214,6 +1203,7 @@ export function OnboardingFlow({
|
||||
jobs={reviewJobs}
|
||||
failedSources={accepted?.failed_sources}
|
||||
sourceSnapshot={imports.sourceSnapshot}
|
||||
importSubmissionState={importSubmissionState}
|
||||
busy={completionBusy}
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
@@ -1249,125 +1239,95 @@ export function OnboardingFlow({
|
||||
/>
|
||||
</div>
|
||||
{setupStep === "mode" && (
|
||||
<>
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<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"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-label={text("记忆模式")}
|
||||
<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()}
|
||||
>
|
||||
<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}
|
||||
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}
|
||||
layout="rows"
|
||||
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"}>
|
||||
|
||||
@@ -76,7 +76,9 @@ describe("OnboardingModelStep", () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
const title = guiMessages[locale]["配置记忆整理模型"];
|
||||
const back = guiMessages[locale]["返回选择模式"];
|
||||
if (!title || !back) throw new Error(`missing ${locale} model copy`);
|
||||
const summary = guiMessages[locale]["记忆整理"];
|
||||
if (!title || !back || !summary)
|
||||
throw new Error(`missing ${locale} model copy`);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<OnboardingModelStep {...props()} />
|
||||
@@ -93,10 +95,11 @@ describe("OnboardingModelStep", () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
|
||||
expect(screen.getByRole("complementary", { name: summary })).toBeTruthy();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("keeps step 3 on memory mode in a generation-only column", () => {
|
||||
it("keeps current behavior in the restored open-source composition", () => {
|
||||
render(<OnboardingModelStep {...props()} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "配置记忆整理模型" }),
|
||||
@@ -110,12 +113,9 @@ describe("OnboardingModelStep", () => {
|
||||
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
|
||||
expect(screen.queryByText("本次沉淀")).toBeNull();
|
||||
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
".onboarding-model-layout--single-column-600" +
|
||||
'[data-responsive="single-column-600"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(document.querySelector(".onboarding-model-layout")).toBeTruthy();
|
||||
expect(document.querySelector(".onboarding-model-summary")).toBeTruthy();
|
||||
expect(screen.getByText("让记忆从这里开始")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("focuses the page title when entering the model substep", async () => {
|
||||
@@ -181,6 +181,18 @@ describe("OnboardingModelStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the visible API key field when it fails in advanced mode", async () => {
|
||||
const p = props();
|
||||
p.advancedOpen = true;
|
||||
p.fieldErrors = { api_key: "请填写 API Key。" };
|
||||
const view = render(<OnboardingModelStep {...p} />);
|
||||
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(false));
|
||||
view.rerender(<OnboardingModelStep {...p} advancedOpen={false} />);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByLabelText("API Key")),
|
||||
);
|
||||
});
|
||||
|
||||
it("links field errors and keeps secrets out of callback errors", async () => {
|
||||
const p = props();
|
||||
p.fieldErrors = { api_key: "请填写 API Key。" };
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import "./onboarding-model-r6.css";
|
||||
|
||||
type Action = "test" | "save-and-enable";
|
||||
|
||||
@@ -60,6 +61,10 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
props.onAdvancedChange(true);
|
||||
return;
|
||||
}
|
||||
if (firstError === "api_key" && props.advancedOpen) {
|
||||
props.onAdvancedChange(false);
|
||||
return;
|
||||
}
|
||||
if (firstError && (!technicalError || props.advancedOpen)) {
|
||||
formRef.current
|
||||
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
|
||||
@@ -173,10 +178,7 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="onboarding-model-layout onboarding-model-layout--single-column-600"
|
||||
data-responsive="single-column-600"
|
||||
>
|
||||
<div className="onboarding-model-layout">
|
||||
<section
|
||||
ref={formRef}
|
||||
className="onboarding-model-form"
|
||||
@@ -184,6 +186,9 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
data-advanced={props.advancedOpen}
|
||||
>
|
||||
<div className="onboarding-model-group">
|
||||
<p className="onboarding-model-intro">
|
||||
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
|
||||
</p>
|
||||
<label
|
||||
className="onboarding-model-field onboarding-model-provider"
|
||||
htmlFor={`${id}-provider`}
|
||||
@@ -263,16 +268,6 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="onboarding-model-disclosure">
|
||||
<p>
|
||||
{text("相关对话会发送给所选服务商。")}
|
||||
{sourceSummary ? ` ${sourceSummary}` : ""}
|
||||
</p>
|
||||
<p>
|
||||
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
|
||||
{text("测试只向此接口发送测试内容,不发送历史对话。")}
|
||||
</p>
|
||||
</div>
|
||||
<GenerationTechnicalDisclosure
|
||||
open={props.advancedOpen}
|
||||
disabled={fieldsLocked}
|
||||
@@ -348,6 +343,35 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<aside
|
||||
className="onboarding-model-summary"
|
||||
aria-label={text("记忆整理")}
|
||||
>
|
||||
<div className="onboarding-model-summary-heading">
|
||||
<p>{text("记忆整理")}</p>
|
||||
<h2>{text("让记忆从这里开始")}</h2>
|
||||
</div>
|
||||
<div className="onboarding-model-connections">
|
||||
<p>{text("对话来源")}</p>
|
||||
<strong>{sourceSummary || text("尚无已选来源")}</strong>
|
||||
<small>
|
||||
{t("onboardingImportedConversations", {
|
||||
count: props.importedConversationCount ?? 0,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
<div className="onboarding-model-summary-status">
|
||||
<small>
|
||||
{text("处理服务:")}
|
||||
{text(provider?.label ?? "待选择")}
|
||||
</small>
|
||||
<small>
|
||||
{text("相关对话会发送给所选服务商。")}
|
||||
<br />
|
||||
{text("测试只向此接口发送测试内容,不发送历史对话。")}
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</R6ColdStartFrame>
|
||||
</div>
|
||||
|
||||
@@ -19,13 +19,23 @@ describe("R6 shared controls", () => {
|
||||
|
||||
it("keeps its label and prevents duplicate activation while pending", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<R6Button pending onClick={onClick}>
|
||||
const view = render(
|
||||
<R6Button pending={false} onClick={onClick}>
|
||||
保存修改
|
||||
</R6Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "保存修改" });
|
||||
const reservedSpinner = button.querySelector(".r6-button-spinner");
|
||||
expect(reservedSpinner).toBeTruthy();
|
||||
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
|
||||
view.rerender(
|
||||
<R6Button pending onClick={onClick}>
|
||||
保存修改
|
||||
</R6Button>,
|
||||
);
|
||||
expect(button.getAttribute("aria-busy")).toBe("true");
|
||||
expect(button.querySelector(".r6-button-spinner")).toBe(reservedSpinner);
|
||||
expect(reservedSpinner?.getAttribute("data-visible")).toBe("true");
|
||||
fireEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
expect(button.textContent).toContain("保存修改");
|
||||
|
||||
@@ -13,24 +13,32 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
|
||||
export function R6Button({
|
||||
variant = "secondary",
|
||||
pending = false,
|
||||
pending,
|
||||
disabled,
|
||||
children,
|
||||
className = "",
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const supportsPending = pending !== undefined;
|
||||
const busy = pending === true;
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
className={`r6-button ${className}`}
|
||||
data-variant={variant}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending || undefined}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
<span className="r6-button-label">{children}</span>
|
||||
{pending && <span className="r6-button-spinner" aria-hidden="true" />}
|
||||
{supportsPending ? (
|
||||
<span
|
||||
className="r6-button-spinner"
|
||||
data-visible={busy || undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ it.each(["missing", "pending"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it("centers one or two apps and keeps three or more in a scrolling row", () => {
|
||||
it("centers one to three apps and keeps four or more in a scrolling row", () => {
|
||||
function shown(ids: string[]) {
|
||||
const view = render(
|
||||
<R6ImportProgress
|
||||
@@ -181,7 +181,7 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
|
||||
}
|
||||
expect(shown(["cursor"])).toBe("1");
|
||||
expect(shown(["cursor", "codex"])).toBe("2");
|
||||
expect(shown(["cursor", "codex", "claude-code"])).toBe("many");
|
||||
expect(shown(["cursor", "codex", "claude-code"])).toBe("3");
|
||||
expect(shown(["cursor", "codex", "claude-code", "workbuddy"])).toBe("many");
|
||||
const four = render(
|
||||
<R6ImportProgress
|
||||
@@ -200,6 +200,38 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
|
||||
.querySelector(".r6-import-progress")
|
||||
?.getAttribute("data-count"),
|
||||
).toBe("many");
|
||||
expect(
|
||||
four.container
|
||||
.querySelector(".r6-import-progress")
|
||||
?.getAttribute("tabindex"),
|
||||
).toBe("0");
|
||||
});
|
||||
|
||||
it("scrolls one card at a time from the keyboard", () => {
|
||||
const ids = ["cursor", "codex", "claude-code", "workbuddy"];
|
||||
render(
|
||||
<R6ImportProgress
|
||||
sourceIds={ids}
|
||||
sources={ids.map((id) => ({
|
||||
...source,
|
||||
source_id: id,
|
||||
display_name: id,
|
||||
}))}
|
||||
jobs={[]}
|
||||
/>,
|
||||
);
|
||||
const progress = screen.getByRole("region", {
|
||||
name: "本次应用导入进度",
|
||||
});
|
||||
Object.defineProperty(progress.firstElementChild, "offsetWidth", {
|
||||
configurable: true,
|
||||
value: 280,
|
||||
});
|
||||
progress.scrollBy = ((options: ScrollToOptions) => {
|
||||
progress.scrollLeft += Number(options.left ?? 0);
|
||||
}) as typeof progress.scrollBy;
|
||||
fireEvent.keyDown(progress, { key: "ArrowRight" });
|
||||
expect(progress.scrollLeft).toBe(296);
|
||||
});
|
||||
|
||||
it("keeps the first app anchored across receipts unless the user scrolls", () => {
|
||||
|
||||
@@ -70,18 +70,39 @@ export function R6ImportProgress({
|
||||
});
|
||||
|
||||
const ids = [...new Set(sourceIds)];
|
||||
const scrollable = ids.length > 3;
|
||||
return (
|
||||
<section
|
||||
className="r6-import-progress"
|
||||
aria-label={text("本次应用导入进度")}
|
||||
data-count={ids.length <= 2 ? String(ids.length) : "many"}
|
||||
aria-keyshortcuts={
|
||||
scrollable ? "ArrowLeft ArrowRight Home End" : undefined
|
||||
}
|
||||
data-count={ids.length <= 3 ? String(ids.length) : "many"}
|
||||
ref={scroller}
|
||||
tabIndex={scrollable ? 0 : -1}
|
||||
onPointerDown={markUserScrollIntent}
|
||||
onTouchStart={markUserScrollIntent}
|
||||
onWheel={markUserScrollIntent}
|
||||
onKeyDown={(event) => {
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
||||
markUserScrollIntent();
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
||||
return;
|
||||
markUserScrollIntent();
|
||||
const element = event.currentTarget;
|
||||
const card = element.querySelector<HTMLElement>(
|
||||
".r6-import-progress-card",
|
||||
);
|
||||
const step = (card?.offsetWidth ?? 0) + 16;
|
||||
event.preventDefault();
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
element.scrollBy({
|
||||
left: event.key === "ArrowRight" ? step : -step,
|
||||
});
|
||||
} else {
|
||||
element.scrollTo({
|
||||
left: event.key === "End" ? element.scrollWidth : 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onScroll={(event) => {
|
||||
if (userScrollIntent.current)
|
||||
@@ -133,7 +154,7 @@ export function R6ImportProgress({
|
||||
<div className="r6-import-progress-orbit" aria-hidden="true">
|
||||
<span />
|
||||
<div className="r6-import-progress-icon">
|
||||
<AppMark id={id} label={source?.display_name ?? id} size={30} />
|
||||
<AppMark id={id} label={source?.display_name ?? id} size={40} />
|
||||
</div>
|
||||
</div>
|
||||
<h2>{source?.display_name ?? id}</h2>
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-evolve-status {
|
||||
width: fit-content;
|
||||
margin: 12px 0 -4px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #cbd8e0;
|
||||
border-radius: 999px;
|
||||
background: #edf3f6;
|
||||
color: #4f6574;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-home-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr 1fr;
|
||||
@@ -76,6 +87,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 +103,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 +120,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 +150,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 +297,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);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { getEvolveOverview } from "./api";
|
||||
import {
|
||||
commitEvolveRun,
|
||||
localEvolveRunning,
|
||||
refreshEvolveOverview,
|
||||
} from "./evolve-state";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
|
||||
vi.mock("./api", () => ({ getEvolveOverview: vi.fn() }));
|
||||
|
||||
const receipt: EvolveRunReceipt = {
|
||||
run_id: "run-1",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
max_batches: null,
|
||||
trigger: "manual",
|
||||
state: "running",
|
||||
started_at: "2026-11-05T14:00:00Z",
|
||||
finished_at: null,
|
||||
rounds: 0,
|
||||
tasks_processed: 0,
|
||||
batch_failures: 0,
|
||||
cycle_status: null,
|
||||
should_continue: null,
|
||||
continuation_reason: null,
|
||||
remaining_clusters: null,
|
||||
remaining_actionable_clusters: null,
|
||||
remaining_fresh_facts: null,
|
||||
capped: false,
|
||||
elapsed_ms: 0,
|
||||
error_code: null,
|
||||
failure_kind: null,
|
||||
request_id: "request-1",
|
||||
};
|
||||
|
||||
const overview: EvolveOverview = {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
resetClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shares the latest server overview through the query cache", async () => {
|
||||
vi.mocked(getEvolveOverview).mockResolvedValue(overview);
|
||||
|
||||
await expect(refreshEvolveOverview()).resolves.toEqual(overview);
|
||||
expect(queryClient.getQueryData(["evolve", "local"])).toEqual(overview);
|
||||
});
|
||||
|
||||
it("publishes an accepted local run immediately from a cold cache", () => {
|
||||
commitEvolveRun(receipt, overview);
|
||||
|
||||
const current = queryClient.getQueryData<EvolveOverview>(["evolve", "local"]);
|
||||
expect(current?.running).toEqual([receipt]);
|
||||
expect(localEvolveRunning(current)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let a pre-mutation overview overwrite the accepted run", async () => {
|
||||
let resolveRead: ((value: EvolveOverview) => void) | undefined;
|
||||
vi.mocked(getEvolveOverview).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRead = resolve;
|
||||
}),
|
||||
);
|
||||
const staleRead = refreshEvolveOverview();
|
||||
|
||||
commitEvolveRun(receipt, overview);
|
||||
resolveRead?.(overview);
|
||||
|
||||
await expect(staleRead).resolves.toMatchObject({ running: [receipt] });
|
||||
expect(
|
||||
queryClient.getQueryData<EvolveOverview>(["evolve", "local"])?.running,
|
||||
).toEqual([receipt]);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "./api";
|
||||
import { onClientReset, queryClient } from "./query-client";
|
||||
|
||||
const EVOLVE_KEY = ["evolve", "local"] as const;
|
||||
|
||||
queryClient.setQueryDefaults(EVOLVE_KEY, {
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
staleTime: 1_000,
|
||||
});
|
||||
|
||||
let mutationGeneration = 0;
|
||||
let readsStarted = 0;
|
||||
let readsLanded = 0;
|
||||
onClientReset(() => {
|
||||
mutationGeneration = 0;
|
||||
readsStarted = 0;
|
||||
readsLanded = 0;
|
||||
});
|
||||
|
||||
export async function refreshEvolveOverview() {
|
||||
const generation = mutationGeneration;
|
||||
const order = ++readsStarted;
|
||||
const next = await api.getEvolveOverview();
|
||||
const current = queryClient.getQueryData<EvolveOverview>(EVOLVE_KEY);
|
||||
if (generation !== mutationGeneration || order < readsLanded)
|
||||
return current ?? next;
|
||||
readsLanded = order;
|
||||
queryClient.setQueryData(EVOLVE_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Publish an accepted run before polling confirms it so every page agrees. */
|
||||
export function commitEvolveRun(
|
||||
receipt: EvolveRunReceipt,
|
||||
fallback?: EvolveOverview,
|
||||
) {
|
||||
mutationGeneration++;
|
||||
queryClient.setQueryData<EvolveOverview>(EVOLVE_KEY, (current) => {
|
||||
const base = current ?? fallback;
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
running: [
|
||||
...base.running.filter(
|
||||
(running) => running.user_id !== receipt.user_id,
|
||||
),
|
||||
receipt,
|
||||
],
|
||||
}
|
||||
: current;
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: EVOLVE_KEY });
|
||||
}
|
||||
|
||||
export function useEvolveState(active = true) {
|
||||
return useQuery(
|
||||
{
|
||||
queryKey: EVOLVE_KEY,
|
||||
queryFn: refreshEvolveOverview,
|
||||
enabled: active,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.running.length ? 1_500 : 10_000,
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
}
|
||||
|
||||
export function useEvolveOverview(active = true) {
|
||||
return useEvolveState(active).data;
|
||||
}
|
||||
|
||||
export function localEvolveRunning(overview?: EvolveOverview | null) {
|
||||
return overview?.running.some((run) => run.user_id === "local") ?? false;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
|
||||
.first-import-r6-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.first-import-r6-main {
|
||||
min-width: 0;
|
||||
@@ -13,6 +12,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.first-import-r6-intro {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.first-import-r6-intro h2 {
|
||||
margin: 0 0 3px;
|
||||
color: #262a2e;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
.first-import-r6-intro p {
|
||||
margin: 0;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.first-import-r6-history-window {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
@@ -25,6 +41,108 @@
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="1"] {
|
||||
grid-auto-columns: min(320px, 100%);
|
||||
justify-content: center;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="2"] {
|
||||
grid-auto-columns: min(320px, calc((100% - 16px) / 2));
|
||||
justify-content: center;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="3"] {
|
||||
grid-auto-columns: calc((100% - 32px) / 3);
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress[data-count="many"] {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: calc((100% - 32px) / 3);
|
||||
justify-content: start;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-color: rgb(102 128 145 / 50%) transparent;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
|
||||
min-height: 128px;
|
||||
padding: 16px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
align-content: center;
|
||||
column-gap: 18px;
|
||||
text-align: left;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 4;
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
|
||||
align-self: end;
|
||||
}
|
||||
.first-import-r6-intro .first-import-r6-submission-status {
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #41697d;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.first-import-r6-submission-status::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(88 124 144 / 28%);
|
||||
border-top-color: #587c90;
|
||||
border-radius: 50%;
|
||||
animation: first-import-r6-checking 1.2s linear infinite;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-submission-status) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-submission-status) h2 {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-intro .first-import-r6-scroll-hint {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
color: #6b7e8b;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.first-import-r6-intro:has(.first-import-r6-scroll-hint) {
|
||||
padding-right: 190px;
|
||||
}
|
||||
@keyframes first-import-r6-checking {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.first-import-r6-feed {
|
||||
flex: 0 0 auto;
|
||||
@@ -76,9 +194,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
min-height: 132px;
|
||||
padding: 22px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
|
||||
align-items: center;
|
||||
gap: 0 28px;
|
||||
border: 1px solid #ffffffe0;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
@@ -87,11 +210,14 @@
|
||||
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;
|
||||
font-size: 12px;
|
||||
@@ -117,12 +243,12 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 124px;
|
||||
margin: 0 0 16px;
|
||||
padding: 18px 16px 12px;
|
||||
min-height: 104px;
|
||||
margin: 0;
|
||||
padding: 18px 52px 16px 18px;
|
||||
border: 1px solid rgb(179 196 209 / 42%);
|
||||
border-radius: 0;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 34%);
|
||||
box-shadow: inset 0 1px 1px #ffffffd9;
|
||||
backdrop-filter: blur(20px);
|
||||
cursor: pointer;
|
||||
@@ -151,8 +277,8 @@
|
||||
.first-import-r6-selection input {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 18px;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
@@ -190,10 +316,11 @@
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-top: 8px;
|
||||
margin-top: 7px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.first-import-r6-options-note {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
margin: 0 0 12px;
|
||||
@@ -390,8 +517,23 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
@media (max-width: 1150px) {
|
||||
.first-import-r6-layout {
|
||||
grid-template-columns: minmax(0, 1fr) 278px;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 859px) {
|
||||
.onboarding-r6:has(.first-import-r6-layout)
|
||||
> :is(.onboarding-r6-titlebar, .onboarding-r6-local-footer) {
|
||||
display: none;
|
||||
}
|
||||
.onboarding-r6:has(.first-import-r6-layout) > .onboarding-r6-stage {
|
||||
min-height: 0;
|
||||
height: 100dvh;
|
||||
padding: 10px 24px 2px;
|
||||
}
|
||||
.onboarding-r6:has(.first-import-r6-layout)
|
||||
> .onboarding-r6-stage
|
||||
> .onboarding-r6-shell {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
@@ -401,12 +543,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 {
|
||||
@@ -424,16 +566,16 @@ dialog.first-import-r6-original[open] {
|
||||
}
|
||||
.first-import-r6-selection,
|
||||
.first-import-r6-selection + .first-import-r6-selection {
|
||||
min-height: 86px;
|
||||
margin-bottom: 8px;
|
||||
padding: 9px 12px;
|
||||
min-height: 96px;
|
||||
margin-bottom: 0;
|
||||
padding: 15px 48px 13px 16px;
|
||||
}
|
||||
.first-import-r6-selection strong {
|
||||
line-height: 24px;
|
||||
}
|
||||
.first-import-r6-selection input {
|
||||
top: 13px;
|
||||
right: 12px;
|
||||
top: 17px;
|
||||
right: 16px;
|
||||
}
|
||||
.first-import-r6-selection span {
|
||||
margin-top: 4px;
|
||||
@@ -454,6 +596,7 @@ dialog.first-import-r6-original[open] {
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-options {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.first-import-r6-footer-note {
|
||||
@@ -511,6 +654,10 @@ dialog.first-import-r6-original[open] {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.first-import-r6-submission-status::before {
|
||||
animation: none;
|
||||
border-color: #587c90;
|
||||
}
|
||||
.first-import-r6-selection,
|
||||
.first-import-r6-selection input::after {
|
||||
transition: none;
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
.memory-mode-cards {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.memory-mode-card-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.memory-mode-card {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 128px;
|
||||
height: 100%;
|
||||
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 {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 18px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
column-gap: 28px;
|
||||
align-items: center;
|
||||
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 {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
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 {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
align-self: start;
|
||||
min-height: 0;
|
||||
margin-top: 6px;
|
||||
color: #526674;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.memory-mode-features {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / -1;
|
||||
align-self: stretch;
|
||||
margin-top: 0;
|
||||
padding: 0 42px 0 28px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
border-left: 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 {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
align-self: start;
|
||||
margin-top: 6px;
|
||||
padding: 7px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
border-top: 1px solid #d4e0e7;
|
||||
background: transparent;
|
||||
color: #405867;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
animation: memory-mode-detail-in 180ms ease-out both;
|
||||
}
|
||||
|
||||
.memory-mode-detail > svg {
|
||||
margin-top: 1px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-mode-detail > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid {
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-card-list {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-card {
|
||||
position: static;
|
||||
flex: none;
|
||||
min-height: 286px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-card-choice {
|
||||
position: static;
|
||||
min-height: 284px;
|
||||
height: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-title {
|
||||
width: 100%;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-radio {
|
||||
position: static;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-description {
|
||||
min-height: 50px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-features {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
padding: 16px 0 2px;
|
||||
border-top: 1px solid #dbe4ea;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-detail {
|
||||
width: calc(100% + 40px);
|
||||
margin: auto -20px -20px;
|
||||
padding: 14px 18px;
|
||||
display: block;
|
||||
border-radius: 0 0 15px 15px;
|
||||
background: #f1f6f8;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.memory-mode-cards--grid .memory-mode-detail > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes memory-mode-detail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.memory-mode-cards--rows {
|
||||
min-height: 360px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-list {
|
||||
gap: 12px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card {
|
||||
min-height: 112px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-choice {
|
||||
min-height: 0;
|
||||
padding: 13px 16px;
|
||||
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
|
||||
column-gap: 22px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-radio {
|
||||
top: 13px;
|
||||
right: 16px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-title strong {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-description {
|
||||
min-height: 0;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features {
|
||||
margin-top: 0;
|
||||
padding: 0 36px 0 22px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-detail {
|
||||
margin-top: 5px;
|
||||
padding: 5px 0 0;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.memory-mode-cards--rows {
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card {
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-card-choice {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-radio {
|
||||
position: static;
|
||||
margin-left: auto;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-title {
|
||||
width: 100%;
|
||||
padding-right: 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-description {
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-features {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid #dbe4ea;
|
||||
border-left: 0;
|
||||
}
|
||||
.memory-mode-cards--rows .memory-mode-detail {
|
||||
width: calc(100% + 40px);
|
||||
margin: 16px -20px -20px;
|
||||
padding: 14px 20px 16px;
|
||||
border-radius: 0 0 15px 15px;
|
||||
background: #f1f6f8;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 681px) and (max-height: 959px) {
|
||||
.memory-mode-cards--grid {
|
||||
gap: 14px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-card {
|
||||
min-height: 194px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-card-choice {
|
||||
min-height: 194px;
|
||||
padding: 16px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-title strong {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-description {
|
||||
min-height: 36px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-features {
|
||||
padding-top: 10px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-detail {
|
||||
width: calc(100% + 32px);
|
||||
margin: auto -16px -16px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.memory-mode-cards--grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-card,
|
||||
.memory-mode-cards--grid .memory-mode-card-choice {
|
||||
min-height: 0;
|
||||
}
|
||||
.memory-mode-cards--grid .memory-mode-description {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.memory-mode-card,
|
||||
.memory-mode-detail {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -588,49 +588,44 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Generation-only cold start. Embedding remains a separate settings lane. */
|
||||
.onboarding-model-r6 .onboarding-r6-head > div {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
.onboarding-model-layout {
|
||||
display: block;
|
||||
width: min(760px, 100%);
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.onboarding-model-form {
|
||||
overflow: visible;
|
||||
}
|
||||
/* The current controller keeps generation-only behavior. The presentation
|
||||
intentionally returns to the original open-source setup composition:
|
||||
an open form column, a separate context column, and a distinct advanced state. */
|
||||
.onboarding-model-group {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid #d8e2e8;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
.onboarding-model-provider,
|
||||
.onboarding-model-key {
|
||||
margin-top: 0;
|
||||
.onboarding-model-group > .onboarding-model-intro {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.onboarding-model-field {
|
||||
gap: 7px;
|
||||
}
|
||||
.onboarding-model-field :is(input, select),
|
||||
.onboarding-model-field .om-option-menu-button,
|
||||
.generation-model-field :is(input, select) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #c8d3db;
|
||||
height: 54px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #c0ccd5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: #f0f5f6;
|
||||
color: #262a2e;
|
||||
font:
|
||||
400 14px / 22px "Noto Sans SC",
|
||||
400 14px / 23px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
transition:
|
||||
border-color var(--r6-fast, 140ms),
|
||||
background-color var(--r6-fast, 140ms);
|
||||
}
|
||||
.onboarding-model-field .om-option-menu-button:hover:not(:disabled),
|
||||
.generation-model-field :is(input, select):hover:not(:disabled) {
|
||||
border-color: #9fb6ad;
|
||||
background: #eaf1f2;
|
||||
}
|
||||
.generation-model-field select:not(:disabled),
|
||||
.generation-technical-toggle:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:is(.onboarding-model-field, .generation-model-field) [aria-invalid="true"] {
|
||||
border-color: #a24646;
|
||||
}
|
||||
.onboarding-model-field small,
|
||||
.generation-model-field small {
|
||||
@@ -641,11 +636,8 @@
|
||||
.onboarding-model-default-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 0;
|
||||
border-block: 1px solid #e4eaee;
|
||||
}
|
||||
.onboarding-model-default-summary span,
|
||||
.onboarding-model-disclosure {
|
||||
min-height: 54px;
|
||||
margin-top: 12px;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
@@ -655,133 +647,131 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.onboarding-model-disclosure {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.generation-technical-disclosure {
|
||||
border-top: 1px solid #e4eaee;
|
||||
padding-top: 4px;
|
||||
margin-top: 23px;
|
||||
}
|
||||
.generation-technical-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #4e6478;
|
||||
font:
|
||||
500 13px / 21px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
transition:
|
||||
background-color var(--r6-fast, 140ms),
|
||||
color var(--r6-fast, 140ms);
|
||||
}
|
||||
.generation-technical-toggle:hover:not(:disabled) {
|
||||
background: #ffffff66;
|
||||
color: #33424d;
|
||||
}
|
||||
.generation-technical-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.generation-model-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.generation-model-field:has([name="base-url"]),
|
||||
.generation-runtime-fields,
|
||||
.generation-thinking {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.generation-runtime-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 19px 30px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
.generation-technical-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.generation-runtime-note {
|
||||
.generation-model-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.generation-model-field:has([name="base_url"]),
|
||||
.generation-runtime-fields,
|
||||
.generation-runtime-note,
|
||||
.generation-thinking {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.generation-model-field :is(input, select) {
|
||||
height: 42px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.generation-runtime-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
.generation-runtime-note {
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
.generation-thinking {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 10px;
|
||||
color: #416e5c;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
}
|
||||
.onboarding-model-r6 .generation-model-error {
|
||||
color: #a24646;
|
||||
}
|
||||
.onboarding-model-recovery-actions {
|
||||
margin-top: 12px;
|
||||
.onboarding-model-form[data-advanced="true"]
|
||||
:is(
|
||||
.onboarding-model-intro,
|
||||
.onboarding-model-provider,
|
||||
.onboarding-model-key,
|
||||
.onboarding-model-default-summary
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
.onboarding-model-form[data-advanced="true"] .generation-technical-disclosure {
|
||||
margin-top: 0;
|
||||
}
|
||||
.onboarding-model-form[data-advanced="true"] .generation-technical-toggle {
|
||||
min-height: 28px;
|
||||
}
|
||||
.onboarding-model-recovery-actions,
|
||||
.onboarding-model-feedback {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-r6-foot {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 20px 32px;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions {
|
||||
flex-wrap: nowrap;
|
||||
margin-left: 0;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
|
||||
min-width: 132px;
|
||||
}
|
||||
@media (min-width: 761px) {
|
||||
.onboarding-model-r6 .onboarding-r6-body {
|
||||
.onboarding-model-group {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.onboarding-model-form {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
scroll-padding: 12px;
|
||||
padding-inline: 4px 16px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.onboarding-model-layout--single-column-600 .onboarding-model-group {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
@media (min-width: 761px) and (max-height: 959px) {
|
||||
.onboarding-model-form[data-advanced="false"]
|
||||
.onboarding-model-field
|
||||
:is(input, select, .om-option-menu-button) {
|
||||
height: 44px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.onboarding-model-layout--single-column-600 .generation-technical-fields,
|
||||
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
|
||||
.generation-technical-fields {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.generation-technical-fields,
|
||||
.generation-runtime-fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.onboarding-model-footer-actions .r6-button,
|
||||
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.onboarding-model-r6 .onboarding-r6-foot {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
|
||||
.onboarding-model-r6
|
||||
.onboarding-model-footer-actions
|
||||
.r6-button[data-variant="primary"] {
|
||||
flex-basis: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -225,6 +225,22 @@
|
||||
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: 100%;
|
||||
@@ -541,41 +557,36 @@
|
||||
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;
|
||||
@@ -623,6 +634,12 @@
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
@media (min-width: 761px) {
|
||||
.onboarding-r6-mode {
|
||||
min-height: 100%;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
.onboarding-r6-empty {
|
||||
margin: 0;
|
||||
padding: 28px 18px;
|
||||
|
||||
@@ -159,7 +159,7 @@ button.r6-button {
|
||||
min-height: 44px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
gap: 0;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--r6-control-edge);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 20%);
|
||||
@@ -244,14 +244,20 @@ button.r6-button[data-variant="quiet"]:disabled {
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-button-spinner {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
visibility: hidden;
|
||||
animation: r6-spin 1.8s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.r6-button-spinner[data-visible="true"] {
|
||||
visibility: visible;
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.r6-stepper {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
safe center falls back to the start if the pair would overflow a narrow column.
|
||||
Three or more keep the existing row and scroll horizontally. */
|
||||
.r6-import-progress[data-count="1"],
|
||||
.r6-import-progress[data-count="2"] {
|
||||
.r6-import-progress[data-count="2"],
|
||||
.r6-import-progress[data-count="3"] {
|
||||
justify-content: safe center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
font-size: 13px;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-memory .memory-evolve-status {
|
||||
width: fit-content;
|
||||
margin: -12px 0 20px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #cbd8e0;
|
||||
border-radius: 999px;
|
||||
background: #edf3f6;
|
||||
color: #4f6574;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-memory .memory-pending-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 44)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 41,
|
||||
"build_number": 44,
|
||||
"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,
|
||||
|
||||
@@ -271,6 +271,87 @@ it("reads Core's SDK context directly and passes native fields unchanged", async
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["omitted", raw],
|
||||
["null", { ...raw, turn_id: null, turn_index: null, turn_count: null }],
|
||||
[
|
||||
"minimum populated",
|
||||
{
|
||||
...raw,
|
||||
turn_id: "t",
|
||||
turn_index: 0,
|
||||
turn_count: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
"maximum populated",
|
||||
{
|
||||
...raw,
|
||||
turn_id: "t".repeat(96),
|
||||
turn_index: Number.MAX_SAFE_INTEGER,
|
||||
turn_count: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
],
|
||||
])("passes through %s optional raw turn metadata", async (_name, item) => {
|
||||
const f = await fixture();
|
||||
const response = receiptFor([item]);
|
||||
f.respond(response);
|
||||
|
||||
const result = await f.post();
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data).toEqual(response);
|
||||
expect(f.diagnostics).not.toContainEqual(
|
||||
expect.objectContaining({ event: "context.response_invalid" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty turn_id", { turn_id: "" }],
|
||||
["overlong turn_id", { turn_id: "t".repeat(97) }],
|
||||
["non-string turn_id", { turn_id: 1 }],
|
||||
["negative turn_index", { turn_index: -1 }],
|
||||
["fractional turn_index", { turn_index: 0.5 }],
|
||||
["non-number turn_index", { turn_index: "0" }],
|
||||
["unsafe turn_index", { turn_index: 2 ** 53 }],
|
||||
["zero turn_count", { turn_count: 0 }],
|
||||
["negative turn_count", { turn_count: -1 }],
|
||||
["fractional turn_count", { turn_count: 1.5 }],
|
||||
["non-number turn_count", { turn_count: "1" }],
|
||||
["unsafe turn_count", { turn_count: 2 ** 53 }],
|
||||
["unknown field", { revision: 1 }],
|
||||
])("rejects raw context with %s", async (_name, fields) => {
|
||||
const f = await fixture();
|
||||
f.respond(receiptFor([{ ...raw, content: "private turn", ...fields }]));
|
||||
|
||||
const result = await f.post();
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.data).toEqual({ error: "context_unavailable" });
|
||||
expect(f.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: "context.response_invalid",
|
||||
error_code: "invalid_context_response",
|
||||
details: { reason: "response" },
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(f.diagnostics)).not.toContain("private turn");
|
||||
});
|
||||
|
||||
it("rejects raw turn metadata on a memory context item", async () => {
|
||||
const f = await fixture();
|
||||
f.respond({
|
||||
...mixed,
|
||||
items: [{ ...memory, turn_id: "t" }],
|
||||
used_characters: [...memory.content].length,
|
||||
});
|
||||
|
||||
const result = await f.post();
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.data).toEqual({ error: "context_unavailable" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[10, 200],
|
||||
[11, 502],
|
||||
|
||||
@@ -184,17 +184,30 @@ function validSdkItem(value: unknown): boolean {
|
||||
if (!record(value) || typeof value.truncated !== "boolean") return false;
|
||||
if (value.kind === "raw")
|
||||
return (
|
||||
exact(value, [
|
||||
"kind",
|
||||
"raw_id",
|
||||
"content",
|
||||
"source",
|
||||
"session_id",
|
||||
"memory_at",
|
||||
"score",
|
||||
"truncated",
|
||||
]) &&
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
"kind",
|
||||
"raw_id",
|
||||
"content",
|
||||
"source",
|
||||
"session_id",
|
||||
"memory_at",
|
||||
"score",
|
||||
"truncated",
|
||||
],
|
||||
["turn_id", "turn_index", "turn_count"],
|
||||
) &&
|
||||
text(value.raw_id, 1, 512) &&
|
||||
(value.turn_id === undefined ||
|
||||
value.turn_id === null ||
|
||||
text(value.turn_id, 1, 96)) &&
|
||||
(value.turn_index === undefined ||
|
||||
value.turn_index === null ||
|
||||
integer(value.turn_index, 0, Number.MAX_SAFE_INTEGER)) &&
|
||||
(value.turn_count === undefined ||
|
||||
value.turn_count === null ||
|
||||
integer(value.turn_count, 1, Number.MAX_SAFE_INTEGER)) &&
|
||||
text(value.content, 0, 20000) &&
|
||||
text(value.source, 1, 128) &&
|
||||
text(value.session_id, 1, 512) &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1063,6 +1063,9 @@ const baseEnglishMessages = {
|
||||
"少一些重复交代,多一些理解。": "Less repetition. More understanding.",
|
||||
onboardingMoreApps: "Scroll for more · {count} apps remaining",
|
||||
"onboardingMoreApps.one": "Scroll for more · {count} app remaining",
|
||||
onboardingMoreImportApps: "Scroll horizontally · {count} apps remaining",
|
||||
onboardingImportChecking:
|
||||
"Checking the import result; your selection is kept",
|
||||
onboardingAllApps: "All {count} apps shown",
|
||||
默认位置: "Default location",
|
||||
自定义位置: "Custom location",
|
||||
|
||||
@@ -83,6 +83,10 @@ export const onboardingSpanishMessages = {
|
||||
onboardingMoreApps: "Desplázate para ver más · Quedan {count} aplicaciones",
|
||||
"onboardingMoreApps.one":
|
||||
"Desplázate para ver más · Queda {count} aplicación",
|
||||
onboardingMoreImportApps:
|
||||
"Desplázate horizontalmente · Quedan {count} aplicaciones",
|
||||
onboardingImportChecking:
|
||||
"Comprobando el resultado de la importación; tu selección se conserva",
|
||||
onboardingAllApps: "Se muestran las {count} aplicaciones",
|
||||
默认位置: "Ubicación predeterminada",
|
||||
自定义位置: "Ubicación personalizada",
|
||||
@@ -393,6 +397,8 @@ export const onboardingChineseMessages = {
|
||||
onboardingImportHistory: "导入 {name} 历史",
|
||||
onboardingMoreApps: "向下查看更多 · 还有 {count} 个应用",
|
||||
"onboardingMoreApps.one": "向下查看更多 · 还有 {count} 个应用",
|
||||
onboardingMoreImportApps: "横向滑动查看更多 · 还有 {count} 个应用",
|
||||
onboardingImportChecking: "正在核对导入结果,已保存的选择不会丢失",
|
||||
onboardingAllApps: "已显示全部 {count} 个应用",
|
||||
onboardingImportRefused: "尚未获准导入({reason})。请核对来源后重试。",
|
||||
onboardingConfirmHistory: "确认这是 {name} 的历史位置",
|
||||
|
||||
@@ -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",
|
||||
@@ -1252,6 +1255,9 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"少一些重复交代,多一些理解。": "Weniger Wiederholung. Mehr Verständnis.",
|
||||
onboardingMoreApps: "Für mehr scrollen · {count} Apps verbleibend",
|
||||
"onboardingMoreApps.one": "Für mehr scrollen · {count} App verbleibend",
|
||||
onboardingMoreImportApps: "Horizontal scrollen · Noch {count} Apps",
|
||||
onboardingImportChecking:
|
||||
"Importergebnis wird geprüft; Ihre Auswahl bleibt erhalten",
|
||||
onboardingAllApps: "Alle {count} Apps angezeigt",
|
||||
默认位置: "Standardort",
|
||||
自定义位置: "Eigener Ort",
|
||||
|
||||
@@ -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",
|
||||
@@ -1268,6 +1271,10 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"Faites défiler pour voir plus · {count} applications restantes",
|
||||
"onboardingMoreApps.one":
|
||||
"Faites défiler pour voir plus · {count} application restante",
|
||||
onboardingMoreImportApps:
|
||||
"Faites défiler horizontalement · encore {count} applications",
|
||||
onboardingImportChecking:
|
||||
"Vérification du résultat de l’importation ; votre sélection est conservée",
|
||||
onboardingAllApps: "Les {count} applications sont affichées",
|
||||
默认位置: "Emplacement par défaut",
|
||||
自定义位置: "Emplacement personnalisé",
|
||||
|
||||
@@ -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",
|
||||
@@ -1236,6 +1239,9 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
onboardingMoreApps: "Gulir untuk melihat lainnya · {count} aplikasi lagi",
|
||||
"onboardingMoreApps.one":
|
||||
"Gulir untuk melihat lainnya · {count} aplikasi lagi",
|
||||
onboardingMoreImportApps: "Gulir horizontal · {count} aplikasi lagi",
|
||||
onboardingImportChecking:
|
||||
"Memeriksa hasil impor; pilihan Anda tetap tersimpan",
|
||||
onboardingAllApps: "Semua {count} aplikasi ditampilkan",
|
||||
默认位置: "Lokasi default",
|
||||
自定义位置: "Lokasi kustom",
|
||||
|
||||
@@ -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": "関連する記憶",
|
||||
@@ -1231,6 +1234,9 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
onboardingMoreApps: "スクロールしてさらに表示 · 残り {count} 個のアプリ",
|
||||
"onboardingMoreApps.one":
|
||||
"スクロールしてさらに表示 · 残り {count} 個のアプリ",
|
||||
onboardingMoreImportApps: "横にスクロール · 残り {count} 個のアプリ",
|
||||
onboardingImportChecking:
|
||||
"インポート結果を確認しています。選択内容は保持されています",
|
||||
onboardingAllApps: "全 {count} 個のアプリを表示中",
|
||||
默认位置: "既定の場所",
|
||||
自定义位置: "カスタムの場所",
|
||||
|
||||
@@ -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": "관련 기억",
|
||||
@@ -1207,6 +1210,9 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"少一些重复交代,多一些理解。": "반복은 줄이고, 이해는 깊게.",
|
||||
onboardingMoreApps: "스크롤하여 더 보기 · 앱 {count}개 남음",
|
||||
"onboardingMoreApps.one": "스크롤하여 더 보기 · 앱 {count}개 남음",
|
||||
onboardingMoreImportApps: "가로로 스크롤 · 앱 {count}개 남음",
|
||||
onboardingImportChecking:
|
||||
"가져오기 결과를 확인하는 중이며 선택 항목은 유지됩니다",
|
||||
onboardingAllApps: "앱 {count}개 모두 표시됨",
|
||||
默认位置: "기본 위치",
|
||||
自定义位置: "사용자 지정 위치",
|
||||
|
||||
@@ -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",
|
||||
@@ -1255,6 +1258,9 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"少一些重复交代,多一些理解。": "Menos repetição. Mais compreensão.",
|
||||
onboardingMoreApps: "Role para ver mais · Apps restantes: {count}",
|
||||
"onboardingMoreApps.one": "Role para ver mais · {count} app restante",
|
||||
onboardingMoreImportApps: "Role horizontalmente · {count} apps restantes",
|
||||
onboardingImportChecking:
|
||||
"Verificando o resultado da importação; sua seleção foi mantida",
|
||||
onboardingAllApps: "Todos os apps exibidos ({count})",
|
||||
默认位置: "Local padrão",
|
||||
自定义位置: "Local personalizado",
|
||||
|
||||
@@ -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": "Связанная запись памяти",
|
||||
@@ -1241,6 +1244,9 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
|
||||
"onboardingMoreApps.one":
|
||||
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
|
||||
onboardingMoreImportApps:
|
||||
"Прокрутите по горизонтали · осталось приложений: {count}",
|
||||
onboardingImportChecking: "Проверяем результат импорта; ваш выбор сохранён",
|
||||
onboardingAllApps: "Показаны все приложения: {count}",
|
||||
默认位置: "Расположение по умолчанию",
|
||||
自定义位置: "Своё расположение",
|
||||
|
||||
@@ -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": "檢視記憶",
|
||||
@@ -1130,6 +1133,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"少一些重复交代,多一些理解。": "少一些重複交代,多一些理解。",
|
||||
onboardingMoreApps: "向下捲動檢視更多 · 還有 {count} 個 App",
|
||||
"onboardingMoreApps.one": "向下捲動檢視更多 · 還有 {count} 個 App",
|
||||
onboardingMoreImportApps: "橫向捲動查看更多 · 還有 {count} 個 App",
|
||||
onboardingImportChecking: "正在確認匯入結果,已保留你的選擇",
|
||||
onboardingAllApps: "已顯示全部 {count} 個 App",
|
||||
默认位置: "預設位置",
|
||||
自定义位置: "自訂位置",
|
||||
|
||||
Reference in new issue
Block a user