Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9737431cf8 | ||
|
|
a8156dd410 | ||
|
|
30f796ddc4 | ||
|
|
d2f1acc84a | ||
|
|
2e22399e2e | ||
|
|
fdb62be235 | ||
|
|
4c2844531d | ||
|
|
d23ebf9bd8 | ||
|
|
7a80287f4b | ||
|
|
9004977163 | ||
|
|
7f0c8198d9 | ||
|
|
25349b4cab | ||
|
|
5a42ce5861 | ||
|
|
b7731ac834 | ||
|
|
9560327ee2 | ||
|
|
f445ad69c4 | ||
|
|
45dbf46545 | ||
|
|
fac7a43ffb | ||
|
|
6e4b22a363 | ||
|
|
c186e5b2fb | ||
|
|
ad84e5decd | ||
|
|
0c43973fc2 | ||
|
|
ebf16b9399 | ||
|
|
87d70b0eb9 | ||
|
|
603e0843c5 | ||
|
|
80f4231e46 | ||
|
|
2f9e028c50 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
bce58e638d |
No files matched your search
+22
-1
@@ -4,6 +4,27 @@ Hy Memory and MemHub are versioned and released independently.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## Hy Memory 2.0.14 — 2026-11-04
|
||||||
|
|
||||||
- `search()` / `async_search()` take an optional `statuses` for explicit
|
- `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
|
## MemHub 1.0.6 — 2026-11-04
|
||||||
|
|
||||||
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
Build 45, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||||
App and Runtime.
|
App and Runtime.
|
||||||
|
|
||||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
- 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`
|
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||||
(build 41),GitHub 仓库为
|
(build 45),GitHub 仓库为
|
||||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||||
`memhub-cli`。当前代码架构见
|
`memhub-cli`。当前代码架构见
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||||
|
|||||||
@@ -644,11 +644,41 @@ class SdkRawPage(BaseModel):
|
|||||||
offset: int = Field(ge=0)
|
offset: int = Field(ge=0)
|
||||||
limit: int = Field(ge=1, le=500)
|
limit: int = Field(ge=1, le=500)
|
||||||
unit: Literal["raw", "turn"] = "raw"
|
unit: Literal["raw", "turn"] = "raw"
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
total_exact: bool = True
|
||||||
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
||||||
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
||||||
time_filtered: bool = False
|
time_filtered: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SdkRawGraphItem(BaseModel):
|
||||||
|
"""Small, non-authoritative projection used to draw the bounded 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 bounded page from the 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=300)
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SdkLayerCounts(BaseModel):
|
class SdkLayerCounts(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -1755,6 +1785,7 @@ def create_app(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
app.state.live_sync_watch_lock = asyncio.Lock()
|
app.state.live_sync_watch_lock = asyncio.Lock()
|
||||||
|
app.state.source_activation_tasks = {}
|
||||||
app.state.live_sync_supervisor.set_watched_sources(
|
app.state.live_sync_supervisor.set_watched_sources(
|
||||||
watch_roots if watching and runtime_settings.live_sync_enabled else ()
|
watch_roots if watching and runtime_settings.live_sync_enabled else ()
|
||||||
)
|
)
|
||||||
@@ -2090,6 +2121,11 @@ def create_app(
|
|||||||
app.state.memory_governance_store.wait_content_candidate_backfill,
|
app.state.memory_governance_store.wait_content_candidate_backfill,
|
||||||
5.0,
|
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:
|
if app.state.live_sync_watch is not None:
|
||||||
await app.state.live_sync_watch.shutdown()
|
await app.state.live_sync_watch.shutdown()
|
||||||
await app.state.live_sync_supervisor.shutdown()
|
await app.state.live_sync_supervisor.shutdown()
|
||||||
@@ -2203,6 +2239,19 @@ def create_app(
|
|||||||
from one_memory.embedding_switch_routes import embedding_switch_router
|
from one_memory.embedding_switch_routes import embedding_switch_router
|
||||||
|
|
||||||
app.include_router(embedding_switch_router(protected))
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -3219,6 +3268,69 @@ def create_app(
|
|||||||
"""`POST /api/v1/import-jobs` on one user's lane (Hermes only for another user)."""
|
"""`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)
|
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(
|
@app.post(
|
||||||
"/api/v1/sources/connect",
|
"/api/v1/sources/connect",
|
||||||
response_model=ConnectSourcesResponse,
|
response_model=ConnectSourcesResponse,
|
||||||
@@ -3308,44 +3420,21 @@ def create_app(
|
|||||||
if grant is not None:
|
if grant is not None:
|
||||||
_supervisor(app).register_adapter(registration.create_adapter(grant))
|
_supervisor(app).register_adapter(registration.create_adapter(grant))
|
||||||
_detection(app).pop(job.source_id, None)
|
_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)
|
configured.append(job.source_id)
|
||||||
if configured:
|
if configured:
|
||||||
previous = {
|
await asyncio.to_thread(
|
||||||
source_id: _config_store(app).sync_enabled(source_id) for source_id in configured
|
_config_store(app).set_sync_many,
|
||||||
}
|
{source_id: request.enable_sync for source_id in configured},
|
||||||
try:
|
)
|
||||||
await asyncio.to_thread(
|
if request.enable_sync:
|
||||||
_config_store(app).set_sync_many,
|
for job in result.jobs:
|
||||||
{source_id: request.enable_sync for source_id in configured},
|
if job.source_id in configured and job.status is ImportStatus.PENDING:
|
||||||
)
|
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
|
||||||
except BaseException:
|
_schedule_source_activation(
|
||||||
for source_id in configured:
|
request.request_id,
|
||||||
if request.enable_sync:
|
tuple(configured),
|
||||||
await _live_sync(app).stop_source(source_id)
|
enable_sync=request.enable_sync,
|
||||||
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
|
|
||||||
return ConnectSourcesResponse(**result.model_dump())
|
return ConnectSourcesResponse(**result.model_dump())
|
||||||
|
|
||||||
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
|
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
|
||||||
@@ -4481,6 +4570,26 @@ def create_app(
|
|||||||
] = {}
|
] = {}
|
||||||
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
|
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/api/v2/raw",
|
"/api/v2/raw",
|
||||||
response_model=SdkRawPage,
|
response_model=SdkRawPage,
|
||||||
@@ -4491,6 +4600,8 @@ def create_app(
|
|||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
unit: Literal["raw", "turn"] = Query(default="raw"),
|
unit: Literal["raw", "turn"] = Query(default="raw"),
|
||||||
|
paging: Literal["offset", "cursor"] = Query(default="offset"),
|
||||||
|
cursor: str | None = Query(default=None, max_length=160),
|
||||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||||
session_id: list[str] | None = Query(default=None), # noqa: B008
|
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||||
@@ -4516,9 +4627,52 @@ def create_app(
|
|||||||
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
||||||
or raw_list_filters()["statuses"]
|
or raw_list_filters()["statuses"]
|
||||||
)
|
)
|
||||||
|
if paging == "cursor" and unit != "turn":
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
|
||||||
|
if cursor is not None and paging != "cursor":
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
|
||||||
|
if paging == "cursor" and (offset or after is not None or before is not None):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
|
||||||
|
raw_cursor = (0, 0, 0, 0, "", "")
|
||||||
|
if cursor:
|
||||||
|
parts = cursor.split(":")
|
||||||
|
if (
|
||||||
|
len(parts) != 7
|
||||||
|
or parts[0] != "turn"
|
||||||
|
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
|
||||||
|
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
|
||||||
|
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||||
|
raw_cursor = (
|
||||||
|
int(parts[1]),
|
||||||
|
int(parts[2]),
|
||||||
|
int(parts[3]),
|
||||||
|
int(parts[4]),
|
||||||
|
parts[5],
|
||||||
|
parts[6],
|
||||||
|
)
|
||||||
|
if any(value > 100_000_000 for value in raw_cursor[:4]):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||||
sources = split_csv(source)
|
sources = split_csv(source)
|
||||||
excluded = split_csv(exclude_sources)
|
excluded = split_csv(exclude_sources)
|
||||||
sessions = split_csv(session_id)
|
sessions = split_csv(session_id)
|
||||||
|
cursor_scope = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"statuses": sorted(chosen_statuses),
|
||||||
|
"sources": sorted(sources),
|
||||||
|
"excluded": sorted(excluded),
|
||||||
|
"sessions": sorted(sessions),
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
if cursor and raw_cursor[5] != cursor_scope:
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
|
||||||
try:
|
try:
|
||||||
client = await _sdk_client_handle(app).client()
|
client = await _sdk_client_handle(app).client()
|
||||||
query = {
|
query = {
|
||||||
@@ -4527,8 +4681,138 @@ def create_app(
|
|||||||
"include_sources": sources or None,
|
"include_sources": sources or None,
|
||||||
"exclude_sources": excluded or None,
|
"exclude_sources": excluded or None,
|
||||||
"session_ids": sessions or None,
|
"session_ids": sessions or None,
|
||||||
"order": order if unit == "raw" else "desc",
|
"order": order if unit == "raw" or paging == "cursor" else "desc",
|
||||||
}
|
}
|
||||||
|
if unit == "turn" and paging == "cursor":
|
||||||
|
(
|
||||||
|
raw_offset,
|
||||||
|
turn_offset,
|
||||||
|
emitted,
|
||||||
|
previous_total,
|
||||||
|
expected_raw,
|
||||||
|
_scope,
|
||||||
|
) = raw_cursor
|
||||||
|
value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
)
|
||||||
|
raw_page = HySdkRawPage.from_memory_list(
|
||||||
|
value, limit=min(limit + 1, 500), offset=raw_offset
|
||||||
|
)
|
||||||
|
|
||||||
|
def raw_anchor(record: HySdkRawRecord) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
f"{record.raw_id}\0{record.content}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
|
if cursor:
|
||||||
|
if raw_page.total < previous_total:
|
||||||
|
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||||
|
observed_total = raw_page.total
|
||||||
|
added = raw_page.total - previous_total
|
||||||
|
if added and order == "desc":
|
||||||
|
raw_offset += added
|
||||||
|
value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
)
|
||||||
|
raw_page = HySdkRawPage.from_memory_list(
|
||||||
|
value,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
offset=raw_offset,
|
||||||
|
)
|
||||||
|
if raw_page.total != observed_total:
|
||||||
|
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")
|
||||||
|
previous_value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset - 1,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
previous_page = HySdkRawPage.from_memory_list(
|
||||||
|
previous_value, limit=1, offset=raw_offset - 1
|
||||||
|
)
|
||||||
|
valid_anchor = (
|
||||||
|
previous_page.total == raw_page.total
|
||||||
|
and bool(previous_page.raw)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if expected_raw and not valid_anchor:
|
||||||
|
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||||
|
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
|
||||||
|
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
|
||||||
|
start = turn_offset if record_index == 0 else 0
|
||||||
|
for index in range(start, len(ordered_turns)):
|
||||||
|
selected_turns.append((record, ordered_turns[index]))
|
||||||
|
if len(selected_turns) < limit:
|
||||||
|
continue
|
||||||
|
absolute_raw = raw_offset + record_index
|
||||||
|
next_raw = absolute_raw
|
||||||
|
next_turn = index + 1
|
||||||
|
if next_turn >= len(ordered_turns):
|
||||||
|
next_raw += 1
|
||||||
|
next_turn = 0
|
||||||
|
if next_raw < raw_page.total:
|
||||||
|
if next_turn:
|
||||||
|
anchor_record = record
|
||||||
|
else:
|
||||||
|
next_index = record_index + 1
|
||||||
|
anchor_record = (
|
||||||
|
raw_page.raw[next_index]
|
||||||
|
if next_index < len(raw_page.raw)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
anchor = (
|
||||||
|
raw_anchor(anchor_record)
|
||||||
|
if anchor_record is not None
|
||||||
|
else f"p{raw_anchor(record)}"
|
||||||
|
)
|
||||||
|
next_cursor = (
|
||||||
|
f"turn:{next_raw}:{next_turn}:"
|
||||||
|
f"{emitted + len(selected_turns)}:{raw_page.total}:"
|
||||||
|
f"{anchor}:{cursor_scope}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if len(selected_turns) >= limit:
|
||||||
|
break
|
||||||
|
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")
|
||||||
|
next_raw = raw_offset + len(raw_page.raw)
|
||||||
|
previous_anchor = raw_anchor(raw_page.raw[-1])
|
||||||
|
next_cursor = (
|
||||||
|
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
|
||||||
|
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
|
||||||
|
)
|
||||||
|
return SdkRawPage(
|
||||||
|
user_id=user_id,
|
||||||
|
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.
|
||||||
|
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
|
||||||
|
offset=emitted,
|
||||||
|
limit=limit,
|
||||||
|
unit="turn",
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
has_more=next_cursor is not None,
|
||||||
|
total_exact=False,
|
||||||
|
)
|
||||||
if unit == "raw":
|
if unit == "raw":
|
||||||
value = await asyncio.to_thread(
|
value = await asyncio.to_thread(
|
||||||
client.list_memories,
|
client.list_memories,
|
||||||
@@ -4691,6 +4975,77 @@ def create_app(
|
|||||||
time_filtered=time_filtered,
|
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=300, ge=1, le=300),
|
||||||
|
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||||
|
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||||
|
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||||
|
statuses: list[str] | None = Query(default=None), # noqa: B008
|
||||||
|
) -> SdkRawGraphPage:
|
||||||
|
"""Bounded 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)
|
||||||
|
if offset:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="raw_graph_is_single_page",
|
||||||
|
)
|
||||||
|
query: dict[str, object] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
**raw_list_filters(chosen_statuses),
|
||||||
|
"include_sources": sources or None,
|
||||||
|
"exclude_sources": excluded or None,
|
||||||
|
"session_ids": sessions or None,
|
||||||
|
"order": "desc",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
client = await _sdk_client_handle(app).client()
|
||||||
|
value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=0,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
page = HySdkRawPage.from_memory_list(
|
||||||
|
value,
|
||||||
|
limit=limit,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise _sdk_http_error(
|
||||||
|
error,
|
||||||
|
"hy_memory_raw_graph_failed",
|
||||||
|
) from None
|
||||||
|
projected = graph_items(page.raw)
|
||||||
|
selected = projected[:limit]
|
||||||
|
total = max(page.total, len(projected))
|
||||||
|
return SdkRawGraphPage(
|
||||||
|
user_id=user_id,
|
||||||
|
items=selected,
|
||||||
|
total=total,
|
||||||
|
offset=0,
|
||||||
|
limit=limit,
|
||||||
|
has_more=page.total > len(page.raw) or len(projected) > limit,
|
||||||
|
)
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/api/v2/memories/ask",
|
"/api/v2/memories/ask",
|
||||||
response_model=SdkMemoryAnswer,
|
response_model=SdkMemoryAnswer,
|
||||||
@@ -6417,9 +6772,8 @@ async def _recall_raw_sources(
|
|||||||
) -> frozenset[str] | None:
|
) -> frozenset[str] | None:
|
||||||
"""The raw sources this caller may read: the `/context` raw-lane rule.
|
"""The raw sources this caller may read: the `/context` raw-lane rule.
|
||||||
|
|
||||||
Recall's own grant does not cover original conversations. A host reads
|
`om_conversations` plus `all-apps` reads every connected, unrevoked source.
|
||||||
them only with `om_conversations` and its granted, unrevoked sources; a
|
A trusted local caller reads every source without an external grant.
|
||||||
trusted local caller reads every source.
|
|
||||||
"""
|
"""
|
||||||
if access is None:
|
if access is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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":
|
if command == "sdk-migration-status":
|
||||||
return sdk_migration_status(options, stream)
|
return sdk_migration_status(options, stream)
|
||||||
if command == "capture" and len(options) == 1:
|
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)
|
return capture_stdio(options[0], sys.stdin, stream)
|
||||||
if command not in {"status", "doctor"} or options not in ([], ["--json"]):
|
if command not in {"status", "doctor"} or options not in ([], ["--json"]):
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ from one_memory.context_models import ContextSearchRequest
|
|||||||
from one_memory.external_memory_access import ExternalMemoryAccess
|
from one_memory.external_memory_access import ExternalMemoryAccess
|
||||||
from one_memory.memory_server.store_binding import store_for
|
from one_memory.memory_server.store_binding import store_for
|
||||||
|
|
||||||
#: The revoked-source query, per engine.
|
#: The connected-source query, per engine.
|
||||||
#:
|
#:
|
||||||
#: Identical text today because it uses no placeholders and reads by position,
|
#: Identical text today because it uses no placeholders and reads by position,
|
||||||
#: which both engines support. Kept as a named constant so that adding a
|
#: which both engines support. Kept as a named constant so that adding a
|
||||||
#: parameter later forces the `?`/`%s` difference to be considered here rather
|
#: parameter later forces the `?`/`%s` difference to be considered here rather
|
||||||
#: than being introduced silently at the call site.
|
#: than being introduced silently at the call site.
|
||||||
REVOKED_SOURCES_SQL = (
|
CONNECTED_SOURCES_SQL = (
|
||||||
"SELECT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NOT NULL"
|
"SELECT DISTINCT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NULL"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,16 +49,10 @@ def resolve_context_authority(
|
|||||||
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
|
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
|
||||||
)
|
)
|
||||||
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
|
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
|
||||||
declared_sources = frozenset(
|
|
||||||
scope.removeprefix("source:") for scope in access.scopes if scope.startswith("source:")
|
|
||||||
)
|
|
||||||
raw_allowed = (
|
raw_allowed = (
|
||||||
raw_requested
|
raw_requested and "om_conversations" in access.tools and "all-apps" in access.scopes
|
||||||
and "om_conversations" in access.tools
|
|
||||||
and "all-apps" in access.scopes
|
|
||||||
and bool(declared_sources)
|
|
||||||
)
|
)
|
||||||
if explicit is not None and (not raw_allowed or not explicit <= declared_sources):
|
if explicit is not None and not raw_allowed:
|
||||||
raise HTTPException(403, "external_context_scope_denied")
|
raise HTTPException(403, "external_context_scope_denied")
|
||||||
|
|
||||||
raw_sources: frozenset[str] = frozenset()
|
raw_sources: frozenset[str] = frozenset()
|
||||||
@@ -70,10 +64,10 @@ def resolve_context_authority(
|
|||||||
# the same code runs on both engines without a branch per engine.
|
# the same code runs on both engines without a branch per engine.
|
||||||
if hasattr(db, "set_progress_handler"):
|
if hasattr(db, "set_progress_handler"):
|
||||||
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
|
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
|
||||||
revoked = frozenset(str(row[0]) for row in db.execute(REVOKED_SOURCES_SQL))
|
connected = frozenset(str(row[0]) for row in db.execute(CONNECTED_SOURCES_SQL))
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
raise TimeoutError
|
raise TimeoutError
|
||||||
raw_sources = declared_sources - revoked
|
raw_sources = connected
|
||||||
if explicit is not None:
|
if explicit is not None:
|
||||||
if not explicit <= raw_sources:
|
if not explicit <= raw_sources:
|
||||||
raise HTTPException(403, "external_context_scope_denied")
|
raise HTTPException(403, "external_context_scope_denied")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Authenticated SDK context search route, without generation or reindex side effects."""
|
"""Authenticated SDK context search route, without generation or reindex side effects."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -36,6 +37,8 @@ from one_memory.semantic_deadline import SemanticMemoryHit
|
|||||||
from one_memory.user_scope import owns
|
from one_memory.user_scope import owns
|
||||||
|
|
||||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||||
|
SDK_CONTEXT_CONTENT_LIMIT = 20_000
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
|
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
|
||||||
#: schema/working floors describe memory lanes; the raw lane is always the
|
#: schema/working floors describe memory lanes; the raw lane is always the
|
||||||
@@ -90,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_context_content(content: str) -> tuple[str, bool]:
|
||||||
|
"""Fit SDK text into its wire contract before Pydantic validates it."""
|
||||||
|
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
|
||||||
|
return content, False
|
||||||
|
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
|
||||||
|
|
||||||
|
|
||||||
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||||
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
||||||
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
||||||
@@ -170,34 +180,39 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
)
|
)
|
||||||
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||||
if not turns:
|
if not turns:
|
||||||
|
content, truncated = bounded_context_content(hit.content)
|
||||||
primary.append(
|
primary.append(
|
||||||
SdkRawContextItem(
|
SdkRawContextItem(
|
||||||
raw_id=hit.raw_id,
|
raw_id=hit.raw_id,
|
||||||
content=hit.content,
|
content=content,
|
||||||
source=hit.source,
|
source=hit.source,
|
||||||
session_id=hit.session_id,
|
session_id=hit.session_id,
|
||||||
memory_at=memory_at,
|
memory_at=memory_at,
|
||||||
score=hit.score,
|
score=hit.score,
|
||||||
|
truncated=truncated,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# The embedding is the batch's: the hit is shown by the turns
|
# The embedding is the batch's: the hit is shown by the turns
|
||||||
# that contain the query, or else by its closest turn, each
|
# that contain the query, or else by its closest turn, each
|
||||||
# with the hit's score.
|
# with the hit's score.
|
||||||
shown = [
|
shown: list[SdkRawContextItem] = []
|
||||||
SdkRawContextItem(
|
for turn in turns_for_hit(turns, value.query):
|
||||||
raw_id=hit.raw_id,
|
content, truncated = bounded_context_content(turn.content)
|
||||||
turn_id=turn.turn_id,
|
shown.append(
|
||||||
turn_index=turn.turn_index,
|
SdkRawContextItem(
|
||||||
turn_count=turn.turn_count,
|
raw_id=hit.raw_id,
|
||||||
content=turn.content,
|
turn_id=turn.turn_id,
|
||||||
source=hit.source,
|
turn_index=turn.turn_index,
|
||||||
session_id=hit.session_id,
|
turn_count=turn.turn_count,
|
||||||
memory_at=memory_at,
|
content=content,
|
||||||
score=hit.score,
|
source=hit.source,
|
||||||
|
session_id=hit.session_id,
|
||||||
|
memory_at=memory_at,
|
||||||
|
score=hit.score,
|
||||||
|
truncated=truncated,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for turn in turns_for_hit(turns, value.query)
|
|
||||||
]
|
|
||||||
primary.append(shown[0])
|
primary.append(shown[0])
|
||||||
siblings.extend(shown[1:])
|
siblings.extend(shown[1:])
|
||||||
chosen = primary[: value.limit]
|
chosen = primary[: value.limit]
|
||||||
@@ -250,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
or not owns(memory.user_id, value.user_id)
|
or not owns(memory.user_id, value.user_id)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
content, truncated = bounded_context_content(
|
||||||
|
hit.formatted_content
|
||||||
|
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
|
||||||
|
else memory.content
|
||||||
|
)
|
||||||
items.append(
|
items.append(
|
||||||
SdkMemoryContextItem(
|
SdkMemoryContextItem(
|
||||||
memory_id=memory.memory_id,
|
memory_id=memory.memory_id,
|
||||||
content=(
|
content=content,
|
||||||
hit.formatted_content
|
|
||||||
if hit.sdk_formatted
|
|
||||||
and hit.formatting_complete
|
|
||||||
and hit.formatted_content
|
|
||||||
else memory.content
|
|
||||||
),
|
|
||||||
layer=memory.layer,
|
layer=memory.layer,
|
||||||
status=memory.status,
|
status=memory.status,
|
||||||
source=memory.source,
|
source=memory.source,
|
||||||
@@ -269,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
score=hit.score,
|
score=hit.score,
|
||||||
|
truncated=truncated,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(items)
|
return tuple(items)
|
||||||
@@ -318,10 +333,45 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
if isinstance(lane, EmbeddingUnavailableError):
|
if isinstance(lane, EmbeddingUnavailableError):
|
||||||
# No lexical fallback: the query could not be embedded.
|
# No lexical fallback: the query could not be embedded.
|
||||||
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
||||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
requested_lanes = {
|
||||||
|
"raw": raw_items,
|
||||||
|
"memory": memory_items,
|
||||||
|
}
|
||||||
|
failed_lanes = {
|
||||||
|
name: lane
|
||||||
|
for name, lane in requested_lanes.items()
|
||||||
|
if name in value.kinds and isinstance(lane, Exception)
|
||||||
|
}
|
||||||
|
for name, error in failed_lanes.items():
|
||||||
|
logger.warning(
|
||||||
|
"sdk_context_lane_failed lane=%s error_class=%s",
|
||||||
|
name,
|
||||||
|
type(error).__name__,
|
||||||
|
)
|
||||||
|
all_requested_failed = len(failed_lanes) == len(value.kinds)
|
||||||
|
partial = bool(failed_lanes)
|
||||||
raw_candidates = (
|
raw_candidates = (
|
||||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||||
)
|
)
|
||||||
|
if access is not None and raw_candidates:
|
||||||
|
try:
|
||||||
|
current = await asyncio.to_thread(
|
||||||
|
resolve_context_authority,
|
||||||
|
value,
|
||||||
|
access,
|
||||||
|
database,
|
||||||
|
deadline=started + value.timeout_ms / 1000,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(503, "context_authority_unavailable") from None
|
||||||
|
current_sources = current.raw_sources or frozenset()
|
||||||
|
raw_candidates = tuple(
|
||||||
|
item
|
||||||
|
for item in raw_candidates
|
||||||
|
if item.kind == "raw" and item.source in current_sources
|
||||||
|
)
|
||||||
memory_candidates = (
|
memory_candidates = (
|
||||||
()
|
()
|
||||||
if isinstance(memory_items, Exception)
|
if isinstance(memory_items, Exception)
|
||||||
@@ -347,11 +397,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
|||||||
if len(content) > remaining:
|
if len(content) > remaining:
|
||||||
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
||||||
truncated = True
|
truncated = True
|
||||||
|
truncated = truncated or item.truncated
|
||||||
selected.append(item)
|
selected.append(item)
|
||||||
used += len(item.content)
|
used += len(item.content)
|
||||||
final = SdkContextSearchResponse(
|
final = SdkContextSearchResponse(
|
||||||
request_id=str(uuid4()),
|
request_id=str(uuid4()),
|
||||||
state=("partial" if partial else "grounded" if selected else "empty"),
|
state=(
|
||||||
|
"unavailable"
|
||||||
|
if all_requested_failed
|
||||||
|
else "partial"
|
||||||
|
if partial
|
||||||
|
else "grounded"
|
||||||
|
if selected
|
||||||
|
else "empty"
|
||||||
|
),
|
||||||
items=tuple(selected),
|
items=tuple(selected),
|
||||||
used_characters=used,
|
used_characters=used,
|
||||||
truncated=truncated,
|
truncated=truncated,
|
||||||
|
|||||||
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
|
|||||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
"""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
|
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
|
did. Every display language, zh-Hans included, is set explicitly. Like a
|
||||||
the collection identity never depends on it.
|
feature, the language never touches `vector_store`, so the collection
|
||||||
|
identity never depends on it.
|
||||||
"""
|
"""
|
||||||
if language is not None:
|
if language is not None:
|
||||||
config.language = language
|
config.language = language
|
||||||
|
|||||||
@@ -99,9 +99,7 @@ class HySdkMemoryRecord(BaseModel):
|
|||||||
created_at = gmt_created
|
created_at = gmt_created
|
||||||
updated_at = _epoch(raw.get("updated_at"))
|
updated_at = _epoch(raw.get("updated_at"))
|
||||||
modified_at = _epoch(raw.get("gmt_modified"))
|
modified_at = _epoch(raw.get("gmt_modified"))
|
||||||
sort_updated_at = (
|
sort_updated_at = modified_at if modified_at is not None else updated_at
|
||||||
modified_at if modified_at is not None else updated_at
|
|
||||||
)
|
|
||||||
return cls(
|
return cls(
|
||||||
memory_id=str(raw["memory_id"]),
|
memory_id=str(raw["memory_id"]),
|
||||||
content=str(raw["content"]),
|
content=str(raw["content"]),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Callable, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ def discovered_off_loop(
|
|||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_directory_names: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
|
directory_predicate: Callable[[Path], bool] | None = None,
|
||||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||||
"""The same walk, for callers that have an event loop to keep free.
|
"""The same walk, for callers that have an event loop to keep free.
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ def discovered_off_loop(
|
|||||||
suffixes=suffixes,
|
suffixes=suffixes,
|
||||||
max_targets=max_targets,
|
max_targets=max_targets,
|
||||||
excluded_directory_names=excluded_directory_names,
|
excluded_directory_names=excluded_directory_names,
|
||||||
|
directory_predicate=directory_predicate,
|
||||||
),
|
),
|
||||||
scope_key=grant.source_id,
|
scope_key=grant.source_id,
|
||||||
)
|
)
|
||||||
@@ -54,6 +56,7 @@ def discover_source_files(
|
|||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_directory_names: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
|
directory_predicate: Callable[[Path], bool] | None = None,
|
||||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||||
|
|
||||||
@@ -71,10 +74,16 @@ def discover_source_files(
|
|||||||
grant.root,
|
grant.root,
|
||||||
followlinks=False,
|
followlinks=False,
|
||||||
):
|
):
|
||||||
|
current = Path(directory)
|
||||||
child_directories[:] = sorted(
|
child_directories[:] = sorted(
|
||||||
child
|
child
|
||||||
for child in child_directories
|
for child in child_directories
|
||||||
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
if not (current / child).is_symlink()
|
||||||
|
and child not in excluded_directory_names
|
||||||
|
and (
|
||||||
|
directory_predicate is None
|
||||||
|
or directory_predicate((current / child).relative_to(grant.root))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for filename in sorted(filenames):
|
for filename in sorted(filenames):
|
||||||
if Path(filename).suffix.lower() not in suffixes:
|
if Path(filename).suffix.lower() not in suffixes:
|
||||||
|
|||||||
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
|
|||||||
|
|
||||||
async def _poll_source(self, source_id: str) -> None:
|
async def _poll_source(self, source_id: str) -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(self.interval_seconds)
|
# A watched source is normally triggered by its filesystem event.
|
||||||
|
# Its timer is only a missed-event backstop; recursively walking
|
||||||
|
# every multi-gigabyte host root once a minute starved unrelated
|
||||||
|
# Core reads even when no source had changed.
|
||||||
|
await asyncio.sleep(self._poll_interval(source_id))
|
||||||
try:
|
try:
|
||||||
await self.trigger(source_id)
|
await self.trigger(source_id)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
|
|||||||
except Exception as again:
|
except Exception as again:
|
||||||
self._note_internal_error(source_id, again)
|
self._note_internal_error(source_id, again)
|
||||||
|
|
||||||
|
def _poll_interval(self, source_id: str) -> float:
|
||||||
|
if source_id in (self._watched_sources or ()):
|
||||||
|
return max(self.interval_seconds, 15 * 60)
|
||||||
|
return self.interval_seconds
|
||||||
|
|
||||||
def _note_worker_still_running(self, source_id: str) -> None:
|
def _note_worker_still_running(self, source_id: str) -> None:
|
||||||
current = self.health(source_id)
|
current = self.health(source_id)
|
||||||
self._set_health(
|
self._set_health(
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
|
|||||||
self.header_gate = header_gate
|
self.header_gate = header_gate
|
||||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||||
self._history_listing_cache: SourceListing | None = None
|
self._history_listing_cache: SourceListing | None = None
|
||||||
|
self._probe_listing_cache: (
|
||||||
|
tuple[dict[str, SourceCursor], int | None, SourceListing] | None
|
||||||
|
) = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def descriptor(self) -> SourceDescriptor:
|
def descriptor(self) -> SourceDescriptor:
|
||||||
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
|
|||||||
predicate=self.file_predicate,
|
predicate=self.file_predicate,
|
||||||
cursors=options.cursors,
|
cursors=options.cursors,
|
||||||
)
|
)
|
||||||
|
self._probe_listing_cache = (
|
||||||
|
dict(options.cursors),
|
||||||
|
options.max_scan_targets,
|
||||||
|
listing,
|
||||||
|
)
|
||||||
return bool(listing.changed or listing.failures)
|
return bool(listing.changed or listing.failures)
|
||||||
|
|
||||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||||
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
|
|||||||
async for event in self._scan_frozen(options):
|
async for event in self._scan_frozen(options):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
listing = await asyncio.to_thread(
|
listing = (
|
||||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
await asyncio.to_thread(self._history_listing, options)
|
||||||
options,
|
if options.reuse_listing
|
||||||
|
else self._take_probe_listing(options)
|
||||||
|
or await asyncio.to_thread(self._fresh_listing, options)
|
||||||
)
|
)
|
||||||
for failure in listing.failures:
|
for failure in listing.failures:
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||||
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
|
|||||||
self._history_listing_cache = listing
|
self._history_listing_cache = listing
|
||||||
return listing
|
return listing
|
||||||
|
|
||||||
|
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
|
||||||
|
cached = self._probe_listing_cache
|
||||||
|
self._probe_listing_cache = None
|
||||||
|
if cached is None:
|
||||||
|
return None
|
||||||
|
cursors, max_scan_targets, listing = cached
|
||||||
|
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||||
|
return None
|
||||||
|
return listing
|
||||||
|
|
||||||
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
|
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
|
||||||
return list_changed_files(
|
return list_changed_files(
|
||||||
self.grant,
|
self.grant,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import JsonValue
|
from pydantic import JsonValue
|
||||||
|
|
||||||
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self.grant = grant
|
self.grant = grant
|
||||||
self.source_version = source_version
|
self.source_version = source_version
|
||||||
self._history_listing_cache: _CursorListing | None = None
|
self._history_listing_cache: _CursorListing | None = None
|
||||||
|
self._probe_listing_cache: (
|
||||||
|
tuple[dict[str, SourceCursor], int | None, _CursorListing] | None
|
||||||
|
) = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def descriptor(self) -> SourceDescriptor:
|
def descriptor(self) -> SourceDescriptor:
|
||||||
@@ -207,6 +211,15 @@ class CursorUnifiedSourceAdapter:
|
|||||||
state = await self.install_state()
|
state = await self.install_state()
|
||||||
return min(state.session_count, limit) if limit is not None else state.session_count
|
return min(state.session_count, limit) if limit is not None else state.session_count
|
||||||
|
|
||||||
|
async def has_changes(self, options: ScanOptions) -> bool:
|
||||||
|
listing = await asyncio.to_thread(self._fresh_listing, options)
|
||||||
|
self._probe_listing_cache = (
|
||||||
|
dict(options.cursors),
|
||||||
|
options.max_scan_targets,
|
||||||
|
listing,
|
||||||
|
)
|
||||||
|
return bool(listing.transcripts or listing.stores or listing.legacy or listing.failures)
|
||||||
|
|
||||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||||
listing = await asyncio.to_thread(
|
listing = await asyncio.to_thread(
|
||||||
@@ -222,9 +235,11 @@ class CursorUnifiedSourceAdapter:
|
|||||||
async for event in self._scan_frozen(options):
|
async for event in self._scan_frozen(options):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
listing = await asyncio.to_thread(
|
listing = (
|
||||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
await asyncio.to_thread(self._history_listing, options)
|
||||||
options,
|
if options.reuse_listing
|
||||||
|
else self._take_probe_listing(options)
|
||||||
|
or await asyncio.to_thread(self._fresh_listing, options)
|
||||||
)
|
)
|
||||||
for failure in listing.failures:
|
for failure in listing.failures:
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||||
@@ -329,6 +344,16 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self._history_listing_cache = listing
|
self._history_listing_cache = listing
|
||||||
return listing
|
return listing
|
||||||
|
|
||||||
|
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
|
||||||
|
cached = self._probe_listing_cache
|
||||||
|
self._probe_listing_cache = None
|
||||||
|
if cached is None:
|
||||||
|
return None
|
||||||
|
cursors, max_scan_targets, listing = cached
|
||||||
|
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||||
|
return None
|
||||||
|
return listing
|
||||||
|
|
||||||
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
||||||
return _list_cursor_files(
|
return _list_cursor_files(
|
||||||
self.grant,
|
self.grant,
|
||||||
@@ -376,6 +401,7 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self.grant,
|
self.grant,
|
||||||
suffixes=frozenset({".db"}),
|
suffixes=frozenset({".db"}),
|
||||||
max_targets=options.max_scan_targets or 100_000,
|
max_targets=options.max_scan_targets or 100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if isinstance(discovered, SourceFileDiscoveryFailure):
|
if isinstance(discovered, SourceFileDiscoveryFailure):
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
||||||
@@ -477,7 +503,10 @@ def _list_cursor_files(
|
|||||||
legacy: list[AuthorizedSourceFile] = []
|
legacy: list[AuthorizedSourceFile] = []
|
||||||
failures: list[SourceFileDiscoveryFailure] = []
|
failures: list[SourceFileDiscoveryFailure] = []
|
||||||
for item in discover_source_files(
|
for item in discover_source_files(
|
||||||
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
|
grant,
|
||||||
|
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
|
||||||
|
max_targets=max_targets,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if isinstance(item, SourceFileDiscoveryFailure):
|
if isinstance(item, SourceFileDiscoveryFailure):
|
||||||
failures.append(item)
|
failures.append(item)
|
||||||
@@ -586,6 +615,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_data_directory(relative_path: Path) -> bool:
|
||||||
|
"""Enter only Cursor directories that can contain conversation authority.
|
||||||
|
|
||||||
|
The granted root also contains extensions, skills, caches, logs and agent
|
||||||
|
artifacts. Recursively walking all of them every live-sync poll made the
|
||||||
|
reader monopolize Core even though none can pass the file predicates.
|
||||||
|
"""
|
||||||
|
parts = relative_path.parts
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
if parts[0] in {"chats", "acp-sessions"}:
|
||||||
|
return True
|
||||||
|
if parts[0] == "projects":
|
||||||
|
return len(parts) <= 2 or parts[2] == "agent-transcripts"
|
||||||
|
if parts[0] == "User":
|
||||||
|
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
|
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
|
||||||
parts = source_file.relative_path.parts
|
parts = source_file.relative_path.parts
|
||||||
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
||||||
@@ -634,6 +682,7 @@ def _select_transcript_files(
|
|||||||
grant,
|
grant,
|
||||||
suffixes=frozenset({".jsonl"}),
|
suffixes=frozenset({".jsonl"}),
|
||||||
max_targets=100_000,
|
max_targets=100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
||||||
continue
|
continue
|
||||||
@@ -654,6 +703,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
|
|||||||
grant,
|
grant,
|
||||||
suffixes=frozenset({".jsonl"}),
|
suffixes=frozenset({".jsonl"}),
|
||||||
max_targets=100_000,
|
max_targets=100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
)
|
)
|
||||||
if isinstance(item, AuthorizedSourceFile)
|
if isinstance(item, AuthorizedSourceFile)
|
||||||
and _is_transcript_file(item)
|
and _is_transcript_file(item)
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ class HyLifecycle:
|
|||||||
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
|
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
|
||||||
other.hidden
|
|
||||||
)
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(tuple(sorted(self.hidden.items())))
|
return hash(tuple(sorted(self.hidden.items())))
|
||||||
@@ -27,9 +25,5 @@ class HyLifecycle:
|
|||||||
|
|
||||||
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
||||||
return tuple(
|
return tuple(
|
||||||
sorted(
|
sorted(old for old, replacements in self.hidden.items() if memory_id in replacements)
|
||||||
old
|
|
||||||
for old, replacements in self.hidden.items()
|
|
||||||
if memory_id in replacements
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
@@ -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 json
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
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 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.database_access import main_database
|
||||||
from one_memory.derived_echo import PREFACE, strip_injected_memory
|
from one_memory.derived_echo import PREFACE, strip_injected_memory
|
||||||
from one_memory.imports.access import opaque_workspace_id, safe_project_label
|
from one_memory.imports.access import opaque_workspace_id, safe_project_label
|
||||||
@@ -19,6 +39,7 @@ from one_memory.imports.models import (
|
|||||||
EvidenceRecord,
|
EvidenceRecord,
|
||||||
OriginMetadata,
|
OriginMetadata,
|
||||||
WrittenBy,
|
WrittenBy,
|
||||||
|
materialize_write_input,
|
||||||
)
|
)
|
||||||
from one_memory.imports.sources.claude_code import parse_claude_code_record
|
from one_memory.imports.sources.claude_code import parse_claude_code_record
|
||||||
from one_memory.imports.sources.codex import HARNESS_PREFIXES, parse_codex_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.pi import _is_automated_session
|
||||||
from one_memory.imports.sources.workbuddy import _is_scheduled_automation, parse_workbuddy_record
|
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"})
|
_UNKNOWN_USER = frozenset({"hermes", "opencode", "openclaw"})
|
||||||
_USER_FIELDS = {
|
_USER_FIELDS = {
|
||||||
"UserPromptSubmit": "prompt",
|
"UserPromptSubmit": "prompt",
|
||||||
@@ -75,112 +94,130 @@ _FILE_FALLBACK = {
|
|||||||
}
|
}
|
||||||
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
||||||
_TEXT_BLOCKS = frozenset({"text"})
|
_TEXT_BLOCKS = frozenset({"text"})
|
||||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
|
||||||
|
|
||||||
|
|
||||||
def database_path() -> Path:
|
class CaptureOutcome(StrEnum):
|
||||||
from one_memory.config import load_settings
|
"""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:
|
def capture_hook(
|
||||||
"""Host hooks must stay silent. A bad payload does not fail the turn."""
|
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
|
Runs on SDK authority through Core's single writer: the lines become
|
||||||
if source_id not in CAPTURE_SOURCES:
|
write-job input exactly as live file sync stages them
|
||||||
return 2
|
(`materialize_write_input` -> `stage_sdk_input` -> `finalize_sdk_file`,
|
||||||
database = database_path()
|
live priority). No Evidence table is involved.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
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):
|
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.
|
# Cursor also runs ~/.claude/settings.json hooks; its own hook already files the turn.
|
||||||
if source_id != "cursor" and "cursor_version" in payload:
|
if source_id != "cursor" and "cursor_version" in payload:
|
||||||
return False
|
return CaptureOutcome.IGNORED
|
||||||
if not database.is_file():
|
if not database.is_file():
|
||||||
return False
|
return CaptureOutcome.IGNORED
|
||||||
rejection = capture_rejection(database, source_id)
|
rejection = capture_rejection(database, source_id)
|
||||||
if rejection is not None:
|
if rejection is not None:
|
||||||
# Silent, and the reason is counted rather than printed: a hook that
|
# Silent, and the reason is counted rather than printed: a hook that
|
||||||
# spoke would put this into the host's turn as text.
|
# spoke would put this into the host's turn as text.
|
||||||
record_capture_rejection(database, source_id, rejection)
|
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"))):
|
if source_id == "pi" and _is_automated_session(first_string(payload.get("cwd"))):
|
||||||
return False
|
return CaptureOutcome.IGNORED
|
||||||
lines = _messages(source_id, payload)
|
if file_polled(source_id):
|
||||||
if lines:
|
# The same turn reaches the SDK once, from the file: the two lanes give
|
||||||
conversation_id = _conversation(payload)
|
# one message different identities, so staging both wrote it twice.
|
||||||
store = EvidenceStore(database)
|
return CaptureOutcome.FILE_POLLED
|
||||||
wrote = False
|
inputs = _hook_inputs(source_id, payload) or _capture_transcript(source_id, payload, database)
|
||||||
for role, written_by, verbatim, message_id in lines:
|
if not inputs:
|
||||||
target = conversation_id
|
return CaptureOutcome.IGNORED
|
||||||
if target is None:
|
observed = datetime.now(UTC)
|
||||||
continue
|
conversations: dict[str, list[EvidenceRecord]] = {}
|
||||||
result = store.put(
|
for value in inputs:
|
||||||
EvidenceInput(
|
conversations.setdefault(value.conversation_id, []).append(
|
||||||
source_id=source_id,
|
materialize_write_input(value, observed_at=observed)
|
||||||
conversation_id=target,
|
)
|
||||||
message_id=message_id,
|
for conversation_id, records in conversations.items():
|
||||||
source_ordinal=None if message_id else "hook-visible",
|
# The turn rule decides what is written (`turn_batches`): a turn is
|
||||||
role=role,
|
# complete when the next user message arrives, batches are 5 turns or
|
||||||
written_by=written_by,
|
# 8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||||
created_at=datetime.now(UTC),
|
queue.assemble_live_records(tuple(records), file_key=f"hook:{conversation_id}")
|
||||||
verbatim=verbatim,
|
return CaptureOutcome.STAGED
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_open(database: Path, source_id: str) -> bool:
|
def _hook_inputs(source_id: str, payload: dict[str, object]) -> list[EvidenceInput]:
|
||||||
return capture_rejection(database, source_id) is None
|
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):
|
class CaptureRejection(StrEnum):
|
||||||
@@ -255,22 +292,6 @@ def record_capture_rejection(
|
|||||||
return
|
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:
|
def _conversation(payload: dict[str, object]) -> str | None:
|
||||||
return first_string(
|
return first_string(
|
||||||
cast(JsonValue | None, payload.get("session_id")),
|
cast(JsonValue | None, payload.get("session_id")),
|
||||||
@@ -470,34 +491,35 @@ def _opencode_messages(
|
|||||||
return lines
|
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."""
|
"""Stop without in-memory text reads the granted transcript, then the same parser."""
|
||||||
|
|
||||||
event = payload.get("hook_event_name") or payload.get("hookEventName")
|
event = payload.get("hook_event_name") or payload.get("hookEventName")
|
||||||
parser = _FILE_FALLBACK.get(source_id)
|
parser = _FILE_FALLBACK.get(source_id)
|
||||||
transcript = payload.get("transcript_path")
|
transcript = payload.get("transcript_path")
|
||||||
if event not in _FALLBACK_EVENTS or parser is None or not isinstance(transcript, str):
|
if event not in _FALLBACK_EVENTS or parser is None or not isinstance(transcript, str):
|
||||||
return False
|
return []
|
||||||
root = _grant_root(database, source_id)
|
root = _grant_root(database, source_id)
|
||||||
if root is None:
|
if root is None:
|
||||||
return False
|
return []
|
||||||
try:
|
try:
|
||||||
granted = root.resolve()
|
granted = root.resolve()
|
||||||
target = Path(transcript).resolve()
|
target = Path(transcript).resolve()
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return []
|
||||||
if target != granted and granted not in target.parents:
|
if target != granted and granted not in target.parents:
|
||||||
return False
|
return []
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
return False
|
return []
|
||||||
_wait_stable(target)
|
_wait_stable(target)
|
||||||
try:
|
try:
|
||||||
raw = target.read_text(encoding="utf-8")
|
raw = target.read_text(encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return []
|
||||||
relative = target.relative_to(granted).as_posix()
|
relative = target.relative_to(granted).as_posix()
|
||||||
store = EvidenceStore(database)
|
inputs: list[EvidenceInput] = []
|
||||||
wrote = False
|
|
||||||
for index, line in enumerate(raw.splitlines()):
|
for index, line in enumerate(raw.splitlines()):
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
@@ -514,7 +536,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
|
|||||||
if not isinstance(parsed, ParsedMessage):
|
if not isinstance(parsed, ParsedMessage):
|
||||||
continue
|
continue
|
||||||
for row_index, row in enumerate(parsed.rows()):
|
for row_index, row in enumerate(parsed.rows()):
|
||||||
result = store.put(
|
inputs.append(
|
||||||
EvidenceInput(
|
EvidenceInput(
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
workspace=opaque_workspace_id(source_id, row.workspace),
|
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()},
|
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_submit_live_memory(
|
return inputs
|
||||||
store, result.record, changed=result.was_created or result.was_revised
|
|
||||||
)
|
|
||||||
wrote = True
|
|
||||||
return wrote
|
|
||||||
|
|
||||||
|
|
||||||
def _grant_root(database: Path, source_id: str) -> Path | None:
|
def _grant_root(database: Path, source_id: str) -> Path | None:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
|||||||
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||||
"model_probe_operation", default=None
|
"model_probe_operation", default=None
|
||||||
)
|
)
|
||||||
|
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
|
||||||
|
|
||||||
|
|
||||||
class ProbeCode(StrEnum):
|
class ProbeCode(StrEnum):
|
||||||
@@ -113,16 +114,26 @@ class ModelProber:
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
token = _operation.set((operations, request.operation_id))
|
token = _operation.set((operations, request.operation_id))
|
||||||
|
dispatch_token = _dispatch_started.set(False)
|
||||||
try:
|
try:
|
||||||
await self._probe(profile_id)
|
await self._probe(profile_id)
|
||||||
except ProbeOperationConflict:
|
except ProbeOperationConflict:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
operations.fail, request.operation_id, "model_probe_profile_changed"
|
operations.fail, request.operation_id, "model_probe_profile_changed"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
# Unknown dispatch/storage outcomes stay reserved, including across restart.
|
if _dispatch_started.get():
|
||||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
# Once a request may have reached the provider, preserve the
|
||||||
|
# reservation so a retry cannot duplicate a possibly billed call.
|
||||||
|
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"model probe failed before dispatch: %s", type(exc).__name__
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
operations.fail, request.operation_id, "model_probe_execution_failed"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
|
_dispatch_started.reset(dispatch_token)
|
||||||
_operation.reset(token)
|
_operation.reset(token)
|
||||||
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
||||||
|
|
||||||
@@ -317,6 +328,8 @@ class ModelProber:
|
|||||||
listing_optional: bool = False,
|
listing_optional: bool = False,
|
||||||
) -> ProbeStage:
|
) -> ProbeStage:
|
||||||
await self._check_operation()
|
await self._check_operation()
|
||||||
|
if _operation.get() is not None:
|
||||||
|
_dispatch_started.set(True)
|
||||||
started = monotonic()
|
started = monotonic()
|
||||||
try:
|
try:
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
|
|||||||
selected_source_ids: tuple[str, ...]
|
selected_source_ids: tuple[str, ...]
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
mode_intent: Literal["lite", "pro"] | None
|
mode_intent: Literal["lite", "pro", "ultra"] | None
|
||||||
setup_step: Literal["review", "mode", "model"] | None
|
setup_step: Literal["review", "mode", "model"] | None
|
||||||
model_profile_id: str | None
|
model_profile_id: str | None
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
mode_intent: Literal["lite", "pro"] | None = None
|
mode_intent: Literal["lite", "pro", "ultra"] | None = None
|
||||||
setup_step: Literal["review", "mode", "model"] | None = None
|
setup_step: Literal["review", "mode", "model"] | None = None
|
||||||
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ instead: plain retrieval over what was imported, the same lane
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -46,6 +47,50 @@ def recall_total_timeout_seconds(timeout_ms: int) -> float:
|
|||||||
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_raw_excerpt(content: str, query: str, limit: int) -> str:
|
||||||
|
"""Fit one raw hit without discarding the highest-ranked candidate.
|
||||||
|
|
||||||
|
Prefer the literal query, then its longest matching word. A semantic hit
|
||||||
|
with no lexical anchor keeps its beginning: that is also the part the
|
||||||
|
embedding service preserves when an input exceeds its own token limit.
|
||||||
|
"""
|
||||||
|
if len(content) <= limit:
|
||||||
|
return content
|
||||||
|
if limit <= 2:
|
||||||
|
return content[:limit]
|
||||||
|
|
||||||
|
anchor = 0
|
||||||
|
needle = query.strip()
|
||||||
|
if needle:
|
||||||
|
exact = re.search(re.escape(needle), content, re.IGNORECASE)
|
||||||
|
if exact is not None:
|
||||||
|
anchor = exact.start()
|
||||||
|
else:
|
||||||
|
terms = sorted(re.findall(r"\w+", needle), key=len, reverse=True)
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
found
|
||||||
|
for term in terms
|
||||||
|
if (found := re.search(re.escape(term), content, re.IGNORECASE)) is not None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match is not None:
|
||||||
|
anchor = match.start()
|
||||||
|
|
||||||
|
payload = limit
|
||||||
|
while True:
|
||||||
|
start = max(0, min(anchor - payload // 3, len(content) - payload))
|
||||||
|
end = min(len(content), start + payload)
|
||||||
|
adjusted = limit - int(start > 0) - int(end < len(content))
|
||||||
|
if adjusted == payload:
|
||||||
|
break
|
||||||
|
payload = adjusted
|
||||||
|
prefix = "…" if start > 0 else ""
|
||||||
|
suffix = "…" if end < len(content) else ""
|
||||||
|
return f"{prefix}{content[start:end]}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
async def recall_with_fallback(
|
async def recall_with_fallback(
|
||||||
request: RecallRequest,
|
request: RecallRequest,
|
||||||
*,
|
*,
|
||||||
@@ -123,6 +168,9 @@ async def _raw_recall(
|
|||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
return receipt(truncated_by=TruncationBudget.TIMEOUT)
|
return receipt(truncated_by=TruncationBudget.TIMEOUT)
|
||||||
|
current_sources = await raw_sources()
|
||||||
|
if sources is not None:
|
||||||
|
sources &= current_sources or frozenset()
|
||||||
hits: list[RecallHit] = []
|
hits: list[RecallHit] = []
|
||||||
used = 0
|
used = 0
|
||||||
truncated_by: TruncationBudget | None = None
|
truncated_by: TruncationBudget | None = None
|
||||||
@@ -137,13 +185,23 @@ async def _raw_recall(
|
|||||||
truncated_by=truncated_by,
|
truncated_by=truncated_by,
|
||||||
unexamined=len(found) - index,
|
unexamined=len(found) - index,
|
||||||
)
|
)
|
||||||
if used + len(hit.content) > request.character_budget:
|
remaining = request.character_budget - used
|
||||||
|
if remaining <= 0:
|
||||||
|
truncated_by = TruncationBudget.CHARACTER
|
||||||
|
return receipt(
|
||||||
|
tuple(hits),
|
||||||
|
used=used,
|
||||||
|
truncated_by=truncated_by,
|
||||||
|
unexamined=len(found) - index,
|
||||||
|
)
|
||||||
|
content = hit.content
|
||||||
|
if len(content) > remaining:
|
||||||
|
content = _bounded_raw_excerpt(content, request.query, remaining)
|
||||||
truncated_by = TruncationBudget.CHARACTER
|
truncated_by = TruncationBudget.CHARACTER
|
||||||
continue
|
|
||||||
hits.append(
|
hits.append(
|
||||||
RecallHit(
|
RecallHit(
|
||||||
memory_id=hit.raw_id,
|
memory_id=hit.raw_id,
|
||||||
content=hit.content,
|
content=content,
|
||||||
kind="raw",
|
kind="raw",
|
||||||
scopes=(),
|
scopes=(),
|
||||||
evidence_ids=(),
|
evidence_ids=(),
|
||||||
@@ -158,5 +216,5 @@ async def _raw_recall(
|
|||||||
session_id=hit.session_id,
|
session_id=hit.session_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
used += len(hit.content)
|
used += len(content)
|
||||||
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
|
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
|
||||||
@@ -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:
|
def _receipt(value: object) -> RuntimeReceipt | None:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -288,4 +288,3 @@ def verify_readback(
|
|||||||
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
|
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
|
||||||
chat_missing_before_migration=chat_missing_before_migration,
|
chat_missing_before_migration=chat_missing_before_migration,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
|
| `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_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_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.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_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. |
|
| `test_live_sync_job_coalescing.py` | `contract` | A poll that finds nothing must not leave an import job behind. |
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ def kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
|
|||||||
database = kuzu.Database(str(db_path))
|
database = kuzu.Database(str(db_path))
|
||||||
connection = kuzu.Connection(database)
|
connection = kuzu.Connection(database)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
f"CREATE NODE TABLE Memory("
|
f"CREATE NODE TABLE Memory(node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
||||||
f"node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
|
||||||
)
|
)
|
||||||
del connection
|
del connection
|
||||||
del database
|
del database
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from one_memory.imports.sources.registry import (
|
|||||||
build_source_catalog,
|
build_source_catalog,
|
||||||
)
|
)
|
||||||
from one_memory.imports.supervisor import ImportSupervisor
|
from one_memory.imports.supervisor import ImportSupervisor
|
||||||
from one_memory.live_capture import capture_hook
|
|
||||||
|
|
||||||
FREEZE_THRESHOLD_SECONDS = 1.0
|
FREEZE_THRESHOLD_SECONDS = 1.0
|
||||||
|
|
||||||
@@ -228,31 +227,17 @@ async def _run(
|
|||||||
|
|
||||||
hook_text = "realtime hook while history import is running"
|
hook_text = "realtime hook while history import is running"
|
||||||
hook_started = time.monotonic()
|
hook_started = time.monotonic()
|
||||||
result["hook_accepted"] = await asyncio.to_thread(
|
# A hook hands its turn to Core over loopback; it no longer writes the
|
||||||
capture_hook,
|
# database itself. What is measured is that hand-off under import load.
|
||||||
"workbuddy",
|
handed = await client.post(
|
||||||
{
|
"/api/v1/live-capture/workbuddy",
|
||||||
|
json={
|
||||||
"hook_event_name": "UserPromptSubmit",
|
"hook_event_name": "UserPromptSubmit",
|
||||||
"session_id": "pressure-live-session",
|
"session_id": "pressure-live-session",
|
||||||
"prompt": hook_text,
|
"prompt": hook_text,
|
||||||
},
|
},
|
||||||
app.state.evidence_store.database_path,
|
|
||||||
)
|
)
|
||||||
hook_deadline = hook_started + 10
|
result["hook_accepted"] = handed.status_code == 202
|
||||||
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_visible_ms"] = round((time.monotonic() - hook_started) * 1000, 1)
|
result["hook_visible_ms"] = round((time.monotonic() - hook_started) * 1000, 1)
|
||||||
|
|
||||||
during, during_unavailable, sampled_terminal = await _measure_recall_until(
|
during, during_unavailable, sampled_terminal = await _measure_recall_until(
|
||||||
|
|||||||
@@ -104,9 +104,7 @@ SDK_PRIVATE_ATTRIBUTES = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
|
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
|
||||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
|
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset({"_graph_store", "_loop_thread", "_run"}),
|
||||||
{"_graph_store", "_loop_thread", "_run"}
|
|
||||||
),
|
|
||||||
# Startup migration runs while Runtime writers are stopped. It is the one
|
# Startup migration runs while Runtime writers are stopped. It is the one
|
||||||
# non-runtime path allowed to manipulate SDK storage directly so that
|
# non-runtime path allowed to manipulate SDK storage directly so that
|
||||||
# migration-only mechanics never become public HyMemoryClient methods.
|
# migration-only mechanics never become public HyMemoryClient methods.
|
||||||
@@ -266,9 +264,7 @@ def test_no_sdk_private_implementation_access() -> None:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if used:
|
if used:
|
||||||
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(
|
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(relative, frozenset())
|
||||||
relative, frozenset()
|
|
||||||
)
|
|
||||||
if unexpected:
|
if unexpected:
|
||||||
offenders.append(f"{relative}: {sorted(unexpected)}")
|
offenders.append(f"{relative}: {sorted(unexpected)}")
|
||||||
assert not offenders, (
|
assert not offenders, (
|
||||||
@@ -291,9 +287,7 @@ def test_sdk_modules_import_only_the_public_package_root() -> None:
|
|||||||
):
|
):
|
||||||
offenders.append(relative)
|
offenders.append(relative)
|
||||||
break
|
break
|
||||||
if isinstance(node, ast.ImportFrom) and (
|
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("hy_memory."):
|
||||||
node.module or ""
|
|
||||||
).startswith("hy_memory."):
|
|
||||||
offenders.append(relative)
|
offenders.append(relative)
|
||||||
break
|
break
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -131,3 +131,24 @@ def test_run_as_the_entry_it_ends_itself_while_still_importing(tmp_path) -> None
|
|||||||
_reap([process])
|
_reap([process])
|
||||||
assert process.returncode == -signal.SIGALRM
|
assert process.returncode == -signal.SIGALRM
|
||||||
assert time.monotonic() - started < 10
|
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()
|
||||||
@@ -16,6 +16,8 @@ def database(tmp_path):
|
|||||||
path = tmp_path / "context.sqlite3"
|
path = tmp_path / "context.sqlite3"
|
||||||
with closing(sqlite3.connect(path)) as db, db:
|
with closing(sqlite3.connect(path)) as db, db:
|
||||||
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
|
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
|
||||||
|
db.execute("INSERT INTO source_grants VALUES('local','cursor',NULL)")
|
||||||
|
db.execute("INSERT INTO source_grants VALUES('local','codex',NULL)")
|
||||||
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
|
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
|
||||||
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
|
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
|
||||||
return path
|
return path
|
||||||
@@ -43,16 +45,14 @@ def test_memory_only_never_reads_raw_authority(database, monkeypatch):
|
|||||||
assert not result.raw_allowed and result.raw_sources == frozenset()
|
assert not result.raw_allowed and result.raw_sources == frozenset()
|
||||||
|
|
||||||
|
|
||||||
def test_raw_permission_does_not_grant_memory_and_removes_revoked_sources(database):
|
def test_all_apps_raw_reads_every_connected_source_and_removes_revoked_sources(database):
|
||||||
result = resolve(
|
result = resolve(database, access(["om_conversations"], ["all-apps", "source:cursor"]))
|
||||||
database, access(["om_conversations"], ["all-apps", "source:cursor", "source:revoked"])
|
assert result.raw_allowed and result.raw_sources == frozenset({"cursor", "codex"})
|
||||||
)
|
|
||||||
assert result.raw_allowed and result.raw_sources == frozenset({"cursor"})
|
|
||||||
assert not result.memory_allowed
|
assert not result.memory_allowed
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("scopes", [["source:cursor"], ["all-apps"], []])
|
@pytest.mark.parametrize("scopes", [["source:cursor"], []])
|
||||||
def test_raw_requires_all_apps_and_source(database, scopes):
|
def test_raw_requires_all_apps(database, scopes):
|
||||||
with pytest.raises(HTTPException) as error:
|
with pytest.raises(HTTPException) as error:
|
||||||
resolve(database, access(["om_conversations"], scopes))
|
resolve(database, access(["om_conversations"], scopes))
|
||||||
assert error.value.status_code == 403
|
assert error.value.status_code == 403
|
||||||
@@ -80,9 +80,7 @@ def test_explicit_raw_filter_must_be_currently_authorized(database, source):
|
|||||||
with pytest.raises(HTTPException) as error:
|
with pytest.raises(HTTPException) as error:
|
||||||
resolve(
|
resolve(
|
||||||
database,
|
database,
|
||||||
access(
|
access(["om_recall", "om_conversations"], ["all-apps"]),
|
||||||
["om_recall", "om_conversations"], ["all-apps", "source:cursor", "source:revoked"]
|
|
||||||
),
|
|
||||||
raw_source_ids=(source,),
|
raw_source_ids=(source,),
|
||||||
)
|
)
|
||||||
assert error.value.status_code == 403
|
assert error.value.status_code == 403
|
||||||
@@ -104,10 +102,19 @@ def test_filters_do_not_change_memory_scope(database):
|
|||||||
assert result.raw_sources == frozenset({"cursor"})
|
assert result.raw_sources == frozenset({"cursor"})
|
||||||
|
|
||||||
|
|
||||||
def test_implicit_revoked_raw_path_can_be_reported_beside_allowed_memory(database):
|
def test_explicit_raw_filter_accepts_any_connected_source_under_all_apps(database):
|
||||||
result = resolve(
|
result = resolve(
|
||||||
database, access(["om_recall", "om_conversations"], ["all-apps", "source:revoked"])
|
database,
|
||||||
|
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
||||||
|
raw_source_ids=("codex",),
|
||||||
)
|
)
|
||||||
|
assert result.raw_allowed and result.raw_sources == frozenset({"codex"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_connected_raw_source_can_be_reported_beside_allowed_memory(database):
|
||||||
|
with closing(sqlite3.connect(database)) as db, db:
|
||||||
|
db.execute("UPDATE source_grants SET revoked_at='2026-09-20' WHERE user_id='local'")
|
||||||
|
result = resolve(database, access(["om_recall", "om_conversations"], ["all-apps"]))
|
||||||
assert result.memory_allowed and not result.raw_allowed
|
assert result.memory_allowed and not result.raw_allowed
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +131,7 @@ def test_expired_deadline_does_not_open_database(database, monkeypatch):
|
|||||||
with pytest.raises(TimeoutError):
|
with pytest.raises(TimeoutError):
|
||||||
resolve_context_authority(
|
resolve_context_authority(
|
||||||
ContextSearchRequest(query="x"),
|
ContextSearchRequest(query="x"),
|
||||||
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
access(["om_conversations"], ["all-apps"]),
|
||||||
database,
|
database,
|
||||||
deadline=time.monotonic() - 1,
|
deadline=time.monotonic() - 1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ def test_external_marker_is_not_an_installation_grant(tmp_path):
|
|||||||
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
|
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
|
||||||
)
|
)
|
||||||
assert denied_tool.status_code == 403
|
assert denied_tool.status_code == 403
|
||||||
# om_conversations reaches Core only as the v2 raw lane, and only
|
# om_conversations reaches Core only as the v2 raw lane. all-apps
|
||||||
# with a source grant: all-apps alone opens no raw source.
|
# has nothing to open because this fresh home has no connected source.
|
||||||
no_raw = await client.post(
|
no_raw = await client.post(
|
||||||
"/api/v2/context/search",
|
"/api/v2/context/search",
|
||||||
json={"query": "history", "kinds": ["raw"]},
|
json={"query": "history", "kinds": ["raw"]},
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -293,6 +293,9 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
|
|||||||
|
|
||||||
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||||
|
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||||
|
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(live.interval_seconds, 15 * 60)
|
||||||
|
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||||
await imports.shutdown()
|
await imports.shutdown()
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|||||||
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
|
||||||
|
store, secrets, profile, ops, calls, respond = env
|
||||||
|
operation = str(uuid4())
|
||||||
|
|
||||||
|
def fail_require(*args, **kwargs):
|
||||||
|
raise RuntimeError("profile load failed")
|
||||||
|
|
||||||
|
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
|
||||||
|
with monkeypatch.context() as patch:
|
||||||
|
patch.setattr(store, "require", fail_require)
|
||||||
|
receipt = asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=operation,
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert receipt.state == "failed"
|
||||||
|
assert receipt.code == "model_probe_execution_failed"
|
||||||
|
assert receipt.result is None
|
||||||
|
assert calls == []
|
||||||
|
assert ops.lookup(operation) == receipt
|
||||||
|
assert (
|
||||||
|
asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=operation,
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== receipt
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=str(uuid4()),
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert completed.state == "completed"
|
||||||
|
assert len(calls) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
||||||
store, secrets, profile, ops, calls, respond = env
|
store, secrets, profile, ops, calls, respond = env
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
|
|||||||
assert replaced.json()["model_profile_id"] == profile_id
|
assert replaced.json()["model_profile_id"] == profile_id
|
||||||
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
||||||
|
|
||||||
|
ultra = await client.put(
|
||||||
|
"/api/v1/onboarding/progress",
|
||||||
|
json={"mode_intent": "ultra", "setup_step": "model"},
|
||||||
|
)
|
||||||
|
assert ultra.status_code == 200
|
||||||
|
assert ultra.json()["mode_intent"] == "ultra"
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
invalid_mode = await client.patch(
|
invalid_mode = await client.patch(
|
||||||
"/api/v1/onboarding/progress",
|
"/api/v1/onboarding/progress",
|
||||||
json={"mode_intent": "ultra"},
|
json={"mode_intent": "future"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert secret.status_code == 422
|
assert secret.status_code == 422
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||||
|
root = tmp_path / ".workbuddy" / "projects"
|
||||||
|
write_jsonl(
|
||||||
|
root / "project" / "session.jsonl",
|
||||||
|
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
|
||||||
|
)
|
||||||
|
adapter = create_workbuddy_adapter(grant("workbuddy", root))
|
||||||
|
from one_memory.imports.sources import common
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
original = common.list_changed_files
|
||||||
|
|
||||||
|
def counted(*args, **kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(common, "list_changed_files", counted)
|
||||||
|
|
||||||
|
async def probe_then_scan():
|
||||||
|
options = ScanOptions()
|
||||||
|
assert await adapter.has_changes(options)
|
||||||
|
return [event async for event in adapter.scan(options)]
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(probe_then_scan()))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
|
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
|
||||||
"""A Cursor store blob can be the host's session context, not the user's words.
|
"""A Cursor store blob can be the host's session context, not the user's words.
|
||||||
|
|
||||||
@@ -1352,6 +1382,68 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
|
|||||||
assert len({item.evidence.source_ordinal for item in records}) == 2
|
assert len({item.evidence.source_ordinal for item in records}) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
|
||||||
|
root = tmp_path / ".cursor"
|
||||||
|
write_jsonl(
|
||||||
|
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
write_jsonl(
|
||||||
|
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||||
|
root = tmp_path / ".cursor"
|
||||||
|
write_jsonl(
|
||||||
|
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "new conversation"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
|
||||||
|
from one_memory.imports.adapters import ScanOptions
|
||||||
|
from one_memory.imports.sources import cursor_unified
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
original = cursor_unified._list_cursor_files
|
||||||
|
|
||||||
|
def counted(*args, **kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
|
||||||
|
|
||||||
|
async def probe_then_scan():
|
||||||
|
options = ScanOptions()
|
||||||
|
assert await adapter.has_changes(options)
|
||||||
|
return [event async for event in adapter.scan(options)]
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(probe_then_scan()))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
||||||
root = tmp_path / ".cursor"
|
root = tmp_path / ".cursor"
|
||||||
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Without a usable generation model no memory can be distilled, and with no
|
Without a usable generation model no memory can be distilled, and with no
|
||||||
memory in the library a memory search can only come back empty. In both cases
|
memory in the library a memory search can only come back empty. In both cases
|
||||||
recall searches the SDK raw layer and says so with `mode: "raw"`. Original
|
recall searches the SDK raw layer and says so with `mode: "raw"`. External
|
||||||
conversations stay under their own grant: recall's grant does not widen it.
|
callers need the conversations tool and all-apps scope; Core resolves that to
|
||||||
|
every connected, unrevoked source.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -18,6 +19,7 @@ from one_memory import api
|
|||||||
from one_memory.api import create_app
|
from one_memory.api import create_app
|
||||||
from one_memory.config import Settings
|
from one_memory.config import Settings
|
||||||
from one_memory.hy_sdk_types import HySdkRawHit
|
from one_memory.hy_sdk_types import HySdkRawHit
|
||||||
|
from one_memory.imports.sources.registry import RootCandidateKind
|
||||||
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
|
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
|
||||||
from one_memory.recall_fallback import recall_with_fallback
|
from one_memory.recall_fallback import recall_with_fallback
|
||||||
from one_memory.secrets import InMemorySecretStore
|
from one_memory.secrets import InMemorySecretStore
|
||||||
@@ -156,6 +158,26 @@ def test_raw_fallback_respects_the_callers_raw_sources():
|
|||||||
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
|
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_fallback_rechecks_sources_after_search_before_releasing_content():
|
||||||
|
_, fakes = _fakes(
|
||||||
|
ready=False,
|
||||||
|
memories=[],
|
||||||
|
has_memories=False,
|
||||||
|
raw=[_raw("r1", "private", source="cursor")],
|
||||||
|
sources=frozenset({"cursor"}),
|
||||||
|
)
|
||||||
|
checks = iter((frozenset({"cursor"}), frozenset()))
|
||||||
|
|
||||||
|
async def sources():
|
||||||
|
return next(checks)
|
||||||
|
|
||||||
|
fakes["raw_sources"] = sources
|
||||||
|
receipt = run(RecallRequest(query="private"), fakes)
|
||||||
|
|
||||||
|
assert receipt.state == RecallState.EMPTY
|
||||||
|
assert receipt.hits == ()
|
||||||
|
|
||||||
|
|
||||||
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
|
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
|
||||||
calls, fakes = _fakes(
|
calls, fakes = _fakes(
|
||||||
ready=False,
|
ready=False,
|
||||||
@@ -187,6 +209,43 @@ def test_raw_fallback_keeps_the_count_and_character_budgets():
|
|||||||
assert by_chars.used_characters == 300
|
assert by_chars.used_characters == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_raw_hit_returns_a_query_anchored_excerpt_instead_of_empty():
|
||||||
|
_, fakes = _fakes(
|
||||||
|
ready=False,
|
||||||
|
memories=[],
|
||||||
|
has_memories=False,
|
||||||
|
raw=[_raw("large", ("x" * 5_000) + "WebGL 黄色节点" + ("y" * 5_000))],
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = run(
|
||||||
|
RecallRequest(query="WebGL 黄色节点", limit=3, character_budget=300),
|
||||||
|
fakes,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert receipt.state == RecallState.GROUNDED
|
||||||
|
assert len(receipt.hits) == 1
|
||||||
|
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||||
|
assert "WebGL 黄色节点" in receipt.hits[0].content
|
||||||
|
assert receipt.truncated_by.value == "character_budget"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prefix_length", [0, 100, 5_000, 9_980])
|
||||||
|
def test_raw_excerpt_never_exceeds_its_character_budget(prefix_length):
|
||||||
|
needle = "WebGL"
|
||||||
|
content = ("x" * prefix_length) + needle + ("y" * (10_000 - prefix_length))
|
||||||
|
_, fakes = _fakes(
|
||||||
|
ready=False,
|
||||||
|
memories=[],
|
||||||
|
has_memories=False,
|
||||||
|
raw=[_raw("large", content)],
|
||||||
|
)
|
||||||
|
|
||||||
|
receipt = run(RecallRequest(query=needle, character_budget=300), fakes)
|
||||||
|
|
||||||
|
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||||
|
assert needle in receipt.hits[0].content
|
||||||
|
|
||||||
|
|
||||||
def test_the_probe_never_falls_back():
|
def test_the_probe_never_falls_back():
|
||||||
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
|
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
|
||||||
|
|
||||||
@@ -237,6 +296,14 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
|||||||
headers={"Authorization": "Bearer recall-test"},
|
headers={"Authorization": "Bearer recall-test"},
|
||||||
) as client,
|
) as client,
|
||||||
):
|
):
|
||||||
|
cursor_root = tmp_path / "cursor"
|
||||||
|
cursor_root.mkdir()
|
||||||
|
app.state.source_grant_store.grant(
|
||||||
|
user_id="local",
|
||||||
|
source_id="cursor",
|
||||||
|
root=cursor_root,
|
||||||
|
candidate_kind=RootCandidateKind.DEFAULT,
|
||||||
|
)
|
||||||
local = await client.post("/api/v1/recall", json={"query": "数据库"})
|
local = await client.post("/api/v1/recall", json={"query": "数据库"})
|
||||||
recall_only = await client.post(
|
recall_only = await client.post(
|
||||||
"/api/v1/recall",
|
"/api/v1/recall",
|
||||||
@@ -247,9 +314,18 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
|||||||
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
|
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return local.json(), recall_only.json()
|
cross_app = await client.post(
|
||||||
|
"/api/v1/recall",
|
||||||
|
json={"query": "数据库"},
|
||||||
|
headers={
|
||||||
|
**EXTERNAL,
|
||||||
|
"X-One-Memory-Allowed-Tools": json.dumps(["om_recall", "om_conversations"]),
|
||||||
|
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps", "source:workbuddy"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return local.json(), recall_only.json(), cross_app.json()
|
||||||
|
|
||||||
local, recall_only = asyncio.run(scenario())
|
local, recall_only, cross_app = asyncio.run(scenario())
|
||||||
|
|
||||||
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
|
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
|
||||||
assert local["hits"][0]["content_format"] == "raw"
|
assert local["hits"][0]["content_format"] == "raw"
|
||||||
@@ -258,7 +334,9 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
|||||||
assert searched[0] is None
|
assert searched[0] is None
|
||||||
# A host granted recall alone may not read original conversations.
|
# A host granted recall alone may not read original conversations.
|
||||||
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
|
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
|
||||||
assert len(searched) == 1
|
# all-apps covers every connected source, not only the host's source scope.
|
||||||
|
assert cross_app["hits"][0]["source"] == "cursor"
|
||||||
|
assert searched == [None, frozenset({"cursor"})]
|
||||||
|
|
||||||
|
|
||||||
def test_the_total_recall_budget_stays_under_the_brokers_wait():
|
def test_the_total_recall_budget_stays_under_the_brokers_wait():
|
||||||
|
|||||||
@@ -214,7 +214,15 @@ class RecordingSdk:
|
|||||||
)
|
)
|
||||||
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
|
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
|
||||||
]
|
]
|
||||||
return {"vdb": {"memories": rows, "total": len(rows)}}
|
total = len(rows)
|
||||||
|
offset = kwargs.get("offset", 0)
|
||||||
|
limit = kwargs.get("limit", 100)
|
||||||
|
return {
|
||||||
|
"vdb": {
|
||||||
|
"memories": rows[offset : offset + limit],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
}
|
||||||
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
|
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
|
||||||
if layers:
|
if layers:
|
||||||
rows = [row for row in rows if row["layer"] in layers]
|
rows = [row for row in rows if row["layer"] in layers]
|
||||||
@@ -461,6 +469,101 @@ def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monk
|
|||||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
|
||||||
|
async def scenario(app, sdk, client):
|
||||||
|
app.state.memory_mode = lambda: "lite"
|
||||||
|
|
||||||
|
def search(query, **kwargs):
|
||||||
|
sdk._record("search", {"query": query, **kwargs})
|
||||||
|
memories = [
|
||||||
|
{
|
||||||
|
"memory_id": "raw-target",
|
||||||
|
"content": "[user]: lite 模式的 3d 图需要调整",
|
||||||
|
"layer": "l1_raw",
|
||||||
|
"status": "active",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"session_id": "target-session",
|
||||||
|
"source": "cursor",
|
||||||
|
"memory_at": 1_790_100_000,
|
||||||
|
"score": 0.97,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"memory_id": "raw-long",
|
||||||
|
"content": f"[user]: {'x' * 20_001}",
|
||||||
|
"layer": "l1_raw",
|
||||||
|
"status": "active",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"session_id": "long-session",
|
||||||
|
"source": "cursor",
|
||||||
|
"memory_at": 1_790_000_000,
|
||||||
|
"score": 0.9,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"request_id": "long-raw",
|
||||||
|
"memories": memories[1:] if query == "oversized" else memories,
|
||||||
|
}
|
||||||
|
|
||||||
|
sdk.search = search
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v2/context/search",
|
||||||
|
json={
|
||||||
|
"query": "3d",
|
||||||
|
"kinds": ["raw"],
|
||||||
|
"raw_unit": "turn",
|
||||||
|
"limit": 20,
|
||||||
|
"character_budget": 20_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["state"] == "grounded"
|
||||||
|
assert body["items"][0]["raw_id"] == "raw-target"
|
||||||
|
assert "3d" in body["items"][0]["content"]
|
||||||
|
assert body["items"][1]["raw_id"] == "raw-long"
|
||||||
|
assert body["items"][1]["truncated"] is True
|
||||||
|
assert body["truncated"] is True
|
||||||
|
|
||||||
|
single = await client.post(
|
||||||
|
"/api/v2/context/search",
|
||||||
|
json={
|
||||||
|
"query": "oversized",
|
||||||
|
"kinds": ["raw"],
|
||||||
|
"raw_unit": "turn",
|
||||||
|
"character_budget": 20_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert single.json()["items"][0]["truncated"] is True
|
||||||
|
assert single.json()["truncated"] is True
|
||||||
|
|
||||||
|
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
|
||||||
|
async def scenario(app, sdk, client):
|
||||||
|
app.state.memory_mode = lambda: "lite"
|
||||||
|
|
||||||
|
def search(_query, **_kwargs):
|
||||||
|
raise ValueError("private conversation text must not reach logs")
|
||||||
|
|
||||||
|
sdk.search = search
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v2/context/search",
|
||||||
|
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["state"] == "unavailable"
|
||||||
|
assert response.json()["items"] == []
|
||||||
|
|
||||||
|
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.fresh_sdk
|
@pytest.mark.fresh_sdk
|
||||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||||
async def scenario(app, sdk, client):
|
async def scenario(app, sdk, client):
|
||||||
@@ -712,6 +815,96 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
|||||||
["我喜欢绿茶", "也记住了。"],
|
["我喜欢绿茶", "也记住了。"],
|
||||||
["我叫费天翔", "好的,记住了。"],
|
["我叫费天翔", "好的,记住了。"],
|
||||||
]
|
]
|
||||||
|
cursor = None
|
||||||
|
first_cursor = None
|
||||||
|
cursor_turns = []
|
||||||
|
cursor_calls = len(sdk.all("list_memories"))
|
||||||
|
while True:
|
||||||
|
params = {"unit": "turn", "paging": "cursor", "limit": 1}
|
||||||
|
if cursor is not None:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
response = await client.get("/api/v2/raw", params=params)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
cursor_page = response.json()
|
||||||
|
assert cursor_page["total_exact"] is False
|
||||||
|
assert len(cursor_page["items"]) == 1
|
||||||
|
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
|
||||||
|
cursor = cursor_page["next_cursor"]
|
||||||
|
if first_cursor is None:
|
||||||
|
first_cursor = cursor
|
||||||
|
if cursor is None:
|
||||||
|
assert cursor_page["has_more"] is False
|
||||||
|
break
|
||||||
|
assert cursor_page["has_more"] is True
|
||||||
|
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
|
||||||
|
assert len(sdk.all("list_memories")) == cursor_calls + 3
|
||||||
|
graph_calls = len(sdk.all("list_memories"))
|
||||||
|
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
|
||||||
|
assert graph.status_code == 200, graph.text
|
||||||
|
assert len(sdk.all("list_memories")) == graph_calls + 1
|
||||||
|
graph_body = graph.json()
|
||||||
|
assert graph_body["total"] == 3
|
||||||
|
assert graph_body["has_more"] is True
|
||||||
|
assert [item["label"] for item in graph_body["items"]] == [
|
||||||
|
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
|
||||||
|
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
|
||||||
|
]
|
||||||
|
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
|
||||||
|
assert (
|
||||||
|
await client.get(
|
||||||
|
"/api/v2/raw/graph",
|
||||||
|
params={"limit": 2, "offset": 2},
|
||||||
|
)
|
||||||
|
).status_code == 422
|
||||||
|
inserted = {
|
||||||
|
"memory_id": "raw-new",
|
||||||
|
"content": "[user]: 新消息",
|
||||||
|
"status": "active",
|
||||||
|
"session_id": "s-new",
|
||||||
|
"source": "cursor",
|
||||||
|
"memory_at": 1_790_200_000,
|
||||||
|
"gmt_created": 1_790_200_100,
|
||||||
|
}
|
||||||
|
RAW_ROWS["local"].insert(0, inserted)
|
||||||
|
try:
|
||||||
|
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={
|
||||||
|
"unit": "turn",
|
||||||
|
"paging": "cursor",
|
||||||
|
"limit": 1,
|
||||||
|
"cursor": first_cursor,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert continued.status_code == 200, continued.text
|
||||||
|
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
|
||||||
|
finally:
|
||||||
|
RAW_ROWS["local"].remove(inserted)
|
||||||
|
assert (
|
||||||
|
await client.get(
|
||||||
|
"/api/v2/raw",
|
||||||
|
params={
|
||||||
|
"unit": "turn",
|
||||||
|
"paging": "cursor",
|
||||||
|
"cursor": first_cursor,
|
||||||
|
"source": "hermes",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).status_code == 422
|
||||||
|
assert (
|
||||||
|
await client.get(
|
||||||
|
"/api/v2/raw",
|
||||||
|
params={
|
||||||
|
"unit": "turn",
|
||||||
|
"paging": "cursor",
|
||||||
|
"cursor": "turn:²:0:0:2::0000000000000000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).status_code == 422
|
||||||
|
|
||||||
calls_before_refresh = len(sdk.all("list_memories"))
|
calls_before_refresh = len(sdk.all("list_memories"))
|
||||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||||
|
|||||||
@@ -293,6 +293,22 @@ def _retry_succeeds(home: Path, monkeypatch) -> None:
|
|||||||
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
|
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):
|
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
|
||||||
import one_memory.sdk_migration_runner as runner
|
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["import_status"] == "completed"
|
||||||
assert result["stored"] == 100_000
|
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_accepted"] is True
|
||||||
assert result["hook_stored"] is True
|
|
||||||
assert result["hook_visible_ms"] < 10_000
|
assert result["hook_visible_ms"] < 10_000
|
||||||
assert result["recall_sampling_terminal"] is True
|
assert result["recall_sampling_terminal"] is True
|
||||||
assert result["during_unavailable"] == 0
|
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())
|
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(
|
@pytest.mark.parametrize(
|
||||||
("turn_count", "age_days", "limited"),
|
("turn_count", "age_days", "limited"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pydantic import SecretStr
|
|||||||
from one_memory import api
|
from one_memory import api
|
||||||
from one_memory.api import create_app
|
from one_memory.api import create_app
|
||||||
from one_memory.config import Settings
|
from one_memory.config import Settings
|
||||||
|
from one_memory.imports.sources.registry import RootCandidateKind
|
||||||
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
||||||
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
|
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
|
||||||
from one_memory.secrets import InMemorySecretStore
|
from one_memory.secrets import InMemorySecretStore
|
||||||
@@ -96,8 +97,11 @@ class Sdk:
|
|||||||
|
|
||||||
|
|
||||||
class Handle:
|
class Handle:
|
||||||
|
def __init__(self, sdk=None):
|
||||||
|
self.sdk = sdk or Sdk()
|
||||||
|
|
||||||
async def client(self):
|
async def client(self):
|
||||||
return Sdk()
|
return self.sdk
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
return None
|
return None
|
||||||
@@ -195,3 +199,50 @@ def test_v2_host_calls_are_recorded_and_renderer_calls_are_not(tmp_path, monkeyp
|
|||||||
assert listed.json()["total"] == 3
|
assert listed.json()["total"] == 3
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fresh_sdk
|
||||||
|
def test_v2_raw_rechecks_a_source_revoked_during_search(tmp_path):
|
||||||
|
app = application(tmp_path)
|
||||||
|
|
||||||
|
class RevokingRawSdk:
|
||||||
|
def search(self, query, **kwargs):
|
||||||
|
app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
|
||||||
|
return {
|
||||||
|
"memories": {
|
||||||
|
"schema": [],
|
||||||
|
"proactive": [],
|
||||||
|
"normal": [
|
||||||
|
{
|
||||||
|
"memory_id": "raw-1",
|
||||||
|
"content": "must not escape",
|
||||||
|
"user_id": "local",
|
||||||
|
"agent_id": "all-apps",
|
||||||
|
"session_id": "session-1",
|
||||||
|
"source": "cursor",
|
||||||
|
"score": 0.9,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with app.router.lifespan_context(app), client_for(app, EXTERNAL) as host:
|
||||||
|
root = tmp_path / "cursor"
|
||||||
|
root.mkdir()
|
||||||
|
app.state.source_grant_store.grant(
|
||||||
|
user_id="local",
|
||||||
|
source_id="cursor",
|
||||||
|
root=root,
|
||||||
|
candidate_kind=RootCandidateKind.DEFAULT,
|
||||||
|
)
|
||||||
|
app.state.sdk_client_handle = Handle(RevokingRawSdk())
|
||||||
|
return await host.post(
|
||||||
|
"/api/v2/context/search",
|
||||||
|
json={"query": "private", "kinds": ["raw"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "must not escape" not in response.text
|
||||||
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
|
|||||||
).rejects.toThrow(/untrusted_window/);
|
).rejects.toThrow(/untrusted_window/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a problem and exports a redacted local bundle", async () => {
|
it("marks a problem and exports the complete local log bundle", async () => {
|
||||||
const value = await fixture();
|
const value = await fixture();
|
||||||
const mark = await value.handlers.get(
|
const mark = await value.handlers.get(
|
||||||
"one-memory:mark-diagnostic-incident",
|
"one-memory:mark-diagnostic-incident",
|
||||||
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
|||||||
incident_id: expect.any(String),
|
incident_id: expect.any(String),
|
||||||
session_id: expect.any(String),
|
session_id: expect.any(String),
|
||||||
});
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(value.dataDir, "logs", "core.log"),
|
||||||
|
"2026-10-14 10:31:00 WARNING database wait\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(value.dataDir, "logs", "service-error.log"),
|
||||||
|
"worker failed\n",
|
||||||
|
);
|
||||||
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(summary).toMatchObject({ build: "3800" });
|
expect(summary).toMatchObject({ build: "3800" });
|
||||||
|
const entries = execFileSync(
|
||||||
|
"/usr/bin/unzip",
|
||||||
|
["-Z1", path.join(value.dataDir, zip)],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.split("\n");
|
||||||
|
expect(entries).toContain("logs/core.log");
|
||||||
|
expect(entries).toContain("logs/service-error.log");
|
||||||
|
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
|
||||||
value.dispose();
|
value.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
|
|||||||
|
|
||||||
export async function exportDesktopDiagnostics(
|
export async function exportDesktopDiagnostics(
|
||||||
options: DiagnosticExportOptions,
|
options: DiagnosticExportOptions,
|
||||||
|
exportOptions: { includeRawLogs?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const build = options.buildNumber ?? options.productVersion;
|
const build = options.buildNumber ?? options.productVersion;
|
||||||
const target = path.join(
|
const target = path.join(
|
||||||
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
|
|||||||
dataDir: options.dataDir,
|
dataDir: options.dataDir,
|
||||||
target,
|
target,
|
||||||
productVersion: build,
|
productVersion: build,
|
||||||
|
includeRawLogs: exportOptions.includeRawLogs === true,
|
||||||
...(options.commit === undefined ? {} : { commit: options.commit }),
|
...(options.commit === undefined ? {} : { commit: options.commit }),
|
||||||
...(options.coreSha256 === undefined
|
...(options.coreSha256 === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
|
|||||||
options.ipc.handle(EXPORT, async (event, ...args) => {
|
options.ipc.handle(EXPORT, async (event, ...args) => {
|
||||||
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
||||||
options.requireTrustedWindow(event);
|
options.requireTrustedWindow(event);
|
||||||
return exportDesktopDiagnostics(options);
|
return exportDesktopDiagnostics(options, { includeRawLogs: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t
|
|||||||
expect(summary).toHaveBeenCalledTimes(calls);
|
expect(summary).toHaveBeenCalledTimes(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { summary, projection, processing, sedimentation, history, liveSync } =
|
const {
|
||||||
vi.hoisted(() => ({
|
summary,
|
||||||
summary: vi.fn(),
|
projection,
|
||||||
projection: vi.fn(),
|
processing,
|
||||||
processing: vi.fn(),
|
sedimentation,
|
||||||
sedimentation: vi.fn(),
|
history,
|
||||||
history: vi.fn(),
|
liveSync,
|
||||||
liveSync: vi.fn(),
|
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) => ({
|
vi.mock("./call-history-client", async (original) => ({
|
||||||
...(await original<typeof import("./call-history-client")>()),
|
...(await original<typeof import("./call-history-client")>()),
|
||||||
getCallHistory: history,
|
getCallHistory: history,
|
||||||
@@ -105,6 +113,7 @@ vi.mock("./api", () => ({
|
|||||||
getSdkRaw: vi.fn(),
|
getSdkRaw: vi.fn(),
|
||||||
getLiveSyncHealth: liveSync,
|
getLiveSyncHealth: liveSync,
|
||||||
getMemoryProcessing: processing,
|
getMemoryProcessing: processing,
|
||||||
|
getEvolveOverview: evolve,
|
||||||
getSedimentation: sedimentation,
|
getSedimentation: sedimentation,
|
||||||
getSources: vi.fn().mockResolvedValue([]),
|
getSources: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
@@ -146,6 +155,12 @@ beforeEach(() => {
|
|||||||
history
|
history
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ available: true, recording_error: false, items: [] });
|
.mockResolvedValue({ available: true, recording_error: false, items: [] });
|
||||||
|
evolve.mockReset().mockResolvedValue({
|
||||||
|
mode: "ultra",
|
||||||
|
schedule: { enabled: true, time: "03:00" },
|
||||||
|
users: {},
|
||||||
|
running: [],
|
||||||
|
});
|
||||||
imports.refresh = vi.fn();
|
imports.refresh = vi.fn();
|
||||||
summary.mockReset().mockResolvedValue({
|
summary.mockReset().mockResolvedValue({
|
||||||
memory_count: 601,
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
resetClient();
|
resetClient();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
retryAutoReconcile,
|
retryAutoReconcile,
|
||||||
useAutoConnect,
|
useAutoConnect,
|
||||||
} from "./connection-reconcile";
|
} from "./connection-reconcile";
|
||||||
|
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||||
import { useGuiLanguage } from "./gui-language";
|
import { useGuiLanguage } from "./gui-language";
|
||||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
sourceConnectionIncomplete,
|
sourceConnectionIncomplete,
|
||||||
useRecallHosts,
|
useRecallHosts,
|
||||||
} from "./host-state";
|
} from "./host-state";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { subscribePoll } from "./poll-scheduler";
|
import { subscribePoll } from "./poll-scheduler";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { prefetchDataRuntime } from "./runtime-state";
|
import { prefetchDataRuntime } from "./runtime-state";
|
||||||
@@ -209,6 +211,10 @@ export function ApplicationsHome({
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { locale, t, tn } = useGuiLanguage();
|
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 hostQuery = useRecallHosts({ poll: active });
|
||||||
const hostBridge = hostBridgeAvailable();
|
const hostBridge = hostBridgeAvailable();
|
||||||
const hosts = hostQuery.data ?? [];
|
const hosts = hostQuery.data ?? [];
|
||||||
@@ -313,6 +319,12 @@ export function ApplicationsHome({
|
|||||||
const [expanded, setExpanded] = useState(retained.expanded);
|
const [expanded, setExpanded] = useState(retained.expanded);
|
||||||
const [phase, setPhase] = useState("idle");
|
const [phase, setPhase] = useState("idle");
|
||||||
const [revision, setRevision] = useState(0);
|
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 confirmedModeSeen = useRef(confirmedMode);
|
||||||
const expandButton = useRef<HTMLButtonElement>(null);
|
const expandButton = useRef<HTMLButtonElement>(null);
|
||||||
const backButton = useRef<HTMLButtonElement>(null);
|
const backButton = useRef<HTMLButtonElement>(null);
|
||||||
@@ -953,6 +965,11 @@ export function ApplicationsHome({
|
|||||||
<header>
|
<header>
|
||||||
<h2>{t("apps.overview")}</h2>
|
<h2>{t("apps.overview")}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
{evolveRunning ? (
|
||||||
|
<p className="r6-evolve-status" role="status">
|
||||||
|
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="r6-home-metrics">
|
<div className="r6-home-metrics">
|
||||||
<div role="status" aria-live="polite" aria-atomic="true">
|
<div role="status" aria-live="polite" aria-atomic="true">
|
||||||
<p>{t(viewModel.countLabel)}</p>
|
<p>{t(viewModel.countLabel)}</p>
|
||||||
|
|||||||
@@ -371,16 +371,14 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
|
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("checkbox", { name: "一键接入应用" })).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.queryByText(
|
||||||
"点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。",
|
/点击底部按钮后|选择草稿|按各应用回执|本页任务控制尚未接通/,
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||||
@@ -398,11 +396,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
|
||||||
screen.getByText(/已保存的对话还在/, {
|
|
||||||
selector: ".first-import-r6-footer-note p",
|
|
||||||
}),
|
|
||||||
).toBeTruthy();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -439,9 +433,7 @@ it.each(["stale", "revoked", "missing"])(
|
|||||||
onContinue={vi.fn()}
|
onContinue={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (kind === "missing")
|
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||||
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
|
|
||||||
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
|
|||||||
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
||||||
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||||
).toHaveLength(1);
|
).toBeNull();
|
||||||
view.update({
|
view.update({
|
||||||
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
||||||
jobs: [{ ...job, stored: 20 }],
|
jobs: [{ ...job, stored: 20 }],
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function defaultHostSourceIds(
|
|||||||
export type ImportConnectionDraft = {
|
export type ImportConnectionDraft = {
|
||||||
hostSourceIds: readonly string[];
|
hostSourceIds: readonly string[];
|
||||||
};
|
};
|
||||||
|
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
|
||||||
export interface FirstImportReviewProps {
|
export interface FirstImportReviewProps {
|
||||||
sourceIds: readonly string[];
|
sourceIds: readonly string[];
|
||||||
sources: SourceResponse[];
|
sources: SourceResponse[];
|
||||||
@@ -54,6 +55,7 @@ export interface FirstImportReviewProps {
|
|||||||
acceptedJobIds?: readonly string[];
|
acceptedJobIds?: readonly string[];
|
||||||
failedSources?: Record<string, string>;
|
failedSources?: Record<string, string>;
|
||||||
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
|
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
|
||||||
|
importSubmissionState?: ImportSubmissionState;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
connectionNote?: string;
|
connectionNote?: string;
|
||||||
@@ -78,6 +80,7 @@ export function FirstImportReview({
|
|||||||
acceptedJobIds,
|
acceptedJobIds,
|
||||||
failedSources = {},
|
failedSources = {},
|
||||||
sourceSnapshot,
|
sourceSnapshot,
|
||||||
|
importSubmissionState = "confirmed",
|
||||||
busy = false,
|
busy = false,
|
||||||
error,
|
error,
|
||||||
connectionNote,
|
connectionNote,
|
||||||
@@ -153,26 +156,6 @@ export function FirstImportReview({
|
|||||||
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
||||||
batch.every((job) => job.status === "completed") &&
|
batch.every((job) => job.status === "completed") &&
|
||||||
!Object.keys(failedSources).length;
|
!Object.keys(failedSources).length;
|
||||||
const allPaused =
|
|
||||||
batch.length > 0 && batch.every((job) => job.status === "paused");
|
|
||||||
const importing =
|
|
||||||
!stale &&
|
|
||||||
batch.some(
|
|
||||||
(job) =>
|
|
||||||
["pending", "running"].includes(job.status) &&
|
|
||||||
sources.some(
|
|
||||||
(source) => source.source_id === job.source_id && source.authorized,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const displayImporting =
|
|
||||||
importing ||
|
|
||||||
(!stale &&
|
|
||||||
!allCompleted &&
|
|
||||||
!allPaused &&
|
|
||||||
batch.length === 0 &&
|
|
||||||
ids.some((id) =>
|
|
||||||
sources.some((source) => source.source_id === id && source.authorized),
|
|
||||||
));
|
|
||||||
const title = "对话正在汇入,记忆从这里开始";
|
const title = "对话正在汇入,记忆从这里开始";
|
||||||
const hostEligible = importHostEligible(sources, ids);
|
const hostEligible = importHostEligible(sources, ids);
|
||||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||||
@@ -282,23 +265,9 @@ export function FirstImportReview({
|
|||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
step={2}
|
step={2}
|
||||||
title={text(title)}
|
title={text(title)}
|
||||||
eyebrow={text("导入进行中 · 顺手连接应用")}
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<div className="first-import-r6-footer-note">
|
<div className="first-import-r6-footer-note">
|
||||||
<p>
|
|
||||||
{text(
|
|
||||||
allCompleted
|
|
||||||
? "对话已保存,记忆尚待生成。"
|
|
||||||
: displayImporting
|
|
||||||
? "导入在后台继续,无需等待。"
|
|
||||||
: stale
|
|
||||||
? "正在重新核对来源;已保存数量仍按任务回执显示。"
|
|
||||||
: allPaused
|
|
||||||
? "本次历史导入已暂停,已保存的对话还在。"
|
|
||||||
: "导入等待处理,已保存的对话还在。",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{(error || actionMessage) && (
|
{(error || actionMessage) && (
|
||||||
<p
|
<p
|
||||||
role={
|
role={
|
||||||
@@ -386,14 +355,18 @@ export function FirstImportReview({
|
|||||||
aria-label={text("导入与原文")}
|
aria-label={text("导入与原文")}
|
||||||
>
|
>
|
||||||
<p className="first-import-r6-intro">
|
<p className="first-import-r6-intro">
|
||||||
{text(
|
{importSubmissionState === "checking"
|
||||||
allCompleted
|
? t("onboardingImportChecking")
|
||||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
: text(
|
||||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
allCompleted
|
||||||
)}
|
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||||
|
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="first-import-r6-subintro">
|
<p className="first-import-r6-subintro">
|
||||||
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
|
{text(
|
||||||
|
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{historyWindowLimited && (
|
{historyWindowLimited && (
|
||||||
<p className="first-import-r6-history-window" role="status">
|
<p className="first-import-r6-history-window" role="status">
|
||||||
@@ -481,13 +454,11 @@ export function FirstImportReview({
|
|||||||
{text(connectionNote)}
|
{text(connectionNote)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="first-import-r6-options-note">
|
{!draftReady && (
|
||||||
{text(
|
<p className="first-import-r6-options-note">
|
||||||
draftReady
|
{text("暂时无法连接应用,当前不会安装连接。")}
|
||||||
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
|
</p>
|
||||||
: "暂时无法连接应用,当前不会安装连接。",
|
)}
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</R6ColdStartFrame>
|
</R6ColdStartFrame>
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
|
|||||||
});
|
});
|
||||||
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
||||||
render(<HealthPanel imports={imports()} />);
|
render(<HealthPanel imports={imports()} />);
|
||||||
|
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
||||||
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
|
|||||||
<Download size={16} aria-hidden="true" />
|
<Download size={16} aria-hidden="true" />
|
||||||
{t("nativeExportLogs")}
|
{t("nativeExportLogs")}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
|
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="health-block" aria-label={t("healthLocal")}>
|
<section className="health-block" aria-label={t("healthLocal")}>
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ function Host({
|
|||||||
onViewHistory,
|
onViewHistory,
|
||||||
onConfigureModel = () => {},
|
onConfigureModel = () => {},
|
||||||
libraryView,
|
libraryView,
|
||||||
|
raw = false,
|
||||||
}: {
|
}: {
|
||||||
simplified?: boolean;
|
simplified?: boolean;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
@@ -509,6 +510,7 @@ function Host({
|
|||||||
onViewHistory?(): void;
|
onViewHistory?(): void;
|
||||||
onConfigureModel?(): void;
|
onConfigureModel?(): void;
|
||||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||||
|
raw?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||||
const [detailLoad, setDetailLoad] = useState<{
|
const [detailLoad, setDetailLoad] = useState<{
|
||||||
@@ -518,6 +520,7 @@ function Host({
|
|||||||
return (
|
return (
|
||||||
<MemoryMap
|
<MemoryMap
|
||||||
simplified={simplified}
|
simplified={simplified}
|
||||||
|
contentKind={raw ? "raw" : "memory"}
|
||||||
libraryView={libraryView}
|
libraryView={libraryView}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
overlayBlocked={overlayBlocked}
|
overlayBlocked={overlayBlocked}
|
||||||
@@ -587,6 +590,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", () => {
|
it("does not present an unverified empty page as zero memories", () => {
|
||||||
const page = {
|
const page = {
|
||||||
items: [],
|
items: [],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { GuiLocale } from "@one-memory/i18n";
|
import type { GuiLocale } from "@one-memory/i18n";
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -110,12 +111,15 @@ type Props = {
|
|||||||
settledSource?: string;
|
settledSource?: string;
|
||||||
settledQuery?: string;
|
settledQuery?: string;
|
||||||
requestedOffset?: number;
|
requestedOffset?: number;
|
||||||
|
cursorPaging?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||||
unattributedSource?: string;
|
unattributedSource?: string;
|
||||||
onQuery(value: string): void;
|
onQuery(value: string): void;
|
||||||
onSource(value: string): void;
|
onSource(value: string): void;
|
||||||
resetPage(): void;
|
resetPage(): void;
|
||||||
onPage(offset: number): void;
|
onPage(offset: number): void;
|
||||||
|
onMore?(): void;
|
||||||
onRetry(): void;
|
onRetry(): void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -176,6 +180,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
|||||||
working: "工作",
|
working: "工作",
|
||||||
coding: "编码",
|
coding: "编码",
|
||||||
};
|
};
|
||||||
|
const RAW_ROW_HEIGHT = 116;
|
||||||
|
const RAW_LIST_OVERSCAN = 6;
|
||||||
|
|
||||||
export function MemoryMap(props: Props) {
|
export function MemoryMap(props: Props) {
|
||||||
const { locale, text, count, plain } = useMemoryLanguage();
|
const { locale, text, count, plain } = useMemoryLanguage();
|
||||||
@@ -254,6 +260,10 @@ export function MemoryMap(props: Props) {
|
|||||||
const pane = useRef<HTMLElement>(null);
|
const pane = useRef<HTMLElement>(null);
|
||||||
const memoryList = useRef<HTMLUListElement>(null);
|
const memoryList = useRef<HTMLUListElement>(null);
|
||||||
const inspectorBody = useRef<HTMLDivElement>(null);
|
const inspectorBody = useRef<HTMLDivElement>(null);
|
||||||
|
const [rawListWindow, setRawListWindow] = useState({
|
||||||
|
start: 0,
|
||||||
|
end: 20,
|
||||||
|
});
|
||||||
const openedDefault = useRef(false);
|
const openedDefault = useRef(false);
|
||||||
const scrollSelection = useRef(false);
|
const scrollSelection = useRef(false);
|
||||||
const controlledSource = library
|
const controlledSource = library
|
||||||
@@ -339,6 +349,33 @@ export function MemoryMap(props: Props) {
|
|||||||
),
|
),
|
||||||
[active, group, search, serverPaged, axis, groupKey],
|
[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(() => {
|
const projection = useMemo(() => {
|
||||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||||
// previous scope's graph would show the wrong source.
|
// previous scope's graph would show the wrong source.
|
||||||
@@ -354,7 +391,7 @@ export function MemoryMap(props: Props) {
|
|||||||
// left-hand list window. Projecting the graph back down to the current
|
// left-hand list window. Projecting the graph back down to the current
|
||||||
// 20-row page made "All applications" look like whichever source happened
|
// 20-row page made "All applications" look like whichever source happened
|
||||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||||
if (serverPaged && !library?.query) {
|
if (serverPaged && (!library?.query || raw)) {
|
||||||
return (
|
return (
|
||||||
baseGraph ??
|
baseGraph ??
|
||||||
projectMap(
|
projectMap(
|
||||||
@@ -406,6 +443,7 @@ export function MemoryMap(props: Props) {
|
|||||||
search,
|
search,
|
||||||
sources,
|
sources,
|
||||||
serverPaged,
|
serverPaged,
|
||||||
|
raw,
|
||||||
library?.query,
|
library?.query,
|
||||||
library?.graphLoading,
|
library?.graphLoading,
|
||||||
library?.graphFailed,
|
library?.graphFailed,
|
||||||
@@ -1023,11 +1061,42 @@ export function MemoryMap(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<ul
|
<ul
|
||||||
ref={memoryList}
|
ref={memoryList}
|
||||||
className="map-memory-list"
|
className={`map-memory-list${raw ? " raw" : ""}`}
|
||||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||||
id="memory-pane-memories"
|
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}>
|
<li key={m.memory_id} data-memory-id={m.memory_id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1083,6 +1152,13 @@ export function MemoryMap(props: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{rawBottomSpace ? (
|
||||||
|
<li
|
||||||
|
className="map-memory-spacer"
|
||||||
|
style={{ height: rawBottomSpace }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
{library?.failed && (
|
{library?.failed && (
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
@@ -1131,62 +1207,93 @@ export function MemoryMap(props: Props) {
|
|||||||
aria-busy={pageTransitioning || undefined}
|
aria-busy={pageTransitioning || undefined}
|
||||||
className="map-pagination"
|
className="map-pagination"
|
||||||
>
|
>
|
||||||
<button
|
{library.cursorPaging ? (
|
||||||
type="button"
|
library.hasMore ? (
|
||||||
className="quiet map-tool"
|
<button
|
||||||
aria-label={text("上一页记忆")}
|
type="button"
|
||||||
title={text("上一页记忆")}
|
className="map-load-more"
|
||||||
disabled={pageTransitioning || library.page.offset === 0}
|
disabled={pageTransitioning}
|
||||||
onClick={() => {
|
aria-label={
|
||||||
if (axis === "topic") setGroupKey("");
|
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||||
if (library.page) {
|
}
|
||||||
const offset = library.page.offset - library.page.limit;
|
title={
|
||||||
setPageRetrying(false);
|
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||||
pageRetrySawLoading.current = false;
|
}
|
||||||
setPendingPageOffset(offset);
|
onClick={() => library.onMore?.()}
|
||||||
library.onPage(offset);
|
>
|
||||||
}
|
<ChevronDown
|
||||||
}}
|
size={18}
|
||||||
>
|
aria-hidden="true"
|
||||||
<ChevronLeft size={16} aria-hidden="true" />
|
className={pageTransitioning ? "loading" : undefined}
|
||||||
</button>
|
/>
|
||||||
<span>
|
</button>
|
||||||
{text(
|
) : (
|
||||||
"共 {count} 条,第 {page} / {pages} 页",
|
<span>{text("已加载全部")}</span>
|
||||||
{
|
)
|
||||||
count: count(library.page.total),
|
) : (
|
||||||
page:
|
<>
|
||||||
library.page.total === 0
|
<button
|
||||||
? 0
|
type="button"
|
||||||
: Math.floor(library.page.offset / library.page.limit) +
|
className="quiet map-tool"
|
||||||
1,
|
aria-label={text("上一页记忆")}
|
||||||
pages: Math.ceil(library.page.total / library.page.limit),
|
title={text("上一页记忆")}
|
||||||
},
|
disabled={pageTransitioning || library.page.offset === 0}
|
||||||
library.page.total,
|
onClick={() => {
|
||||||
)}
|
if (axis === "topic") setGroupKey("");
|
||||||
</span>
|
if (library.page) {
|
||||||
<button
|
const offset = library.page.offset - library.page.limit;
|
||||||
type="button"
|
setPageRetrying(false);
|
||||||
className="quiet map-tool"
|
pageRetrySawLoading.current = false;
|
||||||
aria-label={text("下一页记忆")}
|
setPendingPageOffset(offset);
|
||||||
title={text("下一页记忆")}
|
library.onPage(offset);
|
||||||
disabled={
|
}
|
||||||
pageTransitioning ||
|
}}
|
||||||
library.page.offset + library.page.limit >= library.page.total
|
>
|
||||||
}
|
<ChevronLeft size={16} aria-hidden="true" />
|
||||||
onClick={() => {
|
</button>
|
||||||
if (axis === "topic") setGroupKey("");
|
<span>
|
||||||
if (library.page) {
|
{text(
|
||||||
const offset = library.page.offset + library.page.limit;
|
"共 {count} 条,第 {page} / {pages} 页",
|
||||||
setPageRetrying(false);
|
{
|
||||||
pageRetrySawLoading.current = false;
|
count: count(library.page.total),
|
||||||
setPendingPageOffset(offset);
|
page:
|
||||||
library.onPage(offset);
|
library.page.total === 0
|
||||||
}
|
? 0
|
||||||
}}
|
: Math.floor(
|
||||||
>
|
library.page.offset / library.page.limit,
|
||||||
<ChevronRight size={16} aria-hidden="true" />
|
) + 1,
|
||||||
</button>
|
pages: Math.ceil(
|
||||||
|
library.page.total / library.page.limit,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
library.page.total,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quiet map-tool"
|
||||||
|
aria-label={text("下一页记忆")}
|
||||||
|
title={text("下一页记忆")}
|
||||||
|
disabled={
|
||||||
|
pageTransitioning ||
|
||||||
|
library.page.offset + library.page.limit >=
|
||||||
|
library.page.total
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (axis === "topic") setGroupKey("");
|
||||||
|
if (library.page) {
|
||||||
|
const offset = library.page.offset + library.page.limit;
|
||||||
|
setPageRetrying(false);
|
||||||
|
pageRetrySawLoading.current = false;
|
||||||
|
setPendingPageOffset(offset);
|
||||||
|
library.onPage(offset);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
{!library && loaded && !filtered.length && (search || groupKey) && (
|
{!library && loaded && !filtered.length && (search || groupKey) && (
|
||||||
@@ -1213,19 +1320,21 @@ export function MemoryMap(props: Props) {
|
|||||||
<h2>
|
<h2>
|
||||||
{props.simplified
|
{props.simplified
|
||||||
? raw
|
? raw
|
||||||
? t("memory:原始对话")
|
? text("来源归属")
|
||||||
: text("记忆关联")
|
: text("记忆关联")
|
||||||
: label}
|
: label}
|
||||||
</h2>
|
</h2>
|
||||||
{props.simplified && (
|
{props.simplified &&
|
||||||
<p className="selected-graph-subtitle">
|
(!raw || !projection.nodes.length) &&
|
||||||
{projection.nodes.length
|
!(raw && library?.graphLoading) && (
|
||||||
? text("来源归属")
|
<p className="selected-graph-subtitle">
|
||||||
: raw
|
{projection.nodes.length
|
||||||
? t("sdk.raw.empty")
|
? text("来源归属")
|
||||||
: text("可在沉淀记忆后查看视图")}
|
: raw
|
||||||
</p>
|
? t("sdk.raw.empty")
|
||||||
)}
|
: text("可在沉淀记忆后查看视图")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{compact && (activeId || selectedSource) && !inspectorOpen && (
|
{compact && (activeId || selectedSource) && !inspectorOpen && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @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.getByText("· Pro").className).toContain("memory-mode-tier");
|
||||||
|
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,134 @@
|
|||||||
|
import type { MemoryPipelineMode } from "@one-memory/contracts";
|
||||||
|
import { Check, Clock3 } from "lucide-react";
|
||||||
|
import { type ReactNode, useId } from "react";
|
||||||
|
import { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
|
||||||
|
import { 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",
|
||||||
|
describedBy,
|
||||||
|
onChange,
|
||||||
|
ultraDetail,
|
||||||
|
ultraDetailBusy = false,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
"aria-busy": ariaBusy,
|
||||||
|
}: {
|
||||||
|
value: MemoryPipelineMode | null;
|
||||||
|
current?: MemoryPipelineMode;
|
||||||
|
currentBadge?: ReactNode;
|
||||||
|
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
|
||||||
|
layout?: "grid" | "rows";
|
||||||
|
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
|
||||||
|
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
|
||||||
|
ultraDetail?: ReactNode;
|
||||||
|
ultraDetailBusy?: boolean;
|
||||||
|
"aria-label": string;
|
||||||
|
"aria-busy"?: boolean;
|
||||||
|
}) {
|
||||||
|
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 title = modeDisplayName(text, mode.id);
|
||||||
|
const tier = MODE_TIER[mode.id];
|
||||||
|
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={`${title} · ${tier}`}
|
||||||
|
aria-describedby={describedBy?.[mode.id]}
|
||||||
|
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">· {tier}</span>
|
||||||
|
{current === mode.id && currentBadge ? (
|
||||||
|
<span className="memory-mode-current">{currentBadge}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="memory-mode-radio" aria-hidden="true">
|
||||||
|
{selected ? <Check size={13} /> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="memory-mode-description">
|
||||||
|
{text(mode.description)}
|
||||||
|
</span>
|
||||||
|
<span className="memory-mode-features">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<span key={feature}>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
{text(feature)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||||
|
<div
|
||||||
|
className="memory-mode-detail"
|
||||||
|
aria-busy={ultraDetailBusy || undefined}
|
||||||
|
>
|
||||||
|
<Clock3 size={14} aria-hidden="true" />
|
||||||
|
<div>{ultraDetail}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
setMemoryMode,
|
setMemoryMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||||
|
import { resetClient } from "./query-client";
|
||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
getMemoryMode: vi.fn(),
|
getMemoryMode: vi.fn(),
|
||||||
@@ -74,29 +75,60 @@ async function chooseMode(
|
|||||||
user: ReturnType<typeof userEvent.setup>,
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
name: RegExp,
|
name: RegExp,
|
||||||
) {
|
) {
|
||||||
const option = await screen.findByRole("radio", { name });
|
const control = await screen.findByRole("combobox", {
|
||||||
|
name: "记忆模式",
|
||||||
|
});
|
||||||
|
await user.click(control);
|
||||||
|
const option = await screen.findByRole("option", { name });
|
||||||
await user.click(option);
|
await user.click(option);
|
||||||
return option;
|
return control;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
resetClient();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(apiErrorCode).mockReturnValue(null);
|
vi.mocked(apiErrorCode).mockReturnValue(null);
|
||||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
|
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders all settings modes as one grouped shared choice list", async () => {
|
it("shows the current mode in a compact dropdown", async () => {
|
||||||
prepare("pro");
|
prepare("pro");
|
||||||
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
const group = await screen.findByRole("radiogroup", {
|
const control = await screen.findByRole("combobox", {
|
||||||
name: "当前模式",
|
name: "记忆模式",
|
||||||
});
|
});
|
||||||
expect(within(group).getAllByRole("radio")).toHaveLength(3);
|
expect(control.textContent).toContain("智能记忆 · Pro");
|
||||||
|
expect(control.textContent).toContain("沉淀高级记忆");
|
||||||
expect(
|
expect(
|
||||||
within(group).getByText("当前模式").closest("label")?.textContent,
|
control.querySelector(".om-option-menu-suffix")?.textContent,
|
||||||
).toContain("智能记忆(pro)");
|
).toContain("· Pro");
|
||||||
|
expect(control.textContent).not.toContain("(");
|
||||||
|
expect(screen.queryByText("当前模式")).toBeNull();
|
||||||
|
expect(screen.queryByRole("radiogroup")).toBeNull();
|
||||||
|
|
||||||
|
await user.click(control);
|
||||||
|
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the effective Ultra mode and disables selection when forced by the environment", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(getMemoryMode).mockResolvedValue({
|
||||||
|
...state("ultra"),
|
||||||
|
configured_mode: "pro",
|
||||||
|
forced_by_env: true,
|
||||||
|
});
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
const control = await screen.findByRole("combobox", {
|
||||||
|
name: "记忆模式",
|
||||||
|
});
|
||||||
|
expect(control.textContent).toContain("深度记忆 · Ultra");
|
||||||
|
expect(control).toHaveProperty("disabled", true);
|
||||||
|
expect(screen.getByRole("switch", { name: "每天自动整合" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms the data effects before switching to lite", async () => {
|
it("confirms the data effects before switching to lite", async () => {
|
||||||
@@ -109,37 +141,35 @@ it("confirms the data effects before switching to lite", async () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /基础记忆(lite)/);
|
await chooseMode(user, /基础记忆 · Lite/);
|
||||||
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
|
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
|
||||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(document.activeElement).toBe(
|
expect(document.activeElement).toBe(
|
||||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
screen.getByRole("combobox", { name: "记忆模式" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
it("traps modal focus, closes on Escape and restores the mode dropdown", async () => {
|
||||||
prepare("pro");
|
prepare("pro");
|
||||||
const outside = document.createElement("button");
|
const outside = document.createElement("button");
|
||||||
document.body.append(outside);
|
document.body.append(outside);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
const lite = await chooseMode(user, /基础记忆(lite)/);
|
const lite = await chooseMode(user, /基础记忆 · Lite/);
|
||||||
|
|
||||||
const cancel = screen.getByRole("button", { name: "取消" });
|
const cancel = screen.getByRole("button", { name: "取消" });
|
||||||
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
||||||
expect(outside.inert).toBe(true);
|
expect(outside.inert).toBe(true);
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||||
expect(document.activeElement).toBe(
|
expect(document.activeElement).toBe(
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
screen.getByRole("button", { name: "确认选择" }),
|
||||||
);
|
);
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
@@ -154,10 +184,8 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
|
|||||||
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
|
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
await chooseMode(user, /基础记忆(lite)/);
|
await chooseMode(user, /基础记忆 · Lite/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const modal = screen.getByRole("dialog");
|
const modal = screen.getByRole("dialog");
|
||||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||||
@@ -187,7 +215,7 @@ it("routes to model setup instead of switching pro without a model", async () =>
|
|||||||
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
|
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await chooseMode(user, /智能记忆(pro)/);
|
await chooseMode(user, /智能记忆 · Pro/);
|
||||||
expect(configure).toHaveBeenCalledExactlyOnceWith(
|
expect(configure).toHaveBeenCalledExactlyOnceWith(
|
||||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||||
);
|
);
|
||||||
@@ -201,11 +229,29 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /基础记忆(lite)/);
|
await chooseMode(user, /基础记忆 · Lite/);
|
||||||
expect(planMemoryMode).toHaveBeenCalledWith("lite");
|
expect(planMemoryMode).toHaveBeenCalledWith("lite");
|
||||||
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
|
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores focus to the mode dropdown when planning fails", async () => {
|
||||||
|
prepare("pro");
|
||||||
|
vi.mocked(planMemoryMode).mockRejectedValue(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
await chooseMode(user, /基础记忆 · Lite/);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("模式选择影响暂时无法读取,请重试。"),
|
||||||
|
).toBeTruthy();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("combobox", { name: "记忆模式" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
|
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
|
||||||
prepare("ultra");
|
prepare("ultra");
|
||||||
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
|
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
|
||||||
@@ -213,7 +259,7 @@ it("keeps downgrade effects when an old Core falls back from ultra to pro", asyn
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /智能记忆(pro)/);
|
await chooseMode(user, /智能记忆 · Pro/);
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||||
@@ -225,15 +271,15 @@ it("explains model cost, search scope and the next step before switching to ultr
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /深度记忆(ultra)/);
|
await chooseMode(user, /深度记忆 · Ultra/);
|
||||||
|
|
||||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||||
expect(screen.getByText("切换后")).toBeTruthy();
|
expect(screen.getByText("选择后")).toBeTruthy();
|
||||||
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||||
expect(screen.getByText("知识图谱")).toBeTruthy();
|
expect(screen.getByText("知识图谱")).toBeTruthy();
|
||||||
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
||||||
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
||||||
@@ -241,7 +287,7 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /智能记忆(pro)/);
|
await chooseMode(user, /智能记忆 · Pro/);
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||||
@@ -258,7 +304,7 @@ it("does not show automatic consolidation when switching from lite to pro", asyn
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /智能记忆(pro)/);
|
await chooseMode(user, /智能记忆 · Pro/);
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||||
@@ -275,13 +321,11 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
|||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
await screen.findByRole("combobox", { name: "记忆模式" });
|
||||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||||
|
|
||||||
await chooseMode(user, /深度记忆(ultra)/);
|
await chooseMode(user, /深度记忆 · Ultra/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||||
@@ -291,6 +335,29 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("focuses the recoverable Evolve section when Ultra schedule loading fails", async () => {
|
||||||
|
prepare("pro");
|
||||||
|
vi.mocked(getEvolveSchedule).mockRejectedValue(new Error("offline"));
|
||||||
|
vi.mocked(setMemoryMode).mockResolvedValue({
|
||||||
|
...state("ultra"),
|
||||||
|
previous_mode: "pro",
|
||||||
|
effects: ["existing_memories_added_to_graph"],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
await screen.findByRole("combobox", { name: "记忆模式" });
|
||||||
|
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||||
|
|
||||||
|
await chooseMode(user, /深度记忆 · Ultra/);
|
||||||
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("自动整合设置暂时无法读取,请重试。")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the default nightly evolve controls in ultra", async () => {
|
it("shows the default nightly evolve controls in ultra", async () => {
|
||||||
prepare("ultra");
|
prepare("ultra");
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
@@ -300,13 +367,151 @@ it("shows the default nightly evolve controls in ultra", async () => {
|
|||||||
"aria-checked",
|
"aria-checked",
|
||||||
),
|
),
|
||||||
).toBe("true");
|
).toBe("true");
|
||||||
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
|
const timePicker = screen.getByRole("button", {
|
||||||
"03:00",
|
name: "执行时间,03:00",
|
||||||
);
|
});
|
||||||
|
expect(timePicker.tagName).toBe("BUTTON");
|
||||||
|
expect(timePicker.textContent).toContain("03:00");
|
||||||
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the immediate action visible and recovers when the schedule read fails", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("自动整合设置暂时无法读取,请重试。"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("switch", { name: "每天自动整合" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||||
|
|
||||||
|
const automatic = await screen.findByRole("switch", {
|
||||||
|
name: "每天自动整合",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(automatic));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the time picker from the whole control and saves a complete time", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "04:30",
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
const trigger = await screen.findByRole("button", {
|
||||||
|
name: "执行时间,03:00",
|
||||||
|
});
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
|
||||||
|
"04",
|
||||||
|
);
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||||
|
"30",
|
||||||
|
);
|
||||||
|
expect(setEvolveSchedule).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
time: "04:30",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
|
||||||
|
document.activeElement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
const trigger = await screen.findByRole("button", {
|
||||||
|
name: "执行时间,03:00",
|
||||||
|
});
|
||||||
|
await user.click(trigger);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
const hours = within(picker).getByRole("listbox", {
|
||||||
|
name: "执行时间 HH",
|
||||||
|
});
|
||||||
|
const save = within(picker).getByRole("button", { name: "保存修改" });
|
||||||
|
expect(document.activeElement).toBe(hours);
|
||||||
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||||
|
expect(document.activeElement).toBe(save);
|
||||||
|
await user.keyboard("{Tab}");
|
||||||
|
expect(document.activeElement).toBe(hours);
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the selected time open for retry when saving fails", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||||
|
);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
const minutes = within(picker).getByRole("listbox", {
|
||||||
|
name: "执行时间 MM",
|
||||||
|
});
|
||||||
|
await user.selectOptions(minutes, "30");
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("自动整合设置未能保存,请重试。"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
|
||||||
|
expect((minutes as HTMLSelectElement).value).toBe("30");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
within(picker).getByRole("button", { name: "保存修改" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the time dialog and focus stable while saving is pending", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||||
|
);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||||
|
"30",
|
||||||
|
);
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(picker);
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
|
||||||
|
expect(document.activeElement).toBe(picker);
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
||||||
prepare("ultra");
|
prepare("ultra");
|
||||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||||
@@ -322,16 +527,16 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
|
|||||||
await user.click(automatic);
|
await user.click(automatic);
|
||||||
|
|
||||||
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
||||||
expect(screen.queryByLabelText("执行时间")).toBeNull();
|
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates mode state automatically without showing a manual refresh control", async () => {
|
it("updates mode state automatically without showing a manual refresh control", async () => {
|
||||||
prepare("pro");
|
prepare("pro");
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
|
||||||
|
|
||||||
expect((pro as HTMLInputElement).checked).toBe(true);
|
expect(mode.textContent).toContain("智能记忆 · Pro");
|
||||||
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
||||||
expect(getMemoryMode).toHaveBeenCalledTimes(1);
|
expect(getMemoryMode).toHaveBeenCalledTimes(1);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -345,9 +550,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
|
|||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(await screen.findByRole("radio", {
|
await screen.findByRole("combobox", {
|
||||||
name: /智能记忆(pro)/,
|
name: "记忆模式",
|
||||||
})) as HTMLInputElement,
|
}),
|
||||||
).toHaveProperty("disabled", false);
|
).toHaveProperty("disabled", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,15 +565,17 @@ it("offers a text recovery action when the mode read fails", async () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
|
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
"disabled",
|
||||||
).toHaveProperty("disabled", true);
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||||
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
|
"disabled",
|
||||||
).toHaveProperty("disabled", false),
|
false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,15 +586,13 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /深度记忆(ultra)/);
|
await chooseMode(user, /深度记忆 · Ultra/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(
|
expect(
|
||||||
await within(dialog).findByText(
|
await within(dialog).findByText(
|
||||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -402,15 +607,17 @@ it("keeps lite available while model status is unknown", async () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
|
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
|
||||||
).toHaveProperty("disabled", false);
|
).toHaveProperty("disabled", false);
|
||||||
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
|
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
|
||||||
"disabled",
|
"disabled",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
|
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
|
||||||
).toHaveProperty("disabled", true);
|
).toHaveProperty("disabled", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,12 +630,13 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
"disabled",
|
||||||
).toHaveProperty("disabled", true);
|
true,
|
||||||
|
);
|
||||||
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
||||||
"disabled",
|
"disabled",
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
EvolveOverview,
|
|
||||||
EvolveRunReceipt,
|
|
||||||
EvolveSchedule,
|
EvolveSchedule,
|
||||||
MemoryModeChange,
|
MemoryModeChange,
|
||||||
MemoryModePlan,
|
MemoryModePlan,
|
||||||
@@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
|
|||||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
getEvolveOverview,
|
|
||||||
getEvolveSchedule,
|
getEvolveSchedule,
|
||||||
getMemoryMode,
|
getMemoryMode,
|
||||||
getMemoryProcessing,
|
getMemoryProcessing,
|
||||||
@@ -25,96 +22,19 @@ import {
|
|||||||
commitConfirmedMemoryMode,
|
commitConfirmedMemoryMode,
|
||||||
issueConfirmedModeTicket,
|
issueConfirmedModeTicket,
|
||||||
} from "./confirmed-memory-mode";
|
} from "./confirmed-memory-mode";
|
||||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
import { commitEvolveRun, useEvolveState } from "./evolve-state";
|
||||||
|
import {
|
||||||
|
BLOCKER_COPY,
|
||||||
|
MemoryModeConfirmDialog,
|
||||||
|
MODE_COPY,
|
||||||
|
MODE_TIER,
|
||||||
|
modeDisplayName,
|
||||||
|
} from "./memory-mode-ui";
|
||||||
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6Button, R6Switch } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { R6StateRecovery } from "./R6StateRecovery";
|
import { R6StateRecovery } from "./R6StateRecovery";
|
||||||
|
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||||
const MODE_COPY: Record<
|
|
||||||
MemoryPipelineMode,
|
|
||||||
{ title: string; description: string }
|
|
||||||
> = {
|
|
||||||
lite: {
|
|
||||||
title: "基础记忆(lite)",
|
|
||||||
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
|
||||||
},
|
|
||||||
pro: {
|
|
||||||
title: "智能记忆(pro)",
|
|
||||||
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
|
|
||||||
},
|
|
||||||
ultra: {
|
|
||||||
title: "深度记忆(ultra)",
|
|
||||||
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function planRows(plan: MemoryModePlan) {
|
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
label: "新对话",
|
|
||||||
content:
|
|
||||||
plan.capabilities.content_plane === "raw"
|
|
||||||
? "之后只保存原始对话,不再生成新记忆。"
|
|
||||||
: plan.capabilities.graph_enabled
|
|
||||||
? "新对话会整理成记忆并写入知识图谱。"
|
|
||||||
: "新对话会由模型整理成记忆。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "模型调用与费用",
|
|
||||||
content: plan.capabilities.generation_required
|
|
||||||
? plan.capabilities.evolve_available
|
|
||||||
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
|
|
||||||
: "会调用当前生成模型,可能产生服务商费用。"
|
|
||||||
: "不调用生成模型,不再产生这部分费用。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "搜索范围",
|
|
||||||
content:
|
|
||||||
plan.capabilities.content_plane === "raw"
|
|
||||||
? "切换后搜索原始对话。"
|
|
||||||
: plan.capabilities.graph_enabled
|
|
||||||
? "搜索沉淀记忆,并用知识图谱补充关联。"
|
|
||||||
: "搜索沉淀后的记忆。",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (plan.effects.includes("earlier_raw_not_extracted"))
|
|
||||||
rows.push({
|
|
||||||
label: "已有数据",
|
|
||||||
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("higher_mode_memories_hidden"))
|
|
||||||
rows.push({
|
|
||||||
label: "已有数据",
|
|
||||||
content:
|
|
||||||
plan.effective_target === "pro"
|
|
||||||
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
|
||||||
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("existing_memories_added_to_graph"))
|
|
||||||
rows.push({
|
|
||||||
label: "知识图谱",
|
|
||||||
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("evolve_stopped"))
|
|
||||||
rows.push({
|
|
||||||
label: "每天自动整合",
|
|
||||||
content: "每天自动整合会停止,已经生成的数据不会删除。",
|
|
||||||
});
|
|
||||||
if (plan.capabilities.evolve_available)
|
|
||||||
rows.push({
|
|
||||||
label: "切换后",
|
|
||||||
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
|
|
||||||
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
|
||||||
embedding_maintenance_in_progress:
|
|
||||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
|
||||||
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
|
|
||||||
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MemoryModeSettings({
|
export function MemoryModeSettings({
|
||||||
hasActiveModel,
|
hasActiveModel,
|
||||||
@@ -131,7 +51,11 @@ export function MemoryModeSettings({
|
|||||||
const id = useId();
|
const id = useId();
|
||||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||||
const [schedule, setScheduleState] = useState<EvolveSchedule | 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>(
|
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -140,94 +64,47 @@ export function MemoryModeSettings({
|
|||||||
const [maintenance, setMaintenance] = useState(false);
|
const [maintenance, setMaintenance] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [readFailed, setReadFailed] = useState(false);
|
const [readFailed, setReadFailed] = useState(false);
|
||||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||||
const refreshVersion = useRef(0);
|
const refreshVersion = useRef(0);
|
||||||
const dialog = useRef<HTMLElement>(null);
|
|
||||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||||
const focusEvolveWhenReady = useRef(false);
|
const focusEvolveWhenReady = useRef(false);
|
||||||
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
||||||
const busyRef = useRef(false);
|
const focusScheduleWhenReady = useRef(false);
|
||||||
busyRef.current = busy;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingMode || !dialog.current) return;
|
|
||||||
const panel = dialog.current;
|
|
||||||
const inertSiblings = new Map<HTMLElement, boolean>();
|
|
||||||
let branch: HTMLElement = panel.parentElement ?? panel;
|
|
||||||
while (branch.parentElement) {
|
|
||||||
for (const sibling of branch.parentElement.children) {
|
|
||||||
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
|
|
||||||
inertSiblings.set(sibling, sibling.inert);
|
|
||||||
sibling.inert = true;
|
|
||||||
}
|
|
||||||
branch = branch.parentElement;
|
|
||||||
if (branch === document.body) break;
|
|
||||||
}
|
|
||||||
const controls = () =>
|
|
||||||
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
|
|
||||||
controls()[0]?.focus();
|
|
||||||
const handleKey = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape" && !busyRef.current) {
|
|
||||||
event.preventDefault();
|
|
||||||
setPendingPlan(null);
|
|
||||||
setPendingMode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab") return;
|
|
||||||
const items = controls();
|
|
||||||
const first = items[0];
|
|
||||||
const last = items.at(-1);
|
|
||||||
if (!first || !last) {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
panel.addEventListener("keydown", handleKey);
|
|
||||||
return () => {
|
|
||||||
panel.removeEventListener("keydown", handleKey);
|
|
||||||
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
|
|
||||||
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
|
|
||||||
};
|
|
||||||
}, [pendingMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingMode && busy) dialog.current?.focus();
|
|
||||||
}, [busy, pendingMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
||||||
return;
|
return;
|
||||||
focusEvolveWhenReady.current = false;
|
const evolve = document.getElementById(`${id}-evolve`);
|
||||||
document.getElementById(`${id}-evolve`)?.focus();
|
if (evolve) {
|
||||||
|
focusEvolveWhenReady.current = false;
|
||||||
|
evolve.focus();
|
||||||
|
}
|
||||||
}, [id, pendingMode, state?.mode]);
|
}, [id, pendingMode, state?.mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = focusModeWhenReady.current;
|
const target = focusModeWhenReady.current;
|
||||||
if (!target || busy || pendingMode || state?.mode !== target) return;
|
if (!target || busy || pendingMode || state?.mode !== target) return;
|
||||||
focusModeWhenReady.current = null;
|
focusModeWhenReady.current = null;
|
||||||
|
document.getElementById(`${id}-select`)?.focus();
|
||||||
|
}, [busy, id, pendingMode, state?.mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusScheduleWhenReady.current || scheduleReadFailed || !schedule)
|
||||||
|
return;
|
||||||
|
focusScheduleWhenReady.current = false;
|
||||||
document
|
document
|
||||||
.querySelector<HTMLInputElement>(
|
.getElementById(`${id}-evolve`)
|
||||||
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
|
?.querySelector<HTMLElement>('[role="switch"]')
|
||||||
)
|
|
||||||
?.focus();
|
?.focus();
|
||||||
}, [busy, pendingMode, state?.mode]);
|
}, [id, schedule, scheduleReadFailed]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const version = ++refreshVersion.current;
|
const version = ++refreshVersion.current;
|
||||||
const modeTicket = issueConfirmedModeTicket("settings");
|
const modeTicket = issueConfirmedModeTicket("settings");
|
||||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
const [modeResult, scheduleResult, processingResult] =
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
getMemoryMode(),
|
getMemoryMode(),
|
||||||
getEvolveSchedule(),
|
getEvolveSchedule(),
|
||||||
getEvolveOverview(),
|
|
||||||
getMemoryProcessing(),
|
getMemoryProcessing(),
|
||||||
]);
|
]);
|
||||||
if (version !== refreshVersion.current) return;
|
if (version !== refreshVersion.current) return;
|
||||||
@@ -241,11 +118,11 @@ export function MemoryModeSettings({
|
|||||||
setError("记忆模式状态暂时无法读取,请重试。");
|
setError("记忆模式状态暂时无法读取,请重试。");
|
||||||
setReadFailed(true);
|
setReadFailed(true);
|
||||||
}
|
}
|
||||||
if (scheduleResult.status === "fulfilled")
|
if (scheduleResult.status === "fulfilled") {
|
||||||
setScheduleState(scheduleResult.value);
|
setScheduleState(scheduleResult.value);
|
||||||
if (overviewResult.status === "fulfilled") {
|
setScheduleReadFailed(false);
|
||||||
setOverview(overviewResult.value);
|
} else {
|
||||||
setRun(overviewResult.value.running[0] ?? null);
|
setScheduleReadFailed(true);
|
||||||
}
|
}
|
||||||
if (processingResult.status === "fulfilled")
|
if (processingResult.status === "fulfilled")
|
||||||
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
||||||
@@ -256,21 +133,18 @@ export function MemoryModeSettings({
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const running =
|
const running = maintenance || state?.graph_build.state === "running";
|
||||||
maintenance ||
|
|
||||||
state?.graph_build.state === "running" ||
|
|
||||||
run?.state === "running";
|
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
const timer = window.setInterval(() => void refresh(), 1500);
|
const timer = window.setInterval(() => void refresh(), 1500);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
|
}, [maintenance, refresh, state?.graph_build.state]);
|
||||||
|
|
||||||
const lifecycleBusy =
|
const lifecycleBusy =
|
||||||
maintenance ||
|
maintenance ||
|
||||||
run?.state === "running" ||
|
run?.state === "running" ||
|
||||||
state?.graph_build.state === "running";
|
state?.graph_build.state === "running";
|
||||||
|
|
||||||
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
|
async function choose(mode: MemoryPipelineMode) {
|
||||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||||
setError("");
|
setError("");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -280,6 +154,7 @@ export function MemoryModeSettings({
|
|||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (!memoryModePlanUnsupported(reason)) {
|
if (!memoryModePlanUnsupported(reason)) {
|
||||||
setError("模式切换影响暂时无法读取,请重试。");
|
setError("模式切换影响暂时无法读取,请重试。");
|
||||||
|
focusModeWhenReady.current = state.mode;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,7 +165,7 @@ export function MemoryModeSettings({
|
|||||||
onConfigureModel(BLOCKER_COPY.active_model_required);
|
onConfigureModel(BLOCKER_COPY.active_model_required);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dialogOpener.current = opener;
|
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||||
setPendingPlan(plan);
|
setPendingPlan(plan);
|
||||||
setPendingMode(mode);
|
setPendingMode(mode);
|
||||||
}
|
}
|
||||||
@@ -332,21 +207,34 @@ export function MemoryModeSettings({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
setScheduleState(await setEvolveSchedule(update));
|
setScheduleState(await setEvolveSchedule(update));
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setError("自动整合设置未能保存,请重试。");
|
setError("自动整合设置未能保存,请重试。");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function evolveNow() {
|
async function evolveNow() {
|
||||||
|
if (!state) return;
|
||||||
refreshVersion.current++;
|
refreshVersion.current++;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const receipt = await startEvolve();
|
const receipt = await startEvolve();
|
||||||
setRun(receipt);
|
commitEvolveRun(
|
||||||
await refresh();
|
receipt,
|
||||||
|
overview ??
|
||||||
|
(schedule
|
||||||
|
? {
|
||||||
|
mode: state.mode,
|
||||||
|
schedule,
|
||||||
|
users: {},
|
||||||
|
running: [],
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
const code = apiErrorCode(reason);
|
const code = apiErrorCode(reason);
|
||||||
setError(
|
setError(
|
||||||
@@ -371,189 +259,140 @@ export function MemoryModeSettings({
|
|||||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MemoryModeChoiceList
|
<div
|
||||||
aria-label={text("当前模式")}
|
className="r6-mode-control"
|
||||||
aria-busy={state === null}
|
data-expanded={state?.mode === "ultra" || undefined}
|
||||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
>
|
||||||
mode,
|
<div className="r6-mode-setting-row" aria-busy={state === null}>
|
||||||
title: text(MODE_COPY[mode].title),
|
<OptionMenu
|
||||||
description: text(MODE_COPY[mode].description),
|
id={`${id}-select`}
|
||||||
}))}
|
ariaLabel={text("记忆模式")}
|
||||||
current={state?.mode}
|
value={state?.mode ?? ""}
|
||||||
selected={state?.mode ?? null}
|
disabled={
|
||||||
currentBadge={text("当前模式")}
|
!state || busy || lifecycleBusy || Boolean(state?.forced_by_env)
|
||||||
disabled={(mode) =>
|
}
|
||||||
!state ||
|
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||||
busy ||
|
value: mode,
|
||||||
lifecycleBusy ||
|
label: modeDisplayName(text, mode),
|
||||||
Boolean(state.forced_by_env && mode !== state.mode) ||
|
suffix: MODE_TIER[mode],
|
||||||
(mode !== "lite" && !modelStateKnown)
|
description: text(MODE_COPY[mode].description),
|
||||||
}
|
disabled: mode !== "lite" && !modelStateKnown,
|
||||||
onChange={(mode, control) => void choose(mode, control)}
|
}))}
|
||||||
/>
|
className="r6-language-option-menu r6-mode-option-menu"
|
||||||
{state?.forced_by_env ? (
|
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
|
||||||
<p className="r6-settings-note" role="status">
|
onChange={(value) => {
|
||||||
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
if (value === "lite" || value === "pro" || value === "ultra")
|
||||||
</p>
|
void choose(value);
|
||||||
) : null}
|
}}
|
||||||
{maintenance ? (
|
|
||||||
<p className="r6-settings-note" role="status">
|
|
||||||
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{state?.graph_build.state === "running" ? (
|
|
||||||
<p className="r6-settings-note" role="status">
|
|
||||||
{text("正在把已有高级记忆加入知识图谱…")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{error && !pendingMode ? (
|
|
||||||
readFailed ? (
|
|
||||||
<R6StateRecovery
|
|
||||||
message={text(error)}
|
|
||||||
onRetry={() => void refresh()}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<p className="r6-settings-error" role="alert">
|
{state?.forced_by_env ? (
|
||||||
{text(error)}
|
<p className="r6-settings-note" role="status">
|
||||||
|
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
||||||
</p>
|
</p>
|
||||||
)
|
) : null}
|
||||||
) : null}
|
{maintenance ? (
|
||||||
|
<p className="r6-settings-note" role="status">
|
||||||
{state?.mode === "ultra" && schedule ? (
|
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
||||||
<section
|
</p>
|
||||||
id={`${id}-evolve`}
|
) : null}
|
||||||
className="r6-evolve-settings"
|
{state?.graph_build.state === "running" ? (
|
||||||
aria-labelledby={`${id}-evolve-title`}
|
<p className="r6-settings-note" role="status">
|
||||||
tabIndex={-1}
|
{text("正在把已有高级记忆加入知识图谱…")}
|
||||||
>
|
</p>
|
||||||
<div className="r6-evolve-heading">
|
) : null}
|
||||||
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
|
{error && !pendingMode ? (
|
||||||
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
readFailed ? (
|
||||||
</div>
|
<R6StateRecovery
|
||||||
<div className="r6-evolve-setting">
|
message={text(error)}
|
||||||
<div className="r6-evolve-setting-copy">
|
onRetry={() => void refresh()}
|
||||||
<h3>{text("每天自动整合")}</h3>
|
|
||||||
<p>
|
|
||||||
{text(
|
|
||||||
schedule.enabled
|
|
||||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
|
||||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
|
||||||
).replace("{time}", schedule.time)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<R6Switch
|
|
||||||
label={text("每天自动整合")}
|
|
||||||
checked={schedule.enabled}
|
|
||||||
disabled={busy || maintenance}
|
|
||||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
|
||||||
/>
|
/>
|
||||||
{schedule.enabled ? (
|
) : (
|
||||||
<div className="r6-evolve-time">
|
<p className="r6-settings-error" role="alert">
|
||||||
<div>
|
{text(error)}
|
||||||
<label htmlFor={`${id}-evolve-time`}>
|
</p>
|
||||||
{text("执行时间")}
|
)
|
||||||
</label>
|
) : null}
|
||||||
<p>{text("使用这台设备的本地时间。")}</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id={`${id}-evolve-time`}
|
|
||||||
type="time"
|
|
||||||
value={schedule.time}
|
|
||||||
disabled={busy || maintenance}
|
|
||||||
onChange={(event) =>
|
|
||||||
void changeSchedule({ time: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<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 ? (
|
{state?.mode === "ultra" ? (
|
||||||
<div className="r6-settings-dialog-layer" role="presentation">
|
|
||||||
<section
|
<section
|
||||||
ref={dialog}
|
id={`${id}-evolve`}
|
||||||
className="r6-settings-dialog r6-mode-confirm-dialog"
|
className="r6-evolve-settings"
|
||||||
role="dialog"
|
aria-labelledby={`${id}-evolve-title`}
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="memory-mode-confirm-title"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<h2 id="memory-mode-confirm-title">
|
{schedule ? (
|
||||||
{text("切换到 {mode}?").replace(
|
<UltraEvolveScheduleFields
|
||||||
"{mode}",
|
schedule={schedule}
|
||||||
text(MODE_COPY[pendingMode].title),
|
headingId={`${id}-evolve-title`}
|
||||||
)}
|
disabled={busy || maintenance}
|
||||||
</h2>
|
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
|
||||||
<dl className="r6-mode-confirm-effects">
|
onTimeChange={(time) => changeSchedule({ time })}
|
||||||
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
|
/>
|
||||||
<div key={`${row.label}:${row.content}`}>
|
) : (
|
||||||
<dt>{text(row.label)}</dt>
|
<>
|
||||||
<dd>{text(row.content)}</dd>
|
<div className="r6-evolve-heading">
|
||||||
|
<h3 id={`${id}-evolve-title`}>
|
||||||
|
{text("记忆整合(Evolve)")}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{scheduleReadFailed ? (
|
||||||
</dl>
|
<R6StateRecovery
|
||||||
{(pendingPlan?.blockers ?? []).map((blocker) => (
|
message={text("自动整合设置暂时无法读取,请重试。")}
|
||||||
<p className="r6-settings-error" role="alert" key={blocker}>
|
onRetry={() => {
|
||||||
{text(BLOCKER_COPY[blocker])}
|
focusScheduleWhenReady.current = true;
|
||||||
</p>
|
void refresh();
|
||||||
))}
|
}}
|
||||||
{error ? (
|
/>
|
||||||
<p className="r6-settings-error" role="alert">
|
) : null}
|
||||||
{text(error)}
|
</>
|
||||||
</p>
|
)}
|
||||||
) : null}
|
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||||
<footer>
|
<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
|
<R6Button
|
||||||
disabled={busy}
|
pending={run?.state === "running"}
|
||||||
onClick={() => {
|
disabled={busy || lifecycleBusy}
|
||||||
setPendingPlan(null);
|
onClick={() => void evolveNow()}
|
||||||
setPendingMode(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{text("取消")}
|
{text("立即整合")}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
<R6Button
|
</div>
|
||||||
variant="primary"
|
|
||||||
pending={busy}
|
|
||||||
disabled={Boolean(pendingPlan?.blockers.length)}
|
|
||||||
onClick={() => void commitMode()}
|
|
||||||
>
|
|
||||||
{text("切换到 {mode}").replace(
|
|
||||||
"{mode}",
|
|
||||||
text(MODE_COPY[pendingMode].title),
|
|
||||||
)}
|
|
||||||
</R6Button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingMode && pendingPlan ? (
|
||||||
|
<MemoryModeConfirmDialog
|
||||||
|
mode={pendingMode}
|
||||||
|
plan={pendingPlan}
|
||||||
|
busy={busy}
|
||||||
|
error={error}
|
||||||
|
opener={dialogOpener.current}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingPlan(null);
|
||||||
|
setPendingMode(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void commitMode()}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, expect, it, vi } from "vitest";
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||||
import { MemorySpace } from "./MemorySpace";
|
import { MemorySpace } from "./MemorySpace";
|
||||||
|
import { queryClient, resetClient } from "./query-client";
|
||||||
|
|
||||||
it("translates memory filters and detail affordances live without refetching", async () => {
|
it("translates memory filters and detail affordances live without refetching", async () => {
|
||||||
setup();
|
setup();
|
||||||
@@ -77,7 +78,9 @@ const { reads } = vi.hoisted(() => ({
|
|||||||
processing: vi.fn(),
|
processing: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
raw: vi.fn(),
|
raw: vi.fn(),
|
||||||
|
rawGraph: vi.fn(),
|
||||||
rawSearch: vi.fn(),
|
rawSearch: vi.fn(),
|
||||||
|
evolve: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("./api", async (importOriginal) => ({
|
vi.mock("./api", async (importOriginal) => ({
|
||||||
@@ -87,7 +90,9 @@ vi.mock("./api", async (importOriginal) => ({
|
|||||||
getMemoryProcessing: reads.processing,
|
getMemoryProcessing: reads.processing,
|
||||||
getModelProfiles: reads.profiles,
|
getModelProfiles: reads.profiles,
|
||||||
getSdkRaw: reads.raw,
|
getSdkRaw: reads.raw,
|
||||||
|
getSdkRawGraph: reads.rawGraph,
|
||||||
searchSdkRaw: reads.rawSearch,
|
searchSdkRaw: reads.rawSearch,
|
||||||
|
getEvolveOverview: reads.evolve,
|
||||||
}));
|
}));
|
||||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||||
vi.mock("./MemoryAddComposer", () => ({
|
vi.mock("./MemoryAddComposer", () => ({
|
||||||
@@ -117,10 +122,16 @@ vi.mock("./MemoryAddComposer", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("./MemoryGraph3D", () => ({
|
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}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
resetClient();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -154,7 +165,94 @@ it("translates the model-required prompt without configuring a model", async ()
|
|||||||
localStorage.clear();
|
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() {
|
function setup() {
|
||||||
|
reads.evolve.mockResolvedValue({
|
||||||
|
mode: "ultra",
|
||||||
|
schedule: { enabled: true, time: "03:00" },
|
||||||
|
users: {},
|
||||||
|
running: [],
|
||||||
|
});
|
||||||
const memories = [
|
const memories = [
|
||||||
{
|
{
|
||||||
memory_id: "m1",
|
memory_id: "m1",
|
||||||
@@ -208,6 +306,24 @@ function setup() {
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
offset: 0,
|
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: 300,
|
||||||
|
offset: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
reads.rawSearch.mockResolvedValue([]);
|
reads.rawSearch.mockResolvedValue([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +405,11 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
|||||||
render(<MemorySpace simplified />);
|
render(<MemorySpace simplified />);
|
||||||
|
|
||||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
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(
|
expect(
|
||||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -316,13 +437,101 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
|||||||
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
|
||||||
|
setup();
|
||||||
|
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||||
|
reads.rawGraph.mockResolvedValue({
|
||||||
|
user_id: "local",
|
||||||
|
items: Array.from({ length: 300 }, (_, index) => ({
|
||||||
|
turn_id: `turn-${index}`,
|
||||||
|
raw_id: `raw-${index}`,
|
||||||
|
label: `原始会话 ${index}`,
|
||||||
|
source: "cursor",
|
||||||
|
session_id: `session-${index}`,
|
||||||
|
memory_at: null,
|
||||||
|
gmt_created: 1_761_000_000,
|
||||||
|
})),
|
||||||
|
total: 1_600,
|
||||||
|
limit: 300,
|
||||||
|
offset: 0,
|
||||||
|
has_more: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MemorySpace simplified />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||||
|
expect(reads.rawGraph).toHaveBeenCalledWith({
|
||||||
|
source: undefined,
|
||||||
|
offset: 0,
|
||||||
|
limit: 300,
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
|
||||||
|
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
|
||||||
|
"1,600 条原始对话",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last successful Lite graph when a background refresh fails", async () => {
|
||||||
|
setup();
|
||||||
|
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||||
|
const source = {
|
||||||
|
source_id: "cursor",
|
||||||
|
display_name: "Cursor",
|
||||||
|
authorized: true,
|
||||||
|
imported_messages: 1,
|
||||||
|
imported_conversations: 1,
|
||||||
|
} as SourceResponse;
|
||||||
|
const view = render(<MemorySpace simplified sources={[source]} />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
|
||||||
|
);
|
||||||
|
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
|
||||||
|
view.rerender(
|
||||||
|
<MemorySpace
|
||||||
|
simplified
|
||||||
|
sources={[
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
...source,
|
||||||
|
source_id: "codex",
|
||||||
|
display_name: "Codex",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
|
||||||
|
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
|
||||||
|
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not request the raw graph for a keyword search", async () => {
|
||||||
|
setup();
|
||||||
|
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||||
|
reads.rawSearch.mockResolvedValue([]);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemorySpace simplified />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: "搜索原始对话" }),
|
||||||
|
"发布计划",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||||
|
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("pages and filters the raw conversation data plane in lite", async () => {
|
it("pages and filters the raw conversation data plane in lite", async () => {
|
||||||
setup();
|
setup();
|
||||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||||
reads.raw.mockImplementation(async (options = {}) => {
|
reads.raw.mockImplementation(async (options = {}) => {
|
||||||
const offset = "offset" in options ? (options.offset ?? 0) : 0;
|
const cursor = "cursor" in options ? options.cursor : null;
|
||||||
const source = "source" in options ? options.source : undefined;
|
const source = "source" in options ? options.source : undefined;
|
||||||
const rawId = `${source ?? "all"}-${offset}`;
|
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
|
||||||
return {
|
return {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -333,14 +542,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
|||||||
source: source ?? "cursor",
|
source: source ?? "cursor",
|
||||||
session_id: `session-${rawId}`,
|
session_id: `session-${rawId}`,
|
||||||
memory_at: null,
|
memory_at: null,
|
||||||
gmt_created: 1_761_000_000 - offset,
|
gmt_created: 1_761_000_000,
|
||||||
custom: {},
|
custom: {},
|
||||||
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total: source ? 1 : 21,
|
total: cursor ? 2 : 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset,
|
offset: cursor ? 1 : 0,
|
||||||
|
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
|
||||||
|
has_more: !source && !cursor,
|
||||||
|
total_exact: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -359,30 +571,46 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
|
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||||
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
|
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||||
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
|
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(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
|
expect.objectContaining({
|
||||||
|
source: undefined,
|
||||||
|
cursor: "turn:1:0:1",
|
||||||
|
paging: "cursor",
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||||
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
||||||
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
|
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
|
||||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
|
expect.objectContaining({
|
||||||
|
source: "cursor",
|
||||||
|
cursor: null,
|
||||||
|
paging: "cursor",
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||||
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findAllByText("原始会话 one-memory-manual-0"),
|
await screen.findAllByText("原始会话 one-memory-manual-first"),
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: "one-memory-manual",
|
source: "one-memory-manual",
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
paging: "cursor",
|
||||||
|
cursor: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -599,6 +827,14 @@ it("opens a search hit with the complete role transcript", async () => {
|
|||||||
truncated: true,
|
truncated: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
reads.rawGraph.mockResolvedValue({
|
||||||
|
user_id: "local",
|
||||||
|
items: [],
|
||||||
|
total: 1_600,
|
||||||
|
limit: 300,
|
||||||
|
offset: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemorySpace simplified />);
|
render(<MemorySpace simplified />);
|
||||||
|
|
||||||
@@ -609,6 +845,7 @@ it("opens a search hit with the complete role transcript", async () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||||
|
|
||||||
expect(await screen.findByText("完整问题")).toBeTruthy();
|
expect(await screen.findByText("完整问题")).toBeTruthy();
|
||||||
|
expect(screen.getByText("找到 1 条")).toBeTruthy();
|
||||||
expect(screen.getByText("完整回答")).toBeTruthy();
|
expect(screen.getByText("完整回答")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector(".map-dialogue-reading")?.textContent,
|
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();
|
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();
|
sdkAuthority();
|
||||||
render(<MemorySpace simplified active />);
|
render(<MemorySpace simplified active />);
|
||||||
await waitFor(() =>
|
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;
|
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||||
fireEvent.click(screen.getByText("search deploy"));
|
fireEvent.click(screen.getByText("search deploy"));
|
||||||
await waitFor(() =>
|
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
expect(screen.getByTestId("map-nodes").textContent).toBe("4");
|
||||||
);
|
|
||||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
|
||||||
expect(screen.getByTestId("map-memories").textContent).toBe("");
|
expect(screen.getByTestId("map-memories").textContent).toBe("");
|
||||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||||
|
|
||||||
await act(async () => releaseSearch(searchPage));
|
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 () => {
|
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||||
|
|||||||
@@ -38,15 +38,18 @@ import {
|
|||||||
getSdkMemoryGraph,
|
getSdkMemoryGraph,
|
||||||
getSdkMemoryLibrary,
|
getSdkMemoryLibrary,
|
||||||
getSdkRaw,
|
getSdkRaw,
|
||||||
|
getSdkRawGraph,
|
||||||
manualExtract,
|
manualExtract,
|
||||||
searchSdkRaw,
|
searchSdkRaw,
|
||||||
setExtractionWindow,
|
setExtractionWindow,
|
||||||
updateSdkMemory,
|
updateSdkMemory,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||||
import { GraphWidthPrompt } from "./GraphWidthPrompt";
|
import { GraphWidthPrompt } from "./GraphWidthPrompt";
|
||||||
import { useGuiLanguage } from "./gui-language";
|
import { useGuiLanguage } from "./gui-language";
|
||||||
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
|
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
|
||||||
import { MemoryMap } from "./MemoryMap";
|
import { MemoryMap } from "./MemoryMap";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { subscribePoll } from "./poll-scheduler";
|
import { subscribePoll } from "./poll-scheduler";
|
||||||
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
||||||
import { SdkRawHistory } from "./SdkRawHistory";
|
import { SdkRawHistory } from "./SdkRawHistory";
|
||||||
@@ -60,9 +63,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
type RawMapRecord,
|
type RawMapRecord,
|
||||||
rawDetail,
|
rawDetail,
|
||||||
|
rawGraphProjection,
|
||||||
|
rawGraphRecord,
|
||||||
rawLibraryPage,
|
rawLibraryPage,
|
||||||
rawRecordId,
|
rawRecordId,
|
||||||
rawSearchPage,
|
rawSearchPage,
|
||||||
|
SDK_RAW_PAGE_SIZE,
|
||||||
} from "./sdk-raw-map";
|
} from "./sdk-raw-map";
|
||||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||||
import { traceUiAction } from "./ui-trace";
|
import { traceUiAction } from "./ui-trace";
|
||||||
@@ -114,6 +120,10 @@ export function MemorySpace({
|
|||||||
onFocusMemoryConsumed?(): void;
|
onFocusMemoryConsumed?(): void;
|
||||||
}) {
|
}) {
|
||||||
const { locale, t, tn } = useGuiLanguage();
|
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 memoryText = (text: string) => {
|
||||||
const key = `memory:${text}`;
|
const key = `memory:${text}`;
|
||||||
return isMessageKey(key) ? t(key) : text;
|
return isMessageKey(key) ? t(key) : text;
|
||||||
@@ -123,6 +133,7 @@ export function MemorySpace({
|
|||||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||||
selectedRawRef.current = selectedRaw;
|
selectedRawRef.current = selectedRaw;
|
||||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||||
|
const rawRecordsScope = useRef("");
|
||||||
const [detailLoad, setDetailLoad] = useState<{
|
const [detailLoad, setDetailLoad] = useState<{
|
||||||
memoryId: string;
|
memoryId: string;
|
||||||
status: "loading" | "failed";
|
status: "loading" | "failed";
|
||||||
@@ -164,6 +175,8 @@ export function MemorySpace({
|
|||||||
source.grant_revision,
|
source.grant_revision,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const sourcesRef = useRef(sources);
|
||||||
|
sourcesRef.current = sources;
|
||||||
const management = simplified && displayedMode === "library";
|
const management = simplified && displayedMode === "library";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewActive.current = active;
|
viewActive.current = active;
|
||||||
@@ -190,6 +203,12 @@ export function MemorySpace({
|
|||||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
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<{
|
const [sdkLibrary, setSdkLibrary] = useState<{
|
||||||
page: MemoryLibraryPage | null;
|
page: MemoryLibraryPage | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -209,6 +228,7 @@ export function MemorySpace({
|
|||||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||||
|
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
|
||||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -226,6 +246,10 @@ export function MemorySpace({
|
|||||||
evolve_available: pipelineMode === "ultra",
|
evolve_available: pipelineMode === "ultra",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
const refreshSdkMap = useCallback(() => {
|
||||||
|
if (lite) setSdkRawCursor(null);
|
||||||
|
bumpSdkMap();
|
||||||
|
}, [bumpSdkMap, lite]);
|
||||||
const requestedQuery = sdkLibraryQuery.trim();
|
const requestedQuery = sdkLibraryQuery.trim();
|
||||||
const sdkPageSettled =
|
const sdkPageSettled =
|
||||||
sdkLibrary.settledMode === pipelineMode &&
|
sdkLibrary.settledMode === pipelineMode &&
|
||||||
@@ -233,18 +257,17 @@ export function MemorySpace({
|
|||||||
sdkLibrary.settledQuery.trim() === requestedQuery &&
|
sdkLibrary.settledQuery.trim() === requestedQuery &&
|
||||||
sdkLibrary.page?.offset === sdkLibraryOffset;
|
sdkLibrary.page?.offset === sdkLibraryOffset;
|
||||||
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
|
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
|
||||||
const sdkRequestedScope = JSON.stringify([
|
const sdkGraphRequestedScope = JSON.stringify([
|
||||||
pipelineMode,
|
pipelineMode,
|
||||||
sdkLibraryQuery.trim(),
|
|
||||||
sdkLibrarySource,
|
sdkLibrarySource,
|
||||||
]);
|
]);
|
||||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
|
||||||
const visibleSdkGraph =
|
const visibleSdkGraph =
|
||||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
|
||||||
? sdkGraph.value
|
|
||||||
: null;
|
|
||||||
const sdkGraphFailed =
|
const sdkGraphFailed =
|
||||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||||
|
sdkGraph.status === "failed" &&
|
||||||
|
sdkGraph.value === null;
|
||||||
// A keyword search pages through one server-side result window. The id is
|
// 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
|
// kept for the query and source it was searched with; a new query or
|
||||||
// source starts without one, so the server searches afresh.
|
// source starts without one, so the server searches afresh.
|
||||||
@@ -276,7 +299,10 @@ export function MemorySpace({
|
|||||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||||
try {
|
try {
|
||||||
const rememberRaw = (items: RawMapRecord[]) => {
|
const rememberRaw = (items: RawMapRecord[]) => {
|
||||||
const next = new Map<string, RawMapRecord>();
|
const next =
|
||||||
|
sdkRawCursor && rawRecordsScope.current === windowKey
|
||||||
|
? new Map(rawRecords.current)
|
||||||
|
: new Map<string, RawMapRecord>();
|
||||||
if (selectedRawRef.current)
|
if (selectedRawRef.current)
|
||||||
next.set(
|
next.set(
|
||||||
rawRecordId(selectedRawRef.current),
|
rawRecordId(selectedRawRef.current),
|
||||||
@@ -284,60 +310,39 @@ export function MemorySpace({
|
|||||||
);
|
);
|
||||||
for (const item of items) next.set(rawRecordId(item), item);
|
for (const item of items) next.set(rawRecordId(item), item);
|
||||||
rawRecords.current = next;
|
rawRecords.current = next;
|
||||||
|
rawRecordsScope.current = windowKey;
|
||||||
};
|
};
|
||||||
const [page, graph] = lite
|
const page = lite
|
||||||
? await Promise.all([
|
? query
|
||||||
query
|
? await searchSdkRaw(query, {
|
||||||
? searchSdkRaw(query, {
|
source,
|
||||||
source,
|
signal: request.signal,
|
||||||
signal: request.signal,
|
}).then((items) => {
|
||||||
}).then((items) => {
|
rememberRaw(items);
|
||||||
rememberRaw(items);
|
return rawSearchPage(items);
|
||||||
return rawSearchPage(items);
|
})
|
||||||
})
|
: await getSdkRaw({
|
||||||
: getSdkRaw({
|
source,
|
||||||
source,
|
offset: 0,
|
||||||
offset: sdkLibraryOffset,
|
limit: SDK_RAW_PAGE_SIZE,
|
||||||
limit: SDK_MEMORY_PAGE_SIZE,
|
paging: "cursor",
|
||||||
signal: request.signal,
|
cursor: sdkRawCursor,
|
||||||
}).then((rawPage) => {
|
signal: request.signal,
|
||||||
rememberRaw(rawPage.items);
|
}).then((rawPage) => {
|
||||||
return rawLibraryPage(rawPage);
|
rememberRaw(rawPage.items);
|
||||||
}),
|
return rawLibraryPage(rawPage);
|
||||||
Promise.resolve({
|
})
|
||||||
status: "ready" as const,
|
: await getSdkMemoryLibrary(
|
||||||
value: null,
|
{
|
||||||
}),
|
statuses: ["active"],
|
||||||
])
|
source,
|
||||||
: await Promise.all([
|
query,
|
||||||
getSdkMemoryLibrary(
|
offset: sdkLibraryOffset,
|
||||||
{
|
limit: SDK_MEMORY_PAGE_SIZE,
|
||||||
statuses: ["active"],
|
windowId: query ? sdkSearchWindow.current.id : null,
|
||||||
source,
|
},
|
||||||
query,
|
request.signal,
|
||||||
offset: sdkLibraryOffset,
|
).then(sdkLibraryPage);
|
||||||
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 }),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!lite && sdkSearchWindow.current.key === windowKey)
|
if (!lite && sdkSearchWindow.current.key === windowKey)
|
||||||
sdkSearchWindow.current = {
|
sdkSearchWindow.current = {
|
||||||
@@ -346,17 +351,63 @@ export function MemorySpace({
|
|||||||
};
|
};
|
||||||
// The window expired: the server searched again and answered page 1.
|
// The window expired: the server searched again and answered page 1.
|
||||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||||
setSdkLibrary({
|
setSdkLibrary((current) => {
|
||||||
page,
|
const currentPage = current.page;
|
||||||
loading: false,
|
const append =
|
||||||
failed: false,
|
lite &&
|
||||||
settledSource: sdkLibrarySource,
|
sdkRawCursor !== null &&
|
||||||
settledQuery: sdkLibraryQuery,
|
currentPage !== null &&
|
||||||
settledMode: pipelineMode,
|
current.settledSource === sdkLibrarySource &&
|
||||||
|
current.settledQuery === sdkLibraryQuery &&
|
||||||
|
current.settledMode === pipelineMode;
|
||||||
|
if (!append)
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
loading: false,
|
||||||
|
failed: false,
|
||||||
|
settledSource: sdkLibrarySource,
|
||||||
|
settledQuery: sdkLibraryQuery,
|
||||||
|
settledMode: pipelineMode,
|
||||||
|
};
|
||||||
|
const items = [...currentPage.items];
|
||||||
|
const known = new Set(items.map((item) => item.memory_id));
|
||||||
|
for (const item of page.items)
|
||||||
|
if (!known.has(item.memory_id)) {
|
||||||
|
known.add(item.memory_id);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
page: {
|
||||||
|
...page,
|
||||||
|
items,
|
||||||
|
total: items.length + (page.has_more ? 1 : 0),
|
||||||
|
offset: 0,
|
||||||
|
source_ids_by_memory: {
|
||||||
|
...currentPage.source_ids_by_memory,
|
||||||
|
...page.source_ids_by_memory,
|
||||||
|
},
|
||||||
|
source_time_by_memory: {
|
||||||
|
...currentPage.source_time_by_memory,
|
||||||
|
...page.source_time_by_memory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
failed: false,
|
||||||
|
settledSource: sdkLibrarySource,
|
||||||
|
settledQuery: sdkLibraryQuery,
|
||||||
|
settledMode: pipelineMode,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setSdkGraph({ scope: windowKey, ...graph });
|
} catch (reason) {
|
||||||
} catch {
|
|
||||||
if (cancelled || request.signal.aborted) return;
|
if (cancelled || request.signal.aborted) return;
|
||||||
|
if (
|
||||||
|
lite &&
|
||||||
|
sdkRawCursor !== null &&
|
||||||
|
apiErrorCode(reason) === "raw_cursor_stale"
|
||||||
|
) {
|
||||||
|
setSdkRawCursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSdkLibrary((current) => ({
|
setSdkLibrary((current) => ({
|
||||||
...current,
|
...current,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -365,10 +416,13 @@ export function MemorySpace({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void read(true);
|
void read(true);
|
||||||
const timer = setInterval(() => void read(false), 30_000);
|
const timer =
|
||||||
|
lite && sdkRawCursor !== null
|
||||||
|
? undefined
|
||||||
|
: setInterval(() => void read(false), 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(timer);
|
if (timer !== undefined) clearInterval(timer);
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -377,9 +431,77 @@ export function MemorySpace({
|
|||||||
sdkLibraryOffset,
|
sdkLibraryOffset,
|
||||||
sdkLibraryQuery,
|
sdkLibraryQuery,
|
||||||
sdkLibrarySource,
|
sdkLibrarySource,
|
||||||
|
sdkRawCursor,
|
||||||
lite,
|
lite,
|
||||||
pipelineMode,
|
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;
|
||||||
|
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 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = await getSdkRawGraph({
|
||||||
|
source,
|
||||||
|
offset: 0,
|
||||||
|
limit: 300,
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
rawGraphRecords.current.clear();
|
||||||
|
for (const item of page.items) {
|
||||||
|
const record = rawGraphRecord(item);
|
||||||
|
rawGraphRecords.current.set(rawRecordId(record), record);
|
||||||
|
}
|
||||||
|
setSdkGraph({
|
||||||
|
scope,
|
||||||
|
status: "ready",
|
||||||
|
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
|
||||||
|
});
|
||||||
|
} 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 = sdkPage;
|
||||||
const sdkFilterLabel = [
|
const sdkFilterLabel = [
|
||||||
sdkLibrary.settledSource
|
sdkLibrary.settledSource
|
||||||
? `${t("memory:来源")} ${
|
? `${t("memory:来源")} ${
|
||||||
@@ -509,10 +631,13 @@ export function MemorySpace({
|
|||||||
}, [active, onArrivalChange]);
|
}, [active, onArrivalChange]);
|
||||||
const requestIds = useRef(new Map<string, string>());
|
const requestIds = useRef(new Map<string, string>());
|
||||||
const detailEpoch = useRef(0);
|
const detailEpoch = useRef(0);
|
||||||
|
const rawDetailController = useRef<AbortController | null>(null);
|
||||||
const answerEpoch = useRef(0);
|
const answerEpoch = useRef(0);
|
||||||
const answerMemories = useRef<AskMemory[]>([]);
|
const answerMemories = useRef<AskMemory[]>([]);
|
||||||
const closeDetail = useCallback(() => {
|
const closeDetail = useCallback(() => {
|
||||||
detailEpoch.current++;
|
detailEpoch.current++;
|
||||||
|
rawDetailController.current?.abort();
|
||||||
|
rawDetailController.current = null;
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setSelectedRaw(null);
|
setSelectedRaw(null);
|
||||||
setDetailLoad(null);
|
setDetailLoad(null);
|
||||||
@@ -525,6 +650,7 @@ export function MemorySpace({
|
|||||||
rawRecords.current.clear();
|
rawRecords.current.clear();
|
||||||
sdkSearchWindow.current = { key: "", id: null };
|
sdkSearchWindow.current = { key: "", id: null };
|
||||||
setSdkLibraryOffset(0);
|
setSdkLibraryOffset(0);
|
||||||
|
setSdkRawCursor(null);
|
||||||
setSdkLibraryQuery("");
|
setSdkLibraryQuery("");
|
||||||
setSdkGraph(null);
|
setSdkGraph(null);
|
||||||
setSdkLibrary((current) => ({
|
setSdkLibrary((current) => ({
|
||||||
@@ -586,6 +712,8 @@ export function MemorySpace({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopProcessing();
|
stopProcessing();
|
||||||
detailEpoch.current++;
|
detailEpoch.current++;
|
||||||
|
rawDetailController.current?.abort();
|
||||||
|
rawDetailController.current = null;
|
||||||
answerEpoch.current++;
|
answerEpoch.current++;
|
||||||
deleteAnswer.current?.(false);
|
deleteAnswer.current?.(false);
|
||||||
deleteAnswer.current = null;
|
deleteAnswer.current = null;
|
||||||
@@ -697,11 +825,15 @@ export function MemorySpace({
|
|||||||
async function open(memoryId: string) {
|
async function open(memoryId: string) {
|
||||||
if (simplified && (!viewActive.current || stale.current)) return;
|
if (simplified && (!viewActive.current || stale.current)) return;
|
||||||
const epoch = ++detailEpoch.current;
|
const epoch = ++detailEpoch.current;
|
||||||
|
rawDetailController.current?.abort();
|
||||||
|
rawDetailController.current = null;
|
||||||
setError(null);
|
setError(null);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setSelectedRaw(null);
|
setSelectedRaw(null);
|
||||||
if (lite) {
|
if (lite) {
|
||||||
let record = rawRecords.current.get(memoryId);
|
let record =
|
||||||
|
rawRecords.current.get(memoryId) ??
|
||||||
|
rawGraphRecords.current.get(memoryId);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
setDetailLoad({
|
setDetailLoad({
|
||||||
memoryId,
|
memoryId,
|
||||||
@@ -712,6 +844,8 @@ export function MemorySpace({
|
|||||||
}
|
}
|
||||||
if ("kind" in record) {
|
if ("kind" in record) {
|
||||||
setDetailLoad({ memoryId, status: "loading" });
|
setDetailLoad({ memoryId, status: "loading" });
|
||||||
|
const request = new AbortController();
|
||||||
|
rawDetailController.current = request;
|
||||||
try {
|
try {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -720,6 +854,7 @@ export function MemorySpace({
|
|||||||
sessionId: record.session_id || undefined,
|
sessionId: record.session_id || undefined,
|
||||||
offset,
|
offset,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
signal: request.signal,
|
||||||
});
|
});
|
||||||
const full = page.items.find(
|
const full = page.items.find(
|
||||||
(item) => rawRecordId(item) === memoryId,
|
(item) => rawRecordId(item) === memoryId,
|
||||||
@@ -734,15 +869,18 @@ export function MemorySpace({
|
|||||||
throw new Error("raw_not_found");
|
throw new Error("raw_not_found");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (epoch === detailEpoch.current)
|
if (epoch === detailEpoch.current) {
|
||||||
|
rawDetailController.current = null;
|
||||||
setDetailLoad({
|
setDetailLoad({
|
||||||
memoryId,
|
memoryId,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: t("sdk.raw.loadFailed"),
|
error: t("sdk.raw.loadFailed"),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (epoch !== detailEpoch.current) return;
|
if (epoch !== detailEpoch.current) return;
|
||||||
|
rawDetailController.current = null;
|
||||||
}
|
}
|
||||||
setSelectedRaw(record);
|
setSelectedRaw(record);
|
||||||
setDetail(rawDetail(record));
|
setDetail(rawDetail(record));
|
||||||
@@ -804,7 +942,7 @@ export function MemorySpace({
|
|||||||
);
|
);
|
||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
closeDetail();
|
closeDetail();
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
trace.done(writeTraceStatus(reason), {
|
trace.done(writeTraceStatus(reason), {
|
||||||
reason: "memory_action_failed",
|
reason: "memory_action_failed",
|
||||||
@@ -833,7 +971,7 @@ export function MemorySpace({
|
|||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
setDetail(sdkDetail(saved));
|
setDetail(sdkDetail(saved));
|
||||||
setDraft(sdkEditableText(saved));
|
setDraft(sdkEditableText(saved));
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
trace.done(writeTraceStatus(reason), {
|
trace.done(writeTraceStatus(reason), {
|
||||||
reason: isDefiniteRefusal(reason)
|
reason: isDefiniteRefusal(reason)
|
||||||
@@ -897,11 +1035,12 @@ export function MemorySpace({
|
|||||||
? pipelineMode === null
|
? pipelineMode === null
|
||||||
? t("memory:记忆暂时无法读取")
|
? t("memory:记忆暂时无法读取")
|
||||||
: lite
|
: lite
|
||||||
? sdkPage
|
? displayedSdkPage
|
||||||
? `${t("memory:原始对话")} · ${tn(
|
? displayedSdkPage.total_exact === false
|
||||||
"sdk.raw.found",
|
? t("memory:原始对话")
|
||||||
sdkPage.total,
|
: sdkLibrary.settledQuery.trim()
|
||||||
)}`
|
? tn("sdk.raw.found", displayedSdkPage.total)
|
||||||
|
: tn("sdk.raw.total", displayedSdkPage.total)
|
||||||
: sdkLibrary.failed
|
: sdkLibrary.failed
|
||||||
? null
|
? null
|
||||||
: t("memory:正在读取…")
|
: t("memory:正在读取…")
|
||||||
@@ -934,7 +1073,7 @@ export function MemorySpace({
|
|||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
// A finished addition is new page content: re-read now rather than
|
// A finished addition is new page content: re-read now rather than
|
||||||
// leave it for the next poll.
|
// leave it for the next poll.
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
}}
|
}}
|
||||||
onOpenMemory={(id) => {
|
onOpenMemory={(id) => {
|
||||||
if (!simplified) setMode("library");
|
if (!simplified) setMode("library");
|
||||||
@@ -1009,6 +1148,11 @@ export function MemorySpace({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
{evolveRunning ? (
|
||||||
|
<p className="memory-evolve-status" role="status">
|
||||||
|
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{processing && (!simplified || managementOpen) ? (
|
{processing && (!simplified || managementOpen) ? (
|
||||||
<details className="memory-processing-details">
|
<details className="memory-processing-details">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -1192,21 +1336,30 @@ export function MemorySpace({
|
|||||||
? {
|
? {
|
||||||
query: sdkLibraryQuery,
|
query: sdkLibraryQuery,
|
||||||
source: sdkLibrarySource,
|
source: sdkLibrarySource,
|
||||||
page: sdkPage,
|
page: displayedSdkPage,
|
||||||
loading: sdkLibrary.loading,
|
loading: sdkLibrary.loading,
|
||||||
failed: sdkLibrary.failed,
|
failed: sdkLibrary.failed && !evolveRunning,
|
||||||
graphLoading: !sdkGraphSettled,
|
graphLoading: !sdkGraphSettled,
|
||||||
graphFailed: sdkGraphFailed,
|
graphFailed: sdkGraphFailed && !evolveRunning,
|
||||||
stale: false,
|
stale: false,
|
||||||
settledSource: sdkLibrary.settledSource,
|
settledSource: sdkLibrary.settledSource,
|
||||||
settledQuery: sdkLibrary.settledQuery,
|
settledQuery: sdkLibrary.settledQuery,
|
||||||
requestedOffset: sdkLibraryOffset,
|
requestedOffset: sdkLibraryOffset,
|
||||||
|
cursorPaging: lite && !sdkLibraryQuery.trim(),
|
||||||
|
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
|
||||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||||
onQuery: setSdkLibraryQuery,
|
onQuery: setSdkLibraryQuery,
|
||||||
onSource: setSdkLibrarySource,
|
onSource: setSdkLibrarySource,
|
||||||
resetPage: () => setSdkLibraryOffset(0),
|
resetPage: () => {
|
||||||
|
setSdkLibraryOffset(0);
|
||||||
|
setSdkRawCursor(null);
|
||||||
|
},
|
||||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||||
onRetry: bumpSdkMap,
|
onMore: () => {
|
||||||
|
if (sdkPage?.next_cursor)
|
||||||
|
setSdkRawCursor(sdkPage.next_cursor);
|
||||||
|
},
|
||||||
|
onRetry: refreshSdkMap,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -1215,7 +1368,7 @@ export function MemorySpace({
|
|||||||
displayedMode !== "map" ||
|
displayedMode !== "map" ||
|
||||||
(simplified && pipelineMode === null)
|
(simplified && pipelineMode === null)
|
||||||
}
|
}
|
||||||
memories={sdkPage?.items ?? []}
|
memories={displayedSdkPage?.items ?? []}
|
||||||
graph={visibleSdkGraph}
|
graph={visibleSdkGraph}
|
||||||
sources={sources}
|
sources={sources}
|
||||||
processing={processing}
|
processing={processing}
|
||||||
|
|||||||
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
|
|||||||
...profile,
|
...profile,
|
||||||
profile_id: "created",
|
profile_id: "created",
|
||||||
revision: 1,
|
revision: 1,
|
||||||
capabilities,
|
capabilities: {
|
||||||
|
...capabilities,
|
||||||
|
endpoint_reachable: false,
|
||||||
|
model_available: false,
|
||||||
|
embedding_available: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
profiles = [];
|
profiles = [];
|
||||||
create.mockImplementation(async () => {
|
create.mockImplementation(async () => {
|
||||||
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
|
|||||||
});
|
});
|
||||||
probeProfile.mockResolvedValue({
|
probeProfile.mockResolvedValue({
|
||||||
profile_id: created.profile_id,
|
profile_id: created.profile_id,
|
||||||
profile_revision: created.revision,
|
input_profile_revision: created.revision,
|
||||||
|
profile_revision: 2,
|
||||||
stages: [],
|
stages: [],
|
||||||
capabilities,
|
capabilities,
|
||||||
});
|
});
|
||||||
activate.mockResolvedValue({ ...created, is_active: true });
|
activate.mockResolvedValue({
|
||||||
|
...created,
|
||||||
|
revision: 2,
|
||||||
|
capabilities,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
|
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText("配置名称"), "Current model");
|
await user.type(screen.getByLabelText("配置名称"), "Current model");
|
||||||
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
|
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
|
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
|
||||||
expect(planActivation).toHaveBeenCalledWith(
|
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
|
||||||
created.profile_id,
|
|
||||||
created.revision,
|
|
||||||
);
|
|
||||||
expect(activate).toHaveBeenCalledWith(created.profile_id);
|
expect(activate).toHaveBeenCalledWith(created.profile_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
presetForGeneration,
|
presetForGeneration,
|
||||||
validateGenerationModel,
|
validateGenerationModel,
|
||||||
} from "./GenerationModelPrimitives";
|
} from "./GenerationModelPrimitives";
|
||||||
|
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||||
import { OptionMenu } from "./OptionMenu";
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||||
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ready(
|
|
||||||
profile: ModelProfile,
|
|
||||||
probe: ModelProbeResult | null = null,
|
|
||||||
error: string | null = null,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
!error &&
|
|
||||||
(!probe ||
|
|
||||||
(probe.profile_id === profile.profile_id &&
|
|
||||||
probe.profile_revision === profile.revision &&
|
|
||||||
probe.capabilities.endpoint_reachable &&
|
|
||||||
probe.capabilities.model_available &&
|
|
||||||
probe.capabilities.embedding_available)) &&
|
|
||||||
profile.credential_state !== "unavailable" &&
|
|
||||||
!(profile.has_configured_api_key && !profile.has_api_key) &&
|
|
||||||
profile.capabilities.endpoint_reachable &&
|
|
||||||
profile.capabilities.model_available &&
|
|
||||||
profile.capabilities.embedding_available
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function endpointLabel(value: string | null): string {
|
function endpointLabel(value: string | null): string {
|
||||||
if (!value) return "未提供接口地址";
|
if (!value) return "未提供接口地址";
|
||||||
try {
|
try {
|
||||||
@@ -467,7 +447,11 @@ export function ModelSettings({
|
|||||||
technicalTouched.current.clear();
|
technicalTouched.current.clear();
|
||||||
};
|
};
|
||||||
const isReady = (profile: ModelProfile) =>
|
const isReady = (profile: ModelProfile) =>
|
||||||
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
|
modelProfileReady(
|
||||||
|
profile,
|
||||||
|
models.probeFor(profile),
|
||||||
|
models.probeErrorFor(profile),
|
||||||
|
);
|
||||||
|
|
||||||
function editProfile(profile: ModelProfile) {
|
function editProfile(profile: ModelProfile) {
|
||||||
if (ownerLocked) return;
|
if (ownerLocked) return;
|
||||||
@@ -626,7 +610,7 @@ export function ModelSettings({
|
|||||||
!active ||
|
!active ||
|
||||||
active.profile_id !== profile.profile_id ||
|
active.profile_id !== profile.profile_id ||
|
||||||
!active.is_active ||
|
!active.is_active ||
|
||||||
!ready(active, result)
|
!modelProfileReady(active, result)
|
||||||
) {
|
) {
|
||||||
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
||||||
return false;
|
return false;
|
||||||
@@ -640,38 +624,15 @@ export function ModelSettings({
|
|||||||
setSetupPhase("testing");
|
setSetupPhase("testing");
|
||||||
const result = await models.probeProfile(profile.profile_id);
|
const result = await models.probeProfile(profile.profile_id);
|
||||||
if (!alive.current) return false;
|
if (!alive.current) return false;
|
||||||
if (
|
const testedProfile = profileFromProbe(profile, result);
|
||||||
result &&
|
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
|
||||||
result.profile_id === profile.profile_id &&
|
|
||||||
result.profile_revision !== profile.revision
|
|
||||||
) {
|
|
||||||
// A newer revision used to be accepted here, and then activation turned
|
|
||||||
// on whatever the configuration had become. The user is told the
|
|
||||||
// settings they filled in passed, so this hands them a model they never
|
|
||||||
// tested. `ready` below cannot catch it either: by then the probe and
|
|
||||||
// the activated profile are both on the new revision and agree.
|
|
||||||
setSetupError(
|
|
||||||
result.profile_revision > profile.revision
|
|
||||||
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
|
|
||||||
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!result ||
|
|
||||||
result.profile_id !== profile.profile_id ||
|
|
||||||
result.profile_revision !== profile.revision ||
|
|
||||||
!result.capabilities.endpoint_reachable ||
|
|
||||||
!result.capabilities.model_available ||
|
|
||||||
!result.capabilities.embedding_available
|
|
||||||
) {
|
|
||||||
setSetupError(
|
setSetupError(
|
||||||
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
|
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
|
||||||
);
|
);
|
||||||
setFields(probeFieldErrors(result));
|
setFields(probeFieldErrors(result));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return activateWithPlan(profile, result);
|
return activateWithPlan(testedProfile, result ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateTested(profile: ModelProfile) {
|
async function activateTested(profile: ModelProfile) {
|
||||||
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
|
disabled={
|
||||||
|
busy ||
|
||||||
|
!canEnable ||
|
||||||
|
!modelProfileReady(profile, probe, probeError)
|
||||||
|
}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
>
|
>
|
||||||
{text("设为当前模型")}
|
{text("设为当前模型")}
|
||||||
|
|||||||
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().mockImplementation(async (target) => ({
|
||||||
|
current: "lite",
|
||||||
|
target,
|
||||||
|
effective_target: target,
|
||||||
|
capabilities: {
|
||||||
|
content_plane: target === "lite" ? "raw" : "memory",
|
||||||
|
generation_required: target !== "lite",
|
||||||
|
graph_enabled: target === "ultra",
|
||||||
|
evolve_available: target === "ultra",
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: target === "lite" ? [] : ["active_model_required"],
|
||||||
|
})),
|
||||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@@ -136,13 +156,12 @@ it("moves from import to the actual third step without completing onboarding", a
|
|||||||
);
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
|
||||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
expect(finish).not.toHaveBeenCalled();
|
expect(finish).not.toHaveBeenCalled();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +180,7 @@ it("lets an empty first-time user enter without granting or importing a source",
|
|||||||
onFinish={finish}
|
onFinish={finish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||||
await userEvent.click(skip);
|
await userEvent.click(skip);
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
@@ -184,7 +203,7 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
|||||||
onFinish={finish}
|
onFinish={finish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||||
await userEvent.click(skip);
|
await userEvent.click(skip);
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
@@ -273,7 +292,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
|
|||||||
resolveRefresh = resolve;
|
resolveRefresh = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "开始导入" }),
|
||||||
|
);
|
||||||
const lateSources = [source("workbuddy"), source("codex")];
|
const lateSources = [source("workbuddy"), source("codex")];
|
||||||
rendered.rerender(view({ ...imports, sources: lateSources }));
|
rendered.rerender(view({ ...imports, sources: lateSources }));
|
||||||
expect(checkbox("codex").checked).toBe(false);
|
expect(checkbox("codex").checked).toBe(false);
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getModelProfiles: vi.fn(),
|
getModelProfiles: vi.fn(),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
|
|||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [],
|
||||||
|
}),
|
||||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.queryByText(
|
||||||
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.queryByText(
|
||||||
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("region", {
|
screen.getByRole("region", {
|
||||||
name: "Progreso de importación de las aplicaciones",
|
name: "Progreso de importación de las aplicaciones",
|
||||||
@@ -305,16 +324,15 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/A continuación, elige un modo de memoria/),
|
screen.queryByText(/A continuación, elige un modo de memoria/),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByText("中文"));
|
fireEvent.click(screen.getByText("中文"));
|
||||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(navigation.textContent).toContain("连接应用");
|
expect(navigation.textContent).toContain("连接应用");
|
||||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
expect(draft).not.toHaveBeenCalled();
|
expect(draft).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -406,6 +424,12 @@ it("translates completion recovery live without repeating completion writes", as
|
|||||||
new Error("fixture-offline"),
|
new Error("fixture-offline"),
|
||||||
);
|
);
|
||||||
const imports = controller();
|
const imports = controller();
|
||||||
|
imports.sources = [];
|
||||||
|
vi.mocked(imports.refresh).mockResolvedValue({
|
||||||
|
sources: [],
|
||||||
|
jobs: [],
|
||||||
|
health: null as never,
|
||||||
|
});
|
||||||
const finish = vi.fn();
|
const finish = vi.fn();
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
@@ -415,11 +439,11 @@ it("translates completion recovery live without repeating completion writes", as
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||||
).toHaveProperty("disabled", false),
|
).toHaveProperty("disabled", false),
|
||||||
);
|
);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||||
|
|||||||
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
|
|||||||
getImportJobs: vi.fn(),
|
getImportJobs: vi.fn(),
|
||||||
connectSources: vi.fn(),
|
connectSources: vi.fn(),
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [],
|
||||||
|
}),
|
||||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||||
getSourceConnection: vi.fn(),
|
getSourceConnection: vi.fn(),
|
||||||
@@ -159,8 +178,23 @@ beforeEach(() => {
|
|||||||
} as never);
|
} as never);
|
||||||
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
||||||
vi.mocked(api.planMemoryMode).mockResolvedValue({
|
vi.mocked(api.planMemoryMode).mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
blockers: [],
|
blockers: [],
|
||||||
} as never);
|
} as never);
|
||||||
|
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
});
|
||||||
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
||||||
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -313,11 +347,11 @@ it("keeps the single-source submit frozen when another source arrives during fin
|
|||||||
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
||||||
await ready();
|
await ready();
|
||||||
await waitFor(() => expect(count()).toBe("0"));
|
await waitFor(() => expect(count()).toBe("0"));
|
||||||
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
|
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
|
||||||
"disabled",
|
"disabled",
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -363,13 +397,7 @@ it("partial acceptance preserves the successful job and failure through model-st
|
|||||||
"accepted-job:completed",
|
"accepted-job:completed",
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
|
|
||||||
expect(screen.getByLabelText("保留任务").textContent).toBe(
|
|
||||||
"accepted-job:completed",
|
|
||||||
);
|
|
||||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
|
||||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
|
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByRole("navigation", { name: "首次设置进度" })
|
.getByRole("navigation", { name: "首次设置进度" })
|
||||||
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
|||||||
).toContain("导入历史");
|
).toContain("导入历史");
|
||||||
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
||||||
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
|
|||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||||
await screen.findByText("已受理 workbuddy");
|
await screen.findByText("已受理 workbuddy");
|
||||||
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
||||||
"workbuddy",
|
"workbuddy",
|
||||||
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
|
|||||||
done = resolve;
|
done = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||||
view.unmount();
|
view.unmount();
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
done({
|
done({
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
ConnectSourcesResponse,
|
ConnectSourcesResponse,
|
||||||
|
EvolveSchedule,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
|
OnboardingModeIntent,
|
||||||
OnboardingState,
|
OnboardingState,
|
||||||
SourceResponse,
|
SourceResponse,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
|
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@@ -18,10 +21,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
|
getEvolveSchedule,
|
||||||
getMemoryMode,
|
getMemoryMode,
|
||||||
|
getModelProfiles,
|
||||||
|
memoryModePlanUnsupported,
|
||||||
patchOnboardingProgress,
|
patchOnboardingProgress,
|
||||||
planMemoryMode,
|
planMemoryMode,
|
||||||
putOnboardingProgress,
|
putOnboardingProgress,
|
||||||
|
setEvolveSchedule,
|
||||||
setMemoryMode,
|
setMemoryMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { AppMark } from "./app-mark";
|
import { AppMark } from "./app-mark";
|
||||||
@@ -34,24 +41,29 @@ import {
|
|||||||
defaultHostSourceIds,
|
defaultHostSourceIds,
|
||||||
FirstImportReview,
|
FirstImportReview,
|
||||||
type ImportConnectionDraft,
|
type ImportConnectionDraft,
|
||||||
|
type ImportSubmissionState,
|
||||||
} from "./FirstImportReview";
|
} from "./FirstImportReview";
|
||||||
import {
|
import {
|
||||||
enableHostConnection,
|
enableHostConnection,
|
||||||
hostCapabilities,
|
hostCapabilities,
|
||||||
hostWriteReason,
|
hostWriteReason,
|
||||||
} from "./host-state";
|
} from "./host-state";
|
||||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
import { MemoryModeCards } from "./MemoryModeCards";
|
||||||
|
import { BLOCKER_COPY } from "./memory-mode-ui";
|
||||||
import { OnboardingModelController } from "./OnboardingModelController";
|
import { OnboardingModelController } from "./OnboardingModelController";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { traceUiAction } from "./ui-trace";
|
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 { isDefiniteRefusal } from "./write-outcome";
|
||||||
import "./cold-start.css";
|
import "./cold-start.css";
|
||||||
|
|
||||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||||
type OnboardingMemoryMode = "lite" | "pro";
|
type OnboardingMemoryMode = OnboardingModeIntent;
|
||||||
|
|
||||||
// Cold start neither reads nor writes installation intent.
|
// Cold start neither reads nor writes installation intent.
|
||||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||||
@@ -112,9 +124,8 @@ export function OnboardingFlow({
|
|||||||
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
||||||
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
||||||
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
||||||
const [busy, setBusy] = useState<
|
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
|
||||||
"checking" | "submitting" | "leaving" | null
|
const [completionBusy, setCompletionBusy] = useState(false);
|
||||||
>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||||
const [exitDestination, setExitDestination] =
|
const [exitDestination, setExitDestination] =
|
||||||
@@ -124,8 +135,12 @@ export function OnboardingFlow({
|
|||||||
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [importSubmissionState, setImportSubmissionState] =
|
||||||
|
useState<ImportSubmissionState>("confirmed");
|
||||||
const resumedStep =
|
const resumedStep =
|
||||||
state.mode_intent === "pro" && state.setup_step === "model"
|
state.mode_intent &&
|
||||||
|
state.mode_intent !== "lite" &&
|
||||||
|
state.setup_step === "model"
|
||||||
? "model"
|
? "model"
|
||||||
: state.mode_intent
|
: state.mode_intent
|
||||||
? "mode"
|
? "mode"
|
||||||
@@ -138,8 +153,20 @@ export function OnboardingFlow({
|
|||||||
);
|
);
|
||||||
const [modeDestination, setModeDestination] =
|
const [modeDestination, setModeDestination] =
|
||||||
useState<OnboardingDestination>("sources");
|
useState<OnboardingDestination>("sources");
|
||||||
const [modeSaving, setModeSaving] = useState(false);
|
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
const modeSaving = modePhase !== "idle";
|
||||||
const [modeError, setModeError] = useState<string | null>(null);
|
const [modeError, setModeError] = useState<string | null>(null);
|
||||||
|
const [knownMode, setKnownMode] = useState<OnboardingMemoryMode>("pro");
|
||||||
|
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
});
|
||||||
|
const [scheduleLoaded, setScheduleLoaded] = useState(false);
|
||||||
|
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||||
|
const [scheduleLoading, setScheduleLoading] = useState(true);
|
||||||
|
const scheduleReadOperation = useRef(0);
|
||||||
const [modeFocusToken, setModeFocusToken] = useState(
|
const [modeFocusToken, setModeFocusToken] = useState(
|
||||||
resumedStep === "mode" ? 1 : 0,
|
resumedStep === "mode" ? 1 : 0,
|
||||||
);
|
);
|
||||||
@@ -181,7 +208,10 @@ export function OnboardingFlow({
|
|||||||
)
|
)
|
||||||
.join("、"),
|
.join("、"),
|
||||||
});
|
});
|
||||||
const flight = useRef(false);
|
const sourceFlight = useRef(false);
|
||||||
|
const modeOperation = useRef(0);
|
||||||
|
const proConfirmationOwner = useRef<string | null>(null);
|
||||||
|
const completionFlight = useRef(false);
|
||||||
const connectionAttempt = useRef<string | null>(null);
|
const connectionAttempt = useRef<string | null>(null);
|
||||||
const connectionQueue = useRef(Promise.resolve());
|
const connectionQueue = useRef(Promise.resolve());
|
||||||
const connectionQueueDepth = useRef(0);
|
const connectionQueueDepth = useRef(0);
|
||||||
@@ -368,20 +398,24 @@ export function OnboardingFlow({
|
|||||||
selected.some((chosen) => chosen.source_id === item.source_id),
|
selected.some((chosen) => chosen.source_id === item.source_id),
|
||||||
);
|
);
|
||||||
const locked =
|
const locked =
|
||||||
busy !== null || exitDestination !== null || state.status === "importing";
|
busy !== null ||
|
||||||
|
completionBusy ||
|
||||||
|
exitDestination !== null ||
|
||||||
|
state.status === "importing";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true;
|
alive.current = true;
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
void getMemoryMode().then(
|
void getMemoryMode().then(
|
||||||
(memoryMode) => {
|
(memoryMode) => {
|
||||||
if (alive.current)
|
if (alive.current) {
|
||||||
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
||||||
|
setKnownMode(memoryMode.mode);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
alive.current &&
|
alive.current &&
|
||||||
!modeChoiceTouched.current &&
|
!modeChoiceTouched.current &&
|
||||||
memoryMode.explicitly_configured &&
|
memoryMode.explicitly_configured
|
||||||
memoryMode.configured_mode !== "ultra"
|
|
||||||
)
|
)
|
||||||
setModeChoice(memoryMode.configured_mode);
|
setModeChoice(memoryMode.configured_mode);
|
||||||
},
|
},
|
||||||
@@ -394,6 +428,35 @@ export function OnboardingFlow({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshUltraSchedule = useCallback(async () => {
|
||||||
|
const operation = ++scheduleReadOperation.current;
|
||||||
|
setScheduleLoaded(false);
|
||||||
|
setScheduleLoading(true);
|
||||||
|
try {
|
||||||
|
const schedule = await getEvolveSchedule();
|
||||||
|
if (!alive.current || scheduleReadOperation.current !== operation) return;
|
||||||
|
if (!schedule) return;
|
||||||
|
setUltraSchedule(schedule);
|
||||||
|
setScheduleLoaded(true);
|
||||||
|
setScheduleReadFailed(false);
|
||||||
|
setModeError((current) =>
|
||||||
|
current === "记忆模式状态暂时无法读取,请重试。" ? null : current,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (alive.current && scheduleReadOperation.current === operation) {
|
||||||
|
setScheduleLoaded(false);
|
||||||
|
setScheduleReadFailed(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (alive.current && scheduleReadOperation.current === operation)
|
||||||
|
setScheduleLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshUltraSchedule();
|
||||||
|
}, [refreshUltraSchedule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSources(imports.sources);
|
setSources(imports.sources);
|
||||||
}, [imports.sources]);
|
}, [imports.sources]);
|
||||||
@@ -456,7 +519,7 @@ export function OnboardingFlow({
|
|||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
locked ||
|
locked ||
|
||||||
reviewingImport ||
|
reviewingImport ||
|
||||||
flight.current
|
sourceFlight.current
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!scopeMatches(selected, sources)) return;
|
if (!scopeMatches(selected, sources)) return;
|
||||||
@@ -483,17 +546,24 @@ export function OnboardingFlow({
|
|||||||
[onFinish],
|
[onFinish],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterModelStep = useCallback(() => {
|
const enterModelStep = useCallback(
|
||||||
intentRef.current = "pro";
|
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
|
||||||
setupStepRef.current = "model";
|
intentRef.current = mode;
|
||||||
const token = ++modelGeneration.current;
|
setupStepRef.current = "model";
|
||||||
setModelGenerationToken(token);
|
const token = ++modelGeneration.current;
|
||||||
setVisitedModel(true);
|
setModelGenerationToken(token);
|
||||||
setSetupStep("model");
|
setVisitedModel(true);
|
||||||
}, []);
|
setSetupStep("model");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const leaveModelStep = useCallback(
|
const leaveModelStep = useCallback(
|
||||||
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
|
(
|
||||||
|
nextIntent: OnboardingMemoryMode,
|
||||||
|
nextStep: "review" | "mode",
|
||||||
|
resetModePhase = true,
|
||||||
|
) => {
|
||||||
// This fence is synchronous: hidden model work must observe invalidation
|
// This fence is synchronous: hidden model work must observe invalidation
|
||||||
// before React renders the replacement step.
|
// before React renders the replacement step.
|
||||||
modelGeneration.current += 1;
|
modelGeneration.current += 1;
|
||||||
@@ -502,7 +572,10 @@ export function OnboardingFlow({
|
|||||||
setModeChoice(nextIntent);
|
setModeChoice(nextIntent);
|
||||||
setSetupStep(nextStep);
|
setSetupStep(nextStep);
|
||||||
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
||||||
setModeSaving(false);
|
if (resetModePhase) {
|
||||||
|
modeOperation.current += 1;
|
||||||
|
setModePhase("idle");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -611,12 +684,15 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
const leave = useCallback(
|
const leave = useCallback(
|
||||||
async (destination: OnboardingDestination) => {
|
async (destination: OnboardingDestination) => {
|
||||||
if (flight.current || finished.current) return;
|
if (completionFlight.current || finished.current) return;
|
||||||
flight.current = true;
|
completionFlight.current = true;
|
||||||
setBusy("leaving");
|
setCompletionBusy(true);
|
||||||
await persistFinish(destination);
|
try {
|
||||||
flight.current = false;
|
await persistFinish(destination);
|
||||||
if (alive.current) setBusy(null);
|
} finally {
|
||||||
|
completionFlight.current = false;
|
||||||
|
if (alive.current) setCompletionBusy(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[persistFinish],
|
[persistFinish],
|
||||||
);
|
);
|
||||||
@@ -624,7 +700,8 @@ export function OnboardingFlow({
|
|||||||
const requestModeChoice = useCallback(
|
const requestModeChoice = useCallback(
|
||||||
(destination: OnboardingDestination) => {
|
(destination: OnboardingDestination) => {
|
||||||
if (finished.current) return;
|
if (finished.current) return;
|
||||||
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
|
if (setupStepRef.current === "model")
|
||||||
|
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
|
||||||
else {
|
else {
|
||||||
intentRef.current = modeChoice;
|
intentRef.current = modeChoice;
|
||||||
setupStepRef.current = "mode";
|
setupStepRef.current = "mode";
|
||||||
@@ -639,30 +716,97 @@ export function OnboardingFlow({
|
|||||||
[leaveModelStep, modeChoice],
|
[leaveModelStep, modeChoice],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadModePlan = useCallback(
|
||||||
|
async (target: OnboardingMemoryMode) => {
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[knownMode],
|
||||||
|
);
|
||||||
|
|
||||||
const saveModeChoice = useCallback(async () => {
|
const saveModeChoice = useCallback(async () => {
|
||||||
if (
|
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
|
||||||
!modeChoice ||
|
const operation = ++modeOperation.current;
|
||||||
modeSaving ||
|
const ownsModeOperation = () =>
|
||||||
finished.current ||
|
alive.current && !finished.current && modeOperation.current === operation;
|
||||||
(modeChoice === "lite" && flight.current)
|
setModePhase("saving");
|
||||||
)
|
|
||||||
return;
|
|
||||||
setModeSaving(true);
|
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
try {
|
try {
|
||||||
if (modeChoice === "pro") {
|
if (modeChoice !== "lite") {
|
||||||
intentRef.current = "pro";
|
const target = modeChoice;
|
||||||
|
const [plan, profiles] = await Promise.all([
|
||||||
|
loadModePlan(target),
|
||||||
|
getModelProfiles(),
|
||||||
|
]);
|
||||||
|
if (!ownsModeOperation()) return;
|
||||||
|
const hasActiveModel = profiles.some((profile) => profile.is_active);
|
||||||
|
if (target === "ultra" && !scheduleLoaded) {
|
||||||
|
setModeError(
|
||||||
|
scheduleReadFailed
|
||||||
|
? "记忆模式状态暂时无法读取,请重试。"
|
||||||
|
: "正在读取记忆模式…",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocking = plan.blockers.filter(
|
||||||
|
(blocker) => blocker !== "active_model_required",
|
||||||
|
);
|
||||||
|
if (blocking.length > 0) {
|
||||||
|
setModeError(
|
||||||
|
blocking.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
intentRef.current = target;
|
||||||
setupStepRef.current = "mode";
|
setupStepRef.current = "mode";
|
||||||
const selectionStillCurrent = () =>
|
const selectionStillCurrent = () =>
|
||||||
alive.current &&
|
ownsModeOperation() &&
|
||||||
!finished.current &&
|
intentRef.current === target &&
|
||||||
intentRef.current === "pro" &&
|
|
||||||
setupStepRef.current === "mode";
|
setupStepRef.current === "mode";
|
||||||
|
if (
|
||||||
|
hasActiveModel &&
|
||||||
|
!plan.blockers.includes("active_model_required")
|
||||||
|
) {
|
||||||
|
const changed = await queueModeWrite(target, selectionStillCurrent);
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
|
if (target === "ultra") {
|
||||||
|
try {
|
||||||
|
await setEvolveSchedule(ultraSchedule);
|
||||||
|
} catch {
|
||||||
|
if (selectionStillCurrent())
|
||||||
|
setModeError("自动整合设置未能保存,请重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
}
|
||||||
|
await queueProgressWrite(
|
||||||
|
() =>
|
||||||
|
putOnboardingProgress({
|
||||||
|
mode_intent: target,
|
||||||
|
setup_step: "mode",
|
||||||
|
model_profile_id: null,
|
||||||
|
}),
|
||||||
|
selectionStillCurrent,
|
||||||
|
);
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
setModePhase("completing");
|
||||||
|
await leave(modeDestination);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const savedProgress = await queueProgressWrite(
|
const savedProgress = await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "model",
|
setup_step: "model",
|
||||||
model_profile_id: modelProfileId.current,
|
model_profile_id: modelProfileId.current,
|
||||||
}),
|
}),
|
||||||
@@ -670,15 +814,26 @@ export function OnboardingFlow({
|
|||||||
);
|
);
|
||||||
if (!selectionStillCurrent()) return;
|
if (!selectionStillCurrent()) return;
|
||||||
modelProfileId.current = savedProgress.model_profile_id;
|
modelProfileId.current = savedProgress.model_profile_id;
|
||||||
enterModelStep();
|
enterModelStep(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
leaveModelStep("lite", "mode");
|
leaveModelStep("lite", "mode", false);
|
||||||
const liteStillCurrent = () =>
|
const liteStillCurrent = () =>
|
||||||
alive.current &&
|
ownsModeOperation() &&
|
||||||
!finished.current &&
|
|
||||||
intentRef.current === "lite" &&
|
intentRef.current === "lite" &&
|
||||||
setupStepRef.current === "mode";
|
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);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
await queueProgressWrite(
|
await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
@@ -688,19 +843,17 @@ export function OnboardingFlow({
|
|||||||
}),
|
}),
|
||||||
liteStillCurrent,
|
liteStillCurrent,
|
||||||
);
|
);
|
||||||
if (!alive.current) return;
|
if (!liteStillCurrent()) return;
|
||||||
const changed = await queueModeWrite("lite");
|
setModePhase("completing");
|
||||||
if (!alive.current) return;
|
|
||||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
|
||||||
await leave(modeDestination);
|
await leave(modeDestination);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCompletedProgressConflict(reason)) {
|
if (isCompletedProgressConflict(reason)) {
|
||||||
finishOnce(modeDestination);
|
finishOnce(modeDestination);
|
||||||
} else if (alive.current) {
|
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
|
||||||
setModeError("模式未能保存。你的选择已保留,请重试。");
|
setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setModeSaving(false);
|
if (ownsModeOperation()) setModePhase("idle");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
enterModelStep,
|
enterModelStep,
|
||||||
@@ -709,9 +862,14 @@ export function OnboardingFlow({
|
|||||||
modeChoice,
|
modeChoice,
|
||||||
modeDestination,
|
modeDestination,
|
||||||
modeSaving,
|
modeSaving,
|
||||||
|
completionBusy,
|
||||||
finishOnce,
|
finishOnce,
|
||||||
|
loadModePlan,
|
||||||
queueModeWrite,
|
queueModeWrite,
|
||||||
queueProgressWrite,
|
queueProgressWrite,
|
||||||
|
scheduleLoaded,
|
||||||
|
scheduleReadFailed,
|
||||||
|
ultraSchedule,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const persistModelProfile = useCallback(
|
const persistModelProfile = useCallback(
|
||||||
@@ -729,58 +887,101 @@ export function OnboardingFlow({
|
|||||||
[queueProgressWrite],
|
[queueProgressWrite],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmProMode = useCallback(
|
const confirmModelMode = useCallback(
|
||||||
async (token: number, ownerProfileId: string) => {
|
async (token: number, ownerProfileId: string) => {
|
||||||
|
const target = intentRef.current;
|
||||||
|
if (target !== "pro" && target !== "ultra") return;
|
||||||
|
if (target === "ultra" && !scheduleLoaded) {
|
||||||
|
leaveModelStep("ultra", "mode");
|
||||||
|
setModeError(
|
||||||
|
scheduleReadFailed
|
||||||
|
? "记忆模式状态暂时无法读取,请重试。"
|
||||||
|
: "正在读取记忆模式…",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
|
||||||
const ownsModelStep = () =>
|
const ownsModelStep = () =>
|
||||||
alive.current &&
|
alive.current &&
|
||||||
!finished.current &&
|
!finished.current &&
|
||||||
modelGeneration.current === token &&
|
modelGeneration.current === token &&
|
||||||
intentRef.current === "pro" &&
|
intentRef.current === target &&
|
||||||
setupStepRef.current === "model" &&
|
setupStepRef.current === "model" &&
|
||||||
modelProfileId.current === ownerProfileId;
|
modelProfileId.current === ownerProfileId;
|
||||||
if (!ownsModelStep()) return;
|
if (
|
||||||
setModeSaving(true);
|
!ownsModelStep() ||
|
||||||
|
proConfirmationOwner.current === confirmationOwner ||
|
||||||
|
completionBusy
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
proConfirmationOwner.current = confirmationOwner;
|
||||||
|
const operation = ++modeOperation.current;
|
||||||
|
const ownsConfirmation = () =>
|
||||||
|
ownsModelStep() && modeOperation.current === operation;
|
||||||
|
setModePhase("saving");
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
try {
|
try {
|
||||||
const plan = await planMemoryMode("pro");
|
const plan = await loadModePlan(target);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||||
const changed = await queueModeWrite("pro", ownsModelStep);
|
const changed = await queueModeWrite(target, ownsConfirmation);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
|
if (target === "ultra") {
|
||||||
|
try {
|
||||||
|
await setEvolveSchedule(ultraSchedule);
|
||||||
|
} catch {
|
||||||
|
if (ownsConfirmation()) {
|
||||||
|
leaveModelStep("ultra", "mode");
|
||||||
|
setModeError("自动整合设置未能保存,请重试。");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ownsConfirmation()) return;
|
||||||
|
}
|
||||||
await queueProgressWrite(
|
await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "model",
|
setup_step: "model",
|
||||||
model_profile_id: ownerProfileId,
|
model_profile_id: ownerProfileId,
|
||||||
}),
|
}),
|
||||||
ownsModelStep,
|
ownsConfirmation,
|
||||||
);
|
);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
await persistFinish(modeDestination);
|
setModePhase("completing");
|
||||||
|
await leave(modeDestination);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCompletedProgressConflict(reason)) {
|
if (isCompletedProgressConflict(reason)) {
|
||||||
finishOnce(modeDestination);
|
finishOnce(modeDestination);
|
||||||
} else if (alive.current && !isGenerationInvalid(reason))
|
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
|
||||||
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
|
setModeError("模式切换未能确认,仍显示上次确认的模式。");
|
||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setModeSaving(false);
|
if (proConfirmationOwner.current === confirmationOwner)
|
||||||
|
proConfirmationOwner.current = null;
|
||||||
|
if (ownsConfirmation()) setModePhase("idle");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
completionBusy,
|
||||||
finishOnce,
|
finishOnce,
|
||||||
|
leave,
|
||||||
|
leaveModelStep,
|
||||||
|
loadModePlan,
|
||||||
modeDestination,
|
modeDestination,
|
||||||
persistFinish,
|
|
||||||
queueModeWrite,
|
queueModeWrite,
|
||||||
queueProgressWrite,
|
queueProgressWrite,
|
||||||
|
scheduleLoaded,
|
||||||
|
scheduleReadFailed,
|
||||||
|
ultraSchedule,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function checkLocations() {
|
async function checkLocations() {
|
||||||
if (flight.current || locked) return;
|
if (sourceFlight.current || locked) return;
|
||||||
flight.current = true;
|
sourceFlight.current = true;
|
||||||
scopeVersion.current += 1;
|
scopeVersion.current += 1;
|
||||||
setBusy("checking");
|
setBusy("checking");
|
||||||
setLocationCheck("checking");
|
setLocationCheck("checking");
|
||||||
@@ -805,7 +1006,7 @@ export function OnboardingFlow({
|
|||||||
setLocationCheck("failed");
|
setLocationCheck("failed");
|
||||||
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
||||||
} finally {
|
} finally {
|
||||||
flight.current = false;
|
sourceFlight.current = false;
|
||||||
if (alive.current) setBusy(null);
|
if (alive.current) setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,7 +1014,7 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
function toggleSource(source: SourceResponse, checked: boolean) {
|
function toggleSource(source: SourceResponse, checked: boolean) {
|
||||||
if (
|
if (
|
||||||
flight.current ||
|
sourceFlight.current ||
|
||||||
locked ||
|
locked ||
|
||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
!isAppSource(source) ||
|
!isAppSource(source) ||
|
||||||
@@ -836,7 +1037,7 @@ export function OnboardingFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectAllSources() {
|
function selectAllSources() {
|
||||||
if (flight.current || locked || locationCheck !== "ready") return;
|
if (sourceFlight.current || locked || locationCheck !== "ready") return;
|
||||||
scopeVersion.current += 1;
|
scopeVersion.current += 1;
|
||||||
setError(null);
|
setError(null);
|
||||||
setItemErrors({});
|
setItemErrors({});
|
||||||
@@ -849,7 +1050,7 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
async function startImport() {
|
async function startImport() {
|
||||||
if (
|
if (
|
||||||
flight.current ||
|
sourceFlight.current ||
|
||||||
locked ||
|
locked ||
|
||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
!selected.length
|
!selected.length
|
||||||
@@ -863,7 +1064,8 @@ export function OnboardingFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitImport(approved: SourceResponse[]) {
|
async function submitImport(approved: SourceResponse[]) {
|
||||||
flight.current = true;
|
sourceFlight.current = true;
|
||||||
|
setImportSubmissionState("checking");
|
||||||
if (window.oneMemory) setStartingImport(approved);
|
if (window.oneMemory) setStartingImport(approved);
|
||||||
const version = scopeVersion.current;
|
const version = scopeVersion.current;
|
||||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||||
@@ -875,8 +1077,11 @@ export function OnboardingFlow({
|
|||||||
try {
|
try {
|
||||||
current = (await imports.refresh()).sources;
|
current = (await imports.refresh()).sources;
|
||||||
} catch {
|
} catch {
|
||||||
if (alive.current)
|
if (alive.current) {
|
||||||
|
setStartingImport(null);
|
||||||
|
setImportSubmissionState("refused");
|
||||||
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
|
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!alive.current) return;
|
if (!alive.current) return;
|
||||||
@@ -899,6 +1104,7 @@ export function OnboardingFlow({
|
|||||||
setItemErrors(result.failed_sources);
|
setItemErrors(result.failed_sources);
|
||||||
if (result.accepted_source_ids.length === 0) {
|
if (result.accepted_source_ids.length === 0) {
|
||||||
setStartingImport(null);
|
setStartingImport(null);
|
||||||
|
setImportSubmissionState("refused");
|
||||||
setError(
|
setError(
|
||||||
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||||
);
|
);
|
||||||
@@ -907,22 +1113,36 @@ export function OnboardingFlow({
|
|||||||
// Keep the accepted intent and job IDs while the user inspects early originals.
|
// Keep the accepted intent and job IDs while the user inspects early originals.
|
||||||
setAccepted(result);
|
setAccepted(result);
|
||||||
setStartingImport(null);
|
setStartingImport(null);
|
||||||
|
setImportSubmissionState("confirmed");
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch {
|
} catch (reason) {
|
||||||
if (alive.current) {
|
if (alive.current) {
|
||||||
setStartingImport(null);
|
const refused = isDefiniteAccessRefusal(reason);
|
||||||
setError(
|
if (refused) {
|
||||||
"开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
setStartingImport(null);
|
||||||
);
|
setImportSubmissionState("refused");
|
||||||
|
setError(
|
||||||
|
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setImportSubmissionState("checking");
|
||||||
|
setError(
|
||||||
|
window.oneMemory
|
||||||
|
? null
|
||||||
|
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
connectSent.current = false;
|
connectSent.current = false;
|
||||||
flight.current = false;
|
sourceFlight.current = false;
|
||||||
if (alive.current) setBusy(null);
|
if (alive.current) setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = selected.length;
|
const count = selected.length;
|
||||||
|
const continueWithoutImport =
|
||||||
|
busy === null && locationCheck !== "checking" && count === 0;
|
||||||
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
||||||
// detection must read 未完成 even when sources are listed — otherwise the
|
// detection must read 未完成 even when sources are listed — otherwise the
|
||||||
// number branch answered first and the summary claimed a count while the
|
// number branch answered first and the summary claimed a count while the
|
||||||
@@ -987,10 +1207,11 @@ export function OnboardingFlow({
|
|||||||
jobs={reviewJobs}
|
jobs={reviewJobs}
|
||||||
failedSources={accepted?.failed_sources}
|
failedSources={accepted?.failed_sources}
|
||||||
sourceSnapshot={imports.sourceSnapshot}
|
sourceSnapshot={imports.sourceSnapshot}
|
||||||
busy={busy === "leaving"}
|
importSubmissionState={importSubmissionState}
|
||||||
|
busy={completionBusy}
|
||||||
error={error && text(error)}
|
error={error && text(error)}
|
||||||
onContinue={(destination) => {
|
onContinue={(destination) => {
|
||||||
if (busy === "leaving") return;
|
if (completionBusy) return;
|
||||||
if (completionFailed) {
|
if (completionFailed) {
|
||||||
void leave(destination);
|
void leave(destination);
|
||||||
return;
|
return;
|
||||||
@@ -1025,96 +1246,109 @@ export function OnboardingFlow({
|
|||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
step={3}
|
step={3}
|
||||||
title={text("选择记忆模式")}
|
title={text("选择记忆模式")}
|
||||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
|
||||||
focusToken={modeFocusToken}
|
focusToken={modeFocusToken}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<R6Button
|
||||||
<R6Button
|
className="onboarding-r6-submit"
|
||||||
variant="quiet"
|
variant="primary"
|
||||||
disabled={modeSaving || busy === "leaving"}
|
pending={modeSaving}
|
||||||
onClick={() => {
|
disabled={
|
||||||
setModeError(null);
|
!modeChoice ||
|
||||||
intentRef.current = modeChoice;
|
completionBusy ||
|
||||||
setupStepRef.current = "review";
|
(modeChoice === "ultra" && !scheduleLoaded)
|
||||||
setSetupStep("review");
|
}
|
||||||
void queueProgressWrite(() =>
|
onClick={() => void saveModeChoice()}
|
||||||
patchOnboardingProgress({
|
>
|
||||||
mode_intent: modeChoice,
|
{text(
|
||||||
setup_step: "review",
|
modePhase === "saving"
|
||||||
}),
|
? "正在保存模式…"
|
||||||
).catch((reason) => {
|
: modePhase === "completing"
|
||||||
if (!isCompletedProgressConflict(reason) && alive.current)
|
? "正在完成首次设置…"
|
||||||
setModeError(
|
: !modeChoice
|
||||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
? "继续"
|
||||||
);
|
: modeChoice === "lite"
|
||||||
});
|
? "使用 Lite 进入"
|
||||||
}}
|
: "配置模型",
|
||||||
>
|
)}
|
||||||
{text("返回导入")}
|
</R6Button>
|
||||||
</R6Button>
|
|
||||||
<R6Button
|
|
||||||
className="onboarding-r6-submit"
|
|
||||||
variant="primary"
|
|
||||||
pending={modeSaving}
|
|
||||||
disabled={
|
|
||||||
!modeChoice ||
|
|
||||||
busy === "leaving" ||
|
|
||||||
(modeChoice === "lite" && flight.current)
|
|
||||||
}
|
|
||||||
onClick={() => void saveModeChoice()}
|
|
||||||
>
|
|
||||||
{text(
|
|
||||||
modeSaving
|
|
||||||
? "正在保存…"
|
|
||||||
: modeChoice === "lite"
|
|
||||||
? "使用 Lite 进入"
|
|
||||||
: modeChoice === "pro"
|
|
||||||
? "继续配置模型"
|
|
||||||
: "继续",
|
|
||||||
)}
|
|
||||||
</R6Button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
className="onboarding-r6-mode"
|
className="onboarding-r6-mode"
|
||||||
aria-labelledby="onboarding-memory-mode-title"
|
aria-label={text("记忆模式")}
|
||||||
>
|
>
|
||||||
<div className="onboarding-r6-mode-intro">
|
<div className="onboarding-r6-mode-intro">
|
||||||
<p id="onboarding-memory-mode-title">
|
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
|
||||||
{text("之后可在设置中更改。")}
|
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<MemoryModeChoiceList
|
<MemoryModeCards
|
||||||
aria-label={text("记忆模式")}
|
aria-label={text("记忆模式")}
|
||||||
modes={[
|
value={modeChoice}
|
||||||
{
|
layout="rows"
|
||||||
mode: "lite",
|
describedBy={{
|
||||||
title: "Lite",
|
ultra:
|
||||||
description: text(
|
modeChoice === "ultra"
|
||||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
|
? "onboarding-ultra-schedule-description"
|
||||||
),
|
: undefined,
|
||||||
},
|
}}
|
||||||
{
|
disabled={modeSaving || completionBusy}
|
||||||
mode: "pro",
|
ultraDetailBusy={scheduleLoading}
|
||||||
title: "Pro",
|
|
||||||
description: text(
|
|
||||||
"使用模型理解对话,提取并整理可持续生长的记忆。",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
selected={modeChoice}
|
|
||||||
disabled={modeSaving || busy === "leaving"}
|
|
||||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
|
||||||
onChange={(mode) => {
|
onChange={(mode) => {
|
||||||
modeChoiceTouched.current = true;
|
modeChoiceTouched.current = true;
|
||||||
setModeChoice(mode as OnboardingMemoryMode);
|
intentRef.current = mode;
|
||||||
|
setModeChoice(mode);
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
}}
|
}}
|
||||||
|
ultraDetail={
|
||||||
|
scheduleLoaded ? (
|
||||||
|
<div className="onboarding-r6-ultra-default" role="status">
|
||||||
|
<strong>{text("每天自动整合")}</strong>
|
||||||
|
<small id="onboarding-ultra-schedule-description">
|
||||||
|
{text(
|
||||||
|
ultraSchedule.enabled
|
||||||
|
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||||
|
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||||
|
).replace("{time}", ultraSchedule.time)}
|
||||||
|
{ultraSchedule.enabled ? (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
{text("后续可在设置页自定义时间或关闭。")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
) : scheduleReadFailed ? (
|
||||||
|
<div className="onboarding-r6-schedule-recovery">
|
||||||
|
<p
|
||||||
|
id="onboarding-ultra-schedule-description"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||||
|
</p>
|
||||||
|
<R6Button
|
||||||
|
className="onboarding-r6-schedule-retry"
|
||||||
|
pending={scheduleLoading}
|
||||||
|
disabled={
|
||||||
|
scheduleLoading || modeSaving || completionBusy
|
||||||
|
}
|
||||||
|
onClick={() => void refreshUltraSchedule()}
|
||||||
|
>
|
||||||
|
{text("重试")}
|
||||||
|
</R6Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
id="onboarding-ultra-schedule-description"
|
||||||
|
className="onboarding-r6-schedule-loading"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{text("正在读取自动整合设置…")}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p className="onboarding-r6-mode-ultra-note">
|
|
||||||
{text("Ultra 可在完成首次设置后到设置页开启。")}
|
|
||||||
</p>
|
|
||||||
{(modeError || error) && (
|
{(modeError || error) && (
|
||||||
<p className="onboarding-r6-mode-error" role="alert">
|
<p className="onboarding-r6-mode-error" role="alert">
|
||||||
{text(modeError ?? error ?? "")}
|
{text(modeError ?? error ?? "")}
|
||||||
@@ -1129,7 +1363,8 @@ export function OnboardingFlow({
|
|||||||
generationToken={modelGenerationToken}
|
generationToken={modelGenerationToken}
|
||||||
isGenerationCurrent={(token) =>
|
isGenerationCurrent={(token) =>
|
||||||
modelGeneration.current === token &&
|
modelGeneration.current === token &&
|
||||||
intentRef.current === "pro" &&
|
intentRef.current !== "lite" &&
|
||||||
|
intentRef.current !== null &&
|
||||||
setupStepRef.current === "model"
|
setupStepRef.current === "model"
|
||||||
}
|
}
|
||||||
initialProfileId={modelProfileId.current}
|
initialProfileId={modelProfileId.current}
|
||||||
@@ -1142,7 +1377,7 @@ export function OnboardingFlow({
|
|||||||
void persistModelProfile(profileId)
|
void persistModelProfile(profileId)
|
||||||
}
|
}
|
||||||
onModelActiveConfirmed={(token, ownerProfileId) =>
|
onModelActiveConfirmed={(token, ownerProfileId) =>
|
||||||
void confirmProMode(token, ownerProfileId)
|
void confirmModelMode(token, ownerProfileId)
|
||||||
}
|
}
|
||||||
sourceNames={sourceIds.map(
|
sourceNames={sourceIds.map(
|
||||||
(id) =>
|
(id) =>
|
||||||
@@ -1157,10 +1392,11 @@ export function OnboardingFlow({
|
|||||||
)}
|
)}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
leaveModelStep("pro", "mode");
|
const target = intentRef.current === "ultra" ? "ultra" : "pro";
|
||||||
|
leaveModelStep(target, "mode");
|
||||||
void queueProgressWrite(() =>
|
void queueProgressWrite(() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "mode",
|
setup_step: "mode",
|
||||||
model_profile_id: modelProfileId.current,
|
model_profile_id: modelProfileId.current,
|
||||||
}),
|
}),
|
||||||
@@ -1188,7 +1424,7 @@ export function OnboardingFlow({
|
|||||||
<>
|
<>
|
||||||
<R6Button
|
<R6Button
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
disabled={busy !== null}
|
disabled={busy !== null || completionBusy}
|
||||||
onClick={() => finishOnce(exitDestination)}
|
onClick={() => finishOnce(exitDestination)}
|
||||||
>
|
>
|
||||||
{text(
|
{text(
|
||||||
@@ -1197,7 +1433,7 @@ export function OnboardingFlow({
|
|||||||
</R6Button>
|
</R6Button>
|
||||||
<R6Button
|
<R6Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={busy !== null}
|
disabled={busy !== null || completionBusy}
|
||||||
onClick={() => void leave(exitDestination)}
|
onClick={() => void leave(exitDestination)}
|
||||||
>
|
>
|
||||||
{text("重试保存并进入")}
|
{text("重试保存并进入")}
|
||||||
@@ -1215,16 +1451,8 @@ export function OnboardingFlow({
|
|||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
step={1}
|
step={1}
|
||||||
title={text("把熟悉的背景,带到这里")}
|
title={text("把熟悉的背景,带到这里")}
|
||||||
eyebrow={text("你的记忆,从这里接上")}
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<R6Button
|
|
||||||
variant="quiet"
|
|
||||||
disabled={locked}
|
|
||||||
onClick={() => requestModeChoice("sources")}
|
|
||||||
>
|
|
||||||
{text("稍后添加,先进入")}
|
|
||||||
</R6Button>
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="onboarding-r6-error" role="alert">
|
<p className="onboarding-r6-error" role="alert">
|
||||||
{error && text(error)}
|
{error && text(error)}
|
||||||
@@ -1233,10 +1461,20 @@ export function OnboardingFlow({
|
|||||||
<R6Button
|
<R6Button
|
||||||
className="onboarding-r6-submit"
|
className="onboarding-r6-submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={locked || count === 0}
|
disabled={locked}
|
||||||
onClick={() => void startImport()}
|
onClick={() =>
|
||||||
|
continueWithoutImport
|
||||||
|
? requestModeChoice("sources")
|
||||||
|
: void startImport()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
|
{text(
|
||||||
|
continueWithoutImport
|
||||||
|
? "选择记忆模式"
|
||||||
|
: busy === "submitting"
|
||||||
|
? "正在开始导入…"
|
||||||
|
: "开始导入",
|
||||||
|
)}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -1246,12 +1484,6 @@ export function OnboardingFlow({
|
|||||||
className="onboarding-r6-apps"
|
className="onboarding-r6-apps"
|
||||||
aria-labelledby="cold-start-phase"
|
aria-labelledby="cold-start-phase"
|
||||||
>
|
>
|
||||||
<div className="onboarding-r6-intro">
|
|
||||||
<p>
|
|
||||||
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
|
|
||||||
</p>
|
|
||||||
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="onboarding-r6-list-heading">
|
<div className="onboarding-r6-list-heading">
|
||||||
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
||||||
{text("本机 AI 应用")}
|
{text("本机 AI 应用")}
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
|||||||
max_concurrency: 4,
|
max_concurrency: 4,
|
||||||
context_window: null,
|
context_window: null,
|
||||||
has_api_key: true,
|
has_api_key: true,
|
||||||
|
credential_state: "available" as
|
||||||
|
| "available"
|
||||||
|
| "missing"
|
||||||
|
| "unavailable",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
structured_output: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pendingProbe: null as null | {
|
pendingProbe: null as null | {
|
||||||
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
|||||||
supported: false as boolean | null,
|
supported: false as boolean | null,
|
||||||
planActivation: vi.fn(),
|
planActivation: vi.fn(),
|
||||||
startPlannedSwitch: vi.fn(),
|
startPlannedSwitch: vi.fn(),
|
||||||
shouldUseLegacyActivation: () => true,
|
shouldUseLegacyActivation: (() => true) as () => boolean,
|
||||||
clearPlan: vi.fn(),
|
clearPlan: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -123,6 +133,7 @@ beforeEach(() => {
|
|||||||
models.creationMissing = null;
|
models.creationMissing = null;
|
||||||
embedding.current = null;
|
embedding.current = null;
|
||||||
embedding.supported = false;
|
embedding.supported = false;
|
||||||
|
embedding.shouldUseLegacyActivation = () => true;
|
||||||
step.current = null;
|
step.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
|
|||||||
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
|
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not complete with an active profile whose credential is unavailable", async () => {
|
||||||
|
models.profiles = [
|
||||||
|
{
|
||||||
|
...activeProfile,
|
||||||
|
credential_state: "unavailable",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const enabled = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingModelController
|
||||||
|
sourceNames={[]}
|
||||||
|
initialProfileId="active-profile"
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onModelActiveConfirmed={enabled}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(step.current).not.toBeNull());
|
||||||
|
expect(enabled).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "继续使用当前模型" }),
|
||||||
|
);
|
||||||
|
expect(enabled).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
|
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
|
||||||
models.profiles = [{ ...activeProfile, is_active: false }];
|
models.profiles = [{ ...activeProfile, is_active: false }];
|
||||||
const active = vi.fn();
|
const active = vi.fn();
|
||||||
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reconciles a pending probe instead of saving or creating again", async () => {
|
it("reconciles a pending probe instead of saving or creating again", async () => {
|
||||||
const inactive = { ...activeProfile, is_active: false };
|
const inactive = { ...activeProfile, revision: 4, is_active: false };
|
||||||
models.profiles = [inactive];
|
models.profiles = [inactive];
|
||||||
models.pendingProbe = {
|
models.pendingProbe = {
|
||||||
operationId: "probe-operation",
|
operationId: "probe-operation",
|
||||||
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
|||||||
};
|
};
|
||||||
models.probeProfile.mockResolvedValueOnce({
|
models.probeProfile.mockResolvedValueOnce({
|
||||||
profile_id: "active-profile",
|
profile_id: "active-profile",
|
||||||
profile_revision: 3,
|
input_profile_revision: 3,
|
||||||
|
profile_revision: 4,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
endpoint_reachable: true,
|
endpoint_reachable: true,
|
||||||
model_available: true,
|
model_available: true,
|
||||||
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
||||||
);
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||||
expect(models.update).not.toHaveBeenCalled();
|
expect(models.update).not.toHaveBeenCalled();
|
||||||
expect(models.create).not.toHaveBeenCalled();
|
expect(models.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the first test when Core advances the tested profile revision", async () => {
|
||||||
|
const inactive = { ...activeProfile, revision: 1, is_active: false };
|
||||||
|
models.profiles = [inactive];
|
||||||
|
models.update.mockResolvedValueOnce(inactive);
|
||||||
|
models.probeProfile.mockResolvedValueOnce({
|
||||||
|
profile_id: inactive.profile_id,
|
||||||
|
input_profile_revision: 1,
|
||||||
|
profile_revision: 2,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
},
|
||||||
|
stages: [{ ok: false }],
|
||||||
|
});
|
||||||
|
models.activate.mockResolvedValueOnce({
|
||||||
|
...inactive,
|
||||||
|
revision: 2,
|
||||||
|
is_active: true,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
embedding.supported = true;
|
||||||
|
embedding.current = { writes_paused: false };
|
||||||
|
embedding.shouldUseLegacyActivation = () => false;
|
||||||
|
embedding.planActivation.mockResolvedValueOnce({ needed: false });
|
||||||
|
const confirmed = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingModelController
|
||||||
|
sourceNames={[]}
|
||||||
|
initialProfileId={inactive.profile_id}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onModelActiveConfirmed={confirmed}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(currentStep().profile as { profile_id: string } | null)?.profile_id,
|
||||||
|
).toBe(inactive.profile_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await (currentStep().onTest as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||||
|
expect(currentStep().profile).toMatchObject({ revision: 2 });
|
||||||
|
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
await (currentStep().onSaveAndEnable as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
|
||||||
|
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
|
||||||
|
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an active resumed profile on the model step while writes are paused", async () => {
|
it("keeps an active resumed profile on the model step while writes are paused", async () => {
|
||||||
embedding.supported = true;
|
embedding.supported = true;
|
||||||
embedding.current = { writes_paused: true };
|
embedding.current = { writes_paused: true };
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
ModelProbeResult,
|
|
||||||
ModelProfile,
|
ModelProfile,
|
||||||
ModelProfileInput,
|
ModelProfileInput,
|
||||||
ModelProfilePatch,
|
ModelProfilePatch,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getModelProfiles } from "./api";
|
|
||||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||||
import {
|
import {
|
||||||
presetForGeneration,
|
presetForGeneration,
|
||||||
validateGenerationModel,
|
validateGenerationModel,
|
||||||
} from "./GenerationModelPrimitives";
|
} from "./GenerationModelPrimitives";
|
||||||
|
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||||
import {
|
import {
|
||||||
type OnboardingModelDraft,
|
type OnboardingModelDraft,
|
||||||
OnboardingModelStep,
|
OnboardingModelStep,
|
||||||
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
|
|||||||
context_window: "",
|
context_window: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function probeMatchesProfile(
|
|
||||||
original: ModelProfile,
|
|
||||||
current: ModelProfile | undefined,
|
|
||||||
probe: ModelProbeResult | undefined | null,
|
|
||||||
): current is ModelProfile {
|
|
||||||
return Boolean(
|
|
||||||
current &&
|
|
||||||
probe &&
|
|
||||||
probe.profile_id === original.profile_id &&
|
|
||||||
probe.profile_revision === current.revision &&
|
|
||||||
probe.capabilities.endpoint_reachable &&
|
|
||||||
probe.capabilities.model_available &&
|
|
||||||
probe.capabilities.embedding_available &&
|
|
||||||
probe.stages.every((stage) => stage.ok),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OnboardingModelController({
|
export function OnboardingModelController({
|
||||||
sourceNames,
|
sourceNames,
|
||||||
importedConversationCount = 0,
|
importedConversationCount = 0,
|
||||||
@@ -112,6 +94,17 @@ export function OnboardingModelController({
|
|||||||
>(),
|
>(),
|
||||||
);
|
);
|
||||||
const autoConfirmed = useRef<string | null>(null);
|
const autoConfirmed = useRef<string | null>(null);
|
||||||
|
const confirmReadyProfile = useCallback(
|
||||||
|
(profile: ModelProfile, owner = generationToken) => {
|
||||||
|
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
|
||||||
|
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onModelActiveConfirmed?.(owner, profile.profile_id);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const profile = initialProfileId
|
const profile = initialProfileId
|
||||||
@@ -152,21 +145,19 @@ export function OnboardingModelController({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!saved?.is_active ||
|
!saved?.is_active ||
|
||||||
!isGenerationCurrent(generationToken) ||
|
|
||||||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
||||||
embeddingSwitch.current?.writes_paused
|
embeddingSwitch.current?.writes_paused
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const key = `${generationToken}:${saved.profile_id}`;
|
const key = `${generationToken}:${saved.profile_id}`;
|
||||||
if (autoConfirmed.current === key) return;
|
if (autoConfirmed.current === key) return;
|
||||||
autoConfirmed.current = key;
|
if (confirmReadyProfile(saved, generationToken))
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
autoConfirmed.current = key;
|
||||||
}, [
|
}, [
|
||||||
embeddingSwitch.current,
|
embeddingSwitch.current,
|
||||||
embeddingSwitch.supported,
|
embeddingSwitch.supported,
|
||||||
|
confirmReadyProfile,
|
||||||
generationToken,
|
generationToken,
|
||||||
isGenerationCurrent,
|
|
||||||
onModelActiveConfirmed,
|
|
||||||
saved,
|
saved,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -182,8 +173,7 @@ export function OnboardingModelController({
|
|||||||
flight.current = false;
|
flight.current = false;
|
||||||
setSwitchPendingJobId(null);
|
setSwitchPendingJobId(null);
|
||||||
setPhase("idle");
|
setPhase("idle");
|
||||||
if (saved && isGenerationCurrent(generationToken))
|
if (saved) confirmReadyProfile(saved, generationToken);
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
|
||||||
} else if (finished.state === "rolled_back") {
|
} else if (finished.state === "rolled_back") {
|
||||||
flight.current = false;
|
flight.current = false;
|
||||||
setSwitchPendingJobId(null);
|
setSwitchPendingJobId(null);
|
||||||
@@ -192,9 +182,8 @@ export function OnboardingModelController({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
embeddingSwitch.current?.last_finished,
|
embeddingSwitch.current?.last_finished,
|
||||||
|
confirmReadyProfile,
|
||||||
generationToken,
|
generationToken,
|
||||||
isGenerationCurrent,
|
|
||||||
onModelActiveConfirmed,
|
|
||||||
saved,
|
saved,
|
||||||
switchPendingJobId,
|
switchPendingJobId,
|
||||||
]);
|
]);
|
||||||
@@ -338,11 +327,10 @@ export function OnboardingModelController({
|
|||||||
setPhase("testing");
|
setPhase("testing");
|
||||||
const result = await models.probeProfile(profile.profile_id);
|
const result = await models.probeProfile(profile.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
const current = (await getModelProfiles()).find(
|
const current = profileFromProbe(profile, result);
|
||||||
(item) => item.profile_id === profile.profile_id,
|
const passed = Boolean(
|
||||||
|
current && modelProfileReady(current, result ?? null),
|
||||||
);
|
);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
|
||||||
const passed = probeMatchesProfile(profile, current, result);
|
|
||||||
if (current) setSaved(current);
|
if (current) setSaved(current);
|
||||||
setProbe({
|
setProbe({
|
||||||
state: passed ? "passed" : "failed",
|
state: passed ? "passed" : "failed",
|
||||||
@@ -378,11 +366,10 @@ export function OnboardingModelController({
|
|||||||
try {
|
try {
|
||||||
const result = await models.probeProfile(saved.profile_id);
|
const result = await models.probeProfile(saved.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
const current = (await getModelProfiles()).find(
|
const current = profileFromProbe(saved, result);
|
||||||
(item) => item.profile_id === saved.profile_id,
|
const passed = Boolean(
|
||||||
|
current && modelProfileReady(current, result ?? null),
|
||||||
);
|
);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
|
||||||
const passed = probeMatchesProfile(saved, current, result);
|
|
||||||
if (current) setSaved(current);
|
if (current) setSaved(current);
|
||||||
setProbe({
|
setProbe({
|
||||||
state: passed ? "passed" : "failed",
|
state: passed ? "passed" : "failed",
|
||||||
@@ -408,8 +395,7 @@ export function OnboardingModelController({
|
|||||||
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isGenerationCurrent(generationToken))
|
confirmReadyProfile(saved, generationToken);
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const owner = generationToken;
|
const owner = generationToken;
|
||||||
@@ -433,9 +419,15 @@ export function OnboardingModelController({
|
|||||||
}
|
}
|
||||||
const active = await models.activate(saved.profile_id);
|
const active = await models.activate(saved.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
|
if (
|
||||||
|
!active?.is_active ||
|
||||||
|
active.profile_id !== saved.profile_id ||
|
||||||
|
active.revision !== saved.revision ||
|
||||||
|
!modelProfileReady(active)
|
||||||
|
)
|
||||||
|
throw new Error("model_activation_unconfirmed");
|
||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
onModelActiveConfirmed?.(owner, active.profile_id);
|
confirmReadyProfile(active, owner);
|
||||||
} catch {
|
} catch {
|
||||||
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
||||||
setError("模型未能确认启用,请在设置中核对。");
|
setError("模型未能确认启用,请在设置中核对。");
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ describe("OnboardingModelStep", () => {
|
|||||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||||
const title = guiMessages[locale]["配置记忆整理模型"];
|
const title = guiMessages[locale]["配置记忆整理模型"];
|
||||||
const back = 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(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<OnboardingModelStep {...props()} />
|
<OnboardingModelStep {...props()} />
|
||||||
@@ -93,10 +95,11 @@ describe("OnboardingModelStep", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
|
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("complementary", { name: summary })).toBeTruthy();
|
||||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
|
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()} />);
|
render(<OnboardingModelStep {...props()} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "配置记忆整理模型" }),
|
screen.getByRole("heading", { name: "配置记忆整理模型" }),
|
||||||
@@ -110,12 +113,9 @@ describe("OnboardingModelStep", () => {
|
|||||||
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
|
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
|
||||||
expect(screen.queryByText("本次沉淀")).toBeNull();
|
expect(screen.queryByText("本次沉淀")).toBeNull();
|
||||||
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
|
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
|
||||||
expect(
|
expect(document.querySelector(".onboarding-model-layout")).toBeTruthy();
|
||||||
document.querySelector(
|
expect(document.querySelector(".onboarding-model-summary")).toBeTruthy();
|
||||||
".onboarding-model-layout--single-column-600" +
|
expect(screen.getByText("让记忆从这里开始")).toBeTruthy();
|
||||||
'[data-responsive="single-column-600"]',
|
|
||||||
),
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the page title when entering the model substep", async () => {
|
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 () => {
|
it("links field errors and keeps secrets out of callback errors", async () => {
|
||||||
const p = props();
|
const p = props();
|
||||||
p.fieldErrors = { api_key: "请填写 API Key。" };
|
p.fieldErrors = { api_key: "请填写 API Key。" };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
|
import "./onboarding-model-r6.css";
|
||||||
|
|
||||||
type Action = "test" | "save-and-enable";
|
type Action = "test" | "save-and-enable";
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
|||||||
props.onAdvancedChange(true);
|
props.onAdvancedChange(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (firstError === "api_key" && props.advancedOpen) {
|
||||||
|
props.onAdvancedChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (firstError && (!technicalError || props.advancedOpen)) {
|
if (firstError && (!technicalError || props.advancedOpen)) {
|
||||||
formRef.current
|
formRef.current
|
||||||
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
|
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
|
||||||
@@ -173,10 +178,7 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div className="onboarding-model-layout">
|
||||||
className="onboarding-model-layout onboarding-model-layout--single-column-600"
|
|
||||||
data-responsive="single-column-600"
|
|
||||||
>
|
|
||||||
<section
|
<section
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
className="onboarding-model-form"
|
className="onboarding-model-form"
|
||||||
@@ -184,6 +186,9 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
|||||||
data-advanced={props.advancedOpen}
|
data-advanced={props.advancedOpen}
|
||||||
>
|
>
|
||||||
<div className="onboarding-model-group">
|
<div className="onboarding-model-group">
|
||||||
|
<p className="onboarding-model-intro">
|
||||||
|
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
|
||||||
|
</p>
|
||||||
<label
|
<label
|
||||||
className="onboarding-model-field onboarding-model-provider"
|
className="onboarding-model-field onboarding-model-provider"
|
||||||
htmlFor={`${id}-provider`}
|
htmlFor={`${id}-provider`}
|
||||||
@@ -263,16 +268,6 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
|||||||
)}
|
)}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="onboarding-model-disclosure">
|
|
||||||
<p>
|
|
||||||
{text("相关对话会发送给所选服务商。")}
|
|
||||||
{sourceSummary ? ` ${sourceSummary}` : ""}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
|
|
||||||
{text("测试只向此接口发送测试内容,不发送历史对话。")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<GenerationTechnicalDisclosure
|
<GenerationTechnicalDisclosure
|
||||||
open={props.advancedOpen}
|
open={props.advancedOpen}
|
||||||
disabled={fieldsLocked}
|
disabled={fieldsLocked}
|
||||||
@@ -348,6 +343,35 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</R6ColdStartFrame>
|
</R6ColdStartFrame>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { LanguageProvider } from "./gui-language";
|
||||||
|
import { OptionMenu } from "./OptionMenu";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("shows a suffix without requiring description copy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<LanguageProvider>
|
||||||
|
<OptionMenu
|
||||||
|
ariaLabel="记忆模式"
|
||||||
|
value="pro"
|
||||||
|
options={[
|
||||||
|
{ value: "lite", label: "基础记忆", suffix: "Lite" },
|
||||||
|
{ value: "pro", label: "智能记忆", suffix: "Pro" },
|
||||||
|
]}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</LanguageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const control = screen.getByRole("combobox", { name: "记忆模式" });
|
||||||
|
expect(control.textContent).toContain("智能记忆 · Pro");
|
||||||
|
await user.click(control);
|
||||||
|
expect(screen.getByRole("option", { name: "基础记忆 · Lite" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps option descriptions available to assistive technology", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<LanguageProvider>
|
||||||
|
<OptionMenu
|
||||||
|
ariaLabel="记忆模式"
|
||||||
|
value="pro"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "lite",
|
||||||
|
label: "基础记忆",
|
||||||
|
description: "保存原始会话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pro",
|
||||||
|
label: "智能记忆",
|
||||||
|
suffix: "Pro",
|
||||||
|
description: "调用模型整理记忆",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</LanguageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("combobox", { name: "记忆模式" }));
|
||||||
|
const option = screen.getByRole("option", { name: "智能记忆 · Pro" });
|
||||||
|
const descriptionId = option.getAttribute("aria-describedby");
|
||||||
|
expect(descriptionId).toBeTruthy();
|
||||||
|
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
|
||||||
|
"调用模型整理记忆",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("option", { name: /基础记忆.*保存原始会话/ })
|
||||||
|
.getAttribute("aria-describedby"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
|
|||||||
export interface OptionMenuItem {
|
export interface OptionMenuItem {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
suffix?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
@@ -22,6 +23,17 @@ const VIEWPORT_MARGIN = 12;
|
|||||||
const PANEL_GAP = 4;
|
const PANEL_GAP = 4;
|
||||||
const MAX_PANEL_HEIGHT = 280;
|
const MAX_PANEL_HEIGHT = 280;
|
||||||
|
|
||||||
|
function optionLabel(label: string, suffix?: string) {
|
||||||
|
return suffix ? (
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
<span className="om-option-menu-suffix"> · {suffix}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function optionMenuPosition(
|
export function optionMenuPosition(
|
||||||
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
||||||
panelHeight: number,
|
panelHeight: number,
|
||||||
@@ -268,11 +280,11 @@ export function OptionMenu(props: {
|
|||||||
>
|
>
|
||||||
{selected?.description ? (
|
{selected?.description ? (
|
||||||
<span className="om-option-menu-copy">
|
<span className="om-option-menu-copy">
|
||||||
<span>{visibleLabel}</span>
|
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
|
||||||
<small>{selected.description}</small>
|
<small>{selected.description}</small>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
visibleLabel
|
optionLabel(visibleLabel, selected?.suffix)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open &&
|
{open &&
|
||||||
@@ -299,6 +311,14 @@ export function OptionMenu(props: {
|
|||||||
lang={item.lang}
|
lang={item.lang}
|
||||||
data-active={activeIndex === index || undefined}
|
data-active={activeIndex === index || undefined}
|
||||||
aria-selected={item.value === props.value}
|
aria-selected={item.value === props.value}
|
||||||
|
aria-label={
|
||||||
|
item.suffix ? `${item.label} · ${item.suffix}` : undefined
|
||||||
|
}
|
||||||
|
aria-describedby={
|
||||||
|
item.description && item.suffix
|
||||||
|
? `${panelId}-option-${index}-description`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
@@ -308,11 +328,13 @@ export function OptionMenu(props: {
|
|||||||
>
|
>
|
||||||
{item.description ? (
|
{item.description ? (
|
||||||
<span className="om-option-menu-copy">
|
<span className="om-option-menu-copy">
|
||||||
<span>{item.label}</span>
|
<span>{optionLabel(item.label, item.suffix)}</span>
|
||||||
<small>{item.description}</small>
|
<small id={`${panelId}-option-${index}-description`}>
|
||||||
|
{item.description}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
item.label
|
optionLabel(item.label, item.suffix)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -48,7 +48,41 @@ it("sizes the import action to its label and wraps footer content", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
|
it("uses content-driven shell, discovery and mode geometry", () => {
|
||||||
|
const stage = declaration("onboarding-r6.css", ".onboarding-r6-stage");
|
||||||
|
const shell = declaration("onboarding-r6.css", ".onboarding-r6-shell");
|
||||||
|
const discovery = declaration(
|
||||||
|
"onboarding-r6.css",
|
||||||
|
".onboarding-r6-discovery",
|
||||||
|
);
|
||||||
|
const heading = declaration(
|
||||||
|
"onboarding-r6.css",
|
||||||
|
".onboarding-r6-list-heading",
|
||||||
|
);
|
||||||
|
const mode = declaration("onboarding-r6.css", ".onboarding-r6-mode");
|
||||||
|
|
||||||
|
expect(stage).toMatch(/min-height:\s*0/);
|
||||||
|
expect(stage).not.toMatch(/min-height:\s*852px/);
|
||||||
|
expect(shell).toMatch(/height:\s*100%/);
|
||||||
|
expect(shell).not.toMatch(/(?:^|;)\s*height:\s*820px/);
|
||||||
|
expect(discovery).toMatch(/height:\s*100%/);
|
||||||
|
expect(heading).toMatch(/position:\s*static/);
|
||||||
|
expect(mode).toMatch(/width:\s*100%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reserve an empty eyebrow row", () => {
|
||||||
|
render(
|
||||||
|
<LanguageProvider>
|
||||||
|
<R6ColdStartFrame step={1} title="Fixture" footer={null}>
|
||||||
|
<p>Fixture body</p>
|
||||||
|
</R6ColdStartFrame>
|
||||||
|
</LanguageProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("heading", { name: "Fixture" })).toBeTruthy();
|
||||||
|
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
|
||||||
const card = declaration(
|
const card = declaration(
|
||||||
"r6-import-progress.css",
|
"r6-import-progress.css",
|
||||||
".r6-import-progress-card",
|
".r6-import-progress-card",
|
||||||
@@ -60,13 +94,12 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
|
|||||||
"first-import-r6.css",
|
"first-import-r6.css",
|
||||||
".first-import-r6-main > .r6-import-progress",
|
".first-import-r6-main > .r6-import-progress",
|
||||||
),
|
),
|
||||||
).toMatch(/flex:\s*0 0 auto/);
|
).toMatch(/flex:\s*0 0 218px/);
|
||||||
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
const layout = declaration("first-import-r6.css", ".first-import-r6-layout");
|
||||||
/height:\s*auto/,
|
expect(layout).toMatch(
|
||||||
|
/grid-template-columns:\s*minmax\(0,\s*738px\)\s+278px/,
|
||||||
);
|
);
|
||||||
expect(
|
expect(layout).toMatch(/height:\s*560\.2px/);
|
||||||
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
|
|
||||||
).not.toMatch(/max-height|overflow/);
|
|
||||||
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
||||||
/overflow-y:\s*auto/,
|
/overflow-y:\s*auto/,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
|
|||||||
}: {
|
}: {
|
||||||
step: 1 | 2 | 3;
|
step: 1 | 2 | 3;
|
||||||
title: string;
|
title: string;
|
||||||
eyebrow: string;
|
eyebrow?: string | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer: ReactNode;
|
footer: ReactNode;
|
||||||
focusToken?: number;
|
focusToken?: number;
|
||||||
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
|
|||||||
<section className="onboarding-r6-shell" data-step={step}>
|
<section className="onboarding-r6-shell" data-step={step}>
|
||||||
<header className="onboarding-r6-head">
|
<header className="onboarding-r6-head">
|
||||||
<div>
|
<div>
|
||||||
<p>{eyebrow}</p>
|
{eyebrow ? <p>{eyebrow}</p> : null}
|
||||||
<h1
|
<h1
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
tabIndex={focusToken === undefined ? undefined : -1}
|
tabIndex={focusToken === undefined ? undefined : -1}
|
||||||
|
|||||||
@@ -19,13 +19,24 @@ describe("R6 shared controls", () => {
|
|||||||
|
|
||||||
it("keeps its label and prevents duplicate activation while pending", () => {
|
it("keeps its label and prevents duplicate activation while pending", () => {
|
||||||
const onClick = vi.fn();
|
const onClick = vi.fn();
|
||||||
render(
|
const view = render(
|
||||||
<R6Button pending onClick={onClick}>
|
<R6Button pending={false} onClick={onClick}>
|
||||||
保存修改
|
保存修改
|
||||||
</R6Button>,
|
</R6Button>,
|
||||||
);
|
);
|
||||||
const button = screen.getByRole("button", { name: "保存修改" });
|
const button = screen.getByRole("button", { name: "保存修改" });
|
||||||
|
expect(button.getAttribute("data-pending-capable")).toBe("true");
|
||||||
|
const reservedSpinner = button.querySelector(".r6-button-spinner");
|
||||||
|
expect(reservedSpinner).toBeTruthy();
|
||||||
|
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
|
||||||
|
view.rerender(
|
||||||
|
<R6Button pending onClick={onClick}>
|
||||||
|
保存修改
|
||||||
|
</R6Button>,
|
||||||
|
);
|
||||||
expect(button.getAttribute("aria-busy")).toBe("true");
|
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);
|
fireEvent.click(button);
|
||||||
expect(onClick).not.toHaveBeenCalled();
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
expect(button.textContent).toContain("保存修改");
|
expect(button.textContent).toContain("保存修改");
|
||||||
|
|||||||
@@ -13,24 +13,33 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
|
|
||||||
export function R6Button({
|
export function R6Button({
|
||||||
variant = "secondary",
|
variant = "secondary",
|
||||||
pending = false,
|
pending,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
|
const supportsPending = pending !== undefined;
|
||||||
|
const busy = pending === true;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
type={type}
|
type={type}
|
||||||
className={`r6-button ${className}`}
|
className={`r6-button ${className}`}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
disabled={disabled || pending}
|
data-pending-capable={supportsPending || undefined}
|
||||||
aria-busy={pending || undefined}
|
disabled={disabled || busy}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
>
|
>
|
||||||
<span className="r6-button-label">{children}</span>
|
<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>
|
</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[]) {
|
function shown(ids: string[]) {
|
||||||
const view = render(
|
const view = render(
|
||||||
<R6ImportProgress
|
<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"])).toBe("1");
|
||||||
expect(shown(["cursor", "codex"])).toBe("2");
|
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");
|
expect(shown(["cursor", "codex", "claude-code", "workbuddy"])).toBe("many");
|
||||||
const four = render(
|
const four = render(
|
||||||
<R6ImportProgress
|
<R6ImportProgress
|
||||||
@@ -200,6 +200,38 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
|
|||||||
.querySelector(".r6-import-progress")
|
.querySelector(".r6-import-progress")
|
||||||
?.getAttribute("data-count"),
|
?.getAttribute("data-count"),
|
||||||
).toBe("many");
|
).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", () => {
|
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 ids = [...new Set(sourceIds)];
|
||||||
|
const scrollable = ids.length > 3;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="r6-import-progress"
|
className="r6-import-progress"
|
||||||
aria-label={text("本次应用导入进度")}
|
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}
|
ref={scroller}
|
||||||
|
tabIndex={scrollable ? 0 : -1}
|
||||||
onPointerDown={markUserScrollIntent}
|
onPointerDown={markUserScrollIntent}
|
||||||
onTouchStart={markUserScrollIntent}
|
onTouchStart={markUserScrollIntent}
|
||||||
onWheel={markUserScrollIntent}
|
onWheel={markUserScrollIntent}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
|
||||||
markUserScrollIntent();
|
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) => {
|
onScroll={(event) => {
|
||||||
if (userScrollIntent.current)
|
if (userScrollIntent.current)
|
||||||
@@ -133,7 +154,7 @@ export function R6ImportProgress({
|
|||||||
<div className="r6-import-progress-orbit" aria-hidden="true">
|
<div className="r6-import-progress-orbit" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<div className="r6-import-progress-icon">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<h2>{source?.display_name ?? id}</h2>
|
<h2>{source?.display_name ?? id}</h2>
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
|||||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||||
|
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
||||||
expect(screen.getByText("1.0.4")).toBeTruthy();
|
expect(screen.getByText("1.0.4")).toBeTruthy();
|
||||||
expect(screen.getByText("8")).toBeTruthy();
|
expect(screen.getByText("8")).toBeTruthy();
|
||||||
@@ -203,7 +204,13 @@ it("uses one sidebar hierarchy and explains when a saved model is inactive in li
|
|||||||
await user.click(screen.getByRole("button", { name: "界面语言" }));
|
await user.click(screen.getByRole("button", { name: "界面语言" }));
|
||||||
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
||||||
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
await user.click(screen.getByRole("button", { name: "模型配置" }));
|
||||||
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
(_, element) =>
|
||||||
|
element?.tagName === "STRONG" &&
|
||||||
|
element.textContent === "基础记忆 · Lite",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
|
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
@@ -242,8 +249,9 @@ it("carries a missing-model explanation into model configuration and moves focus
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
|
||||||
await user.click(
|
await user.click(
|
||||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
|
await screen.findByRole("option", { name: /智能记忆 · Pro/ }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type ModelSettingsActions,
|
type ModelSettingsActions,
|
||||||
type ModelSettingsFocusSection,
|
type ModelSettingsFocusSection,
|
||||||
} from "./ModelSettings";
|
} from "./ModelSettings";
|
||||||
|
import { MODE_TIER, modeDisplayName } from "./memory-mode-ui";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6Button, R6Switch } from "./R6Controls";
|
import { R6Button, R6Switch } from "./R6Controls";
|
||||||
import { R6StateRecovery } from "./R6StateRecovery";
|
import { R6StateRecovery } from "./R6StateRecovery";
|
||||||
@@ -394,14 +395,16 @@ export function R6Settings({
|
|||||||
{currentModel && (
|
{currentModel && (
|
||||||
<div className="r6-model-mode-status" role="status">
|
<div className="r6-model-mode-status" role="status">
|
||||||
<strong>
|
<strong>
|
||||||
{text(
|
{memoryMode ? (
|
||||||
memoryMode === "lite"
|
<>
|
||||||
? "当前是基础记忆(lite)"
|
{modeDisplayName(text, memoryMode)}
|
||||||
: memoryMode === "ultra"
|
<span className="r6-model-mode-tier">
|
||||||
? "当前是深度记忆(ultra)"
|
{" "}
|
||||||
: memoryMode === "pro"
|
· {MODE_TIER[memoryMode]}
|
||||||
? "当前是智能记忆(pro)"
|
</span>
|
||||||
: "正在读取记忆模式…",
|
</>
|
||||||
|
) : (
|
||||||
|
text("正在读取记忆模式…")
|
||||||
)}
|
)}
|
||||||
</strong>
|
</strong>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ function declaration(file: string, selector: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("fills settings controls without widening shared fallback controls", () => {
|
it("fills settings controls without widening shared fallback controls", () => {
|
||||||
for (const selector of [
|
const modeMenu = declaration(
|
||||||
".r6-memory-mode-settings .memory-mode-choice-list",
|
"r6-settings.css",
|
||||||
|
".r6-settings .r6-mode-option-menu",
|
||||||
|
);
|
||||||
|
expect(modeMenu).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
||||||
|
expect(modeMenu).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
||||||
|
|
||||||
|
const fallback = declaration(
|
||||||
|
"r6-settings.css",
|
||||||
".r6-settings .connection-fallback .custom-harness-name",
|
".r6-settings .connection-fallback .custom-harness-name",
|
||||||
]) {
|
);
|
||||||
const rule = declaration("r6-settings.css", selector);
|
expect(fallback).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
||||||
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
expect(fallback).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
||||||
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
|
||||||
}
|
|
||||||
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
|
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
|
||||||
/(?:^|[\s;])max-width:\s*420px\s*;/,
|
/(?:^|[\s;])max-width:\s*420px\s*;/,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ it("shows every role and preserves a long original message in the detail pane",
|
|||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: long },
|
{ role: "user", content: long },
|
||||||
{ role: "assistant", content: "已收到" },
|
{ role: "assistant", content: "已收到" },
|
||||||
|
{ role: "tool", content: "3 tests passed" },
|
||||||
|
{ role: "unknown", content: "来源角色缺失" },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
sources={[]}
|
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("工具")).toBeTruthy();
|
||||||
|
expect(screen.getByText("作者待核对")).toBeTruthy();
|
||||||
expect(screen.getByText(long)).toHaveProperty("textContent", long);
|
expect(screen.getByText(long)).toHaveProperty("textContent", long);
|
||||||
expect(screen.getByText("已收到")).toBeTruthy();
|
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 === "user") return t("selectedMemory.author.user");
|
||||||
if (role === "assistant" || role === "agent")
|
if (role === "assistant" || role === "agent")
|
||||||
return t("selectedMemory.author.agent");
|
return t("selectedMemory.author.agent");
|
||||||
|
if (role === "tool") return t("selectedMemory.author.tool");
|
||||||
return t("selectedMemory.author.unknown");
|
return t("selectedMemory.author.unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +47,7 @@ export function SelectedRawDetail({
|
|||||||
record.source || t("selectedMemory.sourceUnknown"),
|
record.source || t("selectedMemory.sourceUnknown"),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<article className="map-dialogue-reading">
|
<article className="map-dialogue-reading" aria-label={t("memory:原始对话")}>
|
||||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
|
||||||
<ul className="map-transcript">
|
<ul className="map-transcript">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { Check, ChevronDown, Clock3 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
|
import { R6Button } from "./R6Controls";
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, value) =>
|
||||||
|
value.toString().padStart(2, "0"),
|
||||||
|
);
|
||||||
|
const MINUTES = Array.from({ length: 60 }, (_, value) =>
|
||||||
|
value.toString().padStart(2, "0"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function splitTime(value: string): [string, string] {
|
||||||
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
||||||
|
return match ? [match[1], match[2]] : ["00", "00"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimePicker({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange(value: string): Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { locale, text } = useOnboardingTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [placement, setPlacement] = useState<"above" | "below">("above");
|
||||||
|
const [hour, setHour] = useState(() => splitTime(value)[0]);
|
||||||
|
const [minute, setMinute] = useState(() => splitTime(value)[1]);
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const panel = useRef<HTMLElement>(null);
|
||||||
|
const hourSelect = useRef<HTMLSelectElement>(null);
|
||||||
|
const minuteSelect = useRef<HTMLSelectElement>(null);
|
||||||
|
const restoreTriggerFocus = useRef(false);
|
||||||
|
const restoreSaveFocus = useRef(false);
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
savingRef.current = saving;
|
||||||
|
const panelId = `${id}-picker`;
|
||||||
|
|
||||||
|
const close = useCallback((restoreFocus = true) => {
|
||||||
|
setOpen(false);
|
||||||
|
restoreTriggerFocus.current = restoreFocus;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const [nextHour, nextMinute] = splitTime(value);
|
||||||
|
setHour(nextHour);
|
||||||
|
setMinute(nextMinute);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const closeOutside = (event: MouseEvent) => {
|
||||||
|
if (savingRef.current) return;
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const label = target?.closest("label");
|
||||||
|
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
|
||||||
|
if (!root.current?.contains(target)) close(false);
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape" || savingRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", closeOutside);
|
||||||
|
document.addEventListener("keydown", closeOnEscape);
|
||||||
|
hourSelect.current?.focus();
|
||||||
|
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOutside);
|
||||||
|
document.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [close, id, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && open && !saving) close(false);
|
||||||
|
if (!disabled && !open && restoreTriggerFocus.current) {
|
||||||
|
restoreTriggerFocus.current = false;
|
||||||
|
trigger.current?.focus();
|
||||||
|
}
|
||||||
|
if (!disabled && open && restoreSaveFocus.current) {
|
||||||
|
restoreSaveFocus.current = false;
|
||||||
|
panel.current
|
||||||
|
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
if (saving && open) panel.current?.focus();
|
||||||
|
}, [close, disabled, open, saving]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const place = () => {
|
||||||
|
const anchor = trigger.current?.getBoundingClientRect();
|
||||||
|
const picker = panel.current?.getBoundingClientRect();
|
||||||
|
if (!anchor || !picker) return;
|
||||||
|
const scroller = trigger.current?.closest(
|
||||||
|
".app-content, .onboarding-frame",
|
||||||
|
);
|
||||||
|
const bounds = scroller?.getBoundingClientRect();
|
||||||
|
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
|
||||||
|
const bottomEdge =
|
||||||
|
(bounds
|
||||||
|
? Math.min(window.innerHeight, bounds.bottom)
|
||||||
|
: window.innerHeight) - 12;
|
||||||
|
const above = anchor.top - topEdge;
|
||||||
|
const below = bottomEdge - anchor.bottom;
|
||||||
|
setPlacement(
|
||||||
|
above >= picker.height || above >= below ? "above" : "below",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener("resize", place);
|
||||||
|
window.addEventListener("scroll", place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", place);
|
||||||
|
window.removeEventListener("scroll", place, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const next = `${hour}:${minute}`;
|
||||||
|
if (next === value) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const saved = await onChange(next);
|
||||||
|
setSaving(false);
|
||||||
|
if (saved) close();
|
||||||
|
else restoreSaveFocus.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="r6-time-picker" ref={root}>
|
||||||
|
<button
|
||||||
|
ref={trigger}
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
className="r6-time-picker-trigger"
|
||||||
|
aria-label={
|
||||||
|
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
|
||||||
|
}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => (open ? close() : show())}
|
||||||
|
>
|
||||||
|
<span>{value}</span>
|
||||||
|
<span className="r6-time-picker-icons" aria-hidden="true">
|
||||||
|
<Clock3 size={15} />
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<section
|
||||||
|
ref={panel}
|
||||||
|
id={panelId}
|
||||||
|
className="r6-time-picker-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-placement={placement}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||||
|
"select:not(:disabled), button:not(:disabled)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!first || !last) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="r6-time-picker-columns">
|
||||||
|
<label className="r6-time-picker-column">
|
||||||
|
<span aria-hidden="true">HH</span>
|
||||||
|
<select
|
||||||
|
ref={hourSelect}
|
||||||
|
size={6}
|
||||||
|
aria-label={`${label} HH`}
|
||||||
|
value={hour}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setHour(event.target.value)}
|
||||||
|
>
|
||||||
|
{HOURS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="r6-time-picker-separator" aria-hidden="true">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<label className="r6-time-picker-column">
|
||||||
|
<span aria-hidden="true">MM</span>
|
||||||
|
<select
|
||||||
|
ref={minuteSelect}
|
||||||
|
size={6}
|
||||||
|
aria-label={`${label} MM`}
|
||||||
|
value={minute}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setMinute(event.target.value)}
|
||||||
|
>
|
||||||
|
{MINUTES.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<R6Button disabled={saving} onClick={() => close()}>
|
||||||
|
{text("取消")}
|
||||||
|
</R6Button>
|
||||||
|
<R6Button
|
||||||
|
variant="primary"
|
||||||
|
pending={saving}
|
||||||
|
data-time-picker-save
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
<Check size={15} aria-hidden="true" />
|
||||||
|
{text("保存修改")}
|
||||||
|
</R6Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { EvolveSchedule } from "@one-memory/contracts";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
|
import { R6Switch } from "./R6Controls";
|
||||||
|
import { TimePicker } from "./TimePicker";
|
||||||
|
|
||||||
|
export function UltraEvolveScheduleFields({
|
||||||
|
schedule,
|
||||||
|
headingId,
|
||||||
|
disabled = false,
|
||||||
|
onEnabledChange,
|
||||||
|
onTimeChange,
|
||||||
|
}: {
|
||||||
|
schedule: EvolveSchedule;
|
||||||
|
headingId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onEnabledChange(enabled: boolean): void;
|
||||||
|
onTimeChange(time: string): Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { text } = useOnboardingTranslation();
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="r6-evolve-heading">
|
||||||
|
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
|
||||||
|
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="r6-evolve-setting">
|
||||||
|
<div className="r6-evolve-setting-copy">
|
||||||
|
<h3>{text("每天自动整合")}</h3>
|
||||||
|
<p>
|
||||||
|
{text(
|
||||||
|
schedule.enabled
|
||||||
|
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||||
|
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||||
|
).replace("{time}", schedule.time)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<R6Switch
|
||||||
|
label={text("每天自动整合")}
|
||||||
|
checked={schedule.enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onEnabledChange}
|
||||||
|
/>
|
||||||
|
{schedule.enabled ? (
|
||||||
|
<div className="r6-evolve-time">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
|
||||||
|
<p>{text("使用这台设备的本地时间。")}</p>
|
||||||
|
</div>
|
||||||
|
<TimePicker
|
||||||
|
id={`${id}-time`}
|
||||||
|
label={text("执行时间")}
|
||||||
|
value={schedule.time}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onTimeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getSdkRaw, searchSdkRaw } from "./api";
|
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -33,13 +33,15 @@ it("lists raw conversations for the selected application", async () => {
|
|||||||
offset: 20,
|
offset: 20,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
unit: "turn",
|
unit: "turn",
|
||||||
|
paging: undefined,
|
||||||
|
cursor: undefined,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the application filter when searching raw conversations", async () => {
|
it("keeps the application filter when searching raw conversations", async () => {
|
||||||
post.mockResolvedValue({ data: { items: [] } });
|
post.mockResolvedValue({ data: { state: "empty", items: [] } });
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
await searchSdkRaw("release plan", { source: "claude-code", signal });
|
await searchSdkRaw("release plan", { source: "claude-code", signal });
|
||||||
@@ -57,3 +59,41 @@ it("keeps the application filter when searching raw conversations", async () =>
|
|||||||
{ signal, timeout: 10_000 },
|
{ signal, timeout: 10_000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not present an unavailable raw search as no matches", async () => {
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: { state: "unavailable", items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests one Pro-sized raw graph page", async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
user_id: "local",
|
||||||
|
items: [],
|
||||||
|
total: 1_600,
|
||||||
|
offset: 0,
|
||||||
|
limit: 300,
|
||||||
|
has_more: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
await getSdkRawGraph({
|
||||||
|
source: "cursor",
|
||||||
|
offset: 0,
|
||||||
|
limit: 300,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
|
||||||
|
params: {
|
||||||
|
source: "cursor",
|
||||||
|
offset: 0,
|
||||||
|
limit: 300,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type SdkMemoryStatus,
|
type SdkMemoryStatus,
|
||||||
type SdkMemoryVariant,
|
type SdkMemoryVariant,
|
||||||
type SdkRawContextItem,
|
type SdkRawContextItem,
|
||||||
|
type SdkRawGraphPage,
|
||||||
type SdkRawPage,
|
type SdkRawPage,
|
||||||
type SourceDirectoryCandidate,
|
type SourceDirectoryCandidate,
|
||||||
type SourceImportReceipt,
|
type SourceImportReceipt,
|
||||||
@@ -987,6 +988,8 @@ export async function getSdkRaw({
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
unit = "turn",
|
unit = "turn",
|
||||||
|
paging,
|
||||||
|
cursor,
|
||||||
signal,
|
signal,
|
||||||
}: {
|
}: {
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -994,10 +997,38 @@ export async function getSdkRaw({
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
unit?: "raw" | "turn";
|
unit?: "raw" | "turn";
|
||||||
|
paging?: "offset" | "cursor";
|
||||||
|
cursor?: string | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
} = {}): Promise<SdkRawPage> {
|
} = {}): Promise<SdkRawPage> {
|
||||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||||
params: { source, session_id: sessionId, offset, limit, unit },
|
params: {
|
||||||
|
source,
|
||||||
|
session_id: sessionId,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
unit,
|
||||||
|
paging,
|
||||||
|
cursor: cursor || undefined,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSdkRawGraph({
|
||||||
|
source,
|
||||||
|
offset = 0,
|
||||||
|
limit = 300,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
source?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {}): Promise<SdkRawGraphPage> {
|
||||||
|
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
|
||||||
|
params: { source, offset, limit },
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1019,6 +1050,8 @@ export async function searchSdkRaw(
|
|||||||
},
|
},
|
||||||
{ signal: options.signal, timeout: 10_000 },
|
{ signal: options.signal, timeout: 10_000 },
|
||||||
);
|
);
|
||||||
|
if (response.data?.state !== "grounded" && response.data?.state !== "empty")
|
||||||
|
throw new Error("raw_search_unavailable");
|
||||||
const items = response.data?.items;
|
const items = response.data?.items;
|
||||||
return Array.isArray(items)
|
return Array.isArray(items)
|
||||||
? items.filter(
|
? items.filter(
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
.r6-home-overview {
|
.r6-home-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 346px;
|
min-height: 346px;
|
||||||
padding: 26px;
|
padding: 26px;
|
||||||
@@ -67,6 +69,17 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
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 {
|
.r6-home-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 1fr 1fr;
|
grid-template-columns: 1.1fr 1fr 1fr;
|
||||||
@@ -74,6 +87,9 @@
|
|||||||
min-height: 129px;
|
min-height: 129px;
|
||||||
}
|
}
|
||||||
.r6-home-metrics > div {
|
.r6-home-metrics > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 20px 72px minmax(20px, auto);
|
||||||
|
align-content: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 4px 24px;
|
padding: 4px 24px;
|
||||||
border-right: 1px solid #d5dfe8;
|
border-right: 1px solid #d5dfe8;
|
||||||
@@ -87,12 +103,16 @@
|
|||||||
.r6-home-metrics p,
|
.r6-home-metrics p,
|
||||||
.r6-home-metrics small {
|
.r6-home-metrics small {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
color: #63707b;
|
color: #63707b;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.r6-home-metrics small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.r6-home-figure {
|
.r6-home-figure {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -100,12 +120,10 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
padding-bottom: 10px;
|
|
||||||
color: #405e75;
|
color: #405e75;
|
||||||
}
|
}
|
||||||
/* Holds the digit slot while the first summary read is in flight, so the 48px
|
/* 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
|
type does not spend that frame rendering a word. */
|
||||||
below (48px x 1.3). */
|
|
||||||
.r6-home-figure-pending {
|
.r6-home-figure-pending {
|
||||||
display: block;
|
display: block;
|
||||||
width: 92px;
|
width: 92px;
|
||||||
@@ -132,24 +150,25 @@
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.r6-home-metrics strong {
|
.r6-home-metrics strong {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.3;
|
line-height: 1;
|
||||||
color: #405e75;
|
color: #405e75;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.r6-home-metrics strong.r6-home-word {
|
.r6-home-metrics strong.r6-home-word {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
padding: 14px 0;
|
line-height: 1.25;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.r6-home-metrics strong.r6-home-time {
|
.r6-home-metrics strong.r6-home-time {
|
||||||
font-size: 34px;
|
font-size: 34px;
|
||||||
line-height: 1.85;
|
line-height: 1.25;
|
||||||
padding: 8px 0;
|
|
||||||
}
|
}
|
||||||
.r6-home-activity {
|
.r6-home-activity {
|
||||||
|
margin-top: auto;
|
||||||
border-top: 1px solid #d5dfe8;
|
border-top: 1px solid #d5dfe8;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -278,7 +297,10 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.r6-home-activity small {
|
.r6-home-activity small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
color: #637786;
|
color: #637786;
|
||||||
}
|
}
|
||||||
.r6-home-runtime {
|
.r6-home-runtime {
|
||||||
|
|||||||
@@ -791,6 +791,44 @@ button.rail-button.active::before {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.map-list-status {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
@@ -829,6 +867,16 @@ button.rail-button.active::before {
|
|||||||
.map-memory-list > li {
|
.map-memory-list > li {
|
||||||
margin-bottom: 4px;
|
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 {
|
.map-memory-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 5px minmax(0, 1fr);
|
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;
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 738px) 278px;
|
grid-template-columns: minmax(0, 738px) 278px;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
min-height: 100%;
|
height: 560.2px;
|
||||||
height: auto;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.first-import-r6-main {
|
.first-import-r6-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -35,12 +35,13 @@
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
.first-import-r6-main > .r6-import-progress {
|
.first-import-r6-main > .r6-import-progress {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 218px;
|
||||||
}
|
}
|
||||||
.first-import-r6-feed {
|
.first-import-r6-feed {
|
||||||
flex: 0 0 auto;
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
overflow: visible;
|
overflow: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
@@ -224,8 +225,10 @@
|
|||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-note {
|
.first-import-r6-footer-note {
|
||||||
flex: 1 1 240px;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-height: 64px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-note p {
|
.first-import-r6-footer-note p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -242,15 +245,12 @@
|
|||||||
}
|
}
|
||||||
.first-import-r6-footer-actions {
|
.first-import-r6-footer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-actions > [data-variant="primary"] {
|
.first-import-r6-footer-actions > [data-variant="primary"] {
|
||||||
min-width: min(160px, 100%);
|
min-width: 210px;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-actions .r6-button-label {
|
.first-import-r6-footer-actions .r6-button-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -403,18 +403,18 @@ dialog.first-import-r6-original[open] {
|
|||||||
}
|
}
|
||||||
@media (min-width: 761px) and (max-height: 959px) {
|
@media (min-width: 761px) and (max-height: 959px) {
|
||||||
.onboarding-r6-body:has(> .first-import-r6-layout) {
|
.onboarding-r6-body:has(> .first-import-r6-layout) {
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
.first-import-r6-layout {
|
.first-import-r6-layout {
|
||||||
height: auto;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 761px) and (max-height: 859px) {
|
@media (min-width: 761px) and (max-height: 859px) {
|
||||||
.first-import-r6-options {
|
.first-import-r6-options {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.first-import-r6-eyebrow {
|
.first-import-r6-eyebrow {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
.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-cards--rows
|
||||||
|
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
|
||||||
|
min-height: 166px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-card[data-disabled="true"] {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-card-choice {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 24px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-content: center;
|
||||||
|
gap: 0;
|
||||||
|
color: #5e6e7a;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-features > span {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-features > span::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgb(199 211 220 / 78%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-features i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-detail {
|
||||||
|
width: auto;
|
||||||
|
margin: 7px 18px 12px;
|
||||||
|
padding: 10px 0 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
border-top: 1px solid rgb(166 190 179 / 48%);
|
||||||
|
background: transparent;
|
||||||
|
color: #405867;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
animation: memory-mode-detail-in 180ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-detail > svg {
|
||||||
|
color: #527565;
|
||||||
|
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;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
border-top: 1px solid #dbe4ea;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-cards--grid .memory-mode-features > span {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-cards--grid .memory-mode-features > span + span {
|
||||||
|
border-top: 1px solid rgb(199 211 220 / 72%);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-cards--grid .memory-mode-features > span::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-mode-cards--grid .memory-mode-detail {
|
||||||
|
width: auto;
|
||||||
|
margin: 8px 18px 16px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
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: 284px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-card-list {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-card {
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows
|
||||||
|
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
|
||||||
|
min-height: 116px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-card-choice {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 11px 16px;
|
||||||
|
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
|
||||||
|
column-gap: 18px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-radio {
|
||||||
|
top: 11px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-title strong {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-description {
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-features {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0 34px 0 18px;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-features > span {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-detail {
|
||||||
|
margin: 6px 14px 10px;
|
||||||
|
padding: 8px 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;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
border-top: 1px solid #dbe4ea;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-features > span {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-features > span + span {
|
||||||
|
border-top: 1px solid rgb(199 211 220 / 72%);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-features > span::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.memory-mode-cards--rows .memory-mode-detail {
|
||||||
|
width: auto;
|
||||||
|
margin: 8px 20px 14px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: auto;
|
||||||
|
margin: 6px 14px 14px;
|
||||||
|
padding: 10px 0 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import type { MemoryModePlan, MemoryPipelineMode } from "@one-memory/contracts";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
|
import { R6Button } from "./R6Controls";
|
||||||
|
|
||||||
|
export const MODE_COPY: Record<
|
||||||
|
MemoryPipelineMode,
|
||||||
|
{ title: string; description: string }
|
||||||
|
> = {
|
||||||
|
lite: {
|
||||||
|
title: "基础记忆(lite)",
|
||||||
|
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
title: "智能记忆(pro)",
|
||||||
|
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
|
||||||
|
},
|
||||||
|
ultra: {
|
||||||
|
title: "深度记忆(ultra)",
|
||||||
|
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MODE_TIER: Record<MemoryPipelineMode, string> = {
|
||||||
|
lite: "Lite",
|
||||||
|
pro: "Pro",
|
||||||
|
ultra: "Ultra",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function modeDisplayName(
|
||||||
|
text: (value: string) => string,
|
||||||
|
mode: MemoryPipelineMode,
|
||||||
|
) {
|
||||||
|
return text(MODE_COPY[mode].title)
|
||||||
|
.replace(/\s*[((](?:lite|pro|ultra)[))]\s*/i, " ")
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
|
||||||
|
{
|
||||||
|
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||||
|
embedding_maintenance_in_progress:
|
||||||
|
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
||||||
|
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
|
||||||
|
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function memoryModePlanRows(plan: MemoryModePlan) {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
label: "新对话",
|
||||||
|
content:
|
||||||
|
plan.capabilities.content_plane === "raw"
|
||||||
|
? "之后只保存原始对话,不再生成新记忆。"
|
||||||
|
: plan.capabilities.graph_enabled
|
||||||
|
? "新对话会整理成记忆并写入知识图谱。"
|
||||||
|
: "新对话会由模型整理成记忆。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "模型调用与费用",
|
||||||
|
content: plan.capabilities.generation_required
|
||||||
|
? plan.capabilities.evolve_available
|
||||||
|
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
|
||||||
|
: "会调用当前生成模型,可能产生服务商费用。"
|
||||||
|
: "不调用生成模型,不再产生这部分费用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "搜索范围",
|
||||||
|
content:
|
||||||
|
plan.capabilities.content_plane === "raw"
|
||||||
|
? "切换后搜索原始对话。"
|
||||||
|
: plan.capabilities.graph_enabled
|
||||||
|
? "搜索沉淀记忆,并用知识图谱补充关联。"
|
||||||
|
: "搜索沉淀后的记忆。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (plan.effects.includes("earlier_raw_not_extracted"))
|
||||||
|
rows.push({
|
||||||
|
label: "已有数据",
|
||||||
|
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
|
||||||
|
});
|
||||||
|
if (plan.effects.includes("higher_mode_memories_hidden"))
|
||||||
|
rows.push({
|
||||||
|
label: "已有数据",
|
||||||
|
content:
|
||||||
|
plan.effective_target === "pro"
|
||||||
|
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
||||||
|
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
||||||
|
});
|
||||||
|
if (plan.effects.includes("existing_memories_added_to_graph"))
|
||||||
|
rows.push({
|
||||||
|
label: "知识图谱",
|
||||||
|
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
|
||||||
|
});
|
||||||
|
if (plan.effects.includes("evolve_stopped"))
|
||||||
|
rows.push({
|
||||||
|
label: "每天自动整合",
|
||||||
|
content: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||||
|
});
|
||||||
|
if (plan.capabilities.evolve_available)
|
||||||
|
rows.push({
|
||||||
|
label: "切换后",
|
||||||
|
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryModeConfirmDialog({
|
||||||
|
mode,
|
||||||
|
plan,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
opener,
|
||||||
|
allowedBlockers = [],
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
mode: MemoryPipelineMode;
|
||||||
|
plan: MemoryModePlan;
|
||||||
|
busy: boolean;
|
||||||
|
error?: string;
|
||||||
|
opener?: HTMLElement | null;
|
||||||
|
allowedBlockers?: readonly MemoryModePlan["blockers"][number][];
|
||||||
|
onCancel(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
}) {
|
||||||
|
const { text } = useOnboardingTranslation();
|
||||||
|
const panel = useRef<HTMLElement>(null);
|
||||||
|
const busyRef = useRef(busy);
|
||||||
|
const onCancelRef = useRef(onCancel);
|
||||||
|
busyRef.current = busy;
|
||||||
|
onCancelRef.current = onCancel;
|
||||||
|
const blocking = plan.blockers.filter(
|
||||||
|
(blocker) => !allowedBlockers.includes(blocker),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = panel.current;
|
||||||
|
if (!element) return;
|
||||||
|
const inertSiblings = new Map<HTMLElement, boolean>();
|
||||||
|
let branch: HTMLElement = element.parentElement ?? element;
|
||||||
|
while (branch.parentElement) {
|
||||||
|
for (const sibling of branch.parentElement.children) {
|
||||||
|
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
|
||||||
|
inertSiblings.set(sibling, sibling.inert);
|
||||||
|
sibling.inert = true;
|
||||||
|
}
|
||||||
|
branch = branch.parentElement;
|
||||||
|
if (branch === document.body) break;
|
||||||
|
}
|
||||||
|
const controls = () =>
|
||||||
|
Array.from(
|
||||||
|
element.querySelectorAll<HTMLElement>("button:not(:disabled)"),
|
||||||
|
);
|
||||||
|
controls()[0]?.focus();
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !busyRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancelRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const items = controls();
|
||||||
|
const first = items[0];
|
||||||
|
const last = items.at(-1);
|
||||||
|
if (!first || !last) {
|
||||||
|
event.preventDefault();
|
||||||
|
element.focus();
|
||||||
|
} else if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element.addEventListener("keydown", handleKey);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("keydown", handleKey);
|
||||||
|
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
|
||||||
|
if (opener?.isConnected) opener.focus();
|
||||||
|
};
|
||||||
|
}, [opener]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (busy) panel.current?.focus();
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="r6-settings-dialog-layer" role="presentation">
|
||||||
|
<section
|
||||||
|
ref={panel}
|
||||||
|
className="r6-settings-dialog r6-mode-confirm-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="memory-mode-confirm-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<h2 id="memory-mode-confirm-title">
|
||||||
|
{text("切换到 {mode}?").replace(
|
||||||
|
"{mode}",
|
||||||
|
`${modeDisplayName(text, mode)} · ${MODE_TIER[mode]}`,
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<dl className="r6-mode-confirm-effects">
|
||||||
|
{memoryModePlanRows(plan).map((row) => (
|
||||||
|
<div key={`${row.label}:${row.content}`}>
|
||||||
|
<dt>{text(row.label)}</dt>
|
||||||
|
<dd>{text(row.content)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{plan.blockers.map((blocker) => (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
blocking.includes(blocker)
|
||||||
|
? "r6-settings-error"
|
||||||
|
: "r6-settings-note"
|
||||||
|
}
|
||||||
|
role={blocking.includes(blocker) ? "alert" : "status"}
|
||||||
|
key={blocker}
|
||||||
|
>
|
||||||
|
{text(BLOCKER_COPY[blocker])}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{error ? (
|
||||||
|
<p className="r6-settings-error" role="alert">
|
||||||
|
{text(error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<footer>
|
||||||
|
<R6Button disabled={busy} onClick={onCancel}>
|
||||||
|
{text("取消")}
|
||||||
|
</R6Button>
|
||||||
|
<R6Button
|
||||||
|
variant="primary"
|
||||||
|
pending={busy}
|
||||||
|
disabled={blocking.length > 0}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{text("切换模式")}
|
||||||
|
</R6Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||||
|
|
||||||
|
const profile: ModelProfile = {
|
||||||
|
profile_id: "saved-1",
|
||||||
|
name: "Saved model",
|
||||||
|
provider_kind: "openai_compatible",
|
||||||
|
base_url: "https://models.example/v1",
|
||||||
|
model: "model-1",
|
||||||
|
embedding_model: null,
|
||||||
|
embedding_provider_kind: null,
|
||||||
|
embedding_base_url: null,
|
||||||
|
embedding_dimensions: null,
|
||||||
|
context_window: null,
|
||||||
|
disable_thinking: false,
|
||||||
|
has_api_key: true,
|
||||||
|
timeout_seconds: 60,
|
||||||
|
max_concurrency: 1,
|
||||||
|
revision: 1,
|
||||||
|
is_active: false,
|
||||||
|
created_at: "2026-09-14T00:00:00Z",
|
||||||
|
updated_at: "2026-09-14T00:00:00Z",
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: false,
|
||||||
|
model_available: false,
|
||||||
|
embedding_available: false,
|
||||||
|
structured_output: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function probe(
|
||||||
|
inputRevision: number | null | undefined = 1,
|
||||||
|
outputRevision = 2,
|
||||||
|
): ModelProbeResult {
|
||||||
|
return {
|
||||||
|
profile_id: profile.profile_id,
|
||||||
|
input_profile_revision: inputRevision,
|
||||||
|
profile_revision: outputRevision,
|
||||||
|
stages: [],
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
structured_output: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a measured one-revision bump before or after profile refresh", () => {
|
||||||
|
const receipt = probe();
|
||||||
|
const beforeRefresh = profileFromProbe(profile, receipt);
|
||||||
|
const afterRefresh = profileFromProbe({ ...profile, revision: 2 }, receipt);
|
||||||
|
|
||||||
|
expect(beforeRefresh).toMatchObject({ revision: 2 });
|
||||||
|
expect(afterRefresh).toMatchObject({ revision: 2 });
|
||||||
|
expect(modelProfileReady(beforeRefresh as ModelProfile, receipt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bump without input evidence, a skipped revision and another profile", () => {
|
||||||
|
expect(profileFromProbe(profile, probe(null, 2))).toBeNull();
|
||||||
|
expect(profileFromProbe(profile, probe(1, 3))).toBeNull();
|
||||||
|
expect(
|
||||||
|
profileFromProbe(profile, {
|
||||||
|
...probe(),
|
||||||
|
profile_id: "another-profile",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unavailable credentials from becoming ready", () => {
|
||||||
|
const receipt = probe();
|
||||||
|
const tested = profileFromProbe(
|
||||||
|
{ ...profile, credential_state: "unavailable" },
|
||||||
|
receipt,
|
||||||
|
);
|
||||||
|
expect(tested).not.toBeNull();
|
||||||
|
expect(modelProfileReady(tested as ModelProfile, receipt)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
|
||||||
|
|
||||||
|
export function profileFromProbe(
|
||||||
|
profile: ModelProfile,
|
||||||
|
probe: ModelProbeResult | null | undefined,
|
||||||
|
): ModelProfile | null {
|
||||||
|
if (!probe || probe.profile_id !== profile.profile_id) return null;
|
||||||
|
const inputRevision = probe.input_profile_revision;
|
||||||
|
if (!Number.isSafeInteger(probe.profile_revision)) return null;
|
||||||
|
if (inputRevision === undefined || inputRevision === null) {
|
||||||
|
if (probe.profile_revision !== profile.revision) return null;
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(inputRevision) ||
|
||||||
|
(probe.profile_revision !== inputRevision &&
|
||||||
|
probe.profile_revision !== inputRevision + 1) ||
|
||||||
|
(profile.revision !== inputRevision &&
|
||||||
|
profile.revision !== probe.profile_revision)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
revision: probe.profile_revision,
|
||||||
|
capabilities: probe.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelProfileReady(
|
||||||
|
profile: ModelProfile,
|
||||||
|
probe: ModelProbeResult | null = null,
|
||||||
|
error: string | null = null,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!error &&
|
||||||
|
(!probe ||
|
||||||
|
(probe.profile_id === profile.profile_id &&
|
||||||
|
probe.profile_revision === profile.revision &&
|
||||||
|
probe.capabilities.endpoint_reachable &&
|
||||||
|
probe.capabilities.model_available &&
|
||||||
|
probe.capabilities.embedding_available)) &&
|
||||||
|
profile.credential_state !== "unavailable" &&
|
||||||
|
!(profile.has_configured_api_key && !profile.has_api_key) &&
|
||||||
|
profile.capabilities.endpoint_reachable &&
|
||||||
|
profile.capabilities.model_available &&
|
||||||
|
profile.capabilities.embedding_available
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -588,49 +588,44 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Generation-only cold start. Embedding remains a separate settings lane. */
|
/* The current controller keeps generation-only behavior. The presentation
|
||||||
.onboarding-model-r6 .onboarding-r6-head > div {
|
intentionally returns to the original open-source setup composition:
|
||||||
width: min(760px, 100%);
|
an open form column, a separate context column, and a distinct advanced state. */
|
||||||
}
|
|
||||||
.onboarding-model-layout {
|
|
||||||
display: block;
|
|
||||||
width: min(760px, 100%);
|
|
||||||
height: auto;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
.onboarding-model-form {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
.onboarding-model-group {
|
.onboarding-model-group {
|
||||||
display: grid;
|
display: block;
|
||||||
gap: 20px;
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid #d8e2e8;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
.onboarding-model-provider,
|
.onboarding-model-group > .onboarding-model-intro {
|
||||||
.onboarding-model-key {
|
margin-bottom: 0;
|
||||||
margin-top: 0;
|
|
||||||
}
|
}
|
||||||
.onboarding-model-field {
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
.onboarding-model-field :is(input, select),
|
|
||||||
.onboarding-model-field .om-option-menu-button,
|
.onboarding-model-field .om-option-menu-button,
|
||||||
.generation-model-field :is(input, select) {
|
.generation-model-field :is(input, select) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 44px;
|
height: 54px;
|
||||||
padding: 9px 12px;
|
padding: 12px 16px;
|
||||||
border: 1px solid #c8d3db;
|
border: 1px solid #c0ccd5;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #f0f5f6;
|
||||||
color: #262a2e;
|
color: #262a2e;
|
||||||
font:
|
font:
|
||||||
400 14px / 22px "Noto Sans SC",
|
400 14px / 23px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
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,
|
.onboarding-model-field small,
|
||||||
.generation-model-field small {
|
.generation-model-field small {
|
||||||
@@ -641,11 +636,8 @@
|
|||||||
.onboarding-model-default-summary {
|
.onboarding-model-default-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 14px 0;
|
min-height: 54px;
|
||||||
border-block: 1px solid #e4eaee;
|
margin-top: 12px;
|
||||||
}
|
|
||||||
.onboarding-model-default-summary span,
|
|
||||||
.onboarding-model-disclosure {
|
|
||||||
color: #63707b;
|
color: #63707b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@@ -655,100 +647,131 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.onboarding-model-disclosure {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.generation-technical-disclosure {
|
.generation-technical-disclosure {
|
||||||
border-top: 1px solid #e4eaee;
|
margin-top: 23px;
|
||||||
padding-top: 4px;
|
|
||||||
}
|
}
|
||||||
.generation-technical-toggle {
|
.generation-technical-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
min-height: 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #4e6478;
|
color: #4e6478;
|
||||||
font:
|
font:
|
||||||
500 13px / 21px "Noto Sans SC",
|
500 13px / 21px "Noto Sans SC",
|
||||||
"PingFang SC",
|
"PingFang SC",
|
||||||
sans-serif;
|
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 {
|
.generation-technical-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 19px 30px;
|
||||||
padding-top: 12px;
|
margin-top: 34px;
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
.generation-technical-fields[hidden] {
|
.generation-technical-fields[hidden] {
|
||||||
display: none;
|
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;
|
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;
|
color: #63707b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 19px;
|
line-height: 19px;
|
||||||
}
|
}
|
||||||
.generation-thinking {
|
.generation-thinking {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 32px;
|
min-height: 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 10px;
|
||||||
color: #416e5c;
|
color: #416e5c;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
line-height: 23px;
|
||||||
}
|
}
|
||||||
.generation-model-error {
|
.onboarding-model-r6 .generation-model-error {
|
||||||
color: #a24646 !important;
|
color: #a24646;
|
||||||
}
|
}
|
||||||
.onboarding-model-recovery-actions {
|
.onboarding-model-form[data-advanced="true"]
|
||||||
margin-top: 12px;
|
: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 {
|
.onboarding-model-feedback {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@media (min-width: 761px) {
|
@media (min-width: 761px) {
|
||||||
.onboarding-model-r6 .onboarding-r6-body {
|
.onboarding-model-group {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
overscroll-behavior: contain;
|
||||||
.onboarding-model-form {
|
scrollbar-width: thin;
|
||||||
display: block;
|
scrollbar-gutter: stable;
|
||||||
overflow: visible;
|
scroll-padding: 12px;
|
||||||
|
padding-inline: 4px 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (min-width: 761px) and (max-height: 959px) {
|
||||||
.onboarding-model-layout--single-column-600 .onboarding-model-group {
|
.onboarding-model-form[data-advanced="false"]
|
||||||
padding: 16px;
|
.onboarding-model-field
|
||||||
border-radius: 10px;
|
:is(input, select, .om-option-menu-button) {
|
||||||
|
height: 44px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
.onboarding-model-layout--single-column-600 .generation-technical-fields,
|
.generation-technical-fields {
|
||||||
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.generation-technical-fields,
|
||||||
|
.generation-runtime-fields {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
gap: 12px;
|
||||||
.onboarding-model-footer-actions .r6-button,
|
|
||||||
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -115,14 +115,16 @@
|
|||||||
}
|
}
|
||||||
.onboarding-r6-stage {
|
.onboarding-r6-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 852px;
|
min-height: 0;
|
||||||
padding: 32px 24px 2px;
|
display: flex;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-shell {
|
.onboarding-r6-shell {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 1120px;
|
width: 1120px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 820px;
|
height: 100%;
|
||||||
|
max-height: 820px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
inset 0 -1px 0 #adc0d22e;
|
inset 0 -1px 0 #adc0d22e;
|
||||||
}
|
}
|
||||||
.onboarding-r6-head {
|
.onboarding-r6-head {
|
||||||
min-height: 123.7px;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -195,6 +197,7 @@
|
|||||||
background: #ffffff38;
|
background: #ffffff38;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
border-radius: 0 0 25px 25px;
|
border-radius: 0 0 25px 25px;
|
||||||
}
|
}
|
||||||
@@ -222,29 +225,39 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: thin;
|
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 {
|
.onboarding-r6-discovery {
|
||||||
height: 560.2px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 738px) 278px;
|
grid-template-columns: minmax(0, 738px) 278px;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-apps {
|
.onboarding-r6-apps {
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
min-height: 0;
|
||||||
.onboarding-r6-intro {
|
display: grid;
|
||||||
margin: 0;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
font-size: 13px;
|
gap: 12px;
|
||||||
line-height: 24.05px;
|
|
||||||
color: #5a6169;
|
|
||||||
}
|
|
||||||
.onboarding-r6-intro p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-heading {
|
.onboarding-r6-list-heading {
|
||||||
position: absolute;
|
position: static;
|
||||||
inset: 96px 0 auto;
|
|
||||||
height: 30px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -277,8 +290,10 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-area {
|
.onboarding-r6-list-area {
|
||||||
position: absolute;
|
position: static;
|
||||||
inset: 142px 0 auto;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
||||||
color: #4e6478;
|
color: #4e6478;
|
||||||
@@ -288,7 +303,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
height: 330px;
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -451,44 +468,6 @@
|
|||||||
content: "↓";
|
content: "↓";
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-import {
|
|
||||||
position: absolute;
|
|
||||||
top: 498px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6a7988;
|
|
||||||
}
|
|
||||||
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
|
|
||||||
markup keeps the sentence first so it stays the accessible label order for
|
|
||||||
the control that follows. Reordering visually only, rather than moving the
|
|
||||||
DOM, keeps that reading order intact. */
|
|
||||||
.onboarding-r6-import > p {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import > .history-import,
|
|
||||||
.onboarding-r6-import > .history-import-trigger {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import > p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import .history-import-trigger {
|
|
||||||
font: inherit;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #4e6478;
|
|
||||||
padding: 5px 11px;
|
|
||||||
min-height: 30px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import .history-import-trigger > svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.onboarding-r6-summary {
|
.onboarding-r6-summary {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid #ffffffe0;
|
border: 1px solid #ffffffe0;
|
||||||
@@ -500,6 +479,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-summary p {
|
.onboarding-r6-summary p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -572,45 +553,94 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode {
|
.onboarding-r6-mode {
|
||||||
width: min(720px, 100%);
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode-intro {
|
.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 {
|
.onboarding-r6-mode-intro p {
|
||||||
margin: 0;
|
margin: 4px 0 0;
|
||||||
color: #637381;
|
color: #637381;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode .memory-mode-choice-list {
|
.onboarding-r6-ultra-default {
|
||||||
background: rgb(255 255 255 / 72%);
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode .memory-mode-choice {
|
.onboarding-r6-ultra-default strong {
|
||||||
min-height: 82px;
|
color: #304757;
|
||||||
padding: 15px 18px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode .memory-mode-choice-copy {
|
.onboarding-r6-ultra-default small {
|
||||||
gap: 4px;
|
color: #607482;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode .memory-mode-choice-copy strong {
|
.onboarding-r6-schedule-loading {
|
||||||
font-size: 16px;
|
margin: 0;
|
||||||
line-height: 25px;
|
color: #607482;
|
||||||
}
|
|
||||||
.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;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
text-align: center;
|
}
|
||||||
|
.memory-mode-detail .onboarding-r6-schedule-recovery {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.memory-mode-detail .onboarding-r6-schedule-retry {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 76px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 18px 20px 4px;
|
||||||
|
border: 1px solid #cfdae2;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgb(255 255 255 / 58%);
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings .r6-evolve-heading {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings .r6-evolve-setting {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings .r6-evolve-setting-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings .r6-evolve-time {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 180px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-evolve-settings .r6-evolve-time label {
|
||||||
|
color: #304757;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
|
||||||
|
color: #944242;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode-error {
|
.onboarding-r6-mode-error {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
@@ -620,6 +650,24 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
.onboarding-r6-mode {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 761px) and (max-height: 959px) {
|
||||||
|
.onboarding-r6-mode {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-mode-intro h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
.onboarding-r6-mode-intro p {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.onboarding-r6-empty {
|
.onboarding-r6-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 28px 18px;
|
padding: 28px 18px;
|
||||||
@@ -763,17 +811,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.onboarding-r6-apps {
|
.onboarding-r6-apps {
|
||||||
min-height: 590px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-heading {
|
.onboarding-r6-list {
|
||||||
top: 110px;
|
height: min(330px, 45vh);
|
||||||
}
|
flex: none;
|
||||||
.onboarding-r6-list-area {
|
|
||||||
top: 154px;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import {
|
|
||||||
top: 552px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-row > label {
|
.onboarding-r6-row > label {
|
||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
@@ -794,6 +836,13 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
}
|
}
|
||||||
|
.memory-mode-detail .onboarding-r6-schedule-recovery {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.memory-mode-detail .onboarding-r6-schedule-retry {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
.onboarding-r6-shell {
|
.onboarding-r6-shell {
|
||||||
@@ -815,20 +864,7 @@
|
|||||||
}
|
}
|
||||||
.onboarding-r6-discovery > .onboarding-r6-apps {
|
.onboarding-r6-discovery > .onboarding-r6-apps {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
grid-template-rows: auto 30px minmax(0, 1fr) auto;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-heading,
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-area,
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-import {
|
|
||||||
position: static;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-area > .onboarding-r6-list {
|
.onboarding-r6-list-area > .onboarding-r6-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ button.r6-button {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
gap: 0;
|
gap: 8px;
|
||||||
border: 1px solid var(--r6-control-edge);
|
border: 1px solid var(--r6-control-edge);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgb(255 255 255 / 20%);
|
background: rgb(255 255 255 / 20%);
|
||||||
@@ -190,6 +190,10 @@ button.r6-button[data-variant="primary"] {
|
|||||||
0 3px 8px -2px rgb(64 87 102 / 12%);
|
0 3px 8px -2px rgb(64 87 102 / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.r6-button[data-pending-capable="true"] {
|
||||||
|
padding-inline: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
button.r6-button[data-variant="quiet"] {
|
button.r6-button[data-variant="quiet"] {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -204,6 +208,10 @@ button.r6-button[data-variant="quiet"] {
|
|||||||
color: var(--r6-muted);
|
color: var(--r6-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
button.r6-button:hover:not(:disabled) {
|
button.r6-button:hover:not(:disabled) {
|
||||||
background: rgb(231 239 244 / 60%);
|
background: rgb(231 239 244 / 60%);
|
||||||
border-color: #aabcc8;
|
border-color: #aabcc8;
|
||||||
@@ -242,16 +250,26 @@ button.r6-button[data-variant="quiet"]:disabled {
|
|||||||
}
|
}
|
||||||
.r6-button-label {
|
.r6-button-label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.r6-button-spinner {
|
.r6-button-spinner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
margin-top: -6px;
|
||||||
border: 1.5px solid currentColor;
|
border: 1.5px solid currentColor;
|
||||||
border-right-color: transparent;
|
border-right-color: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
visibility: hidden;
|
||||||
animation: r6-spin 1.8s linear infinite;
|
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 {
|
.r6-stepper {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
safe center falls back to the start if the pair would overflow a narrow column.
|
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. */
|
Three or more keep the existing row and scroll horizontally. */
|
||||||
.r6-import-progress[data-count="1"],
|
.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;
|
justify-content: safe center;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,17 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #637786;
|
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 {
|
.r6-memory .memory-pending-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -227,10 +227,141 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
.r6-memory-mode-settings .memory-mode-choice-list {
|
.r6-settings .r6-mode-setting-row {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.r6-mode-control[data-expanded="true"] {
|
||||||
|
border: 1px solid #cbd6de;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfcfd;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 #fff,
|
||||||
|
0 4px 14px rgb(48 71 87 / 5%);
|
||||||
|
}
|
||||||
|
.r6-mode-control[data-expanded="true"] .r6-mode-setting-row {
|
||||||
|
margin-top: 0;
|
||||||
|
border-bottom: 1px solid #dbe4ea;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-mode-control[data-expanded="true"]
|
||||||
|
.r6-mode-option-menu
|
||||||
|
.om-option-menu-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px 9px 0 0;
|
||||||
|
box-shadow: inset 0 1px 0 #fff;
|
||||||
|
}
|
||||||
|
.r6-settings .r6-mode-option-menu {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-mode-option-menu.r6-language-option-menu
|
||||||
|
.om-option-menu-button {
|
||||||
|
height: auto;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 12px 64px 12px 18px;
|
||||||
|
border-color: #cbd6de;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafb;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 90%),
|
||||||
|
0 1px 2px rgb(48 71 87 / 4%);
|
||||||
|
white-space: normal;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease-out,
|
||||||
|
background-color 140ms ease-out,
|
||||||
|
box-shadow 140ms ease-out;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-mode-option-menu.r6-language-option-menu
|
||||||
|
.om-option-menu-button::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 52px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 1px;
|
||||||
|
background: #dbe4ea;
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-mode-option-menu.r6-language-option-menu
|
||||||
|
.om-option-menu-button::after {
|
||||||
|
right: 22px;
|
||||||
|
}
|
||||||
|
.r6-mode-option-menu.r6-language-option-menu
|
||||||
|
.om-option-menu-button:hover:not(:disabled) {
|
||||||
|
border-color: #a8bac5;
|
||||||
|
background: #fbfcfd;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 #fff,
|
||||||
|
0 4px 12px rgb(48 71 87 / 7%);
|
||||||
|
}
|
||||||
|
.r6-settings
|
||||||
|
.r6-mode-option-menu.r6-language-option-menu
|
||||||
|
.om-option-menu-button[aria-expanded="true"] {
|
||||||
|
border-color: #7892a6;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 #fff,
|
||||||
|
0 0 0 3px rgb(85 124 147 / 10%);
|
||||||
|
}
|
||||||
|
.r6-mode-option-menu .om-option-menu-copy,
|
||||||
|
.r6-mode-option-menu-panel .om-option-menu-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.r6-mode-option-menu .om-option-menu-copy > span,
|
||||||
|
.r6-mode-option-menu-panel .om-option-menu-copy > span {
|
||||||
|
color: #304757;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.r6-mode-option-menu .om-option-menu-suffix,
|
||||||
|
.r6-mode-option-menu-panel .om-option-menu-suffix {
|
||||||
|
color: var(--ok, #2f6b57);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.r6-mode-option-menu .om-option-menu-copy > small,
|
||||||
|
.r6-mode-option-menu-panel .om-option-menu-copy > small {
|
||||||
|
color: #526674;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel {
|
||||||
|
padding: 8px;
|
||||||
|
border-color: #cbd6de;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 32px rgb(48 71 87 / 12%);
|
||||||
|
}
|
||||||
|
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||||
|
[role="option"] {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||||
|
[role="option"][aria-selected="true"] {
|
||||||
|
background: #e8eff3;
|
||||||
|
}
|
||||||
|
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||||
|
[role="option"][data-active="true"] {
|
||||||
|
background: #f0f4f6;
|
||||||
|
box-shadow: inset 2px 0 #607b8e;
|
||||||
|
}
|
||||||
|
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||||
|
[role="option"][aria-selected="true"][data-active="true"] {
|
||||||
|
background: #e8eff3;
|
||||||
|
}
|
||||||
.r6-settings-note {
|
.r6-settings-note {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -244,11 +375,18 @@
|
|||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
border-top: 1px solid #d8e2ea;
|
border-top: 1px solid #d8e2ea;
|
||||||
|
animation: r6-evolve-settings-in 180ms ease-out both;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings:focus-visible {
|
.r6-evolve-settings:focus-visible {
|
||||||
outline: 2px solid var(--r6-focus);
|
outline: 2px solid var(--r6-focus);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
@keyframes r6-evolve-settings-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
.r6-evolve-heading {
|
.r6-evolve-heading {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -295,7 +433,7 @@
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid #e1e8ed;
|
border-top: 1px solid #e1e8ed;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-evolve-time label {
|
.r6-settings .r6-evolve-time > div > label {
|
||||||
color: #304757;
|
color: #304757;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -304,20 +442,146 @@
|
|||||||
.r6-evolve-time p {
|
.r6-evolve-time p {
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"] {
|
.r6-time-picker {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
min-height: 44px;
|
height: 44px;
|
||||||
padding: 0 10px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px 0 14px;
|
||||||
border: 1px solid #c6d2da;
|
border: 1px solid #c6d2da;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #304757;
|
color: #304757;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
font:
|
||||||
|
500 14px / 22px "Noto Sans SC",
|
||||||
|
"PingFang SC",
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease-out,
|
||||||
|
background-color 140ms ease-out,
|
||||||
|
box-shadow 140ms ease-out;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"]:focus-visible {
|
.r6-time-picker-trigger:hover:not(:disabled) {
|
||||||
|
border-color: #9eafbb;
|
||||||
|
background: #f8fafb;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger[aria-expanded="true"] {
|
||||||
|
border-color: #7892a6;
|
||||||
|
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger:focus-visible {
|
||||||
outline: 2px solid var(--r6-focus);
|
outline: 2px solid var(--r6-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
.r6-time-picker-trigger:disabled {
|
||||||
|
color: #71808b;
|
||||||
|
background: #edf0f3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.r6-time-picker-icons {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #607888;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger[aria-expanded="true"]
|
||||||
|
.r6-time-picker-icons
|
||||||
|
svg:last-child {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1200;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
width: min(280px, calc(100vw - 32px));
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #c6d2da;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel[data-placement="below"] {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #5e6e7a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 174px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #d5dfe8;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #304757;
|
||||||
|
background: #f8fafb;
|
||||||
|
font:
|
||||||
|
500 14px / 24px "Noto Sans SC",
|
||||||
|
"PingFang SC",
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #b6c3cc transparent;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns select:focus-visible {
|
||||||
|
outline: 2px solid var(--r6-focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns option {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns option:checked {
|
||||||
|
color: #304757;
|
||||||
|
background: #dfe9ef;
|
||||||
|
}
|
||||||
|
.r6-time-picker-separator {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
color: #7892a6;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e1e8ed;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel footer .r6-button {
|
||||||
|
min-width: 68px;
|
||||||
|
}
|
||||||
.r6-state-recovery {
|
.r6-state-recovery {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -549,6 +813,20 @@
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
}
|
}
|
||||||
|
.r6-mode-control[data-expanded="true"] > .r6-settings-note,
|
||||||
|
.r6-mode-control[data-expanded="true"] > .r6-settings-error {
|
||||||
|
margin-right: 18px;
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
|
.r6-mode-control[data-expanded="true"] > .r6-evolve-settings {
|
||||||
|
margin: 18px 18px 0;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.r6-mode-control[data-expanded="true"]
|
||||||
|
> .r6-evolve-settings
|
||||||
|
.r6-evolve-setting--manual {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
.r6-settings .r6-settings-read-status {
|
.r6-settings .r6-settings-read-status {
|
||||||
color: #63707b;
|
color: #63707b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -584,6 +862,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
|
.r6-settings .r6-model-mode-tier {
|
||||||
|
color: var(--ok, #2f6b57);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
.r6-settings .r6-model-mode-status p {
|
.r6-settings .r6-model-mode-status p {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
color: var(--r6-muted);
|
color: var(--r6-muted);
|
||||||
@@ -1059,9 +1342,14 @@
|
|||||||
.r6-evolve-time {
|
.r6-evolve-time {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"] {
|
.r6-time-picker,
|
||||||
|
.r6-time-picker-trigger {
|
||||||
width: min(220px, 100%);
|
width: min(220px, 100%);
|
||||||
}
|
}
|
||||||
|
.r6-time-picker-panel {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
.r6-state-recovery {
|
.r6-state-recovery {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user