Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6713a4343 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
218bcf779b | ||
|
|
7e4ee0d8ea |
No files matched your search
@@ -644,6 +644,9 @@ class SdkRawPage(BaseModel):
|
||||
offset: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
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`
|
||||
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
||||
time_filtered: bool = False
|
||||
@@ -4491,6 +4494,8 @@ def create_app(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
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),
|
||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||
@@ -4516,9 +4521,52 @@ def create_app(
|
||||
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
||||
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)
|
||||
excluded = split_csv(exclude_sources)
|
||||
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:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
query = {
|
||||
@@ -4527,8 +4575,154 @@ def create_app(
|
||||
"include_sources": sources or None,
|
||||
"exclude_sources": excluded 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":
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +28,7 @@ def discovered_off_loop(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||
"""The same walk, for callers that have an event loop to keep free.
|
||||
|
||||
@@ -43,6 +44,7 @@ def discovered_off_loop(
|
||||
suffixes=suffixes,
|
||||
max_targets=max_targets,
|
||||
excluded_directory_names=excluded_directory_names,
|
||||
directory_predicate=directory_predicate,
|
||||
),
|
||||
scope_key=grant.source_id,
|
||||
)
|
||||
@@ -54,6 +56,7 @@ def discover_source_files(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||
|
||||
@@ -71,10 +74,16 @@ def discover_source_files(
|
||||
grant.root,
|
||||
followlinks=False,
|
||||
):
|
||||
current = Path(directory)
|
||||
child_directories[:] = sorted(
|
||||
child
|
||||
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):
|
||||
if Path(filename).suffix.lower() not in suffixes:
|
||||
|
||||
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
|
||||
|
||||
async def _poll_source(self, source_id: str) -> None:
|
||||
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:
|
||||
await self.trigger(source_id)
|
||||
except asyncio.CancelledError:
|
||||
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
|
||||
except Exception as 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:
|
||||
current = self.health(source_id)
|
||||
self._set_health(
|
||||
|
||||
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
|
||||
self.header_gate = header_gate
|
||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||
self._history_listing_cache: SourceListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, SourceListing
|
||||
] | None = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
|
||||
predicate=self.file_predicate,
|
||||
cursors=options.cursors,
|
||||
)
|
||||
self._probe_listing_cache = (
|
||||
dict(options.cursors),
|
||||
options.max_scan_targets,
|
||||
listing,
|
||||
)
|
||||
return bool(listing.changed or listing.failures)
|
||||
|
||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
|
||||
async for event in self._scan_frozen(options):
|
||||
yield event
|
||||
return
|
||||
listing = await asyncio.to_thread(
|
||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
||||
options,
|
||||
listing = (
|
||||
await asyncio.to_thread(self._history_listing, 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:
|
||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
|
||||
self._history_listing_cache = 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":
|
||||
return list_changed_files(
|
||||
self.grant,
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant = grant
|
||||
self.source_version = source_version
|
||||
self._history_listing_cache: _CursorListing | None = None
|
||||
self._probe_listing_cache: tuple[
|
||||
dict[str, SourceCursor], int | None, _CursorListing
|
||||
] | None = None
|
||||
|
||||
@property
|
||||
def descriptor(self) -> SourceDescriptor:
|
||||
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
|
||||
state = await self.install_state()
|
||||
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]:
|
||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||
listing = await asyncio.to_thread(
|
||||
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
|
||||
async for event in self._scan_frozen(options):
|
||||
yield event
|
||||
return
|
||||
listing = await asyncio.to_thread(
|
||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
||||
options,
|
||||
listing = (
|
||||
await asyncio.to_thread(self._history_listing, 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:
|
||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
|
||||
self._history_listing_cache = 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":
|
||||
return _list_cursor_files(
|
||||
self.grant,
|
||||
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".db"}),
|
||||
max_targets=options.max_scan_targets or 100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if isinstance(discovered, SourceFileDiscoveryFailure):
|
||||
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
||||
@@ -477,7 +508,10 @@ def _list_cursor_files(
|
||||
legacy: list[AuthorizedSourceFile] = []
|
||||
failures: list[SourceFileDiscoveryFailure] = []
|
||||
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):
|
||||
failures.append(item)
|
||||
@@ -586,6 +620,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:
|
||||
parts = source_file.relative_path.parts
|
||||
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
||||
@@ -634,6 +687,7 @@ def _select_transcript_files(
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
||||
continue
|
||||
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
)
|
||||
if isinstance(item, AuthorizedSourceFile)
|
||||
and _is_transcript_file(item)
|
||||
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||
"model_probe_operation", default=None
|
||||
)
|
||||
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
|
||||
|
||||
|
||||
class ProbeCode(StrEnum):
|
||||
@@ -113,16 +114,26 @@ class ModelProber:
|
||||
if existing is not None:
|
||||
return existing
|
||||
token = _operation.set((operations, request.operation_id))
|
||||
dispatch_token = _dispatch_started.set(False)
|
||||
try:
|
||||
await self._probe(profile_id)
|
||||
except ProbeOperationConflict:
|
||||
await asyncio.to_thread(
|
||||
operations.fail, request.operation_id, "model_probe_profile_changed"
|
||||
)
|
||||
except Exception:
|
||||
# Unknown dispatch/storage outcomes stay reserved, including across restart.
|
||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||
except Exception as exc:
|
||||
if _dispatch_started.get():
|
||||
# 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:
|
||||
_dispatch_started.reset(dispatch_token)
|
||||
_operation.reset(token)
|
||||
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
||||
|
||||
@@ -317,6 +328,8 @@ class ModelProber:
|
||||
listing_optional: bool = False,
|
||||
) -> ProbeStage:
|
||||
await self._check_operation()
|
||||
if _operation.get() is not None:
|
||||
_dispatch_started.set(True)
|
||||
started = monotonic()
|
||||
try:
|
||||
response = await client.request(
|
||||
|
||||
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
|
||||
selected_source_ids: tuple[str, ...]
|
||||
started_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
|
||||
model_profile_id: str | None
|
||||
|
||||
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
|
||||
|
||||
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
|
||||
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||
|
||||
|
||||
@@ -293,6 +293,11 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
|
||||
|
||||
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
|
||||
live.interval_seconds, 15 * 60
|
||||
)
|
||||
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||
await imports.shutdown()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
|
||||
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):
|
||||
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 (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())
|
||||
|
||||
|
||||
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
|
||||
)
|
||||
invalid_mode = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "ultra"},
|
||||
json={"mode_intent": "future"},
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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:
|
||||
"""A Cursor store blob can be the host's session context, not the user's words.
|
||||
|
||||
@@ -1352,6 +1382,73 @@ 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
|
||||
|
||||
|
||||
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:
|
||||
root = tmp_path / ".cursor"
|
||||
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
||||
|
||||
@@ -214,7 +214,15 @@ class RecordingSdk:
|
||||
)
|
||||
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, [])]
|
||||
if layers:
|
||||
rows = [row for row in rows if row["layer"] in layers]
|
||||
@@ -712,6 +720,75 @@ 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
|
||||
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:
|
||||
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"))
|
||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||
|
||||
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
|
||||
).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 mark = await value.handlers.get(
|
||||
"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),
|
||||
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")?.(
|
||||
{},
|
||||
);
|
||||
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
||||
),
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
|
||||
|
||||
export async function exportDesktopDiagnostics(
|
||||
options: DiagnosticExportOptions,
|
||||
exportOptions: { includeRawLogs?: boolean } = {},
|
||||
) {
|
||||
const build = options.buildNumber ?? options.productVersion;
|
||||
const target = path.join(
|
||||
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
|
||||
dataDir: options.dataDir,
|
||||
target,
|
||||
productVersion: build,
|
||||
includeRawLogs: exportOptions.includeRawLogs === true,
|
||||
...(options.commit === undefined ? {} : { commit: options.commit }),
|
||||
...(options.coreSha256 === undefined
|
||||
? {}
|
||||
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
|
||||
options.ipc.handle(EXPORT, async (event, ...args) => {
|
||||
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
||||
options.requireTrustedWindow(event);
|
||||
return exportDesktopDiagnostics(options);
|
||||
return exportDesktopDiagnostics(options, { includeRawLogs: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
|
||||
});
|
||||
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
||||
render(<HealthPanel imports={imports()} />);
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
||||
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
||||
|
||||
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t("nativeExportLogs")}
|
||||
</R6Button>
|
||||
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
|
||||
</div>
|
||||
|
||||
<section className="health-block" aria-label={t("healthLocal")}>
|
||||
|
||||
@@ -110,12 +110,15 @@ type Props = {
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
requestedOffset?: number;
|
||||
cursorPaging?: boolean;
|
||||
hasMore?: boolean;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
onSource(value: string): void;
|
||||
resetPage(): void;
|
||||
onPage(offset: number): void;
|
||||
onMore?(): void;
|
||||
onRetry(): void;
|
||||
};
|
||||
};
|
||||
@@ -1131,62 +1134,81 @@ export function MemoryMap(props: Props) {
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(library.page.offset / library.page.limit) +
|
||||
1,
|
||||
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>
|
||||
{library.cursorPaging ? (
|
||||
library.hasMore ? (
|
||||
<R6Button
|
||||
disabled={pageTransitioning}
|
||||
onClick={() => library.onMore?.()}
|
||||
>
|
||||
{pageTransitioning ? text("正在读取…") : text("加载更多")}
|
||||
</R6Button>
|
||||
) : (
|
||||
<span>{text("已加载全部")}</span>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(
|
||||
library.page.offset / library.page.limit,
|
||||
) + 1,
|
||||
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>
|
||||
)}
|
||||
{!library && loaded && !filtered.length && (search || groupKey) && (
|
||||
|
||||
@@ -115,9 +115,7 @@ it("confirms the data effects before switching to lite", async () => {
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
@@ -139,7 +137,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
|
||||
expect(outside.inert).toBe(true);
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
screen.getByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
@@ -155,9 +153,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
const modal = screen.getByRole("dialog");
|
||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||
@@ -229,11 +225,11 @@ it("explains model cost, search scope and the next step before switching to ultr
|
||||
|
||||
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.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
||||
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
||||
@@ -279,9 +275,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||
@@ -300,13 +294,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
|
||||
"03:00",
|
||||
);
|
||||
const timePicker = screen.getByRole("button", {
|
||||
name: "执行时间,03:00",
|
||||
});
|
||||
expect(timePicker.tagName).toBe("BUTTON");
|
||||
expect(timePicker.textContent).toContain("03:00");
|
||||
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
@@ -322,7 +434,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
|
||||
await user.click(automatic);
|
||||
|
||||
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
||||
expect(screen.queryByLabelText("执行时间")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -380,14 +492,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -423,7 +533,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
|
||||
@@ -26,95 +26,15 @@ import {
|
||||
issueConfirmedModeTicket,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
} from "./memory-mode-ui";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
|
||||
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。",
|
||||
};
|
||||
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||
|
||||
export function MemoryModeSettings({
|
||||
hasActiveModel,
|
||||
@@ -142,65 +62,9 @@ export function MemoryModeSettings({
|
||||
const [readFailed, setReadFailed] = useState(false);
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const refreshVersion = useRef(0);
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||
const focusEvolveWhenReady = useRef(false);
|
||||
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
||||
const busyRef = 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(() => {
|
||||
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
||||
@@ -332,8 +196,10 @@ export function MemoryModeSettings({
|
||||
setError("");
|
||||
try {
|
||||
setScheduleState(await setEvolveSchedule(update));
|
||||
return true;
|
||||
} catch {
|
||||
setError("自动整合设置未能保存,请重试。");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -426,47 +292,13 @@ export function MemoryModeSettings({
|
||||
aria-labelledby={`${id}-evolve-title`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="r6-evolve-heading">
|
||||
<h3 id={`${id}-evolve-title`}>{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={busy || maintenance}
|
||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
||||
/>
|
||||
{schedule.enabled ? (
|
||||
<div className="r6-evolve-time">
|
||||
<div>
|
||||
<label htmlFor={`${id}-evolve-time`}>
|
||||
{text("执行时间")}
|
||||
</label>
|
||||
<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>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={schedule}
|
||||
headingId={`${id}-evolve-title`}
|
||||
disabled={busy || maintenance}
|
||||
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
|
||||
onTimeChange={(time) => changeSchedule({ time })}
|
||||
/>
|
||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("立即整合一次")}</h3>
|
||||
@@ -496,64 +328,19 @@ export function MemoryModeSettings({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{pendingMode ? (
|
||||
<div className="r6-settings-dialog-layer" role="presentation">
|
||||
<section
|
||||
ref={dialog}
|
||||
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}",
|
||||
text(MODE_COPY[pendingMode].title),
|
||||
)}
|
||||
</h2>
|
||||
<dl className="r6-mode-confirm-effects">
|
||||
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
|
||||
<div key={`${row.label}:${row.content}`}>
|
||||
<dt>{text(row.label)}</dt>
|
||||
<dd>{text(row.content)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{(pendingPlan?.blockers ?? []).map((blocker) => (
|
||||
<p className="r6-settings-error" role="alert" key={blocker}>
|
||||
{text(BLOCKER_COPY[blocker])}
|
||||
</p>
|
||||
))}
|
||||
{error ? (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
) : null}
|
||||
<footer>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setPendingPlan(null);
|
||||
setPendingMode(null);
|
||||
}}
|
||||
>
|
||||
{text("取消")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
disabled={Boolean(pendingPlan?.blockers.length)}
|
||||
onClick={() => void commitMode()}
|
||||
>
|
||||
{text("切换到 {mode}").replace(
|
||||
"{mode}",
|
||||
text(MODE_COPY[pendingMode].title),
|
||||
)}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
{pendingMode && pendingPlan ? (
|
||||
<MemoryModeConfirmDialog
|
||||
mode={pendingMode}
|
||||
plan={pendingPlan}
|
||||
busy={busy}
|
||||
error={error}
|
||||
opener={dialogOpener.current}
|
||||
onCancel={() => {
|
||||
setPendingPlan(null);
|
||||
setPendingMode(null);
|
||||
}}
|
||||
onConfirm={() => void commitMode()}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -320,9 +320,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
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 rawId = `${source ?? "all"}-${offset}`;
|
||||
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@@ -333,14 +333,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: source ?? "cursor",
|
||||
session_id: `session-${rawId}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000 - offset,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
||||
},
|
||||
],
|
||||
total: source ? 1 : 21,
|
||||
total: cursor ? 2 : 1,
|
||||
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();
|
||||
@@ -359,30 +362,43 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
|
||||
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
await user.click(screen.getByRole("button", { name: "加载更多" }));
|
||||
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
|
||||
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
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("option", { name: "Cursor" }));
|
||||
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
|
||||
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("option", { name: "手动添加" }));
|
||||
expect(
|
||||
await screen.findAllByText("原始会话 one-memory-manual-0"),
|
||||
await screen.findAllByText("原始会话 one-memory-manual-first"),
|
||||
).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,6 +123,7 @@ export function MemorySpace({
|
||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||
selectedRawRef.current = selectedRaw;
|
||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||
const rawRecordsScope = useRef("");
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
@@ -209,6 +210,7 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
@@ -226,6 +228,10 @@ export function MemorySpace({
|
||||
evolve_available: pipelineMode === "ultra",
|
||||
}
|
||||
: null;
|
||||
const refreshSdkMap = useCallback(() => {
|
||||
if (lite) setSdkRawCursor(null);
|
||||
bumpSdkMap();
|
||||
}, [bumpSdkMap, lite]);
|
||||
const requestedQuery = sdkLibraryQuery.trim();
|
||||
const sdkPageSettled =
|
||||
sdkLibrary.settledMode === pipelineMode &&
|
||||
@@ -276,7 +282,10 @@ export function MemorySpace({
|
||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
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)
|
||||
next.set(
|
||||
rawRecordId(selectedRawRef.current),
|
||||
@@ -284,6 +293,7 @@ export function MemorySpace({
|
||||
);
|
||||
for (const item of items) next.set(rawRecordId(item), item);
|
||||
rawRecords.current = next;
|
||||
rawRecordsScope.current = windowKey;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
? await Promise.all([
|
||||
@@ -297,8 +307,10 @@ export function MemorySpace({
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: sdkLibraryOffset,
|
||||
offset: 0,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
paging: "cursor",
|
||||
cursor: sdkRawCursor,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
@@ -346,17 +358,64 @@ export function MemorySpace({
|
||||
};
|
||||
// The window expired: the server searched again and answered page 1.
|
||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||
setSdkLibrary({
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
setSdkLibrary((current) => {
|
||||
const currentPage = current.page;
|
||||
const append =
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
currentPage !== null &&
|
||||
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 {
|
||||
} catch (reason) {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
if (
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
apiErrorCode(reason) === "raw_cursor_stale"
|
||||
) {
|
||||
setSdkRawCursor(null);
|
||||
return;
|
||||
}
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
@@ -365,10 +424,13 @@ export function MemorySpace({
|
||||
}
|
||||
};
|
||||
void read(true);
|
||||
const timer = setInterval(() => void read(false), 30_000);
|
||||
const timer =
|
||||
lite && sdkRawCursor !== null
|
||||
? undefined
|
||||
: setInterval(() => void read(false), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
controller?.abort();
|
||||
};
|
||||
}, [
|
||||
@@ -377,6 +439,7 @@ export function MemorySpace({
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
sdkRawCursor,
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
@@ -525,6 +588,7 @@ export function MemorySpace({
|
||||
rawRecords.current.clear();
|
||||
sdkSearchWindow.current = { key: "", id: null };
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
setSdkLibraryQuery("");
|
||||
setSdkGraph(null);
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -804,7 +868,7 @@ export function MemorySpace({
|
||||
);
|
||||
trace.done("succeeded");
|
||||
closeDetail();
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: "memory_action_failed",
|
||||
@@ -833,7 +897,7 @@ export function MemorySpace({
|
||||
trace.done("succeeded");
|
||||
setDetail(sdkDetail(saved));
|
||||
setDraft(sdkEditableText(saved));
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
@@ -898,10 +962,12 @@ export function MemorySpace({
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
? sdkPage.total_exact === false
|
||||
? t("memory:原始对话")
|
||||
: `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
@@ -934,7 +1000,7 @@ export function MemorySpace({
|
||||
onSaved={() => {
|
||||
// A finished addition is new page content: re-read now rather than
|
||||
// leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
@@ -1201,12 +1267,21 @@ export function MemorySpace({
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
cursorPaging: lite && !sdkLibraryQuery.trim(),
|
||||
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
resetPage: () => setSdkLibraryOffset(0),
|
||||
resetPage: () => {
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
},
|
||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||
onRetry: bumpSdkMap,
|
||||
onMore: () => {
|
||||
if (sdkPage?.next_cursor)
|
||||
setSdkRawCursor(sdkPage.next_cursor);
|
||||
},
|
||||
onRetry: refreshSdkMap,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
|
||||
...profile,
|
||||
profile_id: "created",
|
||||
revision: 1,
|
||||
capabilities,
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
endpoint_reachable: false,
|
||||
model_available: false,
|
||||
embedding_available: false,
|
||||
},
|
||||
};
|
||||
profiles = [];
|
||||
create.mockImplementation(async () => {
|
||||
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
|
||||
});
|
||||
probeProfile.mockResolvedValue({
|
||||
profile_id: created.profile_id,
|
||||
profile_revision: created.revision,
|
||||
input_profile_revision: created.revision,
|
||||
profile_revision: 2,
|
||||
stages: [],
|
||||
capabilities,
|
||||
});
|
||||
activate.mockResolvedValue({ ...created, is_active: true });
|
||||
activate.mockResolvedValue({
|
||||
...created,
|
||||
revision: 2,
|
||||
capabilities,
|
||||
is_active: true,
|
||||
});
|
||||
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
|
||||
|
||||
await user.type(screen.getByLabelText("配置名称"), "Current model");
|
||||
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
|
||||
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
|
||||
|
||||
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
|
||||
expect(planActivation).toHaveBeenCalledWith(
|
||||
created.profile_id,
|
||||
created.revision,
|
||||
);
|
||||
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
|
||||
expect(activate).toHaveBeenCalledWith(created.profile_id);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
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 {
|
||||
if (!value) return "未提供接口地址";
|
||||
try {
|
||||
@@ -467,7 +447,11 @@ export function ModelSettings({
|
||||
technicalTouched.current.clear();
|
||||
};
|
||||
const isReady = (profile: ModelProfile) =>
|
||||
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
|
||||
modelProfileReady(
|
||||
profile,
|
||||
models.probeFor(profile),
|
||||
models.probeErrorFor(profile),
|
||||
);
|
||||
|
||||
function editProfile(profile: ModelProfile) {
|
||||
if (ownerLocked) return;
|
||||
@@ -626,7 +610,7 @@ export function ModelSettings({
|
||||
!active ||
|
||||
active.profile_id !== profile.profile_id ||
|
||||
!active.is_active ||
|
||||
!ready(active, result)
|
||||
!modelProfileReady(active, result)
|
||||
) {
|
||||
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
||||
return false;
|
||||
@@ -640,38 +624,15 @@ export function ModelSettings({
|
||||
setSetupPhase("testing");
|
||||
const result = await models.probeProfile(profile.profile_id);
|
||||
if (!alive.current) return false;
|
||||
if (
|
||||
result &&
|
||||
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
|
||||
) {
|
||||
const testedProfile = profileFromProbe(profile, result);
|
||||
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
|
||||
setSetupError(
|
||||
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
|
||||
);
|
||||
setFields(probeFieldErrors(result));
|
||||
return false;
|
||||
}
|
||||
return activateWithPlan(profile, result);
|
||||
return activateWithPlan(testedProfile, result ?? null);
|
||||
}
|
||||
|
||||
async function activateTested(profile: ModelProfile) {
|
||||
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
|
||||
disabled={
|
||||
busy ||
|
||||
!canEnable ||
|
||||
!modelProfileReady(profile, probe, probeError)
|
||||
}
|
||||
onClick={onActivate}
|
||||
>
|
||||
{text("设为当前模型")}
|
||||
|
||||
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
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({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({
|
||||
current: "lite",
|
||||
target: "pro",
|
||||
effective_target: "pro",
|
||||
capabilities: {
|
||||
content_plane: "memory",
|
||||
generation_required: true,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: ["active_model_required"],
|
||||
}),
|
||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -136,6 +156,9 @@ it("moves from import to the actual third step without completing onboarding", a
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
@@ -165,6 +188,9 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -188,6 +214,9 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getModelProfiles: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
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 }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -422,6 +441,9 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Confirmar selección" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||
|
||||
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
|
||||
getImportJobs: vi.fn(),
|
||||
connectSources: 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({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
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 }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
getSourceConnection: vi.fn(),
|
||||
@@ -159,8 +178,23 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
||||
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: [],
|
||||
} as never);
|
||||
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
||||
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
||||
localStorage.clear();
|
||||
@@ -319,6 +353,7 @@ it("empty discovery shows zero and allows explicit entry without a connection re
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -371,6 +406,7 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
|
||||
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
|
||||
@@ -40,6 +40,7 @@ const getMemoryProcessing = vi.fn();
|
||||
const previewSourceDirectory = vi.fn();
|
||||
const saveSourceDirectory = vi.fn();
|
||||
const getSourceImportReceipt = vi.fn();
|
||||
let modelActive = false;
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getHealth: vi.fn(),
|
||||
@@ -52,6 +53,15 @@ vi.mock("./api", () => ({
|
||||
setSourceSync: vi.fn(),
|
||||
changeImportJob: vi.fn(),
|
||||
getImportJob: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
apiErrorCode: vi.fn(() => null),
|
||||
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
|
||||
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
|
||||
@@ -91,6 +101,8 @@ vi.mock("./OnboardingModelController", () => ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
modelActive = true;
|
||||
getModelProfiles.mockResolvedValue([{ is_active: true }]);
|
||||
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
|
||||
onModelActiveConfirmed(
|
||||
generationToken,
|
||||
@@ -100,6 +112,20 @@ vi.mock("./OnboardingModelController", () => ({
|
||||
>
|
||||
模型启用回执已确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
modelActive = true;
|
||||
getModelProfiles.mockResolvedValue([{ is_active: true }]);
|
||||
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
|
||||
onModelActiveConfirmed(
|
||||
generationToken,
|
||||
"8be6d465-4cf2-4c72-93f7-15f3c012bc80",
|
||||
);
|
||||
}}
|
||||
>
|
||||
第二个模型启用回执已确认
|
||||
</button>
|
||||
<button type="button" onClick={onSkip ?? onBack}>
|
||||
跳过模型进入应用
|
||||
</button>
|
||||
@@ -213,13 +239,20 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
|
||||
}
|
||||
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
}
|
||||
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
}
|
||||
async function selectMode(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
await user.click(screen.getByRole("radio", { name }));
|
||||
await user.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
}
|
||||
function choice(name = "Claude Code") {
|
||||
return screen.getByRole<HTMLInputElement>("checkbox", {
|
||||
name: `导入 ${name} 历史`,
|
||||
@@ -253,6 +286,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
|
||||
describe("OnboardingFlow S1.3", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
modelActive = false;
|
||||
resetConfirmedMemoryModeForTests();
|
||||
window.localStorage.clear();
|
||||
window.oneMemory = undefined;
|
||||
@@ -261,15 +295,16 @@ describe("OnboardingFlow S1.3", () => {
|
||||
status: "completed",
|
||||
});
|
||||
setMemoryMode.mockResolvedValue({ mode: "pro" });
|
||||
planMemoryMode.mockResolvedValue({
|
||||
planMemoryMode.mockImplementation(async (target) => ({
|
||||
current: "lite",
|
||||
target: "pro",
|
||||
effective_target: "pro",
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
});
|
||||
blockers:
|
||||
target !== "lite" && !modelActive ? ["active_model_required"] : [],
|
||||
}));
|
||||
putOnboardingProgress.mockImplementation(
|
||||
async (update: Partial<OnboardingState>) => ({
|
||||
...onboarding,
|
||||
@@ -287,6 +322,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
});
|
||||
getModelProfiles.mockResolvedValue([]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -445,6 +481,155 @@ describe("OnboardingFlow S1.3", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts Lite once while the import submission is still in flight", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveImport!: (
|
||||
value: Awaited<
|
||||
ReturnType<ImportSourcesController["connectForOnboarding"]>
|
||||
>,
|
||||
) => void;
|
||||
const controller = imports({
|
||||
connectForOnboarding: vi.fn(
|
||||
() =>
|
||||
new Promise<
|
||||
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
|
||||
>((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const finish = vi.fn();
|
||||
setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
|
||||
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "选择记忆模式" }),
|
||||
);
|
||||
await selectMode(user, /lite/i);
|
||||
const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
|
||||
expect(enter).toHaveProperty("disabled", false);
|
||||
|
||||
await user.click(enter);
|
||||
await user.click(enter);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("lite");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () =>
|
||||
resolveImport({
|
||||
accepted_source_ids: ["claude"],
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
}),
|
||||
);
|
||||
expect(finish).toHaveBeenCalledOnce();
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows distinct saving and completion feedback after one Lite click", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveMode!: (value: { mode: "lite" }) => void;
|
||||
let resolveCompletion!: (value: OnboardingState) => void;
|
||||
setMemoryMode.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveMode = resolve;
|
||||
}),
|
||||
);
|
||||
completeOnboarding.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
}),
|
||||
);
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
|
||||
await act(async () => resolveMode({ mode: "lite" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在完成首次设置…" }),
|
||||
).toHaveProperty("disabled", true),
|
||||
);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
resolveCompletion({
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
});
|
||||
|
||||
it("fences duplicate Pro activation receipts while completion is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveCompletion!: (value: OnboardingState) => void;
|
||||
completeOnboarding.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
}),
|
||||
);
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
const receipt = screen.getByRole("button", {
|
||||
name: "模型启用回执已确认",
|
||||
});
|
||||
fireEvent.click(receipt);
|
||||
fireEvent.click(receipt);
|
||||
|
||||
await waitFor(() => expect(completeOnboarding).toHaveBeenCalledOnce());
|
||||
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("pro");
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
resolveCompletion({
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
});
|
||||
|
||||
it("D47 entering the same-page model step does not finish setup or grant processing", async () => {
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
@@ -462,7 +647,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
expect(getModelProfiles).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not trust loaded metadata or select sources before automatic discovery resolves", async () => {
|
||||
@@ -724,7 +909,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
expect(getModelProfiles).toHaveBeenCalledOnce();
|
||||
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "配置一个模型,记忆才会生成" }),
|
||||
@@ -744,7 +929,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
@@ -767,25 +952,330 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
||||
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
|
||||
"Pro",
|
||||
"pro",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
|
||||
).toBeTruthy();
|
||||
const modeStep = screen
|
||||
.getByRole("navigation", { name: "首次设置进度" })
|
||||
.querySelector('[aria-current="step"]');
|
||||
expect(modeStep?.getAttribute("aria-current")).toBe("step");
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await selectMode(user, /lite/i);
|
||||
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("configures Ultra scheduling before model setup and completes after activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
planMemoryMode.mockImplementation(async (target) => ({
|
||||
current: "pro",
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {
|
||||
content_plane: "memory",
|
||||
generation_required: target !== "lite",
|
||||
graph_enabled: target === "ultra",
|
||||
evolve_available: target === "ultra",
|
||||
},
|
||||
effects: target === "ultra" ? ["existing_memories_added_to_graph"] : [],
|
||||
warnings: [],
|
||||
blockers:
|
||||
target !== "lite" && !modelActive ? ["active_model_required"] : [],
|
||||
}));
|
||||
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /ultra/i }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "选择深度记忆(ultra)?",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole("switch", { name: "每天自动整合" })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "执行时间,03:00" }),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(putOnboardingProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode_intent: "ultra",
|
||||
setup_step: "model",
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("ultra"));
|
||||
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a resumed Ultra model overwrite an unread schedule", async () => {
|
||||
let resolveSchedule!: (value: { enabled: boolean; time: string }) => void;
|
||||
vi.mocked(api.getEvolveSchedule).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSchedule = resolve;
|
||||
}),
|
||||
);
|
||||
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
|
||||
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
time: "05:30",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={{
|
||||
...onboarding,
|
||||
mode_intent: "ultra",
|
||||
setup_step: "model",
|
||||
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
|
||||
}}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toContain("正在读取记忆模式");
|
||||
|
||||
await act(async () =>
|
||||
resolveSchedule({
|
||||
enabled: false,
|
||||
time: "05:30",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("switch", { name: "每天自动整合" })
|
||||
.getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
|
||||
enabled: false,
|
||||
time: "05:30",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Ultra selected when schedule persistence fails and retries", async () => {
|
||||
modelActive = true;
|
||||
getModelProfiles.mockResolvedValue([{ is_active: true }]);
|
||||
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(api.setEvolveSchedule)
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({ enabled: true, time: "03:00" });
|
||||
setMemoryMode.mockResolvedValue({ mode: "ultra" });
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /ultra/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"自动整合设置未能保存",
|
||||
);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
|
||||
).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(api.setEvolveSchedule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a failed Ultra schedule read without losing the selection", async () => {
|
||||
vi.mocked(api.getEvolveSchedule)
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /ultra/i);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"记忆模式状态暂时无法读取",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重试" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "执行时间,04:15" }),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
|
||||
).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a late mode plan after leaving the mode step", async () => {
|
||||
let resolvePlan!: (value: {
|
||||
current: "pro";
|
||||
target: "lite";
|
||||
effective_target: "lite";
|
||||
capabilities: {
|
||||
content_plane: "raw";
|
||||
generation_required: false;
|
||||
graph_enabled: false;
|
||||
evolve_available: false;
|
||||
};
|
||||
effects: [];
|
||||
warnings: [];
|
||||
blockers: [];
|
||||
}) => void;
|
||||
planMemoryMode.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePlan = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await act(async () =>
|
||||
resolvePlan({
|
||||
current: "pro",
|
||||
target: "lite",
|
||||
effective_target: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "对话正在汇入,记忆从这里开始",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists Lite mode before resumable progress and completion", async () => {
|
||||
const order: string[] = [];
|
||||
setMemoryMode.mockImplementationOnce(async () => {
|
||||
order.push("mode");
|
||||
return { mode: "lite" };
|
||||
});
|
||||
putOnboardingProgress.mockImplementationOnce(async (update) => {
|
||||
order.push("progress");
|
||||
return { ...onboarding, ...update };
|
||||
});
|
||||
completeOnboarding.mockImplementationOnce(async () => {
|
||||
order.push("complete");
|
||||
return { ...onboarding, status: "completed" };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
await waitFor(() =>
|
||||
expect(order).toEqual(["mode", "progress", "complete"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
|
||||
let resolveMode!: (value: {
|
||||
mode: "pro";
|
||||
@@ -809,7 +1299,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||
await user.click(lite);
|
||||
await selectMode(user, /lite/i);
|
||||
await act(async () => {
|
||||
resolveMode({
|
||||
mode: "pro",
|
||||
@@ -843,7 +1333,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
await user.click(lite);
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
@@ -862,6 +1352,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
|
||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||
let resolveLite!: (value: { mode: "lite" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
@@ -869,7 +1360,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
resolvePro = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ mode: "lite" });
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveLite = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
@@ -882,7 +1378,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
@@ -890,7 +1386,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -900,10 +1396,119 @@ describe("OnboardingFlow S1.3", () => {
|
||||
"pro",
|
||||
"lite",
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
await act(async () => resolveLite({ mode: "lite" }));
|
||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts a new Pro generation while an older Pro write is pending", async () => {
|
||||
let resolveFirst!: (value: { mode: "pro" }) => void;
|
||||
let resolveSecond!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
modelActive = false;
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolveFirst({ mode: "pro" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => resolveSecond({ mode: "pro" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts a new profile receipt within the same Pro generation", async () => {
|
||||
let resolveFirst!: (value: { mode: "pro" }) => void;
|
||||
let resolveSecond!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
);
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "第二个模型启用回执已确认" }),
|
||||
);
|
||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolveFirst({ mode: "pro" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => resolveSecond({ mode: "pro" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
|
||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||
setMemoryMode.mockImplementationOnce(
|
||||
@@ -923,7 +1528,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
|
||||
await user.click(
|
||||
@@ -972,7 +1577,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||
@@ -1012,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
const returnedTitle = screen.getByRole("heading", {
|
||||
@@ -1042,7 +1647,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
@@ -1061,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
const lateReceipt = screen.getByRole("button", {
|
||||
name: "模型启用回执已确认",
|
||||
@@ -1110,7 +1715,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
|
||||
await user.click(pro);
|
||||
await selectMode(user, /pro/i);
|
||||
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"模式未能保存",
|
||||
@@ -1143,11 +1748,11 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await selectMode(user, /lite/i);
|
||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
expect(getModelProfiles).toHaveBeenCalledOnce();
|
||||
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type {
|
||||
ConnectSourcesResponse,
|
||||
EvolveSchedule,
|
||||
ImportJob,
|
||||
MemoryModePlan,
|
||||
MemoryPipelineMode,
|
||||
OnboardingModeIntent,
|
||||
OnboardingState,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import { Check, X } from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
@@ -18,10 +23,14 @@ import {
|
||||
import {
|
||||
apiErrorCode,
|
||||
completeOnboarding,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getModelProfiles,
|
||||
memoryModePlanUnsupported,
|
||||
patchOnboardingProgress,
|
||||
planMemoryMode,
|
||||
putOnboardingProgress,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { AppMark } from "./app-mark";
|
||||
@@ -41,17 +50,23 @@ import {
|
||||
hostWriteReason,
|
||||
} from "./host-state";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
} from "./memory-mode-ui";
|
||||
import { OnboardingModelController } from "./OnboardingModelController";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
import { isDefiniteRefusal } from "./write-outcome";
|
||||
import "./cold-start.css";
|
||||
|
||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||
type OnboardingMemoryMode = "lite" | "pro";
|
||||
type OnboardingMemoryMode = OnboardingModeIntent;
|
||||
|
||||
// Cold start neither reads nor writes installation intent.
|
||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||
@@ -112,9 +127,8 @@ export function OnboardingFlow({
|
||||
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
||||
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
||||
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
||||
const [busy, setBusy] = useState<
|
||||
"checking" | "submitting" | "leaving" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
|
||||
const [completionBusy, setCompletionBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const [exitDestination, setExitDestination] =
|
||||
@@ -125,7 +139,9 @@ export function OnboardingFlow({
|
||||
null,
|
||||
);
|
||||
const resumedStep =
|
||||
state.mode_intent === "pro" && state.setup_step === "model"
|
||||
state.mode_intent &&
|
||||
state.mode_intent !== "lite" &&
|
||||
state.setup_step === "model"
|
||||
? "model"
|
||||
: state.mode_intent
|
||||
? "mode"
|
||||
@@ -138,8 +154,26 @@ export function OnboardingFlow({
|
||||
);
|
||||
const [modeDestination, setModeDestination] =
|
||||
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 [knownMode, setKnownMode] = useState<MemoryPipelineMode>("pro");
|
||||
const [pendingMode, setPendingMode] = useState<OnboardingMemoryMode | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
|
||||
const [modePlanBusy, setModePlanBusy] = useState(false);
|
||||
const modeDialogOpener = useRef<HTMLElement | null>(null);
|
||||
const modePlanOperation = useRef(0);
|
||||
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState(false);
|
||||
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||
const scheduleReadOperation = useRef(0);
|
||||
const [modeFocusToken, setModeFocusToken] = useState(
|
||||
resumedStep === "mode" ? 1 : 0,
|
||||
);
|
||||
@@ -181,7 +215,10 @@ export function OnboardingFlow({
|
||||
)
|
||||
.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 connectionQueue = useRef(Promise.resolve());
|
||||
const connectionQueueDepth = useRef(0);
|
||||
@@ -368,20 +405,24 @@ export function OnboardingFlow({
|
||||
selected.some((chosen) => chosen.source_id === item.source_id),
|
||||
);
|
||||
const locked =
|
||||
busy !== null || exitDestination !== null || state.status === "importing";
|
||||
busy !== null ||
|
||||
completionBusy ||
|
||||
exitDestination !== null ||
|
||||
state.status === "importing";
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
void getMemoryMode().then(
|
||||
(memoryMode) => {
|
||||
if (alive.current)
|
||||
if (alive.current) {
|
||||
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
||||
setKnownMode(memoryMode.mode);
|
||||
}
|
||||
if (
|
||||
alive.current &&
|
||||
!modeChoiceTouched.current &&
|
||||
memoryMode.explicitly_configured &&
|
||||
memoryMode.configured_mode !== "ultra"
|
||||
memoryMode.explicitly_configured
|
||||
)
|
||||
setModeChoice(memoryMode.configured_mode);
|
||||
},
|
||||
@@ -394,6 +435,32 @@ export function OnboardingFlow({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshUltraSchedule = useCallback(async () => {
|
||||
const operation = ++scheduleReadOperation.current;
|
||||
setScheduleLoaded(false);
|
||||
setScheduleReadFailed(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUltraSchedule();
|
||||
}, [refreshUltraSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
setSources(imports.sources);
|
||||
}, [imports.sources]);
|
||||
@@ -456,7 +523,7 @@ export function OnboardingFlow({
|
||||
locationCheck !== "ready" ||
|
||||
locked ||
|
||||
reviewingImport ||
|
||||
flight.current
|
||||
sourceFlight.current
|
||||
)
|
||||
return;
|
||||
if (!scopeMatches(selected, sources)) return;
|
||||
@@ -483,17 +550,24 @@ export function OnboardingFlow({
|
||||
[onFinish],
|
||||
);
|
||||
|
||||
const enterModelStep = useCallback(() => {
|
||||
intentRef.current = "pro";
|
||||
setupStepRef.current = "model";
|
||||
const token = ++modelGeneration.current;
|
||||
setModelGenerationToken(token);
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
}, []);
|
||||
const enterModelStep = useCallback(
|
||||
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
|
||||
intentRef.current = mode;
|
||||
setupStepRef.current = "model";
|
||||
const token = ++modelGeneration.current;
|
||||
setModelGenerationToken(token);
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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
|
||||
// before React renders the replacement step.
|
||||
modelGeneration.current += 1;
|
||||
@@ -502,7 +576,10 @@ export function OnboardingFlow({
|
||||
setModeChoice(nextIntent);
|
||||
setSetupStep(nextStep);
|
||||
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
||||
setModeSaving(false);
|
||||
if (resetModePhase) {
|
||||
modeOperation.current += 1;
|
||||
setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -611,12 +688,15 @@ export function OnboardingFlow({
|
||||
|
||||
const leave = useCallback(
|
||||
async (destination: OnboardingDestination) => {
|
||||
if (flight.current || finished.current) return;
|
||||
flight.current = true;
|
||||
setBusy("leaving");
|
||||
await persistFinish(destination);
|
||||
flight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
if (completionFlight.current || finished.current) return;
|
||||
completionFlight.current = true;
|
||||
setCompletionBusy(true);
|
||||
try {
|
||||
await persistFinish(destination);
|
||||
} finally {
|
||||
completionFlight.current = false;
|
||||
if (alive.current) setCompletionBusy(false);
|
||||
}
|
||||
},
|
||||
[persistFinish],
|
||||
);
|
||||
@@ -624,7 +704,12 @@ export function OnboardingFlow({
|
||||
const requestModeChoice = useCallback(
|
||||
(destination: OnboardingDestination) => {
|
||||
if (finished.current) return;
|
||||
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
if (setupStepRef.current === "model")
|
||||
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
|
||||
else {
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "mode";
|
||||
@@ -639,30 +724,123 @@ export function OnboardingFlow({
|
||||
[leaveModelStep, modeChoice],
|
||||
);
|
||||
|
||||
const requestModeSelection = useCallback(
|
||||
async (mode: OnboardingMemoryMode, opener: HTMLElement) => {
|
||||
if (modePlanBusy || modeSaving || completionBusy) return;
|
||||
const operation = ++modePlanOperation.current;
|
||||
setModePlanBusy(true);
|
||||
setModeError(null);
|
||||
try {
|
||||
let plan: MemoryModePlan;
|
||||
try {
|
||||
plan = await planMemoryMode(mode);
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) throw reason;
|
||||
const profiles = await getModelProfiles();
|
||||
plan = legacyMemoryModePlan(knownMode, mode, {
|
||||
hasActiveModel: profiles.some((profile) => profile.is_active),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!alive.current ||
|
||||
modePlanOperation.current !== operation ||
|
||||
setupStepRef.current !== "mode"
|
||||
)
|
||||
return;
|
||||
modeDialogOpener.current = opener;
|
||||
setPendingMode(mode);
|
||||
setPendingPlan(plan);
|
||||
} catch {
|
||||
if (
|
||||
alive.current &&
|
||||
modePlanOperation.current === operation &&
|
||||
setupStepRef.current === "mode"
|
||||
)
|
||||
setModeError("模式切换影响暂时无法读取,请重试。");
|
||||
} finally {
|
||||
if (alive.current && modePlanOperation.current === operation)
|
||||
setModePlanBusy(false);
|
||||
}
|
||||
},
|
||||
[completionBusy, knownMode, modePlanBusy, modeSaving],
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
if (
|
||||
!modeChoice ||
|
||||
modeSaving ||
|
||||
finished.current ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
)
|
||||
return;
|
||||
setModeSaving(true);
|
||||
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsModeOperation = () =>
|
||||
alive.current && !finished.current && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
if (modeChoice === "pro") {
|
||||
intentRef.current = "pro";
|
||||
if (modeChoice !== "lite") {
|
||||
const target = modeChoice;
|
||||
const [plan, profiles] = await Promise.all([
|
||||
planMemoryMode(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";
|
||||
const selectionStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
intentRef.current === "pro" &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === target &&
|
||||
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(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "model",
|
||||
model_profile_id: modelProfileId.current,
|
||||
}),
|
||||
@@ -670,15 +848,18 @@ export function OnboardingFlow({
|
||||
);
|
||||
if (!selectionStillCurrent()) return;
|
||||
modelProfileId.current = savedProgress.model_profile_id;
|
||||
enterModelStep();
|
||||
enterModelStep(target);
|
||||
return;
|
||||
}
|
||||
leaveModelStep("lite", "mode");
|
||||
leaveModelStep("lite", "mode", false);
|
||||
const liteStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === "lite" &&
|
||||
setupStepRef.current === "mode";
|
||||
const changed = await queueModeWrite("lite", liteStillCurrent);
|
||||
if (!liteStillCurrent()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
setKnownMode(changed.mode);
|
||||
await queueProgressWrite(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
@@ -688,19 +869,17 @@ export function OnboardingFlow({
|
||||
}),
|
||||
liteStillCurrent,
|
||||
);
|
||||
if (!alive.current) return;
|
||||
const changed = await queueModeWrite("lite");
|
||||
if (!alive.current) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
if (!liteStillCurrent()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current) {
|
||||
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
|
||||
setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||
}
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (ownsModeOperation()) setModePhase("idle");
|
||||
}
|
||||
}, [
|
||||
enterModelStep,
|
||||
@@ -709,9 +888,13 @@ export function OnboardingFlow({
|
||||
modeChoice,
|
||||
modeDestination,
|
||||
modeSaving,
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
scheduleReadFailed,
|
||||
ultraSchedule,
|
||||
]);
|
||||
|
||||
const persistModelProfile = useCallback(
|
||||
@@ -729,58 +912,100 @@ export function OnboardingFlow({
|
||||
[queueProgressWrite],
|
||||
);
|
||||
|
||||
const confirmProMode = useCallback(
|
||||
const confirmModelMode = useCallback(
|
||||
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 = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
modelGeneration.current === token &&
|
||||
intentRef.current === "pro" &&
|
||||
intentRef.current === target &&
|
||||
setupStepRef.current === "model" &&
|
||||
modelProfileId.current === ownerProfileId;
|
||||
if (!ownsModelStep()) return;
|
||||
setModeSaving(true);
|
||||
if (
|
||||
!ownsModelStep() ||
|
||||
proConfirmationOwner.current === confirmationOwner ||
|
||||
completionBusy
|
||||
)
|
||||
return;
|
||||
proConfirmationOwner.current = confirmationOwner;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsConfirmation = () =>
|
||||
ownsModelStep() && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
const plan = await planMemoryMode("pro");
|
||||
if (!ownsModelStep()) return;
|
||||
const plan = await planMemoryMode(target);
|
||||
if (!ownsConfirmation()) return;
|
||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||
const changed = await queueModeWrite("pro", ownsModelStep);
|
||||
if (!ownsModelStep()) return;
|
||||
const changed = await queueModeWrite(target, ownsConfirmation);
|
||||
if (!ownsConfirmation()) return;
|
||||
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(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "model",
|
||||
model_profile_id: ownerProfileId,
|
||||
}),
|
||||
ownsModelStep,
|
||||
ownsConfirmation,
|
||||
);
|
||||
if (!ownsModelStep()) return;
|
||||
await persistFinish(modeDestination);
|
||||
if (!ownsConfirmation()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current && !isGenerationInvalid(reason))
|
||||
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
|
||||
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
|
||||
setModeError("模式切换未能确认,仍显示上次确认的模式。");
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (proConfirmationOwner.current === confirmationOwner)
|
||||
proConfirmationOwner.current = null;
|
||||
if (ownsConfirmation()) setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
leave,
|
||||
leaveModelStep,
|
||||
modeDestination,
|
||||
persistFinish,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
scheduleReadFailed,
|
||||
ultraSchedule,
|
||||
],
|
||||
);
|
||||
|
||||
async function checkLocations() {
|
||||
if (flight.current || locked) return;
|
||||
flight.current = true;
|
||||
if (sourceFlight.current || locked) return;
|
||||
sourceFlight.current = true;
|
||||
scopeVersion.current += 1;
|
||||
setBusy("checking");
|
||||
setLocationCheck("checking");
|
||||
@@ -805,7 +1030,7 @@ export function OnboardingFlow({
|
||||
setLocationCheck("failed");
|
||||
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
||||
} finally {
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
@@ -813,7 +1038,7 @@ export function OnboardingFlow({
|
||||
|
||||
function toggleSource(source: SourceResponse, checked: boolean) {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!isAppSource(source) ||
|
||||
@@ -836,7 +1061,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
function selectAllSources() {
|
||||
if (flight.current || locked || locationCheck !== "ready") return;
|
||||
if (sourceFlight.current || locked || locationCheck !== "ready") return;
|
||||
scopeVersion.current += 1;
|
||||
setError(null);
|
||||
setItemErrors({});
|
||||
@@ -849,7 +1074,7 @@ export function OnboardingFlow({
|
||||
|
||||
async function startImport() {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!selected.length
|
||||
@@ -863,7 +1088,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
async function submitImport(approved: SourceResponse[]) {
|
||||
flight.current = true;
|
||||
sourceFlight.current = true;
|
||||
if (window.oneMemory) setStartingImport(approved);
|
||||
const version = scopeVersion.current;
|
||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||
@@ -917,7 +1142,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
} finally {
|
||||
connectSent.current = false;
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
@@ -987,10 +1212,10 @@ export function OnboardingFlow({
|
||||
jobs={reviewJobs}
|
||||
failedSources={accepted?.failed_sources}
|
||||
sourceSnapshot={imports.sourceSnapshot}
|
||||
busy={busy === "leaving"}
|
||||
busy={completionBusy}
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
if (busy === "leaving") return;
|
||||
if (completionBusy) return;
|
||||
if (completionFailed) {
|
||||
void leave(destination);
|
||||
return;
|
||||
@@ -1022,106 +1247,166 @@ export function OnboardingFlow({
|
||||
/>
|
||||
</div>
|
||||
{setupStep === "mode" && (
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
onClick={() => {
|
||||
setModeError(null);
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "review";
|
||||
setSetupStep("review");
|
||||
void queueProgressWrite(() =>
|
||||
patchOnboardingProgress({
|
||||
mode_intent: modeChoice,
|
||||
setup_step: "review",
|
||||
}),
|
||||
).catch((reason) => {
|
||||
if (!isCompletedProgressConflict(reason) && alive.current)
|
||||
setModeError(
|
||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modeSaving
|
||||
? "正在保存…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
<>
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || completionBusy}
|
||||
onClick={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
setModeError(null);
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "review";
|
||||
setSetupStep("review");
|
||||
void queueProgressWrite(() =>
|
||||
patchOnboardingProgress({
|
||||
mode_intent: modeChoice,
|
||||
setup_step: "review",
|
||||
}),
|
||||
).catch((reason) => {
|
||||
if (
|
||||
!isCompletedProgressConflict(reason) &&
|
||||
alive.current
|
||||
)
|
||||
setModeError(
|
||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving || modePlanBusy}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
completionBusy ||
|
||||
modePlanBusy ||
|
||||
(modeChoice === "ultra" && !scheduleLoaded)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: modeChoice === "ultra"
|
||||
? "继续"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<p id="onboarding-memory-mode-title">
|
||||
{text("之后可在设置中更改。")}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
aria-label={text("记忆模式")}
|
||||
modes={[
|
||||
{
|
||||
mode: "lite",
|
||||
title: "Lite",
|
||||
description: text(
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: "pro",
|
||||
title: "Pro",
|
||||
description: text(
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。",
|
||||
),
|
||||
},
|
||||
]}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
||||
onChange={(mode) => {
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<p id="onboarding-memory-mode-title">
|
||||
{text("之后可在设置中更改。")}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
aria-label={text("记忆模式")}
|
||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
mode,
|
||||
title: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
}))}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || completionBusy || modePlanBusy}
|
||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
||||
onChange={(mode, control) =>
|
||||
void requestModeSelection(
|
||||
mode as OnboardingMemoryMode,
|
||||
control,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{modeChoice === "ultra" && scheduleLoaded ? (
|
||||
<section
|
||||
className="r6-evolve-settings onboarding-r6-evolve-settings"
|
||||
aria-label={text("记忆整合(Evolve)")}
|
||||
>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={ultraSchedule}
|
||||
disabled={modeSaving || !scheduleLoaded}
|
||||
onEnabledChange={(enabled) =>
|
||||
setUltraSchedule((current) => ({
|
||||
...current,
|
||||
enabled,
|
||||
}))
|
||||
}
|
||||
onTimeChange={async (time) => {
|
||||
setUltraSchedule((current) => ({ ...current, time }));
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{modeChoice === "ultra" && scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p role="alert">
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
{pendingMode && pendingPlan ? (
|
||||
<MemoryModeConfirmDialog
|
||||
mode={pendingMode}
|
||||
plan={pendingPlan}
|
||||
busy={modePlanBusy}
|
||||
opener={modeDialogOpener.current}
|
||||
allowedBlockers={["active_model_required"]}
|
||||
onCancel={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
modePlanOperation.current += 1;
|
||||
setModePlanBusy(false);
|
||||
modeChoiceTouched.current = true;
|
||||
setModeChoice(mode as OnboardingMemoryMode);
|
||||
intentRef.current = pendingMode;
|
||||
setModeChoice(pendingMode);
|
||||
setModeError(null);
|
||||
setPendingMode(null);
|
||||
setPendingPlan(null);
|
||||
}}
|
||||
/>
|
||||
<p className="onboarding-r6-mode-ultra-note">
|
||||
{text("Ultra 可在完成首次设置后到设置页开启。")}
|
||||
</p>
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{visitedModel && (
|
||||
<div hidden={setupStep !== "model"}>
|
||||
@@ -1129,7 +1414,8 @@ export function OnboardingFlow({
|
||||
generationToken={modelGenerationToken}
|
||||
isGenerationCurrent={(token) =>
|
||||
modelGeneration.current === token &&
|
||||
intentRef.current === "pro" &&
|
||||
intentRef.current !== "lite" &&
|
||||
intentRef.current !== null &&
|
||||
setupStepRef.current === "model"
|
||||
}
|
||||
initialProfileId={modelProfileId.current}
|
||||
@@ -1142,7 +1428,7 @@ export function OnboardingFlow({
|
||||
void persistModelProfile(profileId)
|
||||
}
|
||||
onModelActiveConfirmed={(token, ownerProfileId) =>
|
||||
void confirmProMode(token, ownerProfileId)
|
||||
void confirmModelMode(token, ownerProfileId)
|
||||
}
|
||||
sourceNames={sourceIds.map(
|
||||
(id) =>
|
||||
@@ -1157,10 +1443,11 @@ export function OnboardingFlow({
|
||||
)}
|
||||
onBack={() => {
|
||||
setModeError(null);
|
||||
leaveModelStep("pro", "mode");
|
||||
const target = intentRef.current === "ultra" ? "ultra" : "pro";
|
||||
leaveModelStep(target, "mode");
|
||||
void queueProgressWrite(() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "mode",
|
||||
model_profile_id: modelProfileId.current,
|
||||
}),
|
||||
@@ -1188,7 +1475,7 @@ export function OnboardingFlow({
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => finishOnce(exitDestination)}
|
||||
>
|
||||
{text(
|
||||
@@ -1197,7 +1484,7 @@ export function OnboardingFlow({
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => void leave(exitDestination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
|
||||
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
||||
max_concurrency: 4,
|
||||
context_window: null,
|
||||
has_api_key: true,
|
||||
credential_state: "available" as
|
||||
| "available"
|
||||
| "missing"
|
||||
| "unavailable",
|
||||
is_active: true,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
structured_output: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
pendingProbe: null as null | {
|
||||
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
||||
supported: false as boolean | null,
|
||||
planActivation: vi.fn(),
|
||||
startPlannedSwitch: vi.fn(),
|
||||
shouldUseLegacyActivation: () => true,
|
||||
shouldUseLegacyActivation: (() => true) as () => boolean,
|
||||
clearPlan: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
},
|
||||
@@ -123,6 +133,7 @@ beforeEach(() => {
|
||||
models.creationMissing = null;
|
||||
embedding.current = null;
|
||||
embedding.supported = false;
|
||||
embedding.shouldUseLegacyActivation = () => true;
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
models.profiles = [{ ...activeProfile, is_active: false }];
|
||||
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 () => {
|
||||
const inactive = { ...activeProfile, is_active: false };
|
||||
const inactive = { ...activeProfile, revision: 4, is_active: false };
|
||||
models.profiles = [inactive];
|
||||
models.pendingProbe = {
|
||||
operationId: "probe-operation",
|
||||
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
||||
};
|
||||
models.probeProfile.mockResolvedValueOnce({
|
||||
profile_id: "active-profile",
|
||||
profile_revision: 3,
|
||||
input_profile_revision: 3,
|
||||
profile_revision: 4,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
||||
await waitFor(() =>
|
||||
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||
);
|
||||
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||
expect(models.update).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 () => {
|
||||
embedding.supported = true;
|
||||
embedding.current = { writes_paused: true };
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type {
|
||||
ModelProbeResult,
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
ModelProfilePatch,
|
||||
} from "@one-memory/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getModelProfiles } from "./api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||
import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||
import {
|
||||
type OnboardingModelDraft,
|
||||
OnboardingModelStep,
|
||||
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
|
||||
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({
|
||||
sourceNames,
|
||||
importedConversationCount = 0,
|
||||
@@ -112,6 +94,17 @@ export function OnboardingModelController({
|
||||
>(),
|
||||
);
|
||||
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(() => {
|
||||
const profile = initialProfileId
|
||||
@@ -152,21 +145,19 @@ export function OnboardingModelController({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!saved?.is_active ||
|
||||
!isGenerationCurrent(generationToken) ||
|
||||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
||||
embeddingSwitch.current?.writes_paused
|
||||
)
|
||||
return;
|
||||
const key = `${generationToken}:${saved.profile_id}`;
|
||||
if (autoConfirmed.current === key) return;
|
||||
autoConfirmed.current = key;
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
if (confirmReadyProfile(saved, generationToken))
|
||||
autoConfirmed.current = key;
|
||||
}, [
|
||||
embeddingSwitch.current,
|
||||
embeddingSwitch.supported,
|
||||
confirmReadyProfile,
|
||||
generationToken,
|
||||
isGenerationCurrent,
|
||||
onModelActiveConfirmed,
|
||||
saved,
|
||||
]);
|
||||
|
||||
@@ -182,8 +173,7 @@ export function OnboardingModelController({
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
if (saved && isGenerationCurrent(generationToken))
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
if (saved) confirmReadyProfile(saved, generationToken);
|
||||
} else if (finished.state === "rolled_back") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
@@ -192,9 +182,8 @@ export function OnboardingModelController({
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
confirmReadyProfile,
|
||||
generationToken,
|
||||
isGenerationCurrent,
|
||||
onModelActiveConfirmed,
|
||||
saved,
|
||||
switchPendingJobId,
|
||||
]);
|
||||
@@ -338,11 +327,10 @@ export function OnboardingModelController({
|
||||
setPhase("testing");
|
||||
const result = await models.probeProfile(profile.profile_id);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const current = (await getModelProfiles()).find(
|
||||
(item) => item.profile_id === profile.profile_id,
|
||||
const current = profileFromProbe(profile, result);
|
||||
const passed = Boolean(
|
||||
current && modelProfileReady(current, result ?? null),
|
||||
);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const passed = probeMatchesProfile(profile, current, result);
|
||||
if (current) setSaved(current);
|
||||
setProbe({
|
||||
state: passed ? "passed" : "failed",
|
||||
@@ -378,11 +366,10 @@ export function OnboardingModelController({
|
||||
try {
|
||||
const result = await models.probeProfile(saved.profile_id);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const current = (await getModelProfiles()).find(
|
||||
(item) => item.profile_id === saved.profile_id,
|
||||
const current = profileFromProbe(saved, result);
|
||||
const passed = Boolean(
|
||||
current && modelProfileReady(current, result ?? null),
|
||||
);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const passed = probeMatchesProfile(saved, current, result);
|
||||
if (current) setSaved(current);
|
||||
setProbe({
|
||||
state: passed ? "passed" : "failed",
|
||||
@@ -408,8 +395,7 @@ export function OnboardingModelController({
|
||||
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
||||
return;
|
||||
}
|
||||
if (isGenerationCurrent(generationToken))
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
confirmReadyProfile(saved, generationToken);
|
||||
return;
|
||||
}
|
||||
const owner = generationToken;
|
||||
@@ -433,9 +419,15 @@ export function OnboardingModelController({
|
||||
}
|
||||
const active = await models.activate(saved.profile_id);
|
||||
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");
|
||||
onModelActiveConfirmed?.(owner, active.profile_id);
|
||||
confirmReadyProfile(active, owner);
|
||||
} catch {
|
||||
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
||||
setError("模型未能确认启用,请在设置中核对。");
|
||||
|
||||
@@ -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: "帮助与诊断" }));
|
||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
||||
expect(screen.getByText("1.0.4")).toBeTruthy();
|
||||
expect(screen.getByText("8")).toBeTruthy();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ it("lists raw conversations for the selected application", async () => {
|
||||
offset: 20,
|
||||
limit: 20,
|
||||
unit: "turn",
|
||||
paging: undefined,
|
||||
cursor: undefined,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -987,6 +987,8 @@ export async function getSdkRaw({
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
unit = "turn",
|
||||
paging,
|
||||
cursor,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
@@ -994,10 +996,20 @@ export async function getSdkRaw({
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
unit?: "raw" | "turn";
|
||||
paging?: "offset" | "cursor";
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawPage> {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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 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}",
|
||||
text(MODE_COPY[mode].title),
|
||||
)}
|
||||
</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
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,11 @@
|
||||
background: transparent;
|
||||
color: #4e6478;
|
||||
}
|
||||
.model-settings .generation-technical-toggle:hover:not(:disabled) {
|
||||
border-color: transparent;
|
||||
background: #e9f0f4;
|
||||
color: #304757;
|
||||
}
|
||||
.model-settings .generation-technical-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -719,8 +719,8 @@
|
||||
color: #416e5c;
|
||||
font-size: 13px;
|
||||
}
|
||||
.generation-model-error {
|
||||
color: #a24646 !important;
|
||||
.onboarding-model-r6 .generation-model-error {
|
||||
color: #a24646;
|
||||
}
|
||||
.onboarding-model-recovery-actions {
|
||||
margin-top: 12px;
|
||||
@@ -728,6 +728,19 @@
|
||||
.onboarding-model-feedback {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-r6-foot {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 20px 32px;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions {
|
||||
flex-wrap: nowrap;
|
||||
margin-left: 0;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
|
||||
min-width: 132px;
|
||||
}
|
||||
@media (min-width: 761px) {
|
||||
.onboarding-model-r6 .onboarding-r6-body {
|
||||
overflow-y: auto;
|
||||
@@ -751,6 +764,26 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.onboarding-model-r6 .onboarding-r6-foot {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
|
||||
.onboarding-model-r6
|
||||
.onboarding-model-footer-actions
|
||||
.r6-button[data-variant="primary"] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.generation-technical-toggle,
|
||||
.generation-model-field :is(input, select) {
|
||||
|
||||
@@ -612,6 +612,44 @@
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.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-schedule-recovery {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
color: #944242;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.onboarding-r6-schedule-recovery p {
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-mode-error {
|
||||
min-height: 20px;
|
||||
margin: -6px 0 0;
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
}
|
||||
.r6-settings .r6-evolve-time label {
|
||||
.r6-settings .r6-evolve-time > div > label {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -304,20 +304,146 @@
|
||||
.r6-evolve-time p {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
.r6-time-picker {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
}
|
||||
.r6-time-picker-trigger {
|
||||
box-sizing: border-box;
|
||||
width: 180px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px 0 14px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
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-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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1059,9 +1185,14 @@
|
||||
.r6-evolve-time {
|
||||
gap: 8px;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
.r6-time-picker,
|
||||
.r6-time-picker-trigger {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
.r6-time-picker-panel {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
.r6-state-recovery {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -72,6 +72,11 @@ function pageFor(
|
||||
total: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
cursor?: {
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
},
|
||||
): MemoryLibraryPage {
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
const sourceTime: Record<
|
||||
@@ -97,6 +102,7 @@ function pageFor(
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
...cursor,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
search_window: null,
|
||||
@@ -107,7 +113,11 @@ function pageFor(
|
||||
}
|
||||
|
||||
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
|
||||
return pageFor(page.items, page.total, page.offset, page.limit);
|
||||
return pageFor(page.items, page.total, page.offset, page.limit, {
|
||||
next_cursor: page.next_cursor,
|
||||
has_more: page.has_more,
|
||||
total_exact: page.total_exact,
|
||||
});
|
||||
}
|
||||
|
||||
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
|
||||
|
||||
@@ -318,22 +318,38 @@ it("records a failed test only for the attempted revision and rejects a mismatch
|
||||
expect(result.current.profiles[0].capabilities).toEqual(profile.capabilities);
|
||||
});
|
||||
|
||||
it("shows a probe-induced new revision only after the matching profile refresh", async () => {
|
||||
it("commits a probe-induced revision before the follow-up refresh returns", async () => {
|
||||
vi.mocked(api.getModelProfiles).mockResolvedValueOnce([profile]);
|
||||
const { result } = renderHook(useModelProfiles);
|
||||
await waitFor(() => expect(result.current.profiles).toHaveLength(1));
|
||||
const updated = { ...profile, revision: 2, embedding_dimensions: 512 };
|
||||
const updated = {
|
||||
...profile,
|
||||
revision: 2,
|
||||
embedding_dimensions: 512,
|
||||
capabilities: {
|
||||
...profile.capabilities,
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
};
|
||||
const receipt = {
|
||||
...probeResult(updated),
|
||||
input_profile_revision: profile.revision,
|
||||
};
|
||||
const delayed = deferred<ModelProfile[]>();
|
||||
vi.mocked(api.getModelProfiles).mockReturnValueOnce(delayed.promise);
|
||||
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(probeResult(updated));
|
||||
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(receipt);
|
||||
await act(async () => {
|
||||
await result.current.probeProfile(profile.profile_id);
|
||||
});
|
||||
expect(result.current.probeFor(profile)).toBeNull();
|
||||
expect(result.current.profiles[0]).toMatchObject({
|
||||
revision: 2,
|
||||
capabilities: receipt.capabilities,
|
||||
});
|
||||
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
|
||||
await act(async () => {
|
||||
delayed.resolve([updated]);
|
||||
});
|
||||
expect(result.current.probeFor(result.current.profiles[0])).toEqual(
|
||||
probeResult(updated),
|
||||
);
|
||||
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
updateModelProfile,
|
||||
} from "./api";
|
||||
import { modelErrorText } from "./model-error-text";
|
||||
import { profileFromProbe } from "./model-profile-readiness";
|
||||
import {
|
||||
clearPendingModelCreation,
|
||||
readPendingModelCreation,
|
||||
@@ -314,12 +315,7 @@ export function useModelProfiles() {
|
||||
})]: result,
|
||||
}));
|
||||
setProfiles((previous) =>
|
||||
previous.map((item) =>
|
||||
item.profile_id === result.profile_id &&
|
||||
item.revision === result.profile_revision
|
||||
? { ...item, capabilities: result.capabilities }
|
||||
: item,
|
||||
),
|
||||
previous.map((item) => profileFromProbe(item, result) ?? item),
|
||||
);
|
||||
},
|
||||
(reason) =>
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
||||
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||
|
||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
|
||||
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
|
||||
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
|
||||
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
|
||||
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
|
||||
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
|
||||
|
||||
## 1. Log level 设什么
|
||||
|
||||
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
|
||||
|
||||
## 2. 日志在哪里
|
||||
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
|
||||
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
|
||||
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
|
||||
Core 日志默认位于:
|
||||
|
||||
```text
|
||||
|
||||
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
|
||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
||||
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
||||
|
||||
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
||||
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
|
||||
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
|
||||
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
|
||||
|
||||
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
|
||||
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
|
||||
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
|
||||
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
|
||||
|
||||
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
||||
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
|
||||
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
|
||||
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
|
||||
再 chmod 失败留下伪成功文件的窗口。
|
||||
|
||||
## 覆盖与节流
|
||||
|
||||
|
||||
@@ -412,16 +412,18 @@ mode. A change rebuilds the SDK handles the same way. Contract:
|
||||
|
||||
The App's product rules are:
|
||||
|
||||
- A new installation explicitly chooses lite or pro. Choosing lite persists
|
||||
the mode and completes onboarding without a generation model. Choosing pro
|
||||
opens model setup and completes onboarding only after activation succeeds.
|
||||
- A new installation explicitly chooses lite, pro or ultra. Choosing lite
|
||||
persists the mode and completes onboarding without a generation model.
|
||||
Choosing pro or ultra opens model setup when no model is active and completes
|
||||
onboarding only after activation succeeds.
|
||||
- Lite always ingests authorized imported and live conversations into L1 raw,
|
||||
even when model sedimentation is paused. That pause controls generation-model
|
||||
work in pro/ultra; it never turns raw conversation storage off.
|
||||
- Existing installations keep the default pro behaviour; an absent
|
||||
`memory_mode` is never inferred from model availability.
|
||||
- Ultra is enabled only from Settings. Its nightly schedule is on by default
|
||||
at 03:00 local time, and Settings also exposes a manual evolve action.
|
||||
- Ultra's nightly schedule is configured during first-run selection or later
|
||||
from Settings. It is on by default at 03:00 local time. Settings also exposes
|
||||
a manual evolve action; onboarding does not run evolve immediately.
|
||||
- The memory page shows raw conversations in lite and advanced memories in
|
||||
pro/ultra. Higher-mode memories remain stored while lite is active but are
|
||||
neither shown nor served by lite search.
|
||||
|
||||
@@ -259,7 +259,7 @@ export interface ConnectSourcesResponse {
|
||||
request_id?: string | null;
|
||||
}
|
||||
|
||||
export type OnboardingModeIntent = "lite" | "pro";
|
||||
export type OnboardingModeIntent = "lite" | "pro" | "ultra";
|
||||
export type OnboardingSetupStep = "review" | "mode" | "model";
|
||||
|
||||
export interface OnboardingProgressUpdate {
|
||||
@@ -510,6 +510,10 @@ export interface MemoryLibraryPage {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
/** Cursor paging: opaque continuation for a page whose exact total is not read. */
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
|
||||
source_ids_by_memory?: Record<string, string[]>;
|
||||
/** When each returned memory's cited conversation happened (D96). `import`
|
||||
@@ -709,6 +713,9 @@ export interface SdkRawPage {
|
||||
limit: number;
|
||||
offset: number;
|
||||
unit?: "raw" | "turn";
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean;
|
||||
total_exact?: boolean;
|
||||
/** A time range was applied to this page in Core (the SDK list has none). */
|
||||
time_filtered?: boolean;
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
|
||||
"Keep syncing, processing and providing memories without opening the app.",
|
||||
导出日志: "Export logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Package local diagnostic logs into a file to share with support.",
|
||||
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
|
||||
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"The background service will continue syncing, processing and providing memories.",
|
||||
|
||||
@@ -55,11 +55,11 @@ const modeChineseMessages = {
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
||||
"正在检查或切换向量模型,完成后才能选择记忆模式。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"模式切换未能确认,仍显示上次确认的模式。",
|
||||
"模式选择未能确认,仍显示上次确认的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||
@@ -71,7 +71,7 @@ const modeChineseMessages = {
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
|
||||
每天自动整合: "每天自动整合",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
@@ -83,10 +83,10 @@ const modeChineseMessages = {
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切换到{mode}?",
|
||||
"切换到 {mode}": "切换到{mode}",
|
||||
"切换到 {mode}?": "选择{mode}?",
|
||||
"切换到 {mode}": "选择{mode}",
|
||||
"原始对话在所有模式下都会保留。": "原始对话在所有模式下都会保留。",
|
||||
切换模式: "切换模式",
|
||||
切换模式: "确认选择",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -98,18 +98,18 @@ const modeChineseMessages = {
|
||||
搜索范围: "搜索范围",
|
||||
已有数据: "已有数据",
|
||||
知识图谱: "知识图谱",
|
||||
切换后: "切换后",
|
||||
切换后: "选择后",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之后只保存原始对话,不再生成新记忆。",
|
||||
"新对话会整理成记忆并写入知识图谱。": "新对话会整理成记忆并写入知识图谱。",
|
||||
"新对话会由模型整理成记忆。": "新对话会由模型整理成记忆。",
|
||||
"会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。":
|
||||
"会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。",
|
||||
"会调用当前生成模型;自动整合可能产生额外费用。选择模式本身不会立即整合。",
|
||||
"会调用当前生成模型,可能产生服务商费用。":
|
||||
"会调用当前生成模型,可能产生服务商费用。",
|
||||
"不调用生成模型,不再产生这部分费用。":
|
||||
"不调用生成模型,不再产生这部分费用。",
|
||||
"切换后搜索原始对话。": "切换后搜索原始对话。",
|
||||
"切换后搜索原始对话。": "选择后搜索原始对话。",
|
||||
"搜索沉淀记忆,并用知识图谱补充关联。":
|
||||
"搜索沉淀记忆,并用知识图谱补充关联。",
|
||||
"搜索沉淀后的记忆。": "搜索沉淀后的记忆。",
|
||||
@@ -122,8 +122,8 @@ const modeChineseMessages = {
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。":
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
|
||||
"记忆维护正在进行,完成后才能切换模式。":
|
||||
"记忆维护正在进行,完成后才能切换模式。",
|
||||
"模式切换影响暂时无法读取,请重试。": "模式切换影响暂时无法读取,请重试。",
|
||||
"记忆维护正在进行,完成后才能选择模式。",
|
||||
"模式切换影响暂时无法读取,请重试。": "模式选择影响暂时无法读取,请重试。",
|
||||
"停止调用生成模型;每天自动整合也会停止。":
|
||||
"停止调用生成模型;每天自动整合也会停止。",
|
||||
"停止调用生成模型,不再产生这部分费用。":
|
||||
@@ -281,11 +281,11 @@ export const modeEnglishMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Last consolidated: {time}",
|
||||
尚未整合: "Not yet consolidated",
|
||||
立即整合: "Consolidate now",
|
||||
"切换到 {mode}?": "Switch to {mode}?",
|
||||
"切换到 {mode}": "Switch to {mode}",
|
||||
"切换到 {mode}?": "Choose {mode}?",
|
||||
"切换到 {mode}": "Choose {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Original conversations are kept in every mode.",
|
||||
切换模式: "Switch mode",
|
||||
切换模式: "Confirm choice",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Save original conversations · No generation model calls · Search original conversations",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -485,11 +485,11 @@ export const modeSpanishMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Última consolidación: {time}",
|
||||
尚未整合: "Aún sin consolidar",
|
||||
立即整合: "Consolidar ahora",
|
||||
"切换到 {mode}?": "¿Cambiar a {mode}?",
|
||||
"切换到 {mode}": "Cambiar a {mode}",
|
||||
"切换到 {mode}?": "¿Elegir {mode}?",
|
||||
"切换到 {mode}": "Elegir {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Las conversaciones originales se conservan en todos los modos.",
|
||||
切换模式: "Cambiar modo",
|
||||
切换模式: "Confirmar selección",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Guarda conversaciones originales · Sin llamadas al modelo generativo · Busca conversaciones originales",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -682,10 +682,10 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切換到{mode}?",
|
||||
"切换到 {mode}": "切換到{mode}",
|
||||
"切换到 {mode}?": "選擇{mode}?",
|
||||
"切换到 {mode}": "選擇{mode}",
|
||||
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
||||
切换模式: "切換模式",
|
||||
切换模式: "確認選擇",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"儲存原始對話 · 不呼叫生成模型 · 搜尋原始對話",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -833,10 +833,10 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
"上次整合:{time}": "前回の統合:{time}",
|
||||
尚未整合: "まだ統合されていません",
|
||||
立即整合: "今すぐ統合",
|
||||
"切换到 {mode}?": "{mode} に切り替えますか?",
|
||||
"切换到 {mode}": "{mode} に切り替える",
|
||||
"切换到 {mode}?": "{mode} を選択しますか?",
|
||||
"切换到 {mode}": "{mode} を選択",
|
||||
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
||||
切换模式: "モードを切り替える",
|
||||
切换模式: "選択を確定",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"元の会話を保存 · 生成モデルを呼び出さない · 元の会話を検索",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -1033,11 +1033,11 @@ export const modeGermanMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
|
||||
尚未整合: "Noch nicht konsolidiert",
|
||||
立即整合: "Jetzt konsolidieren",
|
||||
"切换到 {mode}?": "Zu {mode} wechseln?",
|
||||
"切换到 {mode}": "Zu {mode} wechseln",
|
||||
"切换到 {mode}?": "{mode} auswählen?",
|
||||
"切换到 {mode}": "{mode} auswählen",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
||||
切换模式: "Modus wechseln",
|
||||
切换模式: "Auswahl bestätigen",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Originalunterhaltungen speichern · Keine Aufrufe des generativen Modells · Originalunterhaltungen durchsuchen",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -1237,11 +1237,11 @@ export const modePortugueseMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Última consolidação: {time}",
|
||||
尚未整合: "Ainda não consolidado",
|
||||
立即整合: "Consolidar agora",
|
||||
"切换到 {mode}?": "Mudar para {mode}?",
|
||||
"切换到 {mode}": "Mudar para {mode}",
|
||||
"切换到 {mode}?": "Escolher {mode}?",
|
||||
"切换到 {mode}": "Escolher {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"As conversas originais são mantidas em todos os modos.",
|
||||
切换模式: "Mudar de modo",
|
||||
切换模式: "Confirmar escolha",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Guardar conversas originais · Sem chamadas ao modelo generativo · Pesquisar conversas originais",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -1443,11 +1443,11 @@ export const modeFrenchMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Dernière consolidation : {time}",
|
||||
尚未整合: "Pas encore consolidé",
|
||||
立即整合: "Consolider maintenant",
|
||||
"切换到 {mode}?": "Passer à {mode} ?",
|
||||
"切换到 {mode}": "Passer à {mode}",
|
||||
"切换到 {mode}?": "Choisir {mode} ?",
|
||||
"切换到 {mode}": "Choisir {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Les conversations originales sont conservées dans tous les modes.",
|
||||
切换模式: "Changer de mode",
|
||||
切换模式: "Confirmer le choix",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Enregistrer les conversations originales · Aucun appel au modèle génératif · Rechercher les conversations originales",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -1645,10 +1645,10 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
"上次整合:{time}": "마지막 통합: {time}",
|
||||
尚未整合: "아직 통합되지 않음",
|
||||
立即整合: "지금 통합",
|
||||
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
|
||||
"切换到 {mode}": "{mode}(으)로 전환",
|
||||
"切换到 {mode}?": "{mode}을(를) 선택할까요?",
|
||||
"切换到 {mode}": "{mode} 선택",
|
||||
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
||||
切换模式: "모드 전환",
|
||||
切换模式: "선택 확인",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"원본 대화 저장 · 생성 모델 호출 없음 · 원본 대화 검색",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -1844,11 +1844,11 @@ export const modeRussianMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Последнее объединение: {time}",
|
||||
尚未整合: "Ещё не объединялись",
|
||||
立即整合: "Объединить сейчас",
|
||||
"切换到 {mode}?": "Переключиться на {mode}?",
|
||||
"切换到 {mode}": "Переключиться на {mode}",
|
||||
"切换到 {mode}?": "Выбрать {mode}?",
|
||||
"切换到 {mode}": "Выбрать {mode}",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Исходные диалоги сохраняются во всех режимах.",
|
||||
切换模式: "Переключить режим",
|
||||
切换模式: "Подтвердить выбор",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Сохранять исходные диалоги · Не вызывать генеративную модель · Искать в исходных диалогах",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
@@ -2048,10 +2048,10 @@ export const modeIndonesianMessages: ModeMessages = {
|
||||
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
|
||||
尚未整合: "Belum dikonsolidasikan",
|
||||
立即整合: "Konsolidasikan sekarang",
|
||||
"切换到 {mode}?": "Beralih ke {mode}?",
|
||||
"切换到 {mode}": "Beralih ke {mode}",
|
||||
"切换到 {mode}?": "Pilih {mode}?",
|
||||
"切换到 {mode}": "Pilih {mode}",
|
||||
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
||||
切换模式: "Beralih mode",
|
||||
切换模式: "Konfirmasi pilihan",
|
||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||
"Simpan percakapan asli · Tanpa panggilan model generatif · Cari percakapan asli",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
|
||||
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
|
||||
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
|
||||
导出日志: "Exportar registros",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empaqueta los registros locales en un archivo para su diagnóstico.",
|
||||
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
|
||||
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
|
||||
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
|
||||
const chineseOverrides: Partial<
|
||||
Record<keyof typeof settingsSpanishMessages, string>
|
||||
> = {
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
|
||||
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
|
||||
appSettingsTitle: "{name} 应用设置",
|
||||
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
|
||||
|
||||
@@ -239,7 +239,7 @@ describe("GUI messages", () => {
|
||||
"上次整合:11/3/2026, 4:00:00 PM",
|
||||
"Last consolidated: 11/3/2026, 4:00:00 PM",
|
||||
],
|
||||
["切换到 ultra?", "Switch to ultra?"],
|
||||
["切换到 ultra?", "Choose ultra?"],
|
||||
] as const;
|
||||
for (const [source, expected] of cases) {
|
||||
expect(isMessageKey(source)).toBe(true);
|
||||
|
||||
@@ -1065,7 +1065,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
|
||||
导出日志: "Protokolle exportieren",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
|
||||
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
|
||||
导出日志: "Exporter les journaux",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
|
||||
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
|
||||
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
|
||||
|
||||
@@ -1048,7 +1048,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
|
||||
导出日志: "Ekspor log",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
|
||||
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
|
||||
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
|
||||
|
||||
@@ -1045,7 +1045,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
|
||||
导出日志: "ログを書き出す",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
|
||||
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
|
||||
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
|
||||
导出日志: "로그 내보내기",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
|
||||
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
|
||||
|
||||
@@ -1069,7 +1069,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
|
||||
导出日志: "Exportar logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
|
||||
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
|
||||
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
|
||||
导出日志: "Экспорт журналов",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
|
||||
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
|
||||
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
|
||||
|
||||
@@ -951,7 +951,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"不需開啟視窗,也能持續同步、整理和提供記憶。",
|
||||
导出日志: "匯出日誌",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
|
||||
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
|
||||
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"背景服務會繼續同步、整理和提供記憶。",
|
||||
|
||||
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
|
||||
expect(all).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("optionally includes every regular file from the local logs directory", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(logs, "diagnostic-core.jsonl"),
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
timestamp: "2026-10-05T01:00:00.000Z",
|
||||
level: "error",
|
||||
service: "core",
|
||||
component: "core.database",
|
||||
event: "database.error",
|
||||
outcome: "failed",
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
error_code: "database_locked",
|
||||
})}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(logs, "core.log"),
|
||||
"raw private conversation and full local path\n",
|
||||
);
|
||||
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
|
||||
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
|
||||
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
|
||||
await writeFile(path.join(logs, "empty.log"), "");
|
||||
await writeFile(
|
||||
path.join(logs, "large.log"),
|
||||
Buffer.alloc(256 * 1024, 120),
|
||||
);
|
||||
const outside = path.join(await root(), "outside.log");
|
||||
await writeFile(outside, "must not be followed\n");
|
||||
await symlink(outside, path.join(logs, "linked.log"));
|
||||
const target = path.join(outputDir, "MemHub.zip");
|
||||
|
||||
const result = await exportDiagnosticZip({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "3800",
|
||||
includeRawLogs: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
|
||||
const files = unzipSync(await readFile(target));
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
"README.txt",
|
||||
"diagnostics/events.jsonl",
|
||||
"logs/.hidden log",
|
||||
"logs/core.log",
|
||||
"logs/diagnostic-core.jsonl",
|
||||
"logs/empty.log",
|
||||
"logs/large.log",
|
||||
"logs/service-error.log.1",
|
||||
"logs/服务 日志.log",
|
||||
"summary.json",
|
||||
]);
|
||||
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
|
||||
"raw private conversation",
|
||||
);
|
||||
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
|
||||
"原始日志可能包含会话正文",
|
||||
);
|
||||
expect(files["logs/linked.log"]).toBeUndefined();
|
||||
expect(files["logs/empty.log"]).toHaveLength(0);
|
||||
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
|
||||
if (process.platform !== "win32")
|
||||
expect((await lstat(target)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
||||
const dataDir = await root();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
createWriteStream,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
type FileHandle,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
|
||||
|
||||
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
||||
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
|
||||
now?: Date;
|
||||
markers?: DiagnosticMarker[];
|
||||
summary?: Record<string, unknown>;
|
||||
includeRawLogs?: boolean;
|
||||
}): Promise<{ filename: string; file_count: number }> {
|
||||
const now = options.now ?? new Date();
|
||||
const home = options.home ?? os.homedir();
|
||||
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
|
||||
"MemHub diagnostics",
|
||||
"",
|
||||
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
...(options.includeRawLogs
|
||||
? [
|
||||
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
|
||||
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
|
||||
]
|
||||
: [
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
]),
|
||||
...(markers.length
|
||||
? [
|
||||
"",
|
||||
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
|
||||
const directory = path.dirname(options.target);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
|
||||
const rawLogs = options.includeRawLogs
|
||||
? await openRawLogSnapshots(options.dataDir)
|
||||
: [];
|
||||
try {
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
if (options.includeRawLogs)
|
||||
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
|
||||
else
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
await chmod(temporary, 0o600);
|
||||
await publishTemporaryFile(temporary, options.target);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
return {
|
||||
filename: path.basename(options.target),
|
||||
file_count: Object.keys(archive).length,
|
||||
file_count: Object.keys(archive).length + rawLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
|
||||
}
|
||||
}
|
||||
|
||||
interface RawLogSnapshot {
|
||||
name: string;
|
||||
handle: FileHandle;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
directoryInfo = await lstat(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||
fail("diagnostic_directory_unsafe");
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const snapshots: RawLogSnapshot[] = [];
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (
|
||||
entry.name === "." ||
|
||||
entry.name === ".." ||
|
||||
[...entry.name].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
character === "/" || character === "\\" || code < 32 || code === 127
|
||||
);
|
||||
})
|
||||
)
|
||||
fail("diagnostic_log_name_unsafe");
|
||||
const target = path.join(directory, entry.name);
|
||||
const before = await lstat(target).catch(() => null);
|
||||
if (!before?.isFile() || before.isSymbolicLink()) continue;
|
||||
const handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let retained = false;
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino
|
||||
)
|
||||
fail("diagnostic_file_unsafe");
|
||||
snapshots.push({ name: entry.name, handle, size: opened.size });
|
||||
retained = true;
|
||||
} finally {
|
||||
if (!retained) await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const after = await lstat(directory);
|
||||
if (
|
||||
!after.isDirectory() ||
|
||||
after.isSymbolicLink() ||
|
||||
after.dev !== directoryInfo.dev ||
|
||||
after.ino !== directoryInfo.ino
|
||||
)
|
||||
fail("diagnostic_directory_unsafe");
|
||||
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
snapshots.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStreamingDiagnosticZip(
|
||||
target: string,
|
||||
archive: Record<string, Uint8Array>,
|
||||
rawLogs: RawLogSnapshot[],
|
||||
): Promise<void> {
|
||||
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
|
||||
let outputError: Error | null = null;
|
||||
let backpressure = Promise.resolve();
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
output.once("finish", resolve);
|
||||
output.once("error", (error) => {
|
||||
outputError = error;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
// The producer may still be reading a source file when the output stream
|
||||
// fails. Attach a rejection handler immediately; the awaited promise below
|
||||
// still carries the same error into the export's fail-closed cleanup.
|
||||
void finished.catch(() => undefined);
|
||||
const zip = new Zip((error, chunk, final) => {
|
||||
if (error) {
|
||||
outputError = error;
|
||||
output.destroy(error);
|
||||
return;
|
||||
}
|
||||
if (chunk.length > 0 && !output.write(chunk))
|
||||
backpressure = once(output, "drain").then(() => undefined);
|
||||
if (final) output.end();
|
||||
});
|
||||
const ensureWritable = async () => {
|
||||
await backpressure;
|
||||
if (outputError) throw outputError;
|
||||
};
|
||||
try {
|
||||
for (const [name, content] of Object.entries(archive)) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(name);
|
||||
zip.add(input);
|
||||
input.push(content, true);
|
||||
}
|
||||
for (const file of rawLogs) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(`logs/${file.name}`);
|
||||
zip.add(input);
|
||||
let position = 0;
|
||||
while (position < file.size) {
|
||||
await ensureWritable();
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, file.size - position),
|
||||
);
|
||||
const { bytesRead } = await file.handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
position,
|
||||
);
|
||||
if (bytesRead < 1) fail("diagnostic_file_changed");
|
||||
position += bytesRead;
|
||||
input.push(buffer.subarray(0, bytesRead), position === file.size);
|
||||
}
|
||||
if (file.size === 0) input.push(new Uint8Array(), true);
|
||||
}
|
||||
await ensureWritable();
|
||||
zip.end();
|
||||
await finished;
|
||||
} catch (error) {
|
||||
zip.terminate();
|
||||
output.destroy(error instanceof Error ? error : undefined);
|
||||
await finished.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -461,6 +462,8 @@ async def _two_spaces(path):
|
||||
"""A live 1024 space and a stray 512 space sharing some ids."""
|
||||
from hy_memory.data.vector_store_chroma import ChromaVectorStore
|
||||
|
||||
stable_time = datetime(2025, 1, 1)
|
||||
|
||||
async def _store(base, dims, model, contents):
|
||||
cfg = MemoryConfig()
|
||||
cfg.vector_store.provider = "chroma"
|
||||
@@ -475,6 +478,7 @@ async def _two_spaces(path):
|
||||
node = MemoryNode(
|
||||
node_id=node_id, user_id=user, agent_id="a", session_id="s",
|
||||
layer=MemoryLayer.L2_FACT, content=content, status=status,
|
||||
gmt_created=stable_time, gmt_modified=stable_time, valid_from=stable_time,
|
||||
)
|
||||
node.embedding = emb.vector(content)
|
||||
nodes.append(node)
|
||||
|
||||
Reference in new issue
Block a user