Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd86d50cd7 | ||
|
|
80f4231e46 | ||
|
|
2f9e028c50 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
218bcf779b | ||
|
|
7e4ee0d8ea | ||
|
|
bce58e638d |
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
|
||||
|
||||
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
||||
expect(
|
||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
||||
).toBeNull();
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
@@ -398,11 +398,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/已保存的对话还在/, {
|
||||
selector: ".first-import-r6-footer-note p",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -439,9 +435,7 @@ it.each(["stale", "revoked", "missing"])(
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
if (kind === "missing")
|
||||
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
|
||||
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
|
||||
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
||||
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
||||
expect(
|
||||
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toHaveLength(1);
|
||||
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toBeNull();
|
||||
view.update({
|
||||
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
||||
jobs: [{ ...job, stored: 20 }],
|
||||
|
||||
@@ -148,31 +148,6 @@ export function FirstImportReview({
|
||||
const canResume = batch.some((job) =>
|
||||
["paused", "interrupted", "failed"].includes(job.status),
|
||||
);
|
||||
const allCompleted =
|
||||
batch.length > 0 &&
|
||||
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
||||
batch.every((job) => job.status === "completed") &&
|
||||
!Object.keys(failedSources).length;
|
||||
const allPaused =
|
||||
batch.length > 0 && batch.every((job) => job.status === "paused");
|
||||
const importing =
|
||||
!stale &&
|
||||
batch.some(
|
||||
(job) =>
|
||||
["pending", "running"].includes(job.status) &&
|
||||
sources.some(
|
||||
(source) => source.source_id === job.source_id && source.authorized,
|
||||
),
|
||||
);
|
||||
const displayImporting =
|
||||
importing ||
|
||||
(!stale &&
|
||||
!allCompleted &&
|
||||
!allPaused &&
|
||||
batch.length === 0 &&
|
||||
ids.some((id) =>
|
||||
sources.some((source) => source.source_id === id && source.authorized),
|
||||
));
|
||||
const title = "对话正在汇入,记忆从这里开始";
|
||||
const hostEligible = importHostEligible(sources, ids);
|
||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||
@@ -282,24 +257,73 @@ export function FirstImportReview({
|
||||
<R6ColdStartFrame
|
||||
step={2}
|
||||
title={text(title)}
|
||||
eyebrow={text("导入进行中 · 顺手连接应用")}
|
||||
footer={
|
||||
<>
|
||||
<div className="first-import-r6-footer-note">
|
||||
<p>
|
||||
{text(
|
||||
allCompleted
|
||||
? "对话已保存,记忆尚待生成。"
|
||||
: displayImporting
|
||||
? "导入在后台继续,无需等待。"
|
||||
: stale
|
||||
? "正在重新核对来源;已保存数量仍按任务回执显示。"
|
||||
: allPaused
|
||||
? "本次历史导入已暂停,已保存的对话还在。"
|
||||
: "导入等待处理,已保存的对话还在。",
|
||||
<div className="first-import-r6-footer-actions">
|
||||
{changing ? (
|
||||
<R6Button
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
}}
|
||||
>
|
||||
{text("停止后续操作")}
|
||||
</R6Button>
|
||||
) : onChangeJob && (canPause || canResume) ? (
|
||||
<R6Button
|
||||
disabled={busy || (stale && !canPause)}
|
||||
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
</p>
|
||||
{(error || actionMessage) && (
|
||||
{text(canPause ? "暂停导入" : "继续导入")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
recovery.onSkip();
|
||||
}}
|
||||
>
|
||||
{text(
|
||||
recovery.destination === "sources"
|
||||
? "仍进入应用页"
|
||||
: recovery.destination === "settings"
|
||||
? "仍进入设置"
|
||||
: "仍进入工作台",
|
||||
)}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave(recovery.destination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
</R6Button>
|
||||
</>
|
||||
) : (
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="first-import-r6-layout">
|
||||
<section
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
{(error || actionMessage) && (
|
||||
<div className="first-import-r6-feedback">
|
||||
<p
|
||||
role={
|
||||
error?.includes("应用连接") ||
|
||||
@@ -318,83 +342,8 @@ export function FirstImportReview({
|
||||
>
|
||||
{error ? text(error) : actionMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="first-import-r6-footer-actions">
|
||||
{changing ? (
|
||||
<R6Button
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
}}
|
||||
>
|
||||
{text("停止后续操作")}
|
||||
</R6Button>
|
||||
) : onChangeJob && (canPause || canResume) ? (
|
||||
<R6Button
|
||||
disabled={busy || (stale && !canPause)}
|
||||
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
|
||||
>
|
||||
{canPause ? (
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={16} aria-hidden="true" />
|
||||
)}
|
||||
{text(canPause ? "暂停导入" : "继续导入")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
stopBatch.current = true;
|
||||
recovery.onSkip();
|
||||
}}
|
||||
>
|
||||
{text(
|
||||
recovery.destination === "sources"
|
||||
? "仍进入应用页"
|
||||
: recovery.destination === "settings"
|
||||
? "仍进入设置"
|
||||
: "仍进入工作台",
|
||||
)}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave(recovery.destination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
</R6Button>
|
||||
</>
|
||||
) : (
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="first-import-r6-layout">
|
||||
<section
|
||||
className="first-import-r6-main"
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<p className="first-import-r6-intro">
|
||||
{text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||
)}
|
||||
</p>
|
||||
<p className="first-import-r6-subintro">
|
||||
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{historyWindowLimited && (
|
||||
<p className="first-import-r6-history-window" role="status">
|
||||
{t("onboardingHistoryWindow")}
|
||||
|
||||
@@ -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,13 +156,15 @@ it("moves from import to the actual third step without completing onboarding", a
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -161,10 +183,13 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -184,10 +209,13 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "确认选择" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
@@ -273,7 +301,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "开始导入" }),
|
||||
);
|
||||
const lateSources = [source("workbuddy"), source("codex")];
|
||||
rendered.rerender(view({ ...imports, sources: lateSources }));
|
||||
expect(checkbox("codex").checked).toBe(false);
|
||||
|
||||
@@ -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({}),
|
||||
}));
|
||||
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
||||
).toBeTruthy();
|
||||
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("region", {
|
||||
name: "Progreso de importación de las aplicaciones",
|
||||
@@ -305,8 +324,8 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeTruthy();
|
||||
screen.queryByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
||||
).toBeTruthy();
|
||||
@@ -314,7 +333,7 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
||||
fireEvent.click(screen.getByText("中文"));
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(navigation.textContent).toContain("连接应用");
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(draft).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -406,6 +425,12 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
new Error("fixture-offline"),
|
||||
);
|
||||
const imports = controller();
|
||||
imports.sources = [];
|
||||
vi.mocked(imports.refresh).mockResolvedValue({
|
||||
sources: [],
|
||||
jobs: [],
|
||||
health: null as never,
|
||||
});
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<LanguageProvider>
|
||||
@@ -415,13 +440,16 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
).toHaveProperty("disabled", false),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Confirmar selección" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||
|
||||
@@ -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();
|
||||
@@ -313,12 +347,13 @@ it("keeps the single-source submit frozen when another source arrives during fin
|
||||
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
||||
await ready();
|
||||
await waitFor(() => expect(count()).toBe("0"));
|
||||
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
@@ -363,14 +398,9 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
"accepted-job:completed",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByLabelText("保留任务").textContent).toBe(
|
||||
"accepted-job:completed",
|
||||
);
|
||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
|
||||
@@ -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",
|
||||
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "首次设置进度" })
|
||||
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
).toContain("导入历史");
|
||||
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeTruthy();
|
||||
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
||||
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
||||
expect(
|
||||
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
|
||||
expect(
|
||||
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
await screen.findByText("已受理 workbuddy");
|
||||
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
||||
"workbuddy",
|
||||
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
|
||||
done = resolve;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
view.unmount();
|
||||
await act(async () =>
|
||||
done({
|
||||
|
||||
@@ -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>
|
||||
@@ -193,7 +219,7 @@ function imports(
|
||||
|
||||
const READ_CONSENT = "允许读取以上来源的本次历史";
|
||||
const START = "开始导入";
|
||||
const SKIP = "稍后添加,先进入";
|
||||
const SKIP = "选择记忆模式";
|
||||
async function openApps(_user?: ReturnType<typeof userEvent.setup>) {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -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();
|
||||
@@ -434,15 +470,161 @@ describe("OnboardingFlow S1.3", () => {
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
});
|
||||
|
||||
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("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
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 () => {
|
||||
@@ -462,7 +644,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 +906,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 +926,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();
|
||||
@@ -766,26 +948,324 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
|
||||
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.getByText("推荐").closest("label")?.textContent).toContain(
|
||||
"Pro",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||
expect(screen.queryByText("推荐")).toBeNull();
|
||||
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();
|
||||
const view = 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 }));
|
||||
view.unmount();
|
||||
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();
|
||||
});
|
||||
|
||||
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 +1289,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 +1323,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 +1342,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 +1350,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 +1368,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 +1376,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 +1386,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 +1518,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 +1567,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 +1607,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 +1637,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 +1656,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 +1705,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,17 +1738,17 @@ 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();
|
||||
});
|
||||
|
||||
it("skipping after a selection does not grant, install, sync or connect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
@@ -1162,7 +1757,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
await choose(user);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1218,6 +1813,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||
expect(choice()).toHaveProperty("disabled", false);
|
||||
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1242,6 +1838,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
@@ -1324,9 +1921,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(choice());
|
||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||
expect(choice()).toHaveProperty("checked", false);
|
||||
expect(screen.getByRole("button", { name: START })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2133,10 +2730,9 @@ describe("OnboardingFlow S1.3", () => {
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "正在开始导入…" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(controller.refresh).toHaveBeenCalledTimes(2);
|
||||
await act(async () => resolve());
|
||||
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
|
||||
@@ -2479,7 +3075,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
it("lets the user leave after completion cannot be persisted without reconnecting", async () => {
|
||||
const user = userEvent.setup();
|
||||
completeOnboarding.mockRejectedValue(new Error("disk unavailable"));
|
||||
const controller = imports();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
@@ -2488,6 +3084,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
|
||||
@@ -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,12 +1142,14 @@ export function OnboardingFlow({
|
||||
}
|
||||
} finally {
|
||||
connectSent.current = false;
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const count = selected.length;
|
||||
const continueWithoutImport =
|
||||
busy === null && locationCheck !== "checking" && count === 0;
|
||||
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
||||
// detection must read 未完成 even when sources are listed — otherwise the
|
||||
// number branch answered first and the summary claimed a count while the
|
||||
@@ -987,10 +1214,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 +1249,125 @@ 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>
|
||||
<>
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
pending={modeSaving || modePlanBusy}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
completionBusy ||
|
||||
modePlanBusy ||
|
||||
(modeChoice === "ultra" && !scheduleLoaded)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modeSaving
|
||||
? "正在保存…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
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) => {
|
||||
>
|
||||
<MemoryModeChoiceList
|
||||
aria-label={text("记忆模式")}
|
||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
mode,
|
||||
title: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
}))}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || completionBusy || modePlanBusy}
|
||||
onChange={(mode, control) =>
|
||||
void requestModeSelection(
|
||||
mode as OnboardingMemoryMode,
|
||||
control,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{modeChoice === "ultra" && scheduleLoaded ? (
|
||||
<section
|
||||
className="r6-evolve-settings onboarding-r6-evolve-settings"
|
||||
aria-label={text("记忆整合(Evolve)")}
|
||||
>
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={ultraSchedule}
|
||||
disabled={modeSaving || !scheduleLoaded}
|
||||
onEnabledChange={(enabled) =>
|
||||
setUltraSchedule((current) => ({
|
||||
...current,
|
||||
enabled,
|
||||
}))
|
||||
}
|
||||
onTimeChange={async (time) => {
|
||||
setUltraSchedule((current) => ({ ...current, time }));
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{modeChoice === "ultra" && scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p role="alert">
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</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 +1375,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 +1389,7 @@ export function OnboardingFlow({
|
||||
void persistModelProfile(profileId)
|
||||
}
|
||||
onModelActiveConfirmed={(token, ownerProfileId) =>
|
||||
void confirmProMode(token, ownerProfileId)
|
||||
void confirmModelMode(token, ownerProfileId)
|
||||
}
|
||||
sourceNames={sourceIds.map(
|
||||
(id) =>
|
||||
@@ -1157,10 +1404,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 +1436,7 @@ export function OnboardingFlow({
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => finishOnce(exitDestination)}
|
||||
>
|
||||
{text(
|
||||
@@ -1197,7 +1445,7 @@ export function OnboardingFlow({
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => void leave(exitDestination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
@@ -1215,16 +1463,8 @@ export function OnboardingFlow({
|
||||
<R6ColdStartFrame
|
||||
step={1}
|
||||
title={text("把熟悉的背景,带到这里")}
|
||||
eyebrow={text("你的记忆,从这里接上")}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={locked}
|
||||
onClick={() => requestModeChoice("sources")}
|
||||
>
|
||||
{text("稍后添加,先进入")}
|
||||
</R6Button>
|
||||
{error && (
|
||||
<p className="onboarding-r6-error" role="alert">
|
||||
{error && text(error)}
|
||||
@@ -1233,10 +1473,20 @@ export function OnboardingFlow({
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
disabled={locked || count === 0}
|
||||
onClick={() => void startImport()}
|
||||
disabled={locked}
|
||||
onClick={() =>
|
||||
continueWithoutImport
|
||||
? requestModeChoice("sources")
|
||||
: void startImport()
|
||||
}
|
||||
>
|
||||
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
|
||||
{text(
|
||||
continueWithoutImport
|
||||
? "选择记忆模式"
|
||||
: busy === "submitting"
|
||||
? "正在开始导入…"
|
||||
: "开始导入",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
@@ -1246,12 +1496,6 @@ export function OnboardingFlow({
|
||||
className="onboarding-r6-apps"
|
||||
aria-labelledby="cold-start-phase"
|
||||
>
|
||||
<div className="onboarding-r6-intro">
|
||||
<p>
|
||||
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
|
||||
</p>
|
||||
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
|
||||
</div>
|
||||
<div className="onboarding-r6-list-heading">
|
||||
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
||||
{text("本机 AI 应用")}
|
||||
|
||||
@@ -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("模型未能确认启用,请在设置中核对。");
|
||||
|
||||
@@ -48,6 +48,40 @@ it("sizes the import action to its label and wraps footer content", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses content-driven shell, discovery and mode geometry", () => {
|
||||
const stage = declaration("onboarding-r6.css", ".onboarding-r6-stage");
|
||||
const shell = declaration("onboarding-r6.css", ".onboarding-r6-shell");
|
||||
const discovery = declaration(
|
||||
"onboarding-r6.css",
|
||||
".onboarding-r6-discovery",
|
||||
);
|
||||
const heading = declaration(
|
||||
"onboarding-r6.css",
|
||||
".onboarding-r6-list-heading",
|
||||
);
|
||||
const mode = declaration("onboarding-r6.css", ".onboarding-r6-mode");
|
||||
|
||||
expect(stage).toMatch(/min-height:\s*0/);
|
||||
expect(stage).not.toMatch(/min-height:\s*852px/);
|
||||
expect(shell).toMatch(/height:\s*100%/);
|
||||
expect(shell).not.toMatch(/(?:^|;)\s*height:\s*820px/);
|
||||
expect(discovery).toMatch(/height:\s*100%/);
|
||||
expect(heading).toMatch(/position:\s*static/);
|
||||
expect(mode).toMatch(/width:\s*100%/);
|
||||
});
|
||||
|
||||
it("does not reserve an empty eyebrow row", () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<R6ColdStartFrame step={1} title="Fixture" footer={null}>
|
||||
<p>Fixture body</p>
|
||||
</R6ColdStartFrame>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Fixture" })).toBeTruthy();
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
|
||||
const card = declaration(
|
||||
"r6-import-progress.css",
|
||||
@@ -62,10 +96,10 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
|
||||
),
|
||||
).toMatch(/flex:\s*0 0 auto/);
|
||||
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
||||
/height:\s*auto/,
|
||||
/height:\s*100%/,
|
||||
);
|
||||
expect(
|
||||
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
|
||||
declaration("first-import-r6.css", ".first-import-r6-feedback"),
|
||||
).not.toMatch(/max-height|overflow/);
|
||||
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
||||
/overflow-y:\s*auto/,
|
||||
|
||||
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
|
||||
}: {
|
||||
step: 1 | 2 | 3;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
eyebrow?: string | null;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
focusToken?: number;
|
||||
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
|
||||
<section className="onboarding-r6-shell" data-step={step}>
|
||||
<header className="onboarding-r6-head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
{eyebrow ? <p>{eyebrow}</p> : null}
|
||||
<h1
|
||||
ref={titleRef}
|
||||
tabIndex={focusToken === undefined ? undefined : -1}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.r6-home-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 346px;
|
||||
padding: 26px;
|
||||
@@ -150,6 +152,7 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
.r6-home-activity {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #d5dfe8;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
.first-import-r6-main {
|
||||
min-width: 0;
|
||||
@@ -12,20 +13,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.first-import-r6-intro {
|
||||
margin: 0;
|
||||
color: #5a6169;
|
||||
font-size: 13px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.first-import-r6-subintro {
|
||||
margin: 1px 0 20px;
|
||||
color: #63707b;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.first-import-r6-history-window {
|
||||
margin: -10px 0 12px;
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ead7a7;
|
||||
border-radius: 8px;
|
||||
@@ -39,7 +28,7 @@
|
||||
}
|
||||
.first-import-r6-feed {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 26px;
|
||||
margin-top: 20px;
|
||||
overflow: visible;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
@@ -101,6 +90,7 @@
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: contain;
|
||||
align-self: stretch;
|
||||
}
|
||||
.first-import-r6-eyebrow {
|
||||
margin: 0 0 14px;
|
||||
@@ -223,20 +213,22 @@
|
||||
.first-import-r6-options details p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.first-import-r6-footer-note {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
.first-import-r6-feedback {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 50%);
|
||||
}
|
||||
.first-import-r6-footer-note p {
|
||||
.first-import-r6-feedback p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #63707b;
|
||||
}
|
||||
.first-import-r6-footer-note [role="alert"] {
|
||||
.first-import-r6-feedback [role="alert"] {
|
||||
color: #944242;
|
||||
}
|
||||
.first-import-r6-footer-note .first-import-r6-partial-success {
|
||||
.first-import-r6-feedback .first-import-r6-partial-success {
|
||||
color: #245f50;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -248,6 +240,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.first-import-r6-footer-actions > [data-variant="primary"] {
|
||||
min-width: min(160px, 100%);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -115,14 +115,16 @@
|
||||
}
|
||||
.onboarding-r6-stage {
|
||||
flex: 1;
|
||||
min-height: 852px;
|
||||
padding: 32px 24px 2px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding: 24px;
|
||||
}
|
||||
.onboarding-r6-shell {
|
||||
min-height: 0;
|
||||
width: 1120px;
|
||||
max-width: 100%;
|
||||
height: 820px;
|
||||
height: 100%;
|
||||
max-height: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -137,7 +139,7 @@
|
||||
inset 0 -1px 0 #adc0d22e;
|
||||
}
|
||||
.onboarding-r6-head {
|
||||
min-height: 123.7px;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -195,6 +197,7 @@
|
||||
background: #ffffff38;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
border-radius: 0 0 25px 25px;
|
||||
}
|
||||
@@ -224,27 +227,21 @@
|
||||
}
|
||||
}
|
||||
.onboarding-r6-discovery {
|
||||
height: 560.2px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 738px) 278px;
|
||||
gap: 30px;
|
||||
}
|
||||
.onboarding-r6-apps {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.onboarding-r6-intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 24.05px;
|
||||
color: #5a6169;
|
||||
}
|
||||
.onboarding-r6-intro p {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-list-heading {
|
||||
position: absolute;
|
||||
inset: 96px 0 auto;
|
||||
position: static;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -277,8 +274,10 @@
|
||||
cursor: default;
|
||||
}
|
||||
.onboarding-r6-list-area {
|
||||
position: absolute;
|
||||
inset: 142px 0 auto;
|
||||
position: static;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
||||
color: #4e6478;
|
||||
@@ -288,7 +287,9 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
height: 330px;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -451,44 +452,6 @@
|
||||
content: "↓";
|
||||
margin-right: 6px;
|
||||
}
|
||||
.onboarding-r6-import {
|
||||
position: absolute;
|
||||
top: 498px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #6a7988;
|
||||
}
|
||||
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
|
||||
markup keeps the sentence first so it stays the accessible label order for
|
||||
the control that follows. Reordering visually only, rather than moving the
|
||||
DOM, keeps that reading order intact. */
|
||||
.onboarding-r6-import > p {
|
||||
order: 2;
|
||||
}
|
||||
.onboarding-r6-import > .history-import,
|
||||
.onboarding-r6-import > .history-import-trigger {
|
||||
order: 1;
|
||||
}
|
||||
.onboarding-r6-import > p {
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-import .history-import-trigger {
|
||||
font: inherit;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4e6478;
|
||||
padding: 5px 11px;
|
||||
min-height: 30px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.onboarding-r6-import .history-import-trigger > svg {
|
||||
display: none;
|
||||
}
|
||||
.onboarding-r6-summary {
|
||||
border-radius: 22px;
|
||||
border: 1px solid #ffffffe0;
|
||||
@@ -500,6 +463,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-summary p {
|
||||
margin: 0;
|
||||
@@ -572,8 +537,8 @@
|
||||
border: 0;
|
||||
}
|
||||
.onboarding-r6-mode {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -612,6 +577,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;
|
||||
@@ -763,17 +766,11 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.onboarding-r6-apps {
|
||||
min-height: 590px;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-list-heading {
|
||||
top: 110px;
|
||||
}
|
||||
.onboarding-r6-list-area {
|
||||
top: 154px;
|
||||
}
|
||||
.onboarding-r6-import {
|
||||
top: 552px;
|
||||
flex-wrap: wrap;
|
||||
.onboarding-r6-list {
|
||||
height: min(330px, 45vh);
|
||||
flex: none;
|
||||
}
|
||||
.onboarding-r6-row > label {
|
||||
padding: 14px 10px;
|
||||
@@ -815,20 +812,7 @@
|
||||
}
|
||||
.onboarding-r6-discovery > .onboarding-r6-apps {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto 30px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-apps > .onboarding-r6-list-heading,
|
||||
.onboarding-r6-apps > .onboarding-r6-list-area,
|
||||
.onboarding-r6-apps > .onboarding-r6-import {
|
||||
position: static;
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-apps > .onboarding-r6-list-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
.onboarding-r6-list-area > .onboarding-r6-list {
|
||||
min-height: 0;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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