Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13950a4c1 | ||
|
|
603e0843c5 | ||
|
|
80f4231e46 | ||
|
|
2f9e028c50 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
bce58e638d |
No files matched your search
@@ -644,6 +644,9 @@ class SdkRawPage(BaseModel):
|
|||||||
offset: int = Field(ge=0)
|
offset: int = Field(ge=0)
|
||||||
limit: int = Field(ge=1, le=500)
|
limit: int = Field(ge=1, le=500)
|
||||||
unit: Literal["raw", "turn"] = "raw"
|
unit: Literal["raw", "turn"] = "raw"
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
total_exact: bool = True
|
||||||
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
||||||
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
||||||
time_filtered: bool = False
|
time_filtered: bool = False
|
||||||
@@ -4491,6 +4494,8 @@ def create_app(
|
|||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
unit: Literal["raw", "turn"] = Query(default="raw"),
|
unit: Literal["raw", "turn"] = Query(default="raw"),
|
||||||
|
paging: Literal["offset", "cursor"] = Query(default="offset"),
|
||||||
|
cursor: str | None = Query(default=None, max_length=160),
|
||||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||||
session_id: list[str] | None = Query(default=None), # noqa: B008
|
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||||
@@ -4516,9 +4521,52 @@ def create_app(
|
|||||||
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
|
||||||
or raw_list_filters()["statuses"]
|
or raw_list_filters()["statuses"]
|
||||||
)
|
)
|
||||||
|
if paging == "cursor" and unit != "turn":
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
|
||||||
|
if cursor is not None and paging != "cursor":
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
|
||||||
|
if paging == "cursor" and (offset or after is not None or before is not None):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
|
||||||
|
raw_cursor = (0, 0, 0, 0, "", "")
|
||||||
|
if cursor:
|
||||||
|
parts = cursor.split(":")
|
||||||
|
if (
|
||||||
|
len(parts) != 7
|
||||||
|
or parts[0] != "turn"
|
||||||
|
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
|
||||||
|
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
|
||||||
|
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||||
|
raw_cursor = (
|
||||||
|
int(parts[1]),
|
||||||
|
int(parts[2]),
|
||||||
|
int(parts[3]),
|
||||||
|
int(parts[4]),
|
||||||
|
parts[5],
|
||||||
|
parts[6],
|
||||||
|
)
|
||||||
|
if any(value > 100_000_000 for value in raw_cursor[:4]):
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
|
||||||
sources = split_csv(source)
|
sources = split_csv(source)
|
||||||
excluded = split_csv(exclude_sources)
|
excluded = split_csv(exclude_sources)
|
||||||
sessions = split_csv(session_id)
|
sessions = split_csv(session_id)
|
||||||
|
cursor_scope = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"statuses": sorted(chosen_statuses),
|
||||||
|
"sources": sorted(sources),
|
||||||
|
"excluded": sorted(excluded),
|
||||||
|
"sessions": sorted(sessions),
|
||||||
|
"order": order,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
if cursor and raw_cursor[5] != cursor_scope:
|
||||||
|
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
|
||||||
try:
|
try:
|
||||||
client = await _sdk_client_handle(app).client()
|
client = await _sdk_client_handle(app).client()
|
||||||
query = {
|
query = {
|
||||||
@@ -4527,8 +4575,154 @@ def create_app(
|
|||||||
"include_sources": sources or None,
|
"include_sources": sources or None,
|
||||||
"exclude_sources": excluded or None,
|
"exclude_sources": excluded or None,
|
||||||
"session_ids": sessions or None,
|
"session_ids": sessions or None,
|
||||||
"order": order if unit == "raw" else "desc",
|
"order": order if unit == "raw" or paging == "cursor" else "desc",
|
||||||
}
|
}
|
||||||
|
if unit == "turn" and paging == "cursor":
|
||||||
|
(
|
||||||
|
raw_offset,
|
||||||
|
turn_offset,
|
||||||
|
emitted,
|
||||||
|
previous_total,
|
||||||
|
expected_raw,
|
||||||
|
_scope,
|
||||||
|
) = raw_cursor
|
||||||
|
value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
)
|
||||||
|
raw_page = HySdkRawPage.from_memory_list(
|
||||||
|
value, limit=min(limit + 1, 500), offset=raw_offset
|
||||||
|
)
|
||||||
|
|
||||||
|
def raw_anchor(record: HySdkRawRecord) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
f"{record.raw_id}\0{record.content}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
|
if cursor:
|
||||||
|
if raw_page.total < previous_total:
|
||||||
|
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||||
|
observed_total = raw_page.total
|
||||||
|
added = raw_page.total - previous_total
|
||||||
|
if added and order == "desc":
|
||||||
|
raw_offset += added
|
||||||
|
value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
)
|
||||||
|
raw_page = HySdkRawPage.from_memory_list(
|
||||||
|
value,
|
||||||
|
limit=min(limit + 1, 500),
|
||||||
|
offset=raw_offset,
|
||||||
|
)
|
||||||
|
if raw_page.total != observed_total:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="raw_cursor_stale"
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_raw.startswith("p"):
|
||||||
|
if raw_offset == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="raw_cursor_stale"
|
||||||
|
)
|
||||||
|
previous_value = await asyncio.to_thread(
|
||||||
|
client.list_memories,
|
||||||
|
**query,
|
||||||
|
offset=raw_offset - 1,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
previous_page = HySdkRawPage.from_memory_list(
|
||||||
|
previous_value, limit=1, offset=raw_offset - 1
|
||||||
|
)
|
||||||
|
valid_anchor = (
|
||||||
|
previous_page.total == raw_page.total
|
||||||
|
and bool(previous_page.raw)
|
||||||
|
and raw_anchor(previous_page.raw[0])
|
||||||
|
== expected_raw[1:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
valid_anchor = (
|
||||||
|
bool(raw_page.raw)
|
||||||
|
and raw_anchor(raw_page.raw[0]) == expected_raw
|
||||||
|
)
|
||||||
|
if expected_raw and not valid_anchor:
|
||||||
|
raise HTTPException(status_code=409, detail="raw_cursor_stale")
|
||||||
|
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
|
||||||
|
next_cursor: str | None = None
|
||||||
|
for record_index, record in enumerate(raw_page.raw):
|
||||||
|
turns = raw_display_turns(record)
|
||||||
|
ordered_turns = (
|
||||||
|
tuple(reversed(turns)) if order == "desc" else turns
|
||||||
|
)
|
||||||
|
start = turn_offset if record_index == 0 else 0
|
||||||
|
for index in range(start, len(ordered_turns)):
|
||||||
|
selected_turns.append((record, ordered_turns[index]))
|
||||||
|
if len(selected_turns) < limit:
|
||||||
|
continue
|
||||||
|
absolute_raw = raw_offset + record_index
|
||||||
|
next_raw = absolute_raw
|
||||||
|
next_turn = index + 1
|
||||||
|
if next_turn >= len(ordered_turns):
|
||||||
|
next_raw += 1
|
||||||
|
next_turn = 0
|
||||||
|
if next_raw < raw_page.total:
|
||||||
|
if next_turn:
|
||||||
|
anchor_record = record
|
||||||
|
else:
|
||||||
|
next_index = record_index + 1
|
||||||
|
anchor_record = (
|
||||||
|
raw_page.raw[next_index]
|
||||||
|
if next_index < len(raw_page.raw)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
anchor = (
|
||||||
|
raw_anchor(anchor_record)
|
||||||
|
if anchor_record is not None
|
||||||
|
else f"p{raw_anchor(record)}"
|
||||||
|
)
|
||||||
|
next_cursor = (
|
||||||
|
f"turn:{next_raw}:{next_turn}:"
|
||||||
|
f"{emitted + len(selected_turns)}:{raw_page.total}:"
|
||||||
|
f"{anchor}:{cursor_scope}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if len(selected_turns) >= limit:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
next_cursor is None
|
||||||
|
and raw_offset + len(raw_page.raw) < raw_page.total
|
||||||
|
):
|
||||||
|
if not raw_page.raw:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="raw_cursor_stale"
|
||||||
|
)
|
||||||
|
next_raw = raw_offset + len(raw_page.raw)
|
||||||
|
previous_anchor = raw_anchor(raw_page.raw[-1])
|
||||||
|
next_cursor = (
|
||||||
|
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
|
||||||
|
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
|
||||||
|
)
|
||||||
|
return SdkRawPage(
|
||||||
|
user_id=user_id,
|
||||||
|
items=tuple(
|
||||||
|
SdkRawItem.of(record, turn)
|
||||||
|
for record, turn in selected_turns
|
||||||
|
),
|
||||||
|
# Cursor consumers do not use a precise total. This lower
|
||||||
|
# bound preserves the legacy numeric field without forcing
|
||||||
|
# a full scan merely to count variable-size turn batches.
|
||||||
|
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
|
||||||
|
offset=emitted,
|
||||||
|
limit=limit,
|
||||||
|
unit="turn",
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
has_more=next_cursor is not None,
|
||||||
|
total_exact=False,
|
||||||
|
)
|
||||||
if unit == "raw":
|
if unit == "raw":
|
||||||
value = await asyncio.to_thread(
|
value = await asyncio.to_thread(
|
||||||
client.list_memories,
|
client.list_memories,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Callable, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ def discovered_off_loop(
|
|||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_directory_names: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
|
directory_predicate: Callable[[Path], bool] | None = None,
|
||||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||||
"""The same walk, for callers that have an event loop to keep free.
|
"""The same walk, for callers that have an event loop to keep free.
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ def discovered_off_loop(
|
|||||||
suffixes=suffixes,
|
suffixes=suffixes,
|
||||||
max_targets=max_targets,
|
max_targets=max_targets,
|
||||||
excluded_directory_names=excluded_directory_names,
|
excluded_directory_names=excluded_directory_names,
|
||||||
|
directory_predicate=directory_predicate,
|
||||||
),
|
),
|
||||||
scope_key=grant.source_id,
|
scope_key=grant.source_id,
|
||||||
)
|
)
|
||||||
@@ -54,6 +56,7 @@ def discover_source_files(
|
|||||||
suffixes: frozenset[str],
|
suffixes: frozenset[str],
|
||||||
max_targets: int,
|
max_targets: int,
|
||||||
excluded_directory_names: frozenset[str] = frozenset(),
|
excluded_directory_names: frozenset[str] = frozenset(),
|
||||||
|
directory_predicate: Callable[[Path], bool] | None = None,
|
||||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||||
|
|
||||||
@@ -71,10 +74,16 @@ def discover_source_files(
|
|||||||
grant.root,
|
grant.root,
|
||||||
followlinks=False,
|
followlinks=False,
|
||||||
):
|
):
|
||||||
|
current = Path(directory)
|
||||||
child_directories[:] = sorted(
|
child_directories[:] = sorted(
|
||||||
child
|
child
|
||||||
for child in child_directories
|
for child in child_directories
|
||||||
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
if not (current / child).is_symlink()
|
||||||
|
and child not in excluded_directory_names
|
||||||
|
and (
|
||||||
|
directory_predicate is None
|
||||||
|
or directory_predicate((current / child).relative_to(grant.root))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for filename in sorted(filenames):
|
for filename in sorted(filenames):
|
||||||
if Path(filename).suffix.lower() not in suffixes:
|
if Path(filename).suffix.lower() not in suffixes:
|
||||||
|
|||||||
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
|
|||||||
|
|
||||||
async def _poll_source(self, source_id: str) -> None:
|
async def _poll_source(self, source_id: str) -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(self.interval_seconds)
|
# A watched source is normally triggered by its filesystem event.
|
||||||
|
# Its timer is only a missed-event backstop; recursively walking
|
||||||
|
# every multi-gigabyte host root once a minute starved unrelated
|
||||||
|
# Core reads even when no source had changed.
|
||||||
|
await asyncio.sleep(self._poll_interval(source_id))
|
||||||
try:
|
try:
|
||||||
await self.trigger(source_id)
|
await self.trigger(source_id)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
|
|||||||
except Exception as again:
|
except Exception as again:
|
||||||
self._note_internal_error(source_id, again)
|
self._note_internal_error(source_id, again)
|
||||||
|
|
||||||
|
def _poll_interval(self, source_id: str) -> float:
|
||||||
|
if source_id in (self._watched_sources or ()):
|
||||||
|
return max(self.interval_seconds, 15 * 60)
|
||||||
|
return self.interval_seconds
|
||||||
|
|
||||||
def _note_worker_still_running(self, source_id: str) -> None:
|
def _note_worker_still_running(self, source_id: str) -> None:
|
||||||
current = self.health(source_id)
|
current = self.health(source_id)
|
||||||
self._set_health(
|
self._set_health(
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
|
|||||||
self.header_gate = header_gate
|
self.header_gate = header_gate
|
||||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||||
self._history_listing_cache: SourceListing | None = None
|
self._history_listing_cache: SourceListing | None = None
|
||||||
|
self._probe_listing_cache: tuple[
|
||||||
|
dict[str, SourceCursor], int | None, SourceListing
|
||||||
|
] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def descriptor(self) -> SourceDescriptor:
|
def descriptor(self) -> SourceDescriptor:
|
||||||
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
|
|||||||
predicate=self.file_predicate,
|
predicate=self.file_predicate,
|
||||||
cursors=options.cursors,
|
cursors=options.cursors,
|
||||||
)
|
)
|
||||||
|
self._probe_listing_cache = (
|
||||||
|
dict(options.cursors),
|
||||||
|
options.max_scan_targets,
|
||||||
|
listing,
|
||||||
|
)
|
||||||
return bool(listing.changed or listing.failures)
|
return bool(listing.changed or listing.failures)
|
||||||
|
|
||||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||||
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
|
|||||||
async for event in self._scan_frozen(options):
|
async for event in self._scan_frozen(options):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
listing = await asyncio.to_thread(
|
listing = (
|
||||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
await asyncio.to_thread(self._history_listing, options)
|
||||||
options,
|
if options.reuse_listing
|
||||||
|
else self._take_probe_listing(options)
|
||||||
|
or await asyncio.to_thread(self._fresh_listing, options)
|
||||||
)
|
)
|
||||||
for failure in listing.failures:
|
for failure in listing.failures:
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||||
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
|
|||||||
self._history_listing_cache = listing
|
self._history_listing_cache = listing
|
||||||
return listing
|
return listing
|
||||||
|
|
||||||
|
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
|
||||||
|
cached = self._probe_listing_cache
|
||||||
|
self._probe_listing_cache = None
|
||||||
|
if cached is None:
|
||||||
|
return None
|
||||||
|
cursors, max_scan_targets, listing = cached
|
||||||
|
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||||
|
return None
|
||||||
|
return listing
|
||||||
|
|
||||||
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
|
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
|
||||||
return list_changed_files(
|
return list_changed_files(
|
||||||
self.grant,
|
self.grant,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import JsonValue
|
from pydantic import JsonValue
|
||||||
|
|
||||||
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self.grant = grant
|
self.grant = grant
|
||||||
self.source_version = source_version
|
self.source_version = source_version
|
||||||
self._history_listing_cache: _CursorListing | None = None
|
self._history_listing_cache: _CursorListing | None = None
|
||||||
|
self._probe_listing_cache: tuple[
|
||||||
|
dict[str, SourceCursor], int | None, _CursorListing
|
||||||
|
] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def descriptor(self) -> SourceDescriptor:
|
def descriptor(self) -> SourceDescriptor:
|
||||||
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
|
|||||||
state = await self.install_state()
|
state = await self.install_state()
|
||||||
return min(state.session_count, limit) if limit is not None else state.session_count
|
return min(state.session_count, limit) if limit is not None else state.session_count
|
||||||
|
|
||||||
|
async def has_changes(self, options: ScanOptions) -> bool:
|
||||||
|
listing = await asyncio.to_thread(self._fresh_listing, options)
|
||||||
|
self._probe_listing_cache = (
|
||||||
|
dict(options.cursors),
|
||||||
|
options.max_scan_targets,
|
||||||
|
listing,
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
listing.transcripts
|
||||||
|
or listing.stores
|
||||||
|
or listing.legacy
|
||||||
|
or listing.failures
|
||||||
|
)
|
||||||
|
|
||||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||||
listing = await asyncio.to_thread(
|
listing = await asyncio.to_thread(
|
||||||
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
|
|||||||
async for event in self._scan_frozen(options):
|
async for event in self._scan_frozen(options):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
listing = await asyncio.to_thread(
|
listing = (
|
||||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
await asyncio.to_thread(self._history_listing, options)
|
||||||
options,
|
if options.reuse_listing
|
||||||
|
else self._take_probe_listing(options)
|
||||||
|
or await asyncio.to_thread(self._fresh_listing, options)
|
||||||
)
|
)
|
||||||
for failure in listing.failures:
|
for failure in listing.failures:
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||||
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self._history_listing_cache = listing
|
self._history_listing_cache = listing
|
||||||
return listing
|
return listing
|
||||||
|
|
||||||
|
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
|
||||||
|
cached = self._probe_listing_cache
|
||||||
|
self._probe_listing_cache = None
|
||||||
|
if cached is None:
|
||||||
|
return None
|
||||||
|
cursors, max_scan_targets, listing = cached
|
||||||
|
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||||
|
return None
|
||||||
|
return listing
|
||||||
|
|
||||||
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
||||||
return _list_cursor_files(
|
return _list_cursor_files(
|
||||||
self.grant,
|
self.grant,
|
||||||
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
|
|||||||
self.grant,
|
self.grant,
|
||||||
suffixes=frozenset({".db"}),
|
suffixes=frozenset({".db"}),
|
||||||
max_targets=options.max_scan_targets or 100_000,
|
max_targets=options.max_scan_targets or 100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if isinstance(discovered, SourceFileDiscoveryFailure):
|
if isinstance(discovered, SourceFileDiscoveryFailure):
|
||||||
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
||||||
@@ -477,7 +508,10 @@ def _list_cursor_files(
|
|||||||
legacy: list[AuthorizedSourceFile] = []
|
legacy: list[AuthorizedSourceFile] = []
|
||||||
failures: list[SourceFileDiscoveryFailure] = []
|
failures: list[SourceFileDiscoveryFailure] = []
|
||||||
for item in discover_source_files(
|
for item in discover_source_files(
|
||||||
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
|
grant,
|
||||||
|
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
|
||||||
|
max_targets=max_targets,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if isinstance(item, SourceFileDiscoveryFailure):
|
if isinstance(item, SourceFileDiscoveryFailure):
|
||||||
failures.append(item)
|
failures.append(item)
|
||||||
@@ -586,6 +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:
|
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
|
||||||
parts = source_file.relative_path.parts
|
parts = source_file.relative_path.parts
|
||||||
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
||||||
@@ -634,6 +687,7 @@ def _select_transcript_files(
|
|||||||
grant,
|
grant,
|
||||||
suffixes=frozenset({".jsonl"}),
|
suffixes=frozenset({".jsonl"}),
|
||||||
max_targets=100_000,
|
max_targets=100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
):
|
):
|
||||||
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
||||||
continue
|
continue
|
||||||
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
|
|||||||
grant,
|
grant,
|
||||||
suffixes=frozenset({".jsonl"}),
|
suffixes=frozenset({".jsonl"}),
|
||||||
max_targets=100_000,
|
max_targets=100_000,
|
||||||
|
directory_predicate=_cursor_data_directory,
|
||||||
)
|
)
|
||||||
if isinstance(item, AuthorizedSourceFile)
|
if isinstance(item, AuthorizedSourceFile)
|
||||||
and _is_transcript_file(item)
|
and _is_transcript_file(item)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
|||||||
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||||
"model_probe_operation", default=None
|
"model_probe_operation", default=None
|
||||||
)
|
)
|
||||||
|
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
|
||||||
|
|
||||||
|
|
||||||
class ProbeCode(StrEnum):
|
class ProbeCode(StrEnum):
|
||||||
@@ -113,16 +114,26 @@ class ModelProber:
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
token = _operation.set((operations, request.operation_id))
|
token = _operation.set((operations, request.operation_id))
|
||||||
|
dispatch_token = _dispatch_started.set(False)
|
||||||
try:
|
try:
|
||||||
await self._probe(profile_id)
|
await self._probe(profile_id)
|
||||||
except ProbeOperationConflict:
|
except ProbeOperationConflict:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
operations.fail, request.operation_id, "model_probe_profile_changed"
|
operations.fail, request.operation_id, "model_probe_profile_changed"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
# Unknown dispatch/storage outcomes stay reserved, including across restart.
|
if _dispatch_started.get():
|
||||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
# Once a request may have reached the provider, preserve the
|
||||||
|
# reservation so a retry cannot duplicate a possibly billed call.
|
||||||
|
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"model probe failed before dispatch: %s", type(exc).__name__
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
operations.fail, request.operation_id, "model_probe_execution_failed"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
|
_dispatch_started.reset(dispatch_token)
|
||||||
_operation.reset(token)
|
_operation.reset(token)
|
||||||
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
||||||
|
|
||||||
@@ -317,6 +328,8 @@ class ModelProber:
|
|||||||
listing_optional: bool = False,
|
listing_optional: bool = False,
|
||||||
) -> ProbeStage:
|
) -> ProbeStage:
|
||||||
await self._check_operation()
|
await self._check_operation()
|
||||||
|
if _operation.get() is not None:
|
||||||
|
_dispatch_started.set(True)
|
||||||
started = monotonic()
|
started = monotonic()
|
||||||
try:
|
try:
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
|
|||||||
selected_source_ids: tuple[str, ...]
|
selected_source_ids: tuple[str, ...]
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
mode_intent: Literal["lite", "pro"] | None
|
mode_intent: Literal["lite", "pro", "ultra"] | None
|
||||||
setup_step: Literal["review", "mode", "model"] | None
|
setup_step: Literal["review", "mode", "model"] | None
|
||||||
model_profile_id: str | None
|
model_profile_id: str | None
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
mode_intent: Literal["lite", "pro"] | None = None
|
mode_intent: Literal["lite", "pro", "ultra"] | None = None
|
||||||
setup_step: Literal["review", "mode", "model"] | None = None
|
setup_step: Literal["review", "mode", "model"] | None = None
|
||||||
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||||
|
|
||||||
|
|||||||
@@ -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(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||||
|
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||||
|
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
|
||||||
|
live.interval_seconds, 15 * 60
|
||||||
|
)
|
||||||
|
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||||
await imports.shutdown()
|
await imports.shutdown()
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|||||||
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
|
||||||
|
store, secrets, profile, ops, calls, respond = env
|
||||||
|
operation = str(uuid4())
|
||||||
|
|
||||||
|
def fail_require(*args, **kwargs):
|
||||||
|
raise RuntimeError("profile load failed")
|
||||||
|
|
||||||
|
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
|
||||||
|
with monkeypatch.context() as patch:
|
||||||
|
patch.setattr(store, "require", fail_require)
|
||||||
|
receipt = asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=operation,
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert receipt.state == "failed"
|
||||||
|
assert receipt.code == "model_probe_execution_failed"
|
||||||
|
assert receipt.result is None
|
||||||
|
assert calls == []
|
||||||
|
assert ops.lookup(operation) == receipt
|
||||||
|
assert (
|
||||||
|
asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=operation,
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== receipt
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = asyncio.run(
|
||||||
|
prober.probe(
|
||||||
|
profile.profile_id,
|
||||||
|
operation_id=str(uuid4()),
|
||||||
|
expected_profile_revision=profile.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert completed.state == "completed"
|
||||||
|
assert len(calls) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
||||||
store, secrets, profile, ops, calls, respond = env
|
store, secrets, profile, ops, calls, respond = env
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
|
|||||||
assert replaced.json()["model_profile_id"] == profile_id
|
assert replaced.json()["model_profile_id"] == profile_id
|
||||||
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
||||||
|
|
||||||
|
ultra = await client.put(
|
||||||
|
"/api/v1/onboarding/progress",
|
||||||
|
json={"mode_intent": "ultra", "setup_step": "model"},
|
||||||
|
)
|
||||||
|
assert ultra.status_code == 200
|
||||||
|
assert ultra.json()["mode_intent"] == "ultra"
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
invalid_mode = await client.patch(
|
invalid_mode = await client.patch(
|
||||||
"/api/v1/onboarding/progress",
|
"/api/v1/onboarding/progress",
|
||||||
json={"mode_intent": "ultra"},
|
json={"mode_intent": "future"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert secret.status_code == 422
|
assert secret.status_code == 422
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||||
|
root = tmp_path / ".workbuddy" / "projects"
|
||||||
|
write_jsonl(
|
||||||
|
root / "project" / "session.jsonl",
|
||||||
|
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
|
||||||
|
)
|
||||||
|
adapter = create_workbuddy_adapter(grant("workbuddy", root))
|
||||||
|
from one_memory.imports.sources import common
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
original = common.list_changed_files
|
||||||
|
|
||||||
|
def counted(*args, **kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(common, "list_changed_files", counted)
|
||||||
|
|
||||||
|
async def probe_then_scan():
|
||||||
|
options = ScanOptions()
|
||||||
|
assert await adapter.has_changes(options)
|
||||||
|
return [event async for event in adapter.scan(options)]
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(probe_then_scan()))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
|
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
|
||||||
"""A Cursor store blob can be the host's session context, not the user's words.
|
"""A Cursor store blob can be the host's session context, not the user's words.
|
||||||
|
|
||||||
@@ -1352,6 +1382,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
|
assert len({item.evidence.source_ordinal for item in records}) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
|
||||||
|
root = tmp_path / ".cursor"
|
||||||
|
write_jsonl(
|
||||||
|
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
write_jsonl(
|
||||||
|
root
|
||||||
|
/ "extensions"
|
||||||
|
/ "large-cache"
|
||||||
|
/ "agent-transcripts"
|
||||||
|
/ "ignored"
|
||||||
|
/ "ignored.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||||
|
root = tmp_path / ".cursor"
|
||||||
|
write_jsonl(
|
||||||
|
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"message": {"content": [{"type": "text", "text": "new conversation"}]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
|
||||||
|
from one_memory.imports.adapters import ScanOptions
|
||||||
|
from one_memory.imports.sources import cursor_unified
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
original = cursor_unified._list_cursor_files
|
||||||
|
|
||||||
|
def counted(*args, **kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
|
||||||
|
|
||||||
|
async def probe_then_scan():
|
||||||
|
options = ScanOptions()
|
||||||
|
assert await adapter.has_changes(options)
|
||||||
|
return [event async for event in adapter.scan(options)]
|
||||||
|
|
||||||
|
records = evidence(asyncio.run(probe_then_scan()))
|
||||||
|
|
||||||
|
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
||||||
root = tmp_path / ".cursor"
|
root = tmp_path / ".cursor"
|
||||||
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
||||||
|
|||||||
@@ -214,7 +214,15 @@ class RecordingSdk:
|
|||||||
)
|
)
|
||||||
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
|
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
|
||||||
]
|
]
|
||||||
return {"vdb": {"memories": rows, "total": len(rows)}}
|
total = len(rows)
|
||||||
|
offset = kwargs.get("offset", 0)
|
||||||
|
limit = kwargs.get("limit", 100)
|
||||||
|
return {
|
||||||
|
"vdb": {
|
||||||
|
"memories": rows[offset : offset + limit],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
}
|
||||||
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
|
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
|
||||||
if layers:
|
if layers:
|
||||||
rows = [row for row in rows if row["layer"] in layers]
|
rows = [row for row in rows if row["layer"] in layers]
|
||||||
@@ -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"))
|
calls_before_refresh = len(sdk.all("list_memories"))
|
||||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
|
|||||||
).rejects.toThrow(/untrusted_window/);
|
).rejects.toThrow(/untrusted_window/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a problem and exports a redacted local bundle", async () => {
|
it("marks a problem and exports the complete local log bundle", async () => {
|
||||||
const value = await fixture();
|
const value = await fixture();
|
||||||
const mark = await value.handlers.get(
|
const mark = await value.handlers.get(
|
||||||
"one-memory:mark-diagnostic-incident",
|
"one-memory:mark-diagnostic-incident",
|
||||||
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
|||||||
incident_id: expect.any(String),
|
incident_id: expect.any(String),
|
||||||
session_id: expect.any(String),
|
session_id: expect.any(String),
|
||||||
});
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(value.dataDir, "logs", "core.log"),
|
||||||
|
"2026-10-14 10:31:00 WARNING database wait\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(value.dataDir, "logs", "service-error.log"),
|
||||||
|
"worker failed\n",
|
||||||
|
);
|
||||||
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(summary).toMatchObject({ build: "3800" });
|
expect(summary).toMatchObject({ build: "3800" });
|
||||||
|
const entries = execFileSync(
|
||||||
|
"/usr/bin/unzip",
|
||||||
|
["-Z1", path.join(value.dataDir, zip)],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.split("\n");
|
||||||
|
expect(entries).toContain("logs/core.log");
|
||||||
|
expect(entries).toContain("logs/service-error.log");
|
||||||
|
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
|
||||||
value.dispose();
|
value.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
|
|||||||
|
|
||||||
export async function exportDesktopDiagnostics(
|
export async function exportDesktopDiagnostics(
|
||||||
options: DiagnosticExportOptions,
|
options: DiagnosticExportOptions,
|
||||||
|
exportOptions: { includeRawLogs?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const build = options.buildNumber ?? options.productVersion;
|
const build = options.buildNumber ?? options.productVersion;
|
||||||
const target = path.join(
|
const target = path.join(
|
||||||
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
|
|||||||
dataDir: options.dataDir,
|
dataDir: options.dataDir,
|
||||||
target,
|
target,
|
||||||
productVersion: build,
|
productVersion: build,
|
||||||
|
includeRawLogs: exportOptions.includeRawLogs === true,
|
||||||
...(options.commit === undefined ? {} : { commit: options.commit }),
|
...(options.commit === undefined ? {} : { commit: options.commit }),
|
||||||
...(options.coreSha256 === undefined
|
...(options.coreSha256 === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
|
|||||||
options.ipc.handle(EXPORT, async (event, ...args) => {
|
options.ipc.handle(EXPORT, async (event, ...args) => {
|
||||||
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
||||||
options.requireTrustedWindow(event);
|
options.requireTrustedWindow(event);
|
||||||
return exportDesktopDiagnostics(options);
|
return exportDesktopDiagnostics(options, { includeRawLogs: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||||
@@ -398,11 +398,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
|
||||||
screen.getByText(/已保存的对话还在/, {
|
|
||||||
selector: ".first-import-r6-footer-note p",
|
|
||||||
}),
|
|
||||||
).toBeTruthy();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -439,9 +435,7 @@ it.each(["stale", "revoked", "missing"])(
|
|||||||
onContinue={vi.fn()}
|
onContinue={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (kind === "missing")
|
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||||
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
|
|
||||||
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
|
|||||||
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
||||||
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||||
).toHaveLength(1);
|
).toBeNull();
|
||||||
view.update({
|
view.update({
|
||||||
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
||||||
jobs: [{ ...job, stored: 20 }],
|
jobs: [{ ...job, stored: 20 }],
|
||||||
|
|||||||
@@ -148,31 +148,6 @@ export function FirstImportReview({
|
|||||||
const canResume = batch.some((job) =>
|
const canResume = batch.some((job) =>
|
||||||
["paused", "interrupted", "failed"].includes(job.status),
|
["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 title = "对话正在汇入,记忆从这里开始";
|
||||||
const hostEligible = importHostEligible(sources, ids);
|
const hostEligible = importHostEligible(sources, ids);
|
||||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||||
@@ -282,24 +257,73 @@ export function FirstImportReview({
|
|||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
step={2}
|
step={2}
|
||||||
title={text(title)}
|
title={text(title)}
|
||||||
eyebrow={text("导入进行中 · 顺手连接应用")}
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<div className="first-import-r6-footer-actions">
|
||||||
<div className="first-import-r6-footer-note">
|
{changing ? (
|
||||||
<p>
|
<R6Button
|
||||||
{text(
|
onClick={() => {
|
||||||
allCompleted
|
stopBatch.current = true;
|
||||||
? "对话已保存,记忆尚待生成。"
|
}}
|
||||||
: displayImporting
|
>
|
||||||
? "导入在后台继续,无需等待。"
|
{text("停止后续操作")}
|
||||||
: stale
|
</R6Button>
|
||||||
? "正在重新核对来源;已保存数量仍按任务回执显示。"
|
) : onChangeJob && (canPause || canResume) ? (
|
||||||
: allPaused
|
<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>
|
{text(canPause ? "暂停导入" : "继续导入")}
|
||||||
{(error || actionMessage) && (
|
</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
|
<p
|
||||||
role={
|
role={
|
||||||
error?.includes("应用连接") ||
|
error?.includes("应用连接") ||
|
||||||
@@ -318,83 +342,8 @@ export function FirstImportReview({
|
|||||||
>
|
>
|
||||||
{error ? text(error) : actionMessage}
|
{error ? text(error) : actionMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</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>
|
|
||||||
{historyWindowLimited && (
|
{historyWindowLimited && (
|
||||||
<p className="first-import-r6-history-window" role="status">
|
<p className="first-import-r6-history-window" role="status">
|
||||||
{t("onboardingHistoryWindow")}
|
{t("onboardingHistoryWindow")}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
|
|||||||
});
|
});
|
||||||
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
||||||
render(<HealthPanel imports={imports()} />);
|
render(<HealthPanel imports={imports()} />);
|
||||||
|
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
||||||
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
|
|||||||
<Download size={16} aria-hidden="true" />
|
<Download size={16} aria-hidden="true" />
|
||||||
{t("nativeExportLogs")}
|
{t("nativeExportLogs")}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
|
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="health-block" aria-label={t("healthLocal")}>
|
<section className="health-block" aria-label={t("healthLocal")}>
|
||||||
|
|||||||
@@ -110,12 +110,15 @@ type Props = {
|
|||||||
settledSource?: string;
|
settledSource?: string;
|
||||||
settledQuery?: string;
|
settledQuery?: string;
|
||||||
requestedOffset?: number;
|
requestedOffset?: number;
|
||||||
|
cursorPaging?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||||
unattributedSource?: string;
|
unattributedSource?: string;
|
||||||
onQuery(value: string): void;
|
onQuery(value: string): void;
|
||||||
onSource(value: string): void;
|
onSource(value: string): void;
|
||||||
resetPage(): void;
|
resetPage(): void;
|
||||||
onPage(offset: number): void;
|
onPage(offset: number): void;
|
||||||
|
onMore?(): void;
|
||||||
onRetry(): void;
|
onRetry(): void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1131,62 +1134,81 @@ export function MemoryMap(props: Props) {
|
|||||||
aria-busy={pageTransitioning || undefined}
|
aria-busy={pageTransitioning || undefined}
|
||||||
className="map-pagination"
|
className="map-pagination"
|
||||||
>
|
>
|
||||||
<button
|
{library.cursorPaging ? (
|
||||||
type="button"
|
library.hasMore ? (
|
||||||
className="quiet map-tool"
|
<R6Button
|
||||||
aria-label={text("上一页记忆")}
|
disabled={pageTransitioning}
|
||||||
title={text("上一页记忆")}
|
onClick={() => library.onMore?.()}
|
||||||
disabled={pageTransitioning || library.page.offset === 0}
|
>
|
||||||
onClick={() => {
|
{pageTransitioning ? text("正在读取…") : text("加载更多")}
|
||||||
if (axis === "topic") setGroupKey("");
|
</R6Button>
|
||||||
if (library.page) {
|
) : (
|
||||||
const offset = library.page.offset - library.page.limit;
|
<span>{text("已加载全部")}</span>
|
||||||
setPageRetrying(false);
|
)
|
||||||
pageRetrySawLoading.current = false;
|
) : (
|
||||||
setPendingPageOffset(offset);
|
<>
|
||||||
library.onPage(offset);
|
<button
|
||||||
}
|
type="button"
|
||||||
}}
|
className="quiet map-tool"
|
||||||
>
|
aria-label={text("上一页记忆")}
|
||||||
<ChevronLeft size={16} aria-hidden="true" />
|
title={text("上一页记忆")}
|
||||||
</button>
|
disabled={pageTransitioning || library.page.offset === 0}
|
||||||
<span>
|
onClick={() => {
|
||||||
{text(
|
if (axis === "topic") setGroupKey("");
|
||||||
"共 {count} 条,第 {page} / {pages} 页",
|
if (library.page) {
|
||||||
{
|
const offset = library.page.offset - library.page.limit;
|
||||||
count: count(library.page.total),
|
setPageRetrying(false);
|
||||||
page:
|
pageRetrySawLoading.current = false;
|
||||||
library.page.total === 0
|
setPendingPageOffset(offset);
|
||||||
? 0
|
library.onPage(offset);
|
||||||
: Math.floor(library.page.offset / library.page.limit) +
|
}
|
||||||
1,
|
}}
|
||||||
pages: Math.ceil(library.page.total / library.page.limit),
|
>
|
||||||
},
|
<ChevronLeft size={16} aria-hidden="true" />
|
||||||
library.page.total,
|
</button>
|
||||||
)}
|
<span>
|
||||||
</span>
|
{text(
|
||||||
<button
|
"共 {count} 条,第 {page} / {pages} 页",
|
||||||
type="button"
|
{
|
||||||
className="quiet map-tool"
|
count: count(library.page.total),
|
||||||
aria-label={text("下一页记忆")}
|
page:
|
||||||
title={text("下一页记忆")}
|
library.page.total === 0
|
||||||
disabled={
|
? 0
|
||||||
pageTransitioning ||
|
: Math.floor(
|
||||||
library.page.offset + library.page.limit >= library.page.total
|
library.page.offset / library.page.limit,
|
||||||
}
|
) + 1,
|
||||||
onClick={() => {
|
pages: Math.ceil(
|
||||||
if (axis === "topic") setGroupKey("");
|
library.page.total / library.page.limit,
|
||||||
if (library.page) {
|
),
|
||||||
const offset = library.page.offset + library.page.limit;
|
},
|
||||||
setPageRetrying(false);
|
library.page.total,
|
||||||
pageRetrySawLoading.current = false;
|
)}
|
||||||
setPendingPageOffset(offset);
|
</span>
|
||||||
library.onPage(offset);
|
<button
|
||||||
}
|
type="button"
|
||||||
}}
|
className="quiet map-tool"
|
||||||
>
|
aria-label={text("下一页记忆")}
|
||||||
<ChevronRight size={16} aria-hidden="true" />
|
title={text("下一页记忆")}
|
||||||
</button>
|
disabled={
|
||||||
|
pageTransitioning ||
|
||||||
|
library.page.offset + library.page.limit >=
|
||||||
|
library.page.total
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (axis === "topic") setGroupKey("");
|
||||||
|
if (library.page) {
|
||||||
|
const offset = library.page.offset + library.page.limit;
|
||||||
|
setPageRetrying(false);
|
||||||
|
pageRetrySawLoading.current = false;
|
||||||
|
setPendingPageOffset(offset);
|
||||||
|
library.onPage(offset);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
{!library && loaded && !filtered.length && (search || groupKey) && (
|
{!library && loaded && !filtered.length && (search || groupKey) && (
|
||||||
@@ -1213,11 +1235,11 @@ export function MemoryMap(props: Props) {
|
|||||||
<h2>
|
<h2>
|
||||||
{props.simplified
|
{props.simplified
|
||||||
? raw
|
? raw
|
||||||
? t("memory:原始对话")
|
? text("来源归属")
|
||||||
: text("记忆关联")
|
: text("记忆关联")
|
||||||
: label}
|
: label}
|
||||||
</h2>
|
</h2>
|
||||||
{props.simplified && (
|
{props.simplified && (!raw || !projection.nodes.length) && (
|
||||||
<p className="selected-graph-subtitle">
|
<p className="selected-graph-subtitle">
|
||||||
{projection.nodes.length
|
{projection.nodes.length
|
||||||
? text("来源归属")
|
? text("来源归属")
|
||||||
|
|||||||
@@ -115,9 +115,7 @@ it("confirms the data effects before switching to lite", async () => {
|
|||||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(document.activeElement).toBe(
|
expect(document.activeElement).toBe(
|
||||||
@@ -139,7 +137,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
|
|||||||
expect(outside.inert).toBe(true);
|
expect(outside.inert).toBe(true);
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||||
expect(document.activeElement).toBe(
|
expect(document.activeElement).toBe(
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
screen.getByRole("button", { name: "确认选择" }),
|
||||||
);
|
);
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
@@ -155,9 +153,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
await chooseMode(user, /基础记忆(lite)/);
|
await chooseMode(user, /基础记忆(lite)/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const modal = screen.getByRole("dialog");
|
const modal = screen.getByRole("dialog");
|
||||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||||
@@ -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.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||||
expect(screen.getByText("知识图谱")).toBeTruthy();
|
expect(screen.getByText("知识图谱")).toBeTruthy();
|
||||||
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
||||||
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
|
||||||
@@ -279,9 +275,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
|||||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||||
|
|
||||||
await chooseMode(user, /深度记忆(ultra)/);
|
await chooseMode(user, /深度记忆(ultra)/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||||
@@ -300,13 +294,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
|
|||||||
"aria-checked",
|
"aria-checked",
|
||||||
),
|
),
|
||||||
).toBe("true");
|
).toBe("true");
|
||||||
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
|
const timePicker = screen.getByRole("button", {
|
||||||
"03:00",
|
name: "执行时间,03:00",
|
||||||
);
|
});
|
||||||
|
expect(timePicker.tagName).toBe("BUTTON");
|
||||||
|
expect(timePicker.textContent).toContain("03:00");
|
||||||
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the time picker from the whole control and saves a complete time", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "04:30",
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
const trigger = await screen.findByRole("button", {
|
||||||
|
name: "执行时间,03:00",
|
||||||
|
});
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
|
||||||
|
"04",
|
||||||
|
);
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||||
|
"30",
|
||||||
|
);
|
||||||
|
expect(setEvolveSchedule).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
time: "04:30",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
|
||||||
|
document.activeElement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
const trigger = await screen.findByRole("button", {
|
||||||
|
name: "执行时间,03:00",
|
||||||
|
});
|
||||||
|
await user.click(trigger);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
const hours = within(picker).getByRole("listbox", {
|
||||||
|
name: "执行时间 HH",
|
||||||
|
});
|
||||||
|
const save = within(picker).getByRole("button", { name: "保存修改" });
|
||||||
|
expect(document.activeElement).toBe(hours);
|
||||||
|
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||||
|
expect(document.activeElement).toBe(save);
|
||||||
|
await user.keyboard("{Tab}");
|
||||||
|
expect(document.activeElement).toBe(hours);
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the selected time open for retry when saving fails", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||||
|
);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
const minutes = within(picker).getByRole("listbox", {
|
||||||
|
name: "执行时间 MM",
|
||||||
|
});
|
||||||
|
await user.selectOptions(minutes, "30");
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("自动整合设置未能保存,请重试。"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
|
||||||
|
expect((minutes as HTMLSelectElement).value).toBe("30");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
within(picker).getByRole("button", { name: "保存修改" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the time dialog and focus stable while saving is pending", async () => {
|
||||||
|
prepare("ultra");
|
||||||
|
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "执行时间,03:00" }),
|
||||||
|
);
|
||||||
|
const picker = screen.getByRole("dialog", { name: "执行时间" });
|
||||||
|
await user.selectOptions(
|
||||||
|
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
|
||||||
|
"30",
|
||||||
|
);
|
||||||
|
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
|
||||||
|
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(picker);
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
|
||||||
|
expect(document.activeElement).toBe(picker);
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
|
||||||
prepare("ultra");
|
prepare("ultra");
|
||||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||||
@@ -322,7 +434,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
|
|||||||
await user.click(automatic);
|
await user.click(automatic);
|
||||||
|
|
||||||
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
await screen.findByText(/重新开启后仍按 03:00 运行/);
|
||||||
expect(screen.queryByLabelText("执行时间")).toBeNull();
|
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,14 +492,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
|
|||||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||||
|
|
||||||
await chooseMode(user, /深度记忆(ultra)/);
|
await chooseMode(user, /深度记忆(ultra)/);
|
||||||
await user.click(
|
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(
|
expect(
|
||||||
await within(dialog).findByText(
|
await within(dialog).findByText(
|
||||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -423,7 +533,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -26,95 +26,15 @@ import {
|
|||||||
issueConfirmedModeTicket,
|
issueConfirmedModeTicket,
|
||||||
} from "./confirmed-memory-mode";
|
} from "./confirmed-memory-mode";
|
||||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||||
|
import {
|
||||||
|
BLOCKER_COPY,
|
||||||
|
MemoryModeConfirmDialog,
|
||||||
|
MODE_COPY,
|
||||||
|
} from "./memory-mode-ui";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6Button, R6Switch } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { R6StateRecovery } from "./R6StateRecovery";
|
import { R6StateRecovery } from "./R6StateRecovery";
|
||||||
|
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||||
const MODE_COPY: Record<
|
|
||||||
MemoryPipelineMode,
|
|
||||||
{ title: string; description: string }
|
|
||||||
> = {
|
|
||||||
lite: {
|
|
||||||
title: "基础记忆(lite)",
|
|
||||||
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
|
|
||||||
},
|
|
||||||
pro: {
|
|
||||||
title: "智能记忆(pro)",
|
|
||||||
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
|
|
||||||
},
|
|
||||||
ultra: {
|
|
||||||
title: "深度记忆(ultra)",
|
|
||||||
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function planRows(plan: MemoryModePlan) {
|
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
label: "新对话",
|
|
||||||
content:
|
|
||||||
plan.capabilities.content_plane === "raw"
|
|
||||||
? "之后只保存原始对话,不再生成新记忆。"
|
|
||||||
: plan.capabilities.graph_enabled
|
|
||||||
? "新对话会整理成记忆并写入知识图谱。"
|
|
||||||
: "新对话会由模型整理成记忆。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "模型调用与费用",
|
|
||||||
content: plan.capabilities.generation_required
|
|
||||||
? plan.capabilities.evolve_available
|
|
||||||
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
|
|
||||||
: "会调用当前生成模型,可能产生服务商费用。"
|
|
||||||
: "不调用生成模型,不再产生这部分费用。",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "搜索范围",
|
|
||||||
content:
|
|
||||||
plan.capabilities.content_plane === "raw"
|
|
||||||
? "切换后搜索原始对话。"
|
|
||||||
: plan.capabilities.graph_enabled
|
|
||||||
? "搜索沉淀记忆,并用知识图谱补充关联。"
|
|
||||||
: "搜索沉淀后的记忆。",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (plan.effects.includes("earlier_raw_not_extracted"))
|
|
||||||
rows.push({
|
|
||||||
label: "已有数据",
|
|
||||||
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("higher_mode_memories_hidden"))
|
|
||||||
rows.push({
|
|
||||||
label: "已有数据",
|
|
||||||
content:
|
|
||||||
plan.effective_target === "pro"
|
|
||||||
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
|
||||||
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("existing_memories_added_to_graph"))
|
|
||||||
rows.push({
|
|
||||||
label: "知识图谱",
|
|
||||||
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
|
|
||||||
});
|
|
||||||
if (plan.effects.includes("evolve_stopped"))
|
|
||||||
rows.push({
|
|
||||||
label: "每天自动整合",
|
|
||||||
content: "每天自动整合会停止,已经生成的数据不会删除。",
|
|
||||||
});
|
|
||||||
if (plan.capabilities.evolve_available)
|
|
||||||
rows.push({
|
|
||||||
label: "切换后",
|
|
||||||
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
|
|
||||||
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
|
||||||
embedding_maintenance_in_progress:
|
|
||||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
|
||||||
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
|
|
||||||
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MemoryModeSettings({
|
export function MemoryModeSettings({
|
||||||
hasActiveModel,
|
hasActiveModel,
|
||||||
@@ -142,65 +62,9 @@ export function MemoryModeSettings({
|
|||||||
const [readFailed, setReadFailed] = useState(false);
|
const [readFailed, setReadFailed] = useState(false);
|
||||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||||
const refreshVersion = useRef(0);
|
const refreshVersion = useRef(0);
|
||||||
const dialog = useRef<HTMLElement>(null);
|
|
||||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||||
const focusEvolveWhenReady = useRef(false);
|
const focusEvolveWhenReady = useRef(false);
|
||||||
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
|
||||||
const busyRef = useRef(false);
|
|
||||||
busyRef.current = busy;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingMode || !dialog.current) return;
|
|
||||||
const panel = dialog.current;
|
|
||||||
const inertSiblings = new Map<HTMLElement, boolean>();
|
|
||||||
let branch: HTMLElement = panel.parentElement ?? panel;
|
|
||||||
while (branch.parentElement) {
|
|
||||||
for (const sibling of branch.parentElement.children) {
|
|
||||||
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
|
|
||||||
inertSiblings.set(sibling, sibling.inert);
|
|
||||||
sibling.inert = true;
|
|
||||||
}
|
|
||||||
branch = branch.parentElement;
|
|
||||||
if (branch === document.body) break;
|
|
||||||
}
|
|
||||||
const controls = () =>
|
|
||||||
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
|
|
||||||
controls()[0]?.focus();
|
|
||||||
const handleKey = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape" && !busyRef.current) {
|
|
||||||
event.preventDefault();
|
|
||||||
setPendingPlan(null);
|
|
||||||
setPendingMode(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab") return;
|
|
||||||
const items = controls();
|
|
||||||
const first = items[0];
|
|
||||||
const last = items.at(-1);
|
|
||||||
if (!first || !last) {
|
|
||||||
event.preventDefault();
|
|
||||||
panel.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
panel.addEventListener("keydown", handleKey);
|
|
||||||
return () => {
|
|
||||||
panel.removeEventListener("keydown", handleKey);
|
|
||||||
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
|
|
||||||
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
|
|
||||||
};
|
|
||||||
}, [pendingMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingMode && busy) dialog.current?.focus();
|
|
||||||
}, [busy, pendingMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
||||||
@@ -332,8 +196,10 @@ export function MemoryModeSettings({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
setScheduleState(await setEvolveSchedule(update));
|
setScheduleState(await setEvolveSchedule(update));
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setError("自动整合设置未能保存,请重试。");
|
setError("自动整合设置未能保存,请重试。");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -426,47 +292,13 @@ export function MemoryModeSettings({
|
|||||||
aria-labelledby={`${id}-evolve-title`}
|
aria-labelledby={`${id}-evolve-title`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="r6-evolve-heading">
|
<UltraEvolveScheduleFields
|
||||||
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
|
schedule={schedule}
|
||||||
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
headingId={`${id}-evolve-title`}
|
||||||
</div>
|
disabled={busy || maintenance}
|
||||||
<div className="r6-evolve-setting">
|
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
|
||||||
<div className="r6-evolve-setting-copy">
|
onTimeChange={(time) => changeSchedule({ time })}
|
||||||
<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>
|
|
||||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||||
<div className="r6-evolve-setting-copy">
|
<div className="r6-evolve-setting-copy">
|
||||||
<h3>{text("立即整合一次")}</h3>
|
<h3>{text("立即整合一次")}</h3>
|
||||||
@@ -496,64 +328,19 @@ export function MemoryModeSettings({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{pendingMode ? (
|
{pendingMode && pendingPlan ? (
|
||||||
<div className="r6-settings-dialog-layer" role="presentation">
|
<MemoryModeConfirmDialog
|
||||||
<section
|
mode={pendingMode}
|
||||||
ref={dialog}
|
plan={pendingPlan}
|
||||||
className="r6-settings-dialog r6-mode-confirm-dialog"
|
busy={busy}
|
||||||
role="dialog"
|
error={error}
|
||||||
aria-modal="true"
|
opener={dialogOpener.current}
|
||||||
aria-labelledby="memory-mode-confirm-title"
|
onCancel={() => {
|
||||||
tabIndex={-1}
|
setPendingPlan(null);
|
||||||
>
|
setPendingMode(null);
|
||||||
<h2 id="memory-mode-confirm-title">
|
}}
|
||||||
{text("切换到 {mode}?").replace(
|
onConfirm={() => void commitMode()}
|
||||||
"{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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
|||||||
render(<MemorySpace simplified />);
|
render(<MemorySpace simplified />);
|
||||||
|
|
||||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("1 条原始对话")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("找到 1 条")).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "来源归属" }),
|
||||||
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -320,9 +325,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
|||||||
setup();
|
setup();
|
||||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||||
reads.raw.mockImplementation(async (options = {}) => {
|
reads.raw.mockImplementation(async (options = {}) => {
|
||||||
const offset = "offset" in options ? (options.offset ?? 0) : 0;
|
const cursor = "cursor" in options ? options.cursor : null;
|
||||||
const source = "source" in options ? options.source : undefined;
|
const source = "source" in options ? options.source : undefined;
|
||||||
const rawId = `${source ?? "all"}-${offset}`;
|
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
|
||||||
return {
|
return {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -333,14 +338,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
|||||||
source: source ?? "cursor",
|
source: source ?? "cursor",
|
||||||
session_id: `session-${rawId}`,
|
session_id: `session-${rawId}`,
|
||||||
memory_at: null,
|
memory_at: null,
|
||||||
gmt_created: 1_761_000_000 - offset,
|
gmt_created: 1_761_000_000,
|
||||||
custom: {},
|
custom: {},
|
||||||
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total: source ? 1 : 21,
|
total: cursor ? 2 : 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset,
|
offset: cursor ? 1 : 0,
|
||||||
|
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
|
||||||
|
has_more: !source && !cursor,
|
||||||
|
total_exact: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -359,30 +367,43 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
|
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||||
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
|
await user.click(screen.getByRole("button", { name: "加载更多" }));
|
||||||
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
|
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
|
||||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
|
expect.objectContaining({
|
||||||
|
source: undefined,
|
||||||
|
cursor: "turn:1:0:1",
|
||||||
|
paging: "cursor",
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||||
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
||||||
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
|
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
|
||||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
|
expect.objectContaining({
|
||||||
|
source: "cursor",
|
||||||
|
cursor: null,
|
||||||
|
paging: "cursor",
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||||
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findAllByText("原始会话 one-memory-manual-0"),
|
await screen.findAllByText("原始会话 one-memory-manual-first"),
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: "one-memory-manual",
|
source: "one-memory-manual",
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
paging: "cursor",
|
||||||
|
cursor: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -609,6 +630,7 @@ it("opens a search hit with the complete role transcript", async () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||||
|
|
||||||
expect(await screen.findByText("完整问题")).toBeTruthy();
|
expect(await screen.findByText("完整问题")).toBeTruthy();
|
||||||
|
expect(screen.getByText("找到 1 条")).toBeTruthy();
|
||||||
expect(screen.getByText("完整回答")).toBeTruthy();
|
expect(screen.getByText("完整回答")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector(".map-dialogue-reading")?.textContent,
|
document.querySelector(".map-dialogue-reading")?.textContent,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export function MemorySpace({
|
|||||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||||
selectedRawRef.current = selectedRaw;
|
selectedRawRef.current = selectedRaw;
|
||||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||||
|
const rawRecordsScope = useRef("");
|
||||||
const [detailLoad, setDetailLoad] = useState<{
|
const [detailLoad, setDetailLoad] = useState<{
|
||||||
memoryId: string;
|
memoryId: string;
|
||||||
status: "loading" | "failed";
|
status: "loading" | "failed";
|
||||||
@@ -209,6 +210,7 @@ export function MemorySpace({
|
|||||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||||
|
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
|
||||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -226,6 +228,10 @@ export function MemorySpace({
|
|||||||
evolve_available: pipelineMode === "ultra",
|
evolve_available: pipelineMode === "ultra",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
const refreshSdkMap = useCallback(() => {
|
||||||
|
if (lite) setSdkRawCursor(null);
|
||||||
|
bumpSdkMap();
|
||||||
|
}, [bumpSdkMap, lite]);
|
||||||
const requestedQuery = sdkLibraryQuery.trim();
|
const requestedQuery = sdkLibraryQuery.trim();
|
||||||
const sdkPageSettled =
|
const sdkPageSettled =
|
||||||
sdkLibrary.settledMode === pipelineMode &&
|
sdkLibrary.settledMode === pipelineMode &&
|
||||||
@@ -276,7 +282,10 @@ export function MemorySpace({
|
|||||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||||
try {
|
try {
|
||||||
const rememberRaw = (items: RawMapRecord[]) => {
|
const rememberRaw = (items: RawMapRecord[]) => {
|
||||||
const next = new Map<string, RawMapRecord>();
|
const next =
|
||||||
|
sdkRawCursor && rawRecordsScope.current === windowKey
|
||||||
|
? new Map(rawRecords.current)
|
||||||
|
: new Map<string, RawMapRecord>();
|
||||||
if (selectedRawRef.current)
|
if (selectedRawRef.current)
|
||||||
next.set(
|
next.set(
|
||||||
rawRecordId(selectedRawRef.current),
|
rawRecordId(selectedRawRef.current),
|
||||||
@@ -284,6 +293,7 @@ export function MemorySpace({
|
|||||||
);
|
);
|
||||||
for (const item of items) next.set(rawRecordId(item), item);
|
for (const item of items) next.set(rawRecordId(item), item);
|
||||||
rawRecords.current = next;
|
rawRecords.current = next;
|
||||||
|
rawRecordsScope.current = windowKey;
|
||||||
};
|
};
|
||||||
const [page, graph] = lite
|
const [page, graph] = lite
|
||||||
? await Promise.all([
|
? await Promise.all([
|
||||||
@@ -297,8 +307,10 @@ export function MemorySpace({
|
|||||||
})
|
})
|
||||||
: getSdkRaw({
|
: getSdkRaw({
|
||||||
source,
|
source,
|
||||||
offset: sdkLibraryOffset,
|
offset: 0,
|
||||||
limit: SDK_MEMORY_PAGE_SIZE,
|
limit: SDK_MEMORY_PAGE_SIZE,
|
||||||
|
paging: "cursor",
|
||||||
|
cursor: sdkRawCursor,
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
}).then((rawPage) => {
|
}).then((rawPage) => {
|
||||||
rememberRaw(rawPage.items);
|
rememberRaw(rawPage.items);
|
||||||
@@ -346,17 +358,64 @@ export function MemorySpace({
|
|||||||
};
|
};
|
||||||
// The window expired: the server searched again and answered page 1.
|
// The window expired: the server searched again and answered page 1.
|
||||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||||
setSdkLibrary({
|
setSdkLibrary((current) => {
|
||||||
page,
|
const currentPage = current.page;
|
||||||
loading: false,
|
const append =
|
||||||
failed: false,
|
lite &&
|
||||||
settledSource: sdkLibrarySource,
|
sdkRawCursor !== null &&
|
||||||
settledQuery: sdkLibraryQuery,
|
currentPage !== null &&
|
||||||
settledMode: pipelineMode,
|
current.settledSource === sdkLibrarySource &&
|
||||||
|
current.settledQuery === sdkLibraryQuery &&
|
||||||
|
current.settledMode === pipelineMode;
|
||||||
|
if (!append)
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
loading: false,
|
||||||
|
failed: false,
|
||||||
|
settledSource: sdkLibrarySource,
|
||||||
|
settledQuery: sdkLibraryQuery,
|
||||||
|
settledMode: pipelineMode,
|
||||||
|
};
|
||||||
|
const items = [...currentPage.items];
|
||||||
|
const known = new Set(items.map((item) => item.memory_id));
|
||||||
|
for (const item of page.items)
|
||||||
|
if (!known.has(item.memory_id)) {
|
||||||
|
known.add(item.memory_id);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
page: {
|
||||||
|
...page,
|
||||||
|
items,
|
||||||
|
total: items.length + (page.has_more ? 1 : 0),
|
||||||
|
offset: 0,
|
||||||
|
source_ids_by_memory: {
|
||||||
|
...currentPage.source_ids_by_memory,
|
||||||
|
...page.source_ids_by_memory,
|
||||||
|
},
|
||||||
|
source_time_by_memory: {
|
||||||
|
...currentPage.source_time_by_memory,
|
||||||
|
...page.source_time_by_memory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
failed: false,
|
||||||
|
settledSource: sdkLibrarySource,
|
||||||
|
settledQuery: sdkLibraryQuery,
|
||||||
|
settledMode: pipelineMode,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setSdkGraph({ scope: windowKey, ...graph });
|
setSdkGraph({ scope: windowKey, ...graph });
|
||||||
} catch {
|
} catch (reason) {
|
||||||
if (cancelled || request.signal.aborted) return;
|
if (cancelled || request.signal.aborted) return;
|
||||||
|
if (
|
||||||
|
lite &&
|
||||||
|
sdkRawCursor !== null &&
|
||||||
|
apiErrorCode(reason) === "raw_cursor_stale"
|
||||||
|
) {
|
||||||
|
setSdkRawCursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSdkLibrary((current) => ({
|
setSdkLibrary((current) => ({
|
||||||
...current,
|
...current,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -365,10 +424,13 @@ export function MemorySpace({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void read(true);
|
void read(true);
|
||||||
const timer = setInterval(() => void read(false), 30_000);
|
const timer =
|
||||||
|
lite && sdkRawCursor !== null
|
||||||
|
? undefined
|
||||||
|
: setInterval(() => void read(false), 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(timer);
|
if (timer !== undefined) clearInterval(timer);
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -377,6 +439,7 @@ export function MemorySpace({
|
|||||||
sdkLibraryOffset,
|
sdkLibraryOffset,
|
||||||
sdkLibraryQuery,
|
sdkLibraryQuery,
|
||||||
sdkLibrarySource,
|
sdkLibrarySource,
|
||||||
|
sdkRawCursor,
|
||||||
lite,
|
lite,
|
||||||
pipelineMode,
|
pipelineMode,
|
||||||
]);
|
]);
|
||||||
@@ -525,6 +588,7 @@ export function MemorySpace({
|
|||||||
rawRecords.current.clear();
|
rawRecords.current.clear();
|
||||||
sdkSearchWindow.current = { key: "", id: null };
|
sdkSearchWindow.current = { key: "", id: null };
|
||||||
setSdkLibraryOffset(0);
|
setSdkLibraryOffset(0);
|
||||||
|
setSdkRawCursor(null);
|
||||||
setSdkLibraryQuery("");
|
setSdkLibraryQuery("");
|
||||||
setSdkGraph(null);
|
setSdkGraph(null);
|
||||||
setSdkLibrary((current) => ({
|
setSdkLibrary((current) => ({
|
||||||
@@ -804,7 +868,7 @@ export function MemorySpace({
|
|||||||
);
|
);
|
||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
closeDetail();
|
closeDetail();
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
trace.done(writeTraceStatus(reason), {
|
trace.done(writeTraceStatus(reason), {
|
||||||
reason: "memory_action_failed",
|
reason: "memory_action_failed",
|
||||||
@@ -833,7 +897,7 @@ export function MemorySpace({
|
|||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
setDetail(sdkDetail(saved));
|
setDetail(sdkDetail(saved));
|
||||||
setDraft(sdkEditableText(saved));
|
setDraft(sdkEditableText(saved));
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
trace.done(writeTraceStatus(reason), {
|
trace.done(writeTraceStatus(reason), {
|
||||||
reason: isDefiniteRefusal(reason)
|
reason: isDefiniteRefusal(reason)
|
||||||
@@ -898,10 +962,11 @@ export function MemorySpace({
|
|||||||
? t("memory:记忆暂时无法读取")
|
? t("memory:记忆暂时无法读取")
|
||||||
: lite
|
: lite
|
||||||
? sdkPage
|
? sdkPage
|
||||||
? `${t("memory:原始对话")} · ${tn(
|
? sdkPage.total_exact === false
|
||||||
"sdk.raw.found",
|
? t("memory:原始对话")
|
||||||
sdkPage.total,
|
: sdkLibrary.settledQuery.trim()
|
||||||
)}`
|
? tn("sdk.raw.found", sdkPage.total)
|
||||||
|
: tn("sdk.raw.total", sdkPage.total)
|
||||||
: sdkLibrary.failed
|
: sdkLibrary.failed
|
||||||
? null
|
? null
|
||||||
: t("memory:正在读取…")
|
: t("memory:正在读取…")
|
||||||
@@ -934,7 +999,7 @@ export function MemorySpace({
|
|||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
// A finished addition is new page content: re-read now rather than
|
// A finished addition is new page content: re-read now rather than
|
||||||
// leave it for the next poll.
|
// leave it for the next poll.
|
||||||
bumpSdkMap();
|
refreshSdkMap();
|
||||||
}}
|
}}
|
||||||
onOpenMemory={(id) => {
|
onOpenMemory={(id) => {
|
||||||
if (!simplified) setMode("library");
|
if (!simplified) setMode("library");
|
||||||
@@ -1201,12 +1266,21 @@ export function MemorySpace({
|
|||||||
settledSource: sdkLibrary.settledSource,
|
settledSource: sdkLibrary.settledSource,
|
||||||
settledQuery: sdkLibrary.settledQuery,
|
settledQuery: sdkLibrary.settledQuery,
|
||||||
requestedOffset: sdkLibraryOffset,
|
requestedOffset: sdkLibraryOffset,
|
||||||
|
cursorPaging: lite && !sdkLibraryQuery.trim(),
|
||||||
|
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
|
||||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||||
onQuery: setSdkLibraryQuery,
|
onQuery: setSdkLibraryQuery,
|
||||||
onSource: setSdkLibrarySource,
|
onSource: setSdkLibrarySource,
|
||||||
resetPage: () => setSdkLibraryOffset(0),
|
resetPage: () => {
|
||||||
|
setSdkLibraryOffset(0);
|
||||||
|
setSdkRawCursor(null);
|
||||||
|
},
|
||||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||||
onRetry: bumpSdkMap,
|
onMore: () => {
|
||||||
|
if (sdkPage?.next_cursor)
|
||||||
|
setSdkRawCursor(sdkPage.next_cursor);
|
||||||
|
},
|
||||||
|
onRetry: refreshSdkMap,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
|
|||||||
...profile,
|
...profile,
|
||||||
profile_id: "created",
|
profile_id: "created",
|
||||||
revision: 1,
|
revision: 1,
|
||||||
capabilities,
|
capabilities: {
|
||||||
|
...capabilities,
|
||||||
|
endpoint_reachable: false,
|
||||||
|
model_available: false,
|
||||||
|
embedding_available: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
profiles = [];
|
profiles = [];
|
||||||
create.mockImplementation(async () => {
|
create.mockImplementation(async () => {
|
||||||
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
|
|||||||
});
|
});
|
||||||
probeProfile.mockResolvedValue({
|
probeProfile.mockResolvedValue({
|
||||||
profile_id: created.profile_id,
|
profile_id: created.profile_id,
|
||||||
profile_revision: created.revision,
|
input_profile_revision: created.revision,
|
||||||
|
profile_revision: 2,
|
||||||
stages: [],
|
stages: [],
|
||||||
capabilities,
|
capabilities,
|
||||||
});
|
});
|
||||||
activate.mockResolvedValue({ ...created, is_active: true });
|
activate.mockResolvedValue({
|
||||||
|
...created,
|
||||||
|
revision: 2,
|
||||||
|
capabilities,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
|
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText("配置名称"), "Current model");
|
await user.type(screen.getByLabelText("配置名称"), "Current model");
|
||||||
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
|
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
|
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
|
||||||
expect(planActivation).toHaveBeenCalledWith(
|
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
|
||||||
created.profile_id,
|
|
||||||
created.revision,
|
|
||||||
);
|
|
||||||
expect(activate).toHaveBeenCalledWith(created.profile_id);
|
expect(activate).toHaveBeenCalledWith(created.profile_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
presetForGeneration,
|
presetForGeneration,
|
||||||
validateGenerationModel,
|
validateGenerationModel,
|
||||||
} from "./GenerationModelPrimitives";
|
} from "./GenerationModelPrimitives";
|
||||||
|
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||||
import { OptionMenu } from "./OptionMenu";
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||||
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ready(
|
|
||||||
profile: ModelProfile,
|
|
||||||
probe: ModelProbeResult | null = null,
|
|
||||||
error: string | null = null,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
!error &&
|
|
||||||
(!probe ||
|
|
||||||
(probe.profile_id === profile.profile_id &&
|
|
||||||
probe.profile_revision === profile.revision &&
|
|
||||||
probe.capabilities.endpoint_reachable &&
|
|
||||||
probe.capabilities.model_available &&
|
|
||||||
probe.capabilities.embedding_available)) &&
|
|
||||||
profile.credential_state !== "unavailable" &&
|
|
||||||
!(profile.has_configured_api_key && !profile.has_api_key) &&
|
|
||||||
profile.capabilities.endpoint_reachable &&
|
|
||||||
profile.capabilities.model_available &&
|
|
||||||
profile.capabilities.embedding_available
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function endpointLabel(value: string | null): string {
|
function endpointLabel(value: string | null): string {
|
||||||
if (!value) return "未提供接口地址";
|
if (!value) return "未提供接口地址";
|
||||||
try {
|
try {
|
||||||
@@ -467,7 +447,11 @@ export function ModelSettings({
|
|||||||
technicalTouched.current.clear();
|
technicalTouched.current.clear();
|
||||||
};
|
};
|
||||||
const isReady = (profile: ModelProfile) =>
|
const isReady = (profile: ModelProfile) =>
|
||||||
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
|
modelProfileReady(
|
||||||
|
profile,
|
||||||
|
models.probeFor(profile),
|
||||||
|
models.probeErrorFor(profile),
|
||||||
|
);
|
||||||
|
|
||||||
function editProfile(profile: ModelProfile) {
|
function editProfile(profile: ModelProfile) {
|
||||||
if (ownerLocked) return;
|
if (ownerLocked) return;
|
||||||
@@ -626,7 +610,7 @@ export function ModelSettings({
|
|||||||
!active ||
|
!active ||
|
||||||
active.profile_id !== profile.profile_id ||
|
active.profile_id !== profile.profile_id ||
|
||||||
!active.is_active ||
|
!active.is_active ||
|
||||||
!ready(active, result)
|
!modelProfileReady(active, result)
|
||||||
) {
|
) {
|
||||||
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
||||||
return false;
|
return false;
|
||||||
@@ -640,38 +624,15 @@ export function ModelSettings({
|
|||||||
setSetupPhase("testing");
|
setSetupPhase("testing");
|
||||||
const result = await models.probeProfile(profile.profile_id);
|
const result = await models.probeProfile(profile.profile_id);
|
||||||
if (!alive.current) return false;
|
if (!alive.current) return false;
|
||||||
if (
|
const testedProfile = profileFromProbe(profile, result);
|
||||||
result &&
|
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
|
||||||
result.profile_id === profile.profile_id &&
|
|
||||||
result.profile_revision !== profile.revision
|
|
||||||
) {
|
|
||||||
// A newer revision used to be accepted here, and then activation turned
|
|
||||||
// on whatever the configuration had become. The user is told the
|
|
||||||
// settings they filled in passed, so this hands them a model they never
|
|
||||||
// tested. `ready` below cannot catch it either: by then the probe and
|
|
||||||
// the activated profile are both on the new revision and agree.
|
|
||||||
setSetupError(
|
|
||||||
result.profile_revision > profile.revision
|
|
||||||
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
|
|
||||||
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!result ||
|
|
||||||
result.profile_id !== profile.profile_id ||
|
|
||||||
result.profile_revision !== profile.revision ||
|
|
||||||
!result.capabilities.endpoint_reachable ||
|
|
||||||
!result.capabilities.model_available ||
|
|
||||||
!result.capabilities.embedding_available
|
|
||||||
) {
|
|
||||||
setSetupError(
|
setSetupError(
|
||||||
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
|
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
|
||||||
);
|
);
|
||||||
setFields(probeFieldErrors(result));
|
setFields(probeFieldErrors(result));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return activateWithPlan(profile, result);
|
return activateWithPlan(testedProfile, result ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateTested(profile: ModelProfile) {
|
async function activateTested(profile: ModelProfile) {
|
||||||
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
|
disabled={
|
||||||
|
busy ||
|
||||||
|
!canEnable ||
|
||||||
|
!modelProfileReady(profile, probe, probeError)
|
||||||
|
}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
>
|
>
|
||||||
{text("设为当前模型")}
|
{text("设为当前模型")}
|
||||||
|
|||||||
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().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 }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
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("button", { name: "进入第三步" }));
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "确认选择" }),
|
||||||
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
expect(finish).not.toHaveBeenCalled();
|
expect(finish).not.toHaveBeenCalled();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,10 +183,13 @@ it("lets an empty first-time user enter without granting or importing a source",
|
|||||||
onFinish={finish}
|
onFinish={finish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||||
await userEvent.click(skip);
|
await userEvent.click(skip);
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "确认选择" }),
|
||||||
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||||
@@ -184,10 +209,13 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
|||||||
onFinish={finish}
|
onFinish={finish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||||
await userEvent.click(skip);
|
await userEvent.click(skip);
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "确认选择" }),
|
||||||
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||||
expect(finish).not.toHaveBeenCalled();
|
expect(finish).not.toHaveBeenCalled();
|
||||||
@@ -273,7 +301,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
|
|||||||
resolveRefresh = resolve;
|
resolveRefresh = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "开始导入" }),
|
||||||
|
);
|
||||||
const lateSources = [source("workbuddy"), source("codex")];
|
const lateSources = [source("workbuddy"), source("codex")];
|
||||||
rendered.rerender(view({ ...imports, sources: lateSources }));
|
rendered.rerender(view({ ...imports, sources: lateSources }));
|
||||||
expect(checkbox("codex").checked).toBe(false);
|
expect(checkbox("codex").checked).toBe(false);
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getModelProfiles: vi.fn(),
|
getModelProfiles: vi.fn(),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
|
|||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [],
|
||||||
|
}),
|
||||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.queryByText(
|
||||||
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.queryByText(
|
||||||
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
||||||
),
|
),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("region", {
|
screen.getByRole("region", {
|
||||||
name: "Progreso de importación de las aplicaciones",
|
name: "Progreso de importación de las aplicaciones",
|
||||||
@@ -305,8 +324,8 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
|||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/A continuación, elige un modo de memoria/),
|
screen.queryByText(/A continuación, elige un modo de memoria/),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
@@ -314,7 +333,7 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
|||||||
fireEvent.click(screen.getByText("中文"));
|
fireEvent.click(screen.getByText("中文"));
|
||||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(navigation.textContent).toContain("连接应用");
|
expect(navigation.textContent).toContain("连接应用");
|
||||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
expect(draft).not.toHaveBeenCalled();
|
expect(draft).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -406,6 +425,12 @@ it("translates completion recovery live without repeating completion writes", as
|
|||||||
new Error("fixture-offline"),
|
new Error("fixture-offline"),
|
||||||
);
|
);
|
||||||
const imports = controller();
|
const imports = controller();
|
||||||
|
imports.sources = [];
|
||||||
|
vi.mocked(imports.refresh).mockResolvedValue({
|
||||||
|
sources: [],
|
||||||
|
jobs: [],
|
||||||
|
health: null as never,
|
||||||
|
});
|
||||||
const finish = vi.fn();
|
const finish = vi.fn();
|
||||||
render(
|
render(
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
@@ -415,13 +440,16 @@ it("translates completion recovery live without repeating completion writes", as
|
|||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||||
).toHaveProperty("disabled", false),
|
).toHaveProperty("disabled", false),
|
||||||
);
|
);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Confirmar selección" }),
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||||
|
|||||||
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
|
|||||||
getImportJobs: vi.fn(),
|
getImportJobs: vi.fn(),
|
||||||
connectSources: vi.fn(),
|
connectSources: vi.fn(),
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
}),
|
}),
|
||||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
planMemoryMode: vi.fn().mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
|
blockers: [],
|
||||||
|
}),
|
||||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||||
getSourceConnection: vi.fn(),
|
getSourceConnection: vi.fn(),
|
||||||
@@ -159,8 +178,23 @@ beforeEach(() => {
|
|||||||
} as never);
|
} as never);
|
||||||
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
||||||
vi.mocked(api.planMemoryMode).mockResolvedValue({
|
vi.mocked(api.planMemoryMode).mockResolvedValue({
|
||||||
|
current: "pro",
|
||||||
|
target: "lite",
|
||||||
|
effective_target: "lite",
|
||||||
|
capabilities: {
|
||||||
|
content_plane: "raw",
|
||||||
|
generation_required: false,
|
||||||
|
graph_enabled: false,
|
||||||
|
evolve_available: false,
|
||||||
|
},
|
||||||
|
effects: [],
|
||||||
|
warnings: [],
|
||||||
blockers: [],
|
blockers: [],
|
||||||
} as never);
|
} as never);
|
||||||
|
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
});
|
||||||
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
||||||
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -313,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 () => {
|
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
||||||
await ready();
|
await ready();
|
||||||
await waitFor(() => expect(count()).toBe("0"));
|
await waitFor(() => expect(count()).toBe("0"));
|
||||||
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
|
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
|
||||||
"disabled",
|
"disabled",
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||||
@@ -363,14 +398,9 @@ it("partial acceptance preserves the successful job and failure through model-st
|
|||||||
"accepted-job:completed",
|
"accepted-job:completed",
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
|
|
||||||
expect(screen.getByLabelText("保留任务").textContent).toBe(
|
|
||||||
"accepted-job:completed",
|
|
||||||
);
|
|
||||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
|
||||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
|||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
completeOnboarding: vi.fn(),
|
completeOnboarding: vi.fn(),
|
||||||
|
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
time: "03:00",
|
||||||
|
}),
|
||||||
|
setEvolveSchedule: vi.fn(),
|
||||||
|
memoryModePlanUnsupported: vi.fn(() => false),
|
||||||
getMemoryMode: vi.fn().mockResolvedValue({
|
getMemoryMode: vi.fn().mockResolvedValue({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
|
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getByRole("navigation", { name: "首次设置进度" })
|
.getByRole("navigation", { name: "首次设置进度" })
|
||||||
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
|||||||
).toContain("导入历史");
|
).toContain("导入历史");
|
||||||
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||||
).toBeTruthy();
|
).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
||||||
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
|
|||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||||
await screen.findByText("已受理 workbuddy");
|
await screen.findByText("已受理 workbuddy");
|
||||||
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
||||||
"workbuddy",
|
"workbuddy",
|
||||||
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
|
|||||||
done = resolve;
|
done = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||||
view.unmount();
|
view.unmount();
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
done({
|
done({
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const getMemoryProcessing = vi.fn();
|
|||||||
const previewSourceDirectory = vi.fn();
|
const previewSourceDirectory = vi.fn();
|
||||||
const saveSourceDirectory = vi.fn();
|
const saveSourceDirectory = vi.fn();
|
||||||
const getSourceImportReceipt = vi.fn();
|
const getSourceImportReceipt = vi.fn();
|
||||||
|
let modelActive = false;
|
||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
getHealth: vi.fn(),
|
getHealth: vi.fn(),
|
||||||
@@ -52,6 +53,15 @@ vi.mock("./api", () => ({
|
|||||||
setSourceSync: vi.fn(),
|
setSourceSync: vi.fn(),
|
||||||
changeImportJob: vi.fn(),
|
changeImportJob: vi.fn(),
|
||||||
getImportJob: 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),
|
apiErrorCode: vi.fn(() => null),
|
||||||
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
|
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
|
||||||
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
|
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
|
||||||
@@ -91,6 +101,8 @@ vi.mock("./OnboardingModelController", () => ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
modelActive = true;
|
||||||
|
getModelProfiles.mockResolvedValue([{ is_active: true }]);
|
||||||
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
|
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
|
||||||
onModelActiveConfirmed(
|
onModelActiveConfirmed(
|
||||||
generationToken,
|
generationToken,
|
||||||
@@ -100,6 +112,20 @@ vi.mock("./OnboardingModelController", () => ({
|
|||||||
>
|
>
|
||||||
模型启用回执已确认
|
模型启用回执已确认
|
||||||
</button>
|
</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 type="button" onClick={onSkip ?? onBack}>
|
||||||
跳过模型进入应用
|
跳过模型进入应用
|
||||||
</button>
|
</button>
|
||||||
@@ -193,7 +219,7 @@ function imports(
|
|||||||
|
|
||||||
const READ_CONSENT = "允许读取以上来源的本次历史";
|
const READ_CONSENT = "允许读取以上来源的本次历史";
|
||||||
const START = "开始导入";
|
const START = "开始导入";
|
||||||
const SKIP = "稍后添加,先进入";
|
const SKIP = "选择记忆模式";
|
||||||
async function openApps(_user?: ReturnType<typeof userEvent.setup>) {
|
async function openApps(_user?: ReturnType<typeof userEvent.setup>) {
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -213,13 +239,20 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
}
|
}
|
||||||
async function openModelStep(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("button", { name: "选择记忆模式" }));
|
||||||
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: "继续配置模型" }));
|
||||||
}
|
}
|
||||||
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
|
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 进入" }));
|
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") {
|
function choice(name = "Claude Code") {
|
||||||
return screen.getByRole<HTMLInputElement>("checkbox", {
|
return screen.getByRole<HTMLInputElement>("checkbox", {
|
||||||
name: `导入 ${name} 历史`,
|
name: `导入 ${name} 历史`,
|
||||||
@@ -253,6 +286,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
describe("OnboardingFlow S1.3", () => {
|
describe("OnboardingFlow S1.3", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
modelActive = false;
|
||||||
resetConfirmedMemoryModeForTests();
|
resetConfirmedMemoryModeForTests();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
window.oneMemory = undefined;
|
window.oneMemory = undefined;
|
||||||
@@ -261,15 +295,16 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
});
|
});
|
||||||
setMemoryMode.mockResolvedValue({ mode: "pro" });
|
setMemoryMode.mockResolvedValue({ mode: "pro" });
|
||||||
planMemoryMode.mockResolvedValue({
|
planMemoryMode.mockImplementation(async (target) => ({
|
||||||
current: "lite",
|
current: "lite",
|
||||||
target: "pro",
|
target,
|
||||||
effective_target: "pro",
|
effective_target: target,
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
effects: [],
|
effects: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
blockers: [],
|
blockers:
|
||||||
});
|
target !== "lite" && !modelActive ? ["active_model_required"] : [],
|
||||||
|
}));
|
||||||
putOnboardingProgress.mockImplementation(
|
putOnboardingProgress.mockImplementation(
|
||||||
async (update: Partial<OnboardingState>) => ({
|
async (update: Partial<OnboardingState>) => ({
|
||||||
...onboarding,
|
...onboarding,
|
||||||
@@ -287,6 +322,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
configured_mode: "pro",
|
configured_mode: "pro",
|
||||||
explicitly_configured: false,
|
explicitly_configured: false,
|
||||||
});
|
});
|
||||||
|
getModelProfiles.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -434,15 +470,161 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
modelBoundary,
|
modelBoundary,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByRole("alert").textContent).toContain(
|
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(
|
expect(
|
||||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||||
).toBeTruthy();
|
).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 () => {
|
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(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
expect(finish).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 () => {
|
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: "模型启用回执已确认" }),
|
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||||
);
|
);
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
expect(getModelProfiles).toHaveBeenCalledOnce();
|
||||||
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("heading", { name: "配置一个模型,记忆才会生成" }),
|
screen.queryByRole("heading", { name: "配置一个模型,记忆才会生成" }),
|
||||||
@@ -744,7 +926,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
);
|
);
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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 进入" }));
|
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||||
@@ -766,26 +948,324 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
|
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
|
||||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||||
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
|
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
|
||||||
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
|
expect(screen.queryByText("推荐")).toBeNull();
|
||||||
"Pro",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
|
|
||||||
).toBeTruthy();
|
|
||||||
const modeStep = screen
|
const modeStep = screen
|
||||||
.getByRole("navigation", { name: "首次设置进度" })
|
.getByRole("navigation", { name: "首次设置进度" })
|
||||||
.querySelector('[aria-current="step"]');
|
.querySelector('[aria-current="step"]');
|
||||||
expect(modeStep?.getAttribute("aria-current")).toBe("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();
|
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();
|
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 () => {
|
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
|
||||||
let resolveMode!: (value: {
|
let resolveMode!: (value: {
|
||||||
mode: "pro";
|
mode: "pro";
|
||||||
@@ -809,7 +1289,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||||
await user.click(lite);
|
await selectMode(user, /lite/i);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
resolveMode({
|
resolveMode({
|
||||||
mode: "pro",
|
mode: "pro",
|
||||||
@@ -843,7 +1323,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
"disabled",
|
"disabled",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
await user.click(lite);
|
await selectMode(user, /lite/i);
|
||||||
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
|
|
||||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
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 () => {
|
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
|
||||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||||
|
let resolveLite!: (value: { mode: "lite" }) => void;
|
||||||
setMemoryMode
|
setMemoryMode
|
||||||
.mockImplementationOnce(
|
.mockImplementationOnce(
|
||||||
() =>
|
() =>
|
||||||
@@ -869,7 +1350,12 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
resolvePro = resolve;
|
resolvePro = resolve;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mockResolvedValueOnce({ mode: "lite" });
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveLite = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const finish = vi.fn();
|
const finish = vi.fn();
|
||||||
render(
|
render(
|
||||||
@@ -882,7 +1368,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
|
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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: "继续配置模型" }));
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||||
@@ -890,7 +1376,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
|
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
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 进入" }));
|
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
expect(setMemoryMode).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -900,10 +1386,119 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
"pro",
|
"pro",
|
||||||
"lite",
|
"lite",
|
||||||
]);
|
]);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "正在保存模式…" }),
|
||||||
|
).toHaveProperty("disabled", true);
|
||||||
|
await act(async () => resolveLite({ mode: "lite" }));
|
||||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
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 () => {
|
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
|
||||||
let resolvePro!: (value: { mode: "pro" }) => void;
|
let resolvePro!: (value: { mode: "pro" }) => void;
|
||||||
setMemoryMode.mockImplementationOnce(
|
setMemoryMode.mockImplementationOnce(
|
||||||
@@ -923,7 +1518,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
|
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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: "继续配置模型" }));
|
||||||
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
|
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
|
||||||
await user.click(
|
await user.click(
|
||||||
@@ -972,7 +1567,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
|
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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: "继续配置模型" }));
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
screen.getByRole("button", { name: "模型启用回执已确认" }),
|
||||||
@@ -1012,7 +1607,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
|
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
|
||||||
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
|
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: "继续配置模型" }));
|
||||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||||
const returnedTitle = screen.getByRole("heading", {
|
const returnedTitle = screen.getByRole("heading", {
|
||||||
@@ -1042,7 +1637,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
|
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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: "继续配置模型" }));
|
||||||
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
|
||||||
expect(screen.queryByRole("alert")).toBeNull();
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
@@ -1061,7 +1656,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
|
|
||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
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: "继续配置模型" }));
|
||||||
const lateReceipt = screen.getByRole("button", {
|
const lateReceipt = screen.getByRole("button", {
|
||||||
name: "模型启用回执已确认",
|
name: "模型启用回执已确认",
|
||||||
@@ -1110,7 +1705,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await openApps();
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
|
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: "继续配置模型" }));
|
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
"模式未能保存",
|
"模式未能保存",
|
||||||
@@ -1143,17 +1738,17 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||||
expect(onFinish).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 进入" }));
|
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
expect(getModelProfiles).toHaveBeenCalledOnce();
|
||||||
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
expect(getMemoryProcessing).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skipping after a selection does not grant, install, sync or connect", async () => {
|
it("skipping after a selection does not grant, install, sync or connect", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const controller = imports();
|
const controller = imports({ sources: [] });
|
||||||
const onFinish = vi.fn();
|
const onFinish = vi.fn();
|
||||||
render(
|
render(
|
||||||
<OnboardingFlow
|
<OnboardingFlow
|
||||||
@@ -1162,7 +1757,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await choose(user);
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
await chooseLiteAndContinue(user);
|
await chooseLiteAndContinue(user);
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
@@ -1218,6 +1813,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||||
expect(choice()).toHaveProperty("disabled", false);
|
expect(choice()).toHaveProperty("disabled", false);
|
||||||
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
||||||
|
await user.click(choice());
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
await chooseLiteAndContinue(user);
|
await chooseLiteAndContinue(user);
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
@@ -1242,6 +1838,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
expect(screen.getByRole("alert")).toBeTruthy();
|
expect(screen.getByRole("alert")).toBeTruthy();
|
||||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||||
expect(onFinish).not.toHaveBeenCalled();
|
expect(onFinish).not.toHaveBeenCalled();
|
||||||
|
await user.click(choice());
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
await chooseLiteAndContinue(user);
|
await chooseLiteAndContinue(user);
|
||||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||||
@@ -1324,9 +1921,9 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
await user.click(choice());
|
await user.click(choice());
|
||||||
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
|
||||||
expect(choice()).toHaveProperty("checked", false);
|
expect(choice()).toHaveProperty("checked", false);
|
||||||
expect(screen.getByRole("button", { name: START })).toHaveProperty(
|
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
||||||
"disabled",
|
"disabled",
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2133,10 +2730,9 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
"disabled",
|
"disabled",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
|
expect(
|
||||||
"disabled",
|
screen.getByRole("button", { name: "正在开始导入…" }),
|
||||||
true,
|
).toHaveProperty("disabled", true);
|
||||||
);
|
|
||||||
expect(controller.refresh).toHaveBeenCalledTimes(2);
|
expect(controller.refresh).toHaveBeenCalledTimes(2);
|
||||||
await act(async () => resolve());
|
await act(async () => resolve());
|
||||||
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
|
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 () => {
|
it("lets the user leave after completion cannot be persisted without reconnecting", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
completeOnboarding.mockRejectedValue(new Error("disk unavailable"));
|
completeOnboarding.mockRejectedValue(new Error("disk unavailable"));
|
||||||
const controller = imports();
|
const controller = imports({ sources: [] });
|
||||||
const onFinish = vi.fn();
|
const onFinish = vi.fn();
|
||||||
render(
|
render(
|
||||||
<OnboardingFlow
|
<OnboardingFlow
|
||||||
@@ -2488,6 +3084,7 @@ describe("OnboardingFlow S1.3", () => {
|
|||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await openApps();
|
||||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||||
await chooseLiteAndContinue(user);
|
await chooseLiteAndContinue(user);
|
||||||
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
ConnectSourcesResponse,
|
ConnectSourcesResponse,
|
||||||
|
EvolveSchedule,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
|
MemoryModePlan,
|
||||||
|
MemoryPipelineMode,
|
||||||
|
OnboardingModeIntent,
|
||||||
OnboardingState,
|
OnboardingState,
|
||||||
SourceResponse,
|
SourceResponse,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
|
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@@ -18,10 +23,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
|
getEvolveSchedule,
|
||||||
getMemoryMode,
|
getMemoryMode,
|
||||||
|
getModelProfiles,
|
||||||
|
memoryModePlanUnsupported,
|
||||||
patchOnboardingProgress,
|
patchOnboardingProgress,
|
||||||
planMemoryMode,
|
planMemoryMode,
|
||||||
putOnboardingProgress,
|
putOnboardingProgress,
|
||||||
|
setEvolveSchedule,
|
||||||
setMemoryMode,
|
setMemoryMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { AppMark } from "./app-mark";
|
import { AppMark } from "./app-mark";
|
||||||
@@ -41,17 +50,23 @@ import {
|
|||||||
hostWriteReason,
|
hostWriteReason,
|
||||||
} from "./host-state";
|
} from "./host-state";
|
||||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||||
|
import {
|
||||||
|
BLOCKER_COPY,
|
||||||
|
MemoryModeConfirmDialog,
|
||||||
|
MODE_COPY,
|
||||||
|
} from "./memory-mode-ui";
|
||||||
import { OnboardingModelController } from "./OnboardingModelController";
|
import { OnboardingModelController } from "./OnboardingModelController";
|
||||||
import { useOnboardingTranslation } from "./onboarding-language";
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
|
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
|
||||||
import { traceUiAction } from "./ui-trace";
|
import { traceUiAction } from "./ui-trace";
|
||||||
import type { ImportSourcesController } from "./use-import-sources";
|
import type { ImportSourcesController } from "./use-import-sources";
|
||||||
import { isDefiniteRefusal } from "./write-outcome";
|
import { isDefiniteRefusal } from "./write-outcome";
|
||||||
import "./cold-start.css";
|
import "./cold-start.css";
|
||||||
|
|
||||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||||
type OnboardingMemoryMode = "lite" | "pro";
|
type OnboardingMemoryMode = OnboardingModeIntent;
|
||||||
|
|
||||||
// Cold start neither reads nor writes installation intent.
|
// Cold start neither reads nor writes installation intent.
|
||||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||||
@@ -112,9 +127,8 @@ export function OnboardingFlow({
|
|||||||
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
||||||
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
||||||
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
||||||
const [busy, setBusy] = useState<
|
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
|
||||||
"checking" | "submitting" | "leaving" | null
|
const [completionBusy, setCompletionBusy] = useState(false);
|
||||||
>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||||
const [exitDestination, setExitDestination] =
|
const [exitDestination, setExitDestination] =
|
||||||
@@ -125,7 +139,9 @@ export function OnboardingFlow({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const resumedStep =
|
const resumedStep =
|
||||||
state.mode_intent === "pro" && state.setup_step === "model"
|
state.mode_intent &&
|
||||||
|
state.mode_intent !== "lite" &&
|
||||||
|
state.setup_step === "model"
|
||||||
? "model"
|
? "model"
|
||||||
: state.mode_intent
|
: state.mode_intent
|
||||||
? "mode"
|
? "mode"
|
||||||
@@ -138,8 +154,26 @@ export function OnboardingFlow({
|
|||||||
);
|
);
|
||||||
const [modeDestination, setModeDestination] =
|
const [modeDestination, setModeDestination] =
|
||||||
useState<OnboardingDestination>("sources");
|
useState<OnboardingDestination>("sources");
|
||||||
const [modeSaving, setModeSaving] = useState(false);
|
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
const modeSaving = modePhase !== "idle";
|
||||||
const [modeError, setModeError] = useState<string | null>(null);
|
const [modeError, setModeError] = useState<string | null>(null);
|
||||||
|
const [knownMode, setKnownMode] = useState<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(
|
const [modeFocusToken, setModeFocusToken] = useState(
|
||||||
resumedStep === "mode" ? 1 : 0,
|
resumedStep === "mode" ? 1 : 0,
|
||||||
);
|
);
|
||||||
@@ -181,7 +215,10 @@ export function OnboardingFlow({
|
|||||||
)
|
)
|
||||||
.join("、"),
|
.join("、"),
|
||||||
});
|
});
|
||||||
const flight = useRef(false);
|
const sourceFlight = useRef(false);
|
||||||
|
const modeOperation = useRef(0);
|
||||||
|
const proConfirmationOwner = useRef<string | null>(null);
|
||||||
|
const completionFlight = useRef(false);
|
||||||
const connectionAttempt = useRef<string | null>(null);
|
const connectionAttempt = useRef<string | null>(null);
|
||||||
const connectionQueue = useRef(Promise.resolve());
|
const connectionQueue = useRef(Promise.resolve());
|
||||||
const connectionQueueDepth = useRef(0);
|
const connectionQueueDepth = useRef(0);
|
||||||
@@ -368,20 +405,24 @@ export function OnboardingFlow({
|
|||||||
selected.some((chosen) => chosen.source_id === item.source_id),
|
selected.some((chosen) => chosen.source_id === item.source_id),
|
||||||
);
|
);
|
||||||
const locked =
|
const locked =
|
||||||
busy !== null || exitDestination !== null || state.status === "importing";
|
busy !== null ||
|
||||||
|
completionBusy ||
|
||||||
|
exitDestination !== null ||
|
||||||
|
state.status === "importing";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true;
|
alive.current = true;
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
void getMemoryMode().then(
|
void getMemoryMode().then(
|
||||||
(memoryMode) => {
|
(memoryMode) => {
|
||||||
if (alive.current)
|
if (alive.current) {
|
||||||
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
||||||
|
setKnownMode(memoryMode.mode);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
alive.current &&
|
alive.current &&
|
||||||
!modeChoiceTouched.current &&
|
!modeChoiceTouched.current &&
|
||||||
memoryMode.explicitly_configured &&
|
memoryMode.explicitly_configured
|
||||||
memoryMode.configured_mode !== "ultra"
|
|
||||||
)
|
)
|
||||||
setModeChoice(memoryMode.configured_mode);
|
setModeChoice(memoryMode.configured_mode);
|
||||||
},
|
},
|
||||||
@@ -394,6 +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(() => {
|
useEffect(() => {
|
||||||
setSources(imports.sources);
|
setSources(imports.sources);
|
||||||
}, [imports.sources]);
|
}, [imports.sources]);
|
||||||
@@ -456,7 +523,7 @@ export function OnboardingFlow({
|
|||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
locked ||
|
locked ||
|
||||||
reviewingImport ||
|
reviewingImport ||
|
||||||
flight.current
|
sourceFlight.current
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!scopeMatches(selected, sources)) return;
|
if (!scopeMatches(selected, sources)) return;
|
||||||
@@ -483,17 +550,24 @@ export function OnboardingFlow({
|
|||||||
[onFinish],
|
[onFinish],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterModelStep = useCallback(() => {
|
const enterModelStep = useCallback(
|
||||||
intentRef.current = "pro";
|
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
|
||||||
setupStepRef.current = "model";
|
intentRef.current = mode;
|
||||||
const token = ++modelGeneration.current;
|
setupStepRef.current = "model";
|
||||||
setModelGenerationToken(token);
|
const token = ++modelGeneration.current;
|
||||||
setVisitedModel(true);
|
setModelGenerationToken(token);
|
||||||
setSetupStep("model");
|
setVisitedModel(true);
|
||||||
}, []);
|
setSetupStep("model");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const leaveModelStep = useCallback(
|
const leaveModelStep = useCallback(
|
||||||
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
|
(
|
||||||
|
nextIntent: OnboardingMemoryMode,
|
||||||
|
nextStep: "review" | "mode",
|
||||||
|
resetModePhase = true,
|
||||||
|
) => {
|
||||||
// This fence is synchronous: hidden model work must observe invalidation
|
// This fence is synchronous: hidden model work must observe invalidation
|
||||||
// before React renders the replacement step.
|
// before React renders the replacement step.
|
||||||
modelGeneration.current += 1;
|
modelGeneration.current += 1;
|
||||||
@@ -502,7 +576,10 @@ export function OnboardingFlow({
|
|||||||
setModeChoice(nextIntent);
|
setModeChoice(nextIntent);
|
||||||
setSetupStep(nextStep);
|
setSetupStep(nextStep);
|
||||||
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
||||||
setModeSaving(false);
|
if (resetModePhase) {
|
||||||
|
modeOperation.current += 1;
|
||||||
|
setModePhase("idle");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -611,12 +688,15 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
const leave = useCallback(
|
const leave = useCallback(
|
||||||
async (destination: OnboardingDestination) => {
|
async (destination: OnboardingDestination) => {
|
||||||
if (flight.current || finished.current) return;
|
if (completionFlight.current || finished.current) return;
|
||||||
flight.current = true;
|
completionFlight.current = true;
|
||||||
setBusy("leaving");
|
setCompletionBusy(true);
|
||||||
await persistFinish(destination);
|
try {
|
||||||
flight.current = false;
|
await persistFinish(destination);
|
||||||
if (alive.current) setBusy(null);
|
} finally {
|
||||||
|
completionFlight.current = false;
|
||||||
|
if (alive.current) setCompletionBusy(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[persistFinish],
|
[persistFinish],
|
||||||
);
|
);
|
||||||
@@ -624,7 +704,12 @@ export function OnboardingFlow({
|
|||||||
const requestModeChoice = useCallback(
|
const requestModeChoice = useCallback(
|
||||||
(destination: OnboardingDestination) => {
|
(destination: OnboardingDestination) => {
|
||||||
if (finished.current) return;
|
if (finished.current) return;
|
||||||
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
|
modePlanOperation.current += 1;
|
||||||
|
setModePlanBusy(false);
|
||||||
|
setPendingMode(null);
|
||||||
|
setPendingPlan(null);
|
||||||
|
if (setupStepRef.current === "model")
|
||||||
|
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
|
||||||
else {
|
else {
|
||||||
intentRef.current = modeChoice;
|
intentRef.current = modeChoice;
|
||||||
setupStepRef.current = "mode";
|
setupStepRef.current = "mode";
|
||||||
@@ -639,30 +724,123 @@ export function OnboardingFlow({
|
|||||||
[leaveModelStep, modeChoice],
|
[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 () => {
|
const saveModeChoice = useCallback(async () => {
|
||||||
if (
|
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
|
||||||
!modeChoice ||
|
const operation = ++modeOperation.current;
|
||||||
modeSaving ||
|
const ownsModeOperation = () =>
|
||||||
finished.current ||
|
alive.current && !finished.current && modeOperation.current === operation;
|
||||||
(modeChoice === "lite" && flight.current)
|
setModePhase("saving");
|
||||||
)
|
|
||||||
return;
|
|
||||||
setModeSaving(true);
|
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
try {
|
try {
|
||||||
if (modeChoice === "pro") {
|
if (modeChoice !== "lite") {
|
||||||
intentRef.current = "pro";
|
const target = modeChoice;
|
||||||
|
const [plan, profiles] = await Promise.all([
|
||||||
|
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";
|
setupStepRef.current = "mode";
|
||||||
const selectionStillCurrent = () =>
|
const selectionStillCurrent = () =>
|
||||||
alive.current &&
|
ownsModeOperation() &&
|
||||||
!finished.current &&
|
intentRef.current === target &&
|
||||||
intentRef.current === "pro" &&
|
|
||||||
setupStepRef.current === "mode";
|
setupStepRef.current === "mode";
|
||||||
|
if (
|
||||||
|
hasActiveModel &&
|
||||||
|
!plan.blockers.includes("active_model_required")
|
||||||
|
) {
|
||||||
|
const changed = await queueModeWrite(target, selectionStillCurrent);
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
|
if (target === "ultra") {
|
||||||
|
try {
|
||||||
|
await setEvolveSchedule(ultraSchedule);
|
||||||
|
} catch {
|
||||||
|
if (selectionStillCurrent())
|
||||||
|
setModeError("自动整合设置未能保存,请重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
}
|
||||||
|
await queueProgressWrite(
|
||||||
|
() =>
|
||||||
|
putOnboardingProgress({
|
||||||
|
mode_intent: target,
|
||||||
|
setup_step: "mode",
|
||||||
|
model_profile_id: null,
|
||||||
|
}),
|
||||||
|
selectionStillCurrent,
|
||||||
|
);
|
||||||
|
if (!selectionStillCurrent()) return;
|
||||||
|
setModePhase("completing");
|
||||||
|
await leave(modeDestination);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const savedProgress = await queueProgressWrite(
|
const savedProgress = await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "model",
|
setup_step: "model",
|
||||||
model_profile_id: modelProfileId.current,
|
model_profile_id: modelProfileId.current,
|
||||||
}),
|
}),
|
||||||
@@ -670,15 +848,18 @@ export function OnboardingFlow({
|
|||||||
);
|
);
|
||||||
if (!selectionStillCurrent()) return;
|
if (!selectionStillCurrent()) return;
|
||||||
modelProfileId.current = savedProgress.model_profile_id;
|
modelProfileId.current = savedProgress.model_profile_id;
|
||||||
enterModelStep();
|
enterModelStep(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
leaveModelStep("lite", "mode");
|
leaveModelStep("lite", "mode", false);
|
||||||
const liteStillCurrent = () =>
|
const liteStillCurrent = () =>
|
||||||
alive.current &&
|
ownsModeOperation() &&
|
||||||
!finished.current &&
|
|
||||||
intentRef.current === "lite" &&
|
intentRef.current === "lite" &&
|
||||||
setupStepRef.current === "mode";
|
setupStepRef.current === "mode";
|
||||||
|
const changed = await queueModeWrite("lite", liteStillCurrent);
|
||||||
|
if (!liteStillCurrent()) return;
|
||||||
|
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
await queueProgressWrite(
|
await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
@@ -688,19 +869,17 @@ export function OnboardingFlow({
|
|||||||
}),
|
}),
|
||||||
liteStillCurrent,
|
liteStillCurrent,
|
||||||
);
|
);
|
||||||
if (!alive.current) return;
|
if (!liteStillCurrent()) return;
|
||||||
const changed = await queueModeWrite("lite");
|
setModePhase("completing");
|
||||||
if (!alive.current) return;
|
|
||||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
|
||||||
await leave(modeDestination);
|
await leave(modeDestination);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCompletedProgressConflict(reason)) {
|
if (isCompletedProgressConflict(reason)) {
|
||||||
finishOnce(modeDestination);
|
finishOnce(modeDestination);
|
||||||
} else if (alive.current) {
|
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
|
||||||
setModeError("模式未能保存。你的选择已保留,请重试。");
|
setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setModeSaving(false);
|
if (ownsModeOperation()) setModePhase("idle");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
enterModelStep,
|
enterModelStep,
|
||||||
@@ -709,9 +888,13 @@ export function OnboardingFlow({
|
|||||||
modeChoice,
|
modeChoice,
|
||||||
modeDestination,
|
modeDestination,
|
||||||
modeSaving,
|
modeSaving,
|
||||||
|
completionBusy,
|
||||||
finishOnce,
|
finishOnce,
|
||||||
queueModeWrite,
|
queueModeWrite,
|
||||||
queueProgressWrite,
|
queueProgressWrite,
|
||||||
|
scheduleLoaded,
|
||||||
|
scheduleReadFailed,
|
||||||
|
ultraSchedule,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const persistModelProfile = useCallback(
|
const persistModelProfile = useCallback(
|
||||||
@@ -729,58 +912,100 @@ export function OnboardingFlow({
|
|||||||
[queueProgressWrite],
|
[queueProgressWrite],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmProMode = useCallback(
|
const confirmModelMode = useCallback(
|
||||||
async (token: number, ownerProfileId: string) => {
|
async (token: number, ownerProfileId: string) => {
|
||||||
|
const target = intentRef.current;
|
||||||
|
if (target !== "pro" && target !== "ultra") return;
|
||||||
|
if (target === "ultra" && !scheduleLoaded) {
|
||||||
|
leaveModelStep("ultra", "mode");
|
||||||
|
setModeError(
|
||||||
|
scheduleReadFailed
|
||||||
|
? "记忆模式状态暂时无法读取,请重试。"
|
||||||
|
: "正在读取记忆模式…",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
|
||||||
const ownsModelStep = () =>
|
const ownsModelStep = () =>
|
||||||
alive.current &&
|
alive.current &&
|
||||||
!finished.current &&
|
!finished.current &&
|
||||||
modelGeneration.current === token &&
|
modelGeneration.current === token &&
|
||||||
intentRef.current === "pro" &&
|
intentRef.current === target &&
|
||||||
setupStepRef.current === "model" &&
|
setupStepRef.current === "model" &&
|
||||||
modelProfileId.current === ownerProfileId;
|
modelProfileId.current === ownerProfileId;
|
||||||
if (!ownsModelStep()) return;
|
if (
|
||||||
setModeSaving(true);
|
!ownsModelStep() ||
|
||||||
|
proConfirmationOwner.current === confirmationOwner ||
|
||||||
|
completionBusy
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
proConfirmationOwner.current = confirmationOwner;
|
||||||
|
const operation = ++modeOperation.current;
|
||||||
|
const ownsConfirmation = () =>
|
||||||
|
ownsModelStep() && modeOperation.current === operation;
|
||||||
|
setModePhase("saving");
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||||
try {
|
try {
|
||||||
const plan = await planMemoryMode("pro");
|
const plan = await planMemoryMode(target);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||||
const changed = await queueModeWrite("pro", ownsModelStep);
|
const changed = await queueModeWrite(target, ownsConfirmation);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||||
|
setKnownMode(changed.mode);
|
||||||
|
if (target === "ultra") {
|
||||||
|
try {
|
||||||
|
await setEvolveSchedule(ultraSchedule);
|
||||||
|
} catch {
|
||||||
|
if (ownsConfirmation()) {
|
||||||
|
leaveModelStep("ultra", "mode");
|
||||||
|
setModeError("自动整合设置未能保存,请重试。");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ownsConfirmation()) return;
|
||||||
|
}
|
||||||
await queueProgressWrite(
|
await queueProgressWrite(
|
||||||
() =>
|
() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "model",
|
setup_step: "model",
|
||||||
model_profile_id: ownerProfileId,
|
model_profile_id: ownerProfileId,
|
||||||
}),
|
}),
|
||||||
ownsModelStep,
|
ownsConfirmation,
|
||||||
);
|
);
|
||||||
if (!ownsModelStep()) return;
|
if (!ownsConfirmation()) return;
|
||||||
await persistFinish(modeDestination);
|
setModePhase("completing");
|
||||||
|
await leave(modeDestination);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (isCompletedProgressConflict(reason)) {
|
if (isCompletedProgressConflict(reason)) {
|
||||||
finishOnce(modeDestination);
|
finishOnce(modeDestination);
|
||||||
} else if (alive.current && !isGenerationInvalid(reason))
|
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
|
||||||
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
|
setModeError("模式切换未能确认,仍显示上次确认的模式。");
|
||||||
} finally {
|
} finally {
|
||||||
if (alive.current) setModeSaving(false);
|
if (proConfirmationOwner.current === confirmationOwner)
|
||||||
|
proConfirmationOwner.current = null;
|
||||||
|
if (ownsConfirmation()) setModePhase("idle");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
completionBusy,
|
||||||
finishOnce,
|
finishOnce,
|
||||||
|
leave,
|
||||||
|
leaveModelStep,
|
||||||
modeDestination,
|
modeDestination,
|
||||||
persistFinish,
|
|
||||||
queueModeWrite,
|
queueModeWrite,
|
||||||
queueProgressWrite,
|
queueProgressWrite,
|
||||||
|
scheduleLoaded,
|
||||||
|
scheduleReadFailed,
|
||||||
|
ultraSchedule,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function checkLocations() {
|
async function checkLocations() {
|
||||||
if (flight.current || locked) return;
|
if (sourceFlight.current || locked) return;
|
||||||
flight.current = true;
|
sourceFlight.current = true;
|
||||||
scopeVersion.current += 1;
|
scopeVersion.current += 1;
|
||||||
setBusy("checking");
|
setBusy("checking");
|
||||||
setLocationCheck("checking");
|
setLocationCheck("checking");
|
||||||
@@ -805,7 +1030,7 @@ export function OnboardingFlow({
|
|||||||
setLocationCheck("failed");
|
setLocationCheck("failed");
|
||||||
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
||||||
} finally {
|
} finally {
|
||||||
flight.current = false;
|
sourceFlight.current = false;
|
||||||
if (alive.current) setBusy(null);
|
if (alive.current) setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,7 +1038,7 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
function toggleSource(source: SourceResponse, checked: boolean) {
|
function toggleSource(source: SourceResponse, checked: boolean) {
|
||||||
if (
|
if (
|
||||||
flight.current ||
|
sourceFlight.current ||
|
||||||
locked ||
|
locked ||
|
||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
!isAppSource(source) ||
|
!isAppSource(source) ||
|
||||||
@@ -836,7 +1061,7 @@ export function OnboardingFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectAllSources() {
|
function selectAllSources() {
|
||||||
if (flight.current || locked || locationCheck !== "ready") return;
|
if (sourceFlight.current || locked || locationCheck !== "ready") return;
|
||||||
scopeVersion.current += 1;
|
scopeVersion.current += 1;
|
||||||
setError(null);
|
setError(null);
|
||||||
setItemErrors({});
|
setItemErrors({});
|
||||||
@@ -849,7 +1074,7 @@ export function OnboardingFlow({
|
|||||||
|
|
||||||
async function startImport() {
|
async function startImport() {
|
||||||
if (
|
if (
|
||||||
flight.current ||
|
sourceFlight.current ||
|
||||||
locked ||
|
locked ||
|
||||||
locationCheck !== "ready" ||
|
locationCheck !== "ready" ||
|
||||||
!selected.length
|
!selected.length
|
||||||
@@ -863,7 +1088,7 @@ export function OnboardingFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitImport(approved: SourceResponse[]) {
|
async function submitImport(approved: SourceResponse[]) {
|
||||||
flight.current = true;
|
sourceFlight.current = true;
|
||||||
if (window.oneMemory) setStartingImport(approved);
|
if (window.oneMemory) setStartingImport(approved);
|
||||||
const version = scopeVersion.current;
|
const version = scopeVersion.current;
|
||||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||||
@@ -917,12 +1142,14 @@ export function OnboardingFlow({
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
connectSent.current = false;
|
connectSent.current = false;
|
||||||
flight.current = false;
|
sourceFlight.current = false;
|
||||||
if (alive.current) setBusy(null);
|
if (alive.current) setBusy(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = selected.length;
|
const count = selected.length;
|
||||||
|
const continueWithoutImport =
|
||||||
|
busy === null && locationCheck !== "checking" && count === 0;
|
||||||
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
||||||
// detection must read 未完成 even when sources are listed — otherwise the
|
// detection must read 未完成 even when sources are listed — otherwise the
|
||||||
// number branch answered first and the summary claimed a count while the
|
// number branch answered first and the summary claimed a count while the
|
||||||
@@ -987,10 +1214,10 @@ export function OnboardingFlow({
|
|||||||
jobs={reviewJobs}
|
jobs={reviewJobs}
|
||||||
failedSources={accepted?.failed_sources}
|
failedSources={accepted?.failed_sources}
|
||||||
sourceSnapshot={imports.sourceSnapshot}
|
sourceSnapshot={imports.sourceSnapshot}
|
||||||
busy={busy === "leaving"}
|
busy={completionBusy}
|
||||||
error={error && text(error)}
|
error={error && text(error)}
|
||||||
onContinue={(destination) => {
|
onContinue={(destination) => {
|
||||||
if (busy === "leaving") return;
|
if (completionBusy) return;
|
||||||
if (completionFailed) {
|
if (completionFailed) {
|
||||||
void leave(destination);
|
void leave(destination);
|
||||||
return;
|
return;
|
||||||
@@ -1022,106 +1249,125 @@ export function OnboardingFlow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{setupStep === "mode" && (
|
{setupStep === "mode" && (
|
||||||
<R6ColdStartFrame
|
<>
|
||||||
step={3}
|
<R6ColdStartFrame
|
||||||
title={text("选择记忆模式")}
|
step={3}
|
||||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
title={text("选择记忆模式")}
|
||||||
focusToken={modeFocusToken}
|
focusToken={modeFocusToken}
|
||||||
footer={
|
footer={
|
||||||
<>
|
|
||||||
<R6Button
|
|
||||||
variant="quiet"
|
|
||||||
disabled={modeSaving || busy === "leaving"}
|
|
||||||
onClick={() => {
|
|
||||||
setModeError(null);
|
|
||||||
intentRef.current = modeChoice;
|
|
||||||
setupStepRef.current = "review";
|
|
||||||
setSetupStep("review");
|
|
||||||
void queueProgressWrite(() =>
|
|
||||||
patchOnboardingProgress({
|
|
||||||
mode_intent: modeChoice,
|
|
||||||
setup_step: "review",
|
|
||||||
}),
|
|
||||||
).catch((reason) => {
|
|
||||||
if (!isCompletedProgressConflict(reason) && alive.current)
|
|
||||||
setModeError(
|
|
||||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text("返回导入")}
|
|
||||||
</R6Button>
|
|
||||||
<R6Button
|
<R6Button
|
||||||
className="onboarding-r6-submit"
|
className="onboarding-r6-submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
pending={modeSaving}
|
pending={modeSaving || modePlanBusy}
|
||||||
disabled={
|
disabled={
|
||||||
!modeChoice ||
|
!modeChoice ||
|
||||||
busy === "leaving" ||
|
completionBusy ||
|
||||||
(modeChoice === "lite" && flight.current)
|
modePlanBusy ||
|
||||||
|
(modeChoice === "ultra" && !scheduleLoaded)
|
||||||
}
|
}
|
||||||
onClick={() => void saveModeChoice()}
|
onClick={() => void saveModeChoice()}
|
||||||
>
|
>
|
||||||
{text(
|
{text(
|
||||||
modeSaving
|
modePhase === "saving"
|
||||||
? "正在保存…"
|
? "正在保存模式…"
|
||||||
: modeChoice === "lite"
|
: modePhase === "completing"
|
||||||
? "使用 Lite 进入"
|
? "正在完成首次设置…"
|
||||||
: modeChoice === "pro"
|
: modeChoice === "lite"
|
||||||
? "继续配置模型"
|
? "使用 Lite 进入"
|
||||||
: "继续",
|
: modeChoice === "pro"
|
||||||
|
? "继续配置模型"
|
||||||
|
: "继续",
|
||||||
)}
|
)}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
</>
|
}
|
||||||
}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
className="onboarding-r6-mode"
|
|
||||||
aria-labelledby="onboarding-memory-mode-title"
|
|
||||||
>
|
>
|
||||||
<div className="onboarding-r6-mode-intro">
|
<section
|
||||||
<p id="onboarding-memory-mode-title">
|
className="onboarding-r6-mode"
|
||||||
{text("之后可在设置中更改。")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MemoryModeChoiceList
|
|
||||||
aria-label={text("记忆模式")}
|
aria-label={text("记忆模式")}
|
||||||
modes={[
|
>
|
||||||
{
|
<MemoryModeChoiceList
|
||||||
mode: "lite",
|
aria-label={text("记忆模式")}
|
||||||
title: "Lite",
|
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||||
description: text(
|
mode,
|
||||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
|
title: text(MODE_COPY[mode].title),
|
||||||
),
|
description: text(MODE_COPY[mode].description),
|
||||||
},
|
}))}
|
||||||
{
|
selected={modeChoice}
|
||||||
mode: "pro",
|
disabled={modeSaving || completionBusy || modePlanBusy}
|
||||||
title: "Pro",
|
onChange={(mode, control) =>
|
||||||
description: text(
|
void requestModeSelection(
|
||||||
"使用模型理解对话,提取并整理可持续生长的记忆。",
|
mode as OnboardingMemoryMode,
|
||||||
),
|
control,
|
||||||
},
|
)
|
||||||
]}
|
}
|
||||||
selected={modeChoice}
|
/>
|
||||||
disabled={modeSaving || busy === "leaving"}
|
{modeChoice === "ultra" && scheduleLoaded ? (
|
||||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
<section
|
||||||
onChange={(mode) => {
|
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;
|
modeChoiceTouched.current = true;
|
||||||
setModeChoice(mode as OnboardingMemoryMode);
|
intentRef.current = pendingMode;
|
||||||
|
setModeChoice(pendingMode);
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
|
setPendingMode(null);
|
||||||
|
setPendingPlan(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="onboarding-r6-mode-ultra-note">
|
) : null}
|
||||||
{text("Ultra 可在完成首次设置后到设置页开启。")}
|
</>
|
||||||
</p>
|
|
||||||
{(modeError || error) && (
|
|
||||||
<p className="onboarding-r6-mode-error" role="alert">
|
|
||||||
{text(modeError ?? error ?? "")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</R6ColdStartFrame>
|
|
||||||
)}
|
)}
|
||||||
{visitedModel && (
|
{visitedModel && (
|
||||||
<div hidden={setupStep !== "model"}>
|
<div hidden={setupStep !== "model"}>
|
||||||
@@ -1129,7 +1375,8 @@ export function OnboardingFlow({
|
|||||||
generationToken={modelGenerationToken}
|
generationToken={modelGenerationToken}
|
||||||
isGenerationCurrent={(token) =>
|
isGenerationCurrent={(token) =>
|
||||||
modelGeneration.current === token &&
|
modelGeneration.current === token &&
|
||||||
intentRef.current === "pro" &&
|
intentRef.current !== "lite" &&
|
||||||
|
intentRef.current !== null &&
|
||||||
setupStepRef.current === "model"
|
setupStepRef.current === "model"
|
||||||
}
|
}
|
||||||
initialProfileId={modelProfileId.current}
|
initialProfileId={modelProfileId.current}
|
||||||
@@ -1142,7 +1389,7 @@ export function OnboardingFlow({
|
|||||||
void persistModelProfile(profileId)
|
void persistModelProfile(profileId)
|
||||||
}
|
}
|
||||||
onModelActiveConfirmed={(token, ownerProfileId) =>
|
onModelActiveConfirmed={(token, ownerProfileId) =>
|
||||||
void confirmProMode(token, ownerProfileId)
|
void confirmModelMode(token, ownerProfileId)
|
||||||
}
|
}
|
||||||
sourceNames={sourceIds.map(
|
sourceNames={sourceIds.map(
|
||||||
(id) =>
|
(id) =>
|
||||||
@@ -1157,10 +1404,11 @@ export function OnboardingFlow({
|
|||||||
)}
|
)}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setModeError(null);
|
setModeError(null);
|
||||||
leaveModelStep("pro", "mode");
|
const target = intentRef.current === "ultra" ? "ultra" : "pro";
|
||||||
|
leaveModelStep(target, "mode");
|
||||||
void queueProgressWrite(() =>
|
void queueProgressWrite(() =>
|
||||||
putOnboardingProgress({
|
putOnboardingProgress({
|
||||||
mode_intent: "pro",
|
mode_intent: target,
|
||||||
setup_step: "mode",
|
setup_step: "mode",
|
||||||
model_profile_id: modelProfileId.current,
|
model_profile_id: modelProfileId.current,
|
||||||
}),
|
}),
|
||||||
@@ -1188,7 +1436,7 @@ export function OnboardingFlow({
|
|||||||
<>
|
<>
|
||||||
<R6Button
|
<R6Button
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
disabled={busy !== null}
|
disabled={busy !== null || completionBusy}
|
||||||
onClick={() => finishOnce(exitDestination)}
|
onClick={() => finishOnce(exitDestination)}
|
||||||
>
|
>
|
||||||
{text(
|
{text(
|
||||||
@@ -1197,7 +1445,7 @@ export function OnboardingFlow({
|
|||||||
</R6Button>
|
</R6Button>
|
||||||
<R6Button
|
<R6Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={busy !== null}
|
disabled={busy !== null || completionBusy}
|
||||||
onClick={() => void leave(exitDestination)}
|
onClick={() => void leave(exitDestination)}
|
||||||
>
|
>
|
||||||
{text("重试保存并进入")}
|
{text("重试保存并进入")}
|
||||||
@@ -1215,16 +1463,8 @@ export function OnboardingFlow({
|
|||||||
<R6ColdStartFrame
|
<R6ColdStartFrame
|
||||||
step={1}
|
step={1}
|
||||||
title={text("把熟悉的背景,带到这里")}
|
title={text("把熟悉的背景,带到这里")}
|
||||||
eyebrow={text("你的记忆,从这里接上")}
|
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<R6Button
|
|
||||||
variant="quiet"
|
|
||||||
disabled={locked}
|
|
||||||
onClick={() => requestModeChoice("sources")}
|
|
||||||
>
|
|
||||||
{text("稍后添加,先进入")}
|
|
||||||
</R6Button>
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="onboarding-r6-error" role="alert">
|
<p className="onboarding-r6-error" role="alert">
|
||||||
{error && text(error)}
|
{error && text(error)}
|
||||||
@@ -1233,10 +1473,20 @@ export function OnboardingFlow({
|
|||||||
<R6Button
|
<R6Button
|
||||||
className="onboarding-r6-submit"
|
className="onboarding-r6-submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={locked || count === 0}
|
disabled={locked}
|
||||||
onClick={() => void startImport()}
|
onClick={() =>
|
||||||
|
continueWithoutImport
|
||||||
|
? requestModeChoice("sources")
|
||||||
|
: void startImport()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
|
{text(
|
||||||
|
continueWithoutImport
|
||||||
|
? "选择记忆模式"
|
||||||
|
: busy === "submitting"
|
||||||
|
? "正在开始导入…"
|
||||||
|
: "开始导入",
|
||||||
|
)}
|
||||||
</R6Button>
|
</R6Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -1246,12 +1496,6 @@ export function OnboardingFlow({
|
|||||||
className="onboarding-r6-apps"
|
className="onboarding-r6-apps"
|
||||||
aria-labelledby="cold-start-phase"
|
aria-labelledby="cold-start-phase"
|
||||||
>
|
>
|
||||||
<div className="onboarding-r6-intro">
|
|
||||||
<p>
|
|
||||||
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
|
|
||||||
</p>
|
|
||||||
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="onboarding-r6-list-heading">
|
<div className="onboarding-r6-list-heading">
|
||||||
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
||||||
{text("本机 AI 应用")}
|
{text("本机 AI 应用")}
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
|||||||
max_concurrency: 4,
|
max_concurrency: 4,
|
||||||
context_window: null,
|
context_window: null,
|
||||||
has_api_key: true,
|
has_api_key: true,
|
||||||
|
credential_state: "available" as
|
||||||
|
| "available"
|
||||||
|
| "missing"
|
||||||
|
| "unavailable",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
structured_output: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pendingProbe: null as null | {
|
pendingProbe: null as null | {
|
||||||
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
|||||||
supported: false as boolean | null,
|
supported: false as boolean | null,
|
||||||
planActivation: vi.fn(),
|
planActivation: vi.fn(),
|
||||||
startPlannedSwitch: vi.fn(),
|
startPlannedSwitch: vi.fn(),
|
||||||
shouldUseLegacyActivation: () => true,
|
shouldUseLegacyActivation: (() => true) as () => boolean,
|
||||||
clearPlan: vi.fn(),
|
clearPlan: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -123,6 +133,7 @@ beforeEach(() => {
|
|||||||
models.creationMissing = null;
|
models.creationMissing = null;
|
||||||
embedding.current = null;
|
embedding.current = null;
|
||||||
embedding.supported = false;
|
embedding.supported = false;
|
||||||
|
embedding.shouldUseLegacyActivation = () => true;
|
||||||
step.current = null;
|
step.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
|
|||||||
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
|
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not complete with an active profile whose credential is unavailable", async () => {
|
||||||
|
models.profiles = [
|
||||||
|
{
|
||||||
|
...activeProfile,
|
||||||
|
credential_state: "unavailable",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const enabled = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingModelController
|
||||||
|
sourceNames={[]}
|
||||||
|
initialProfileId="active-profile"
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onModelActiveConfirmed={enabled}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(step.current).not.toBeNull());
|
||||||
|
expect(enabled).not.toHaveBeenCalled();
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "继续使用当前模型" }),
|
||||||
|
);
|
||||||
|
expect(enabled).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
|
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
|
||||||
models.profiles = [{ ...activeProfile, is_active: false }];
|
models.profiles = [{ ...activeProfile, is_active: false }];
|
||||||
const active = vi.fn();
|
const active = vi.fn();
|
||||||
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reconciles a pending probe instead of saving or creating again", async () => {
|
it("reconciles a pending probe instead of saving or creating again", async () => {
|
||||||
const inactive = { ...activeProfile, is_active: false };
|
const inactive = { ...activeProfile, revision: 4, is_active: false };
|
||||||
models.profiles = [inactive];
|
models.profiles = [inactive];
|
||||||
models.pendingProbe = {
|
models.pendingProbe = {
|
||||||
operationId: "probe-operation",
|
operationId: "probe-operation",
|
||||||
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
|||||||
};
|
};
|
||||||
models.probeProfile.mockResolvedValueOnce({
|
models.probeProfile.mockResolvedValueOnce({
|
||||||
profile_id: "active-profile",
|
profile_id: "active-profile",
|
||||||
profile_revision: 3,
|
input_profile_revision: 3,
|
||||||
|
profile_revision: 4,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
endpoint_reachable: true,
|
endpoint_reachable: true,
|
||||||
model_available: true,
|
model_available: true,
|
||||||
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
||||||
);
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||||
expect(models.update).not.toHaveBeenCalled();
|
expect(models.update).not.toHaveBeenCalled();
|
||||||
expect(models.create).not.toHaveBeenCalled();
|
expect(models.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the first test when Core advances the tested profile revision", async () => {
|
||||||
|
const inactive = { ...activeProfile, revision: 1, is_active: false };
|
||||||
|
models.profiles = [inactive];
|
||||||
|
models.update.mockResolvedValueOnce(inactive);
|
||||||
|
models.probeProfile.mockResolvedValueOnce({
|
||||||
|
profile_id: inactive.profile_id,
|
||||||
|
input_profile_revision: 1,
|
||||||
|
profile_revision: 2,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
},
|
||||||
|
stages: [{ ok: false }],
|
||||||
|
});
|
||||||
|
models.activate.mockResolvedValueOnce({
|
||||||
|
...inactive,
|
||||||
|
revision: 2,
|
||||||
|
is_active: true,
|
||||||
|
capabilities: {
|
||||||
|
endpoint_reachable: true,
|
||||||
|
model_available: true,
|
||||||
|
embedding_available: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
embedding.supported = true;
|
||||||
|
embedding.current = { writes_paused: false };
|
||||||
|
embedding.shouldUseLegacyActivation = () => false;
|
||||||
|
embedding.planActivation.mockResolvedValueOnce({ needed: false });
|
||||||
|
const confirmed = vi.fn();
|
||||||
|
render(
|
||||||
|
<OnboardingModelController
|
||||||
|
sourceNames={[]}
|
||||||
|
initialProfileId={inactive.profile_id}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onModelActiveConfirmed={confirmed}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(currentStep().profile as { profile_id: string } | null)?.profile_id,
|
||||||
|
).toBe(inactive.profile_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await (currentStep().onTest as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||||
|
expect(currentStep().profile).toMatchObject({ revision: 2 });
|
||||||
|
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
await (currentStep().onSaveAndEnable as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
|
||||||
|
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
|
||||||
|
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an active resumed profile on the model step while writes are paused", async () => {
|
it("keeps an active resumed profile on the model step while writes are paused", async () => {
|
||||||
embedding.supported = true;
|
embedding.supported = true;
|
||||||
embedding.current = { writes_paused: true };
|
embedding.current = { writes_paused: true };
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
ModelProbeResult,
|
|
||||||
ModelProfile,
|
ModelProfile,
|
||||||
ModelProfileInput,
|
ModelProfileInput,
|
||||||
ModelProfilePatch,
|
ModelProfilePatch,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getModelProfiles } from "./api";
|
|
||||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||||
import {
|
import {
|
||||||
presetForGeneration,
|
presetForGeneration,
|
||||||
validateGenerationModel,
|
validateGenerationModel,
|
||||||
} from "./GenerationModelPrimitives";
|
} from "./GenerationModelPrimitives";
|
||||||
|
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||||
import {
|
import {
|
||||||
type OnboardingModelDraft,
|
type OnboardingModelDraft,
|
||||||
OnboardingModelStep,
|
OnboardingModelStep,
|
||||||
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
|
|||||||
context_window: "",
|
context_window: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function probeMatchesProfile(
|
|
||||||
original: ModelProfile,
|
|
||||||
current: ModelProfile | undefined,
|
|
||||||
probe: ModelProbeResult | undefined | null,
|
|
||||||
): current is ModelProfile {
|
|
||||||
return Boolean(
|
|
||||||
current &&
|
|
||||||
probe &&
|
|
||||||
probe.profile_id === original.profile_id &&
|
|
||||||
probe.profile_revision === current.revision &&
|
|
||||||
probe.capabilities.endpoint_reachable &&
|
|
||||||
probe.capabilities.model_available &&
|
|
||||||
probe.capabilities.embedding_available &&
|
|
||||||
probe.stages.every((stage) => stage.ok),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OnboardingModelController({
|
export function OnboardingModelController({
|
||||||
sourceNames,
|
sourceNames,
|
||||||
importedConversationCount = 0,
|
importedConversationCount = 0,
|
||||||
@@ -112,6 +94,17 @@ export function OnboardingModelController({
|
|||||||
>(),
|
>(),
|
||||||
);
|
);
|
||||||
const autoConfirmed = useRef<string | null>(null);
|
const autoConfirmed = useRef<string | null>(null);
|
||||||
|
const confirmReadyProfile = useCallback(
|
||||||
|
(profile: ModelProfile, owner = generationToken) => {
|
||||||
|
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
|
||||||
|
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onModelActiveConfirmed?.(owner, profile.profile_id);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const profile = initialProfileId
|
const profile = initialProfileId
|
||||||
@@ -152,21 +145,19 @@ export function OnboardingModelController({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!saved?.is_active ||
|
!saved?.is_active ||
|
||||||
!isGenerationCurrent(generationToken) ||
|
|
||||||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
||||||
embeddingSwitch.current?.writes_paused
|
embeddingSwitch.current?.writes_paused
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const key = `${generationToken}:${saved.profile_id}`;
|
const key = `${generationToken}:${saved.profile_id}`;
|
||||||
if (autoConfirmed.current === key) return;
|
if (autoConfirmed.current === key) return;
|
||||||
autoConfirmed.current = key;
|
if (confirmReadyProfile(saved, generationToken))
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
autoConfirmed.current = key;
|
||||||
}, [
|
}, [
|
||||||
embeddingSwitch.current,
|
embeddingSwitch.current,
|
||||||
embeddingSwitch.supported,
|
embeddingSwitch.supported,
|
||||||
|
confirmReadyProfile,
|
||||||
generationToken,
|
generationToken,
|
||||||
isGenerationCurrent,
|
|
||||||
onModelActiveConfirmed,
|
|
||||||
saved,
|
saved,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -182,8 +173,7 @@ export function OnboardingModelController({
|
|||||||
flight.current = false;
|
flight.current = false;
|
||||||
setSwitchPendingJobId(null);
|
setSwitchPendingJobId(null);
|
||||||
setPhase("idle");
|
setPhase("idle");
|
||||||
if (saved && isGenerationCurrent(generationToken))
|
if (saved) confirmReadyProfile(saved, generationToken);
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
|
||||||
} else if (finished.state === "rolled_back") {
|
} else if (finished.state === "rolled_back") {
|
||||||
flight.current = false;
|
flight.current = false;
|
||||||
setSwitchPendingJobId(null);
|
setSwitchPendingJobId(null);
|
||||||
@@ -192,9 +182,8 @@ export function OnboardingModelController({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
embeddingSwitch.current?.last_finished,
|
embeddingSwitch.current?.last_finished,
|
||||||
|
confirmReadyProfile,
|
||||||
generationToken,
|
generationToken,
|
||||||
isGenerationCurrent,
|
|
||||||
onModelActiveConfirmed,
|
|
||||||
saved,
|
saved,
|
||||||
switchPendingJobId,
|
switchPendingJobId,
|
||||||
]);
|
]);
|
||||||
@@ -338,11 +327,10 @@ export function OnboardingModelController({
|
|||||||
setPhase("testing");
|
setPhase("testing");
|
||||||
const result = await models.probeProfile(profile.profile_id);
|
const result = await models.probeProfile(profile.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
const current = (await getModelProfiles()).find(
|
const current = profileFromProbe(profile, result);
|
||||||
(item) => item.profile_id === profile.profile_id,
|
const passed = Boolean(
|
||||||
|
current && modelProfileReady(current, result ?? null),
|
||||||
);
|
);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
|
||||||
const passed = probeMatchesProfile(profile, current, result);
|
|
||||||
if (current) setSaved(current);
|
if (current) setSaved(current);
|
||||||
setProbe({
|
setProbe({
|
||||||
state: passed ? "passed" : "failed",
|
state: passed ? "passed" : "failed",
|
||||||
@@ -378,11 +366,10 @@ export function OnboardingModelController({
|
|||||||
try {
|
try {
|
||||||
const result = await models.probeProfile(saved.profile_id);
|
const result = await models.probeProfile(saved.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
const current = (await getModelProfiles()).find(
|
const current = profileFromProbe(saved, result);
|
||||||
(item) => item.profile_id === saved.profile_id,
|
const passed = Boolean(
|
||||||
|
current && modelProfileReady(current, result ?? null),
|
||||||
);
|
);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
|
||||||
const passed = probeMatchesProfile(saved, current, result);
|
|
||||||
if (current) setSaved(current);
|
if (current) setSaved(current);
|
||||||
setProbe({
|
setProbe({
|
||||||
state: passed ? "passed" : "failed",
|
state: passed ? "passed" : "failed",
|
||||||
@@ -408,8 +395,7 @@ export function OnboardingModelController({
|
|||||||
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isGenerationCurrent(generationToken))
|
confirmReadyProfile(saved, generationToken);
|
||||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const owner = generationToken;
|
const owner = generationToken;
|
||||||
@@ -433,9 +419,15 @@ export function OnboardingModelController({
|
|||||||
}
|
}
|
||||||
const active = await models.activate(saved.profile_id);
|
const active = await models.activate(saved.profile_id);
|
||||||
if (!isGenerationCurrent(owner)) return;
|
if (!isGenerationCurrent(owner)) return;
|
||||||
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
|
if (
|
||||||
|
!active?.is_active ||
|
||||||
|
active.profile_id !== saved.profile_id ||
|
||||||
|
active.revision !== saved.revision ||
|
||||||
|
!modelProfileReady(active)
|
||||||
|
)
|
||||||
|
throw new Error("model_activation_unconfirmed");
|
||||||
trace.done("succeeded");
|
trace.done("succeeded");
|
||||||
onModelActiveConfirmed?.(owner, active.profile_id);
|
confirmReadyProfile(active, owner);
|
||||||
} catch {
|
} catch {
|
||||||
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
||||||
setError("模型未能确认启用,请在设置中核对。");
|
setError("模型未能确认启用,请在设置中核对。");
|
||||||
|
|||||||
@@ -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", () => {
|
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
|
||||||
const card = declaration(
|
const card = declaration(
|
||||||
"r6-import-progress.css",
|
"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/);
|
).toMatch(/flex:\s*0 0 auto/);
|
||||||
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
|
||||||
/height:\s*auto/,
|
/height:\s*100%/,
|
||||||
);
|
);
|
||||||
expect(
|
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/);
|
).not.toMatch(/max-height|overflow/);
|
||||||
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
|
||||||
/overflow-y:\s*auto/,
|
/overflow-y:\s*auto/,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
|
|||||||
}: {
|
}: {
|
||||||
step: 1 | 2 | 3;
|
step: 1 | 2 | 3;
|
||||||
title: string;
|
title: string;
|
||||||
eyebrow: string;
|
eyebrow?: string | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer: ReactNode;
|
footer: ReactNode;
|
||||||
focusToken?: number;
|
focusToken?: number;
|
||||||
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
|
|||||||
<section className="onboarding-r6-shell" data-step={step}>
|
<section className="onboarding-r6-shell" data-step={step}>
|
||||||
<header className="onboarding-r6-head">
|
<header className="onboarding-r6-head">
|
||||||
<div>
|
<div>
|
||||||
<p>{eyebrow}</p>
|
{eyebrow ? <p>{eyebrow}</p> : null}
|
||||||
<h1
|
<h1
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
tabIndex={focusToken === undefined ? undefined : -1}
|
tabIndex={focusToken === undefined ? undefined : -1}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
|||||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||||
|
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
||||||
expect(screen.getByText("1.0.4")).toBeTruthy();
|
expect(screen.getByText("1.0.4")).toBeTruthy();
|
||||||
expect(screen.getByText("8")).toBeTruthy();
|
expect(screen.getByText("8")).toBeTruthy();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ it("shows every role and preserves a long original message in the detail pane",
|
|||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: long },
|
{ role: "user", content: long },
|
||||||
{ role: "assistant", content: "已收到" },
|
{ role: "assistant", content: "已收到" },
|
||||||
|
{ role: "tool", content: "3 tests passed" },
|
||||||
|
{ role: "unknown", content: "来源角色缺失" },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
sources={[]}
|
sources={[]}
|
||||||
@@ -38,6 +40,10 @@ it("shows every role and preserves a long original message in the detail pane",
|
|||||||
|
|
||||||
expect(screen.getByText("你")).toBeTruthy();
|
expect(screen.getByText("你")).toBeTruthy();
|
||||||
expect(screen.getByText("助手")).toBeTruthy();
|
expect(screen.getByText("助手")).toBeTruthy();
|
||||||
|
expect(screen.getByText("工具")).toBeTruthy();
|
||||||
|
expect(screen.getByText("作者待核对")).toBeTruthy();
|
||||||
expect(screen.getByText(long)).toHaveProperty("textContent", long);
|
expect(screen.getByText(long)).toHaveProperty("textContent", long);
|
||||||
expect(screen.getByText("已收到")).toBeTruthy();
|
expect(screen.getByText("已收到")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("原始对话")).toBeNull();
|
||||||
|
expect(screen.getByRole("article", { name: "原始对话" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
|
|||||||
if (role === "user") return t("selectedMemory.author.user");
|
if (role === "user") return t("selectedMemory.author.user");
|
||||||
if (role === "assistant" || role === "agent")
|
if (role === "assistant" || role === "agent")
|
||||||
return t("selectedMemory.author.agent");
|
return t("selectedMemory.author.agent");
|
||||||
|
if (role === "tool") return t("selectedMemory.author.tool");
|
||||||
return t("selectedMemory.author.unknown");
|
return t("selectedMemory.author.unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +47,7 @@ export function SelectedRawDetail({
|
|||||||
record.source || t("selectedMemory.sourceUnknown"),
|
record.source || t("selectedMemory.sourceUnknown"),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<article className="map-dialogue-reading">
|
<article className="map-dialogue-reading" aria-label={t("memory:原始对话")}>
|
||||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
|
||||||
<ul className="map-transcript">
|
<ul className="map-transcript">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { Check, ChevronDown, Clock3 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
|
import { R6Button } from "./R6Controls";
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, value) =>
|
||||||
|
value.toString().padStart(2, "0"),
|
||||||
|
);
|
||||||
|
const MINUTES = Array.from({ length: 60 }, (_, value) =>
|
||||||
|
value.toString().padStart(2, "0"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function splitTime(value: string): [string, string] {
|
||||||
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
|
||||||
|
return match ? [match[1], match[2]] : ["00", "00"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimePicker({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange(value: string): Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { locale, text } = useOnboardingTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [placement, setPlacement] = useState<"above" | "below">("above");
|
||||||
|
const [hour, setHour] = useState(() => splitTime(value)[0]);
|
||||||
|
const [minute, setMinute] = useState(() => splitTime(value)[1]);
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const panel = useRef<HTMLElement>(null);
|
||||||
|
const hourSelect = useRef<HTMLSelectElement>(null);
|
||||||
|
const minuteSelect = useRef<HTMLSelectElement>(null);
|
||||||
|
const restoreTriggerFocus = useRef(false);
|
||||||
|
const restoreSaveFocus = useRef(false);
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
savingRef.current = saving;
|
||||||
|
const panelId = `${id}-picker`;
|
||||||
|
|
||||||
|
const close = useCallback((restoreFocus = true) => {
|
||||||
|
setOpen(false);
|
||||||
|
restoreTriggerFocus.current = restoreFocus;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const [nextHour, nextMinute] = splitTime(value);
|
||||||
|
setHour(nextHour);
|
||||||
|
setMinute(nextMinute);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const closeOutside = (event: MouseEvent) => {
|
||||||
|
if (savingRef.current) return;
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const label = target?.closest("label");
|
||||||
|
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
|
||||||
|
if (!root.current?.contains(target)) close(false);
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape" || savingRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", closeOutside);
|
||||||
|
document.addEventListener("keydown", closeOnEscape);
|
||||||
|
hourSelect.current?.focus();
|
||||||
|
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOutside);
|
||||||
|
document.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [close, id, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && open && !saving) close(false);
|
||||||
|
if (!disabled && !open && restoreTriggerFocus.current) {
|
||||||
|
restoreTriggerFocus.current = false;
|
||||||
|
trigger.current?.focus();
|
||||||
|
}
|
||||||
|
if (!disabled && open && restoreSaveFocus.current) {
|
||||||
|
restoreSaveFocus.current = false;
|
||||||
|
panel.current
|
||||||
|
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
if (saving && open) panel.current?.focus();
|
||||||
|
}, [close, disabled, open, saving]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const place = () => {
|
||||||
|
const anchor = trigger.current?.getBoundingClientRect();
|
||||||
|
const picker = panel.current?.getBoundingClientRect();
|
||||||
|
if (!anchor || !picker) return;
|
||||||
|
const scroller = trigger.current?.closest(
|
||||||
|
".app-content, .onboarding-frame",
|
||||||
|
);
|
||||||
|
const bounds = scroller?.getBoundingClientRect();
|
||||||
|
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
|
||||||
|
const bottomEdge =
|
||||||
|
(bounds
|
||||||
|
? Math.min(window.innerHeight, bounds.bottom)
|
||||||
|
: window.innerHeight) - 12;
|
||||||
|
const above = anchor.top - topEdge;
|
||||||
|
const below = bottomEdge - anchor.bottom;
|
||||||
|
setPlacement(
|
||||||
|
above >= picker.height || above >= below ? "above" : "below",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener("resize", place);
|
||||||
|
window.addEventListener("scroll", place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", place);
|
||||||
|
window.removeEventListener("scroll", place, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const next = `${hour}:${minute}`;
|
||||||
|
if (next === value) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const saved = await onChange(next);
|
||||||
|
setSaving(false);
|
||||||
|
if (saved) close();
|
||||||
|
else restoreSaveFocus.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="r6-time-picker" ref={root}>
|
||||||
|
<button
|
||||||
|
ref={trigger}
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
className="r6-time-picker-trigger"
|
||||||
|
aria-label={
|
||||||
|
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
|
||||||
|
}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => (open ? close() : show())}
|
||||||
|
>
|
||||||
|
<span>{value}</span>
|
||||||
|
<span className="r6-time-picker-icons" aria-hidden="true">
|
||||||
|
<Clock3 size={15} />
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<section
|
||||||
|
ref={panel}
|
||||||
|
id={panelId}
|
||||||
|
className="r6-time-picker-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-placement={placement}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLElement>(
|
||||||
|
"select:not(:disabled), button:not(:disabled)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!first || !last) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="r6-time-picker-columns">
|
||||||
|
<label className="r6-time-picker-column">
|
||||||
|
<span aria-hidden="true">HH</span>
|
||||||
|
<select
|
||||||
|
ref={hourSelect}
|
||||||
|
size={6}
|
||||||
|
aria-label={`${label} HH`}
|
||||||
|
value={hour}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setHour(event.target.value)}
|
||||||
|
>
|
||||||
|
{HOURS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="r6-time-picker-separator" aria-hidden="true">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<label className="r6-time-picker-column">
|
||||||
|
<span aria-hidden="true">MM</span>
|
||||||
|
<select
|
||||||
|
ref={minuteSelect}
|
||||||
|
size={6}
|
||||||
|
aria-label={`${label} MM`}
|
||||||
|
value={minute}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setMinute(event.target.value)}
|
||||||
|
>
|
||||||
|
{MINUTES.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<R6Button disabled={saving} onClick={() => close()}>
|
||||||
|
{text("取消")}
|
||||||
|
</R6Button>
|
||||||
|
<R6Button
|
||||||
|
variant="primary"
|
||||||
|
pending={saving}
|
||||||
|
data-time-picker-save
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
<Check size={15} aria-hidden="true" />
|
||||||
|
{text("保存修改")}
|
||||||
|
</R6Button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { EvolveSchedule } from "@one-memory/contracts";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useOnboardingTranslation } from "./onboarding-language";
|
||||||
|
import { R6Switch } from "./R6Controls";
|
||||||
|
import { TimePicker } from "./TimePicker";
|
||||||
|
|
||||||
|
export function UltraEvolveScheduleFields({
|
||||||
|
schedule,
|
||||||
|
headingId,
|
||||||
|
disabled = false,
|
||||||
|
onEnabledChange,
|
||||||
|
onTimeChange,
|
||||||
|
}: {
|
||||||
|
schedule: EvolveSchedule;
|
||||||
|
headingId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onEnabledChange(enabled: boolean): void;
|
||||||
|
onTimeChange(time: string): Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { text } = useOnboardingTranslation();
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="r6-evolve-heading">
|
||||||
|
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
|
||||||
|
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="r6-evolve-setting">
|
||||||
|
<div className="r6-evolve-setting-copy">
|
||||||
|
<h3>{text("每天自动整合")}</h3>
|
||||||
|
<p>
|
||||||
|
{text(
|
||||||
|
schedule.enabled
|
||||||
|
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||||
|
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||||
|
).replace("{time}", schedule.time)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<R6Switch
|
||||||
|
label={text("每天自动整合")}
|
||||||
|
checked={schedule.enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onEnabledChange}
|
||||||
|
/>
|
||||||
|
{schedule.enabled ? (
|
||||||
|
<div className="r6-evolve-time">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
|
||||||
|
<p>{text("使用这台设备的本地时间。")}</p>
|
||||||
|
</div>
|
||||||
|
<TimePicker
|
||||||
|
id={`${id}-time`}
|
||||||
|
label={text("执行时间")}
|
||||||
|
value={schedule.time}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onTimeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ it("lists raw conversations for the selected application", async () => {
|
|||||||
offset: 20,
|
offset: 20,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
unit: "turn",
|
unit: "turn",
|
||||||
|
paging: undefined,
|
||||||
|
cursor: undefined,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -987,6 +987,8 @@ export async function getSdkRaw({
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
unit = "turn",
|
unit = "turn",
|
||||||
|
paging,
|
||||||
|
cursor,
|
||||||
signal,
|
signal,
|
||||||
}: {
|
}: {
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -994,10 +996,20 @@ export async function getSdkRaw({
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
unit?: "raw" | "turn";
|
unit?: "raw" | "turn";
|
||||||
|
paging?: "offset" | "cursor";
|
||||||
|
cursor?: string | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
} = {}): Promise<SdkRawPage> {
|
} = {}): Promise<SdkRawPage> {
|
||||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||||
params: { source, session_id: sessionId, offset, limit, unit },
|
params: {
|
||||||
|
source,
|
||||||
|
session_id: sessionId,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
unit,
|
||||||
|
paging,
|
||||||
|
cursor: cursor || undefined,
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
.r6-home-overview {
|
.r6-home-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 346px;
|
min-height: 346px;
|
||||||
padding: 26px;
|
padding: 26px;
|
||||||
@@ -150,6 +152,7 @@
|
|||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
.r6-home-activity {
|
.r6-home-activity {
|
||||||
|
margin-top: auto;
|
||||||
border-top: 1px solid #d5dfe8;
|
border-top: 1px solid #d5dfe8;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 738px) 278px;
|
grid-template-columns: minmax(0, 738px) 278px;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
min-height: 100%;
|
min-height: 0;
|
||||||
height: auto;
|
height: 100%;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.first-import-r6-main {
|
.first-import-r6-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -12,20 +13,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.first-import-r6-history-window {
|
||||||
margin: -10px 0 12px;
|
margin: 0 0 12px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid #ead7a7;
|
border: 1px solid #ead7a7;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -39,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
.first-import-r6-feed {
|
.first-import-r6-feed {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-top: 26px;
|
margin-top: 20px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -101,6 +90,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
.first-import-r6-eyebrow {
|
.first-import-r6-eyebrow {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
@@ -223,20 +213,22 @@
|
|||||||
.first-import-r6-options details p {
|
.first-import-r6-options details p {
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-note {
|
.first-import-r6-feedback {
|
||||||
flex: 1 1 240px;
|
margin: 0 0 12px;
|
||||||
min-width: 0;
|
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;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #63707b;
|
color: #63707b;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-note [role="alert"] {
|
.first-import-r6-feedback [role="alert"] {
|
||||||
color: #944242;
|
color: #944242;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-note .first-import-r6-partial-success {
|
.first-import-r6-feedback .first-import-r6-partial-success {
|
||||||
color: #245f50;
|
color: #245f50;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -248,6 +240,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.first-import-r6-footer-actions > [data-variant="primary"] {
|
.first-import-r6-footer-actions > [data-variant="primary"] {
|
||||||
min-width: min(160px, 100%);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -719,8 +719,8 @@
|
|||||||
color: #416e5c;
|
color: #416e5c;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.generation-model-error {
|
.onboarding-model-r6 .generation-model-error {
|
||||||
color: #a24646 !important;
|
color: #a24646;
|
||||||
}
|
}
|
||||||
.onboarding-model-recovery-actions {
|
.onboarding-model-recovery-actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -728,6 +728,19 @@
|
|||||||
.onboarding-model-feedback {
|
.onboarding-model-feedback {
|
||||||
margin-top: 8px;
|
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) {
|
@media (min-width: 761px) {
|
||||||
.onboarding-model-r6 .onboarding-r6-body {
|
.onboarding-model-r6 .onboarding-r6-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -751,6 +764,26 @@
|
|||||||
flex-basis: 100%;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.generation-technical-toggle,
|
.generation-technical-toggle,
|
||||||
.generation-model-field :is(input, select) {
|
.generation-model-field :is(input, select) {
|
||||||
|
|||||||
@@ -115,14 +115,16 @@
|
|||||||
}
|
}
|
||||||
.onboarding-r6-stage {
|
.onboarding-r6-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 852px;
|
min-height: 0;
|
||||||
padding: 32px 24px 2px;
|
display: flex;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-shell {
|
.onboarding-r6-shell {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 1120px;
|
width: 1120px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 820px;
|
height: 100%;
|
||||||
|
max-height: 820px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
inset 0 -1px 0 #adc0d22e;
|
inset 0 -1px 0 #adc0d22e;
|
||||||
}
|
}
|
||||||
.onboarding-r6-head {
|
.onboarding-r6-head {
|
||||||
min-height: 123.7px;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -195,6 +197,7 @@
|
|||||||
background: #ffffff38;
|
background: #ffffff38;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
border-radius: 0 0 25px 25px;
|
border-radius: 0 0 25px 25px;
|
||||||
}
|
}
|
||||||
@@ -224,27 +227,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onboarding-r6-discovery {
|
.onboarding-r6-discovery {
|
||||||
height: 560.2px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 738px) 278px;
|
grid-template-columns: minmax(0, 738px) 278px;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-apps {
|
.onboarding-r6-apps {
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
min-height: 0;
|
||||||
.onboarding-r6-intro {
|
display: grid;
|
||||||
margin: 0;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
font-size: 13px;
|
gap: 12px;
|
||||||
line-height: 24.05px;
|
|
||||||
color: #5a6169;
|
|
||||||
}
|
|
||||||
.onboarding-r6-intro p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-heading {
|
.onboarding-r6-list-heading {
|
||||||
position: absolute;
|
position: static;
|
||||||
inset: 96px 0 auto;
|
|
||||||
height: 30px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -277,8 +274,10 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-area {
|
.onboarding-r6-list-area {
|
||||||
position: absolute;
|
position: static;
|
||||||
inset: 142px 0 auto;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
|
||||||
color: #4e6478;
|
color: #4e6478;
|
||||||
@@ -288,7 +287,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
height: 330px;
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -451,44 +452,6 @@
|
|||||||
content: "↓";
|
content: "↓";
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
.onboarding-r6-import {
|
|
||||||
position: absolute;
|
|
||||||
top: 498px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6a7988;
|
|
||||||
}
|
|
||||||
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
|
|
||||||
markup keeps the sentence first so it stays the accessible label order for
|
|
||||||
the control that follows. Reordering visually only, rather than moving the
|
|
||||||
DOM, keeps that reading order intact. */
|
|
||||||
.onboarding-r6-import > p {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import > .history-import,
|
|
||||||
.onboarding-r6-import > .history-import-trigger {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import > p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import .history-import-trigger {
|
|
||||||
font: inherit;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #4e6478;
|
|
||||||
padding: 5px 11px;
|
|
||||||
min-height: 30px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import .history-import-trigger > svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.onboarding-r6-summary {
|
.onboarding-r6-summary {
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid #ffffffe0;
|
border: 1px solid #ffffffe0;
|
||||||
@@ -500,6 +463,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-summary p {
|
.onboarding-r6-summary p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -572,8 +537,8 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-mode {
|
.onboarding-r6-mode {
|
||||||
width: min(720px, 100%);
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@@ -612,6 +577,44 @@
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
text-align: center;
|
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 {
|
.onboarding-r6-mode-error {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin: -6px 0 0;
|
margin: -6px 0 0;
|
||||||
@@ -763,17 +766,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.onboarding-r6-apps {
|
.onboarding-r6-apps {
|
||||||
min-height: 590px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-heading {
|
.onboarding-r6-list {
|
||||||
top: 110px;
|
height: min(330px, 45vh);
|
||||||
}
|
flex: none;
|
||||||
.onboarding-r6-list-area {
|
|
||||||
top: 154px;
|
|
||||||
}
|
|
||||||
.onboarding-r6-import {
|
|
||||||
top: 552px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-row > label {
|
.onboarding-r6-row > label {
|
||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
@@ -815,20 +812,7 @@
|
|||||||
}
|
}
|
||||||
.onboarding-r6-discovery > .onboarding-r6-apps {
|
.onboarding-r6-discovery > .onboarding-r6-apps {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
grid-template-rows: auto 30px minmax(0, 1fr) auto;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-heading,
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-area,
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-import {
|
|
||||||
position: static;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.onboarding-r6-apps > .onboarding-r6-list-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
.onboarding-r6-list-area > .onboarding-r6-list {
|
.onboarding-r6-list-area > .onboarding-r6-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -295,7 +295,7 @@
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid #e1e8ed;
|
border-top: 1px solid #e1e8ed;
|
||||||
}
|
}
|
||||||
.r6-settings .r6-evolve-time label {
|
.r6-settings .r6-evolve-time > div > label {
|
||||||
color: #304757;
|
color: #304757;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -304,20 +304,146 @@
|
|||||||
.r6-evolve-time p {
|
.r6-evolve-time p {
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"] {
|
.r6-time-picker {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
min-height: 44px;
|
height: 44px;
|
||||||
padding: 0 10px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px 0 14px;
|
||||||
border: 1px solid #c6d2da;
|
border: 1px solid #c6d2da;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #304757;
|
color: #304757;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
font:
|
||||||
|
500 14px / 22px "Noto Sans SC",
|
||||||
|
"PingFang SC",
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 140ms ease-out,
|
||||||
|
background-color 140ms ease-out,
|
||||||
|
box-shadow 140ms ease-out;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"]:focus-visible {
|
.r6-time-picker-trigger:hover:not(:disabled) {
|
||||||
|
border-color: #9eafbb;
|
||||||
|
background: #f8fafb;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger[aria-expanded="true"] {
|
||||||
|
border-color: #7892a6;
|
||||||
|
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger:focus-visible {
|
||||||
outline: 2px solid var(--r6-focus);
|
outline: 2px solid var(--r6-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
.r6-time-picker-trigger:disabled {
|
||||||
|
color: #71808b;
|
||||||
|
background: #edf0f3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.r6-time-picker-icons {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #607888;
|
||||||
|
}
|
||||||
|
.r6-time-picker-trigger[aria-expanded="true"]
|
||||||
|
.r6-time-picker-icons
|
||||||
|
svg:last-child {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1200;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
width: min(280px, calc(100vw - 32px));
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #c6d2da;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel[data-placement="below"] {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #5e6e7a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 174px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #d5dfe8;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #304757;
|
||||||
|
background: #f8fafb;
|
||||||
|
font:
|
||||||
|
500 14px / 24px "Noto Sans SC",
|
||||||
|
"PingFang SC",
|
||||||
|
sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #b6c3cc transparent;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns select:focus-visible {
|
||||||
|
outline: 2px solid var(--r6-focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns option {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.r6-time-picker-columns option:checked {
|
||||||
|
color: #304757;
|
||||||
|
background: #dfe9ef;
|
||||||
|
}
|
||||||
|
.r6-time-picker-separator {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
color: #7892a6;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e1e8ed;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.r6-time-picker-panel footer .r6-button {
|
||||||
|
min-width: 68px;
|
||||||
|
}
|
||||||
.r6-state-recovery {
|
.r6-state-recovery {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1059,9 +1185,14 @@
|
|||||||
.r6-evolve-time {
|
.r6-evolve-time {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.r6-evolve-settings input[type="time"] {
|
.r6-time-picker,
|
||||||
|
.r6-time-picker-trigger {
|
||||||
width: min(220px, 100%);
|
width: min(220px, 100%);
|
||||||
}
|
}
|
||||||
|
.r6-time-picker-panel {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
.r6-state-recovery {
|
.r6-state-recovery {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ function pageFor(
|
|||||||
total: number,
|
total: number,
|
||||||
offset: number,
|
offset: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
cursor?: {
|
||||||
|
next_cursor?: string | null;
|
||||||
|
has_more?: boolean;
|
||||||
|
total_exact?: boolean;
|
||||||
|
},
|
||||||
): MemoryLibraryPage {
|
): MemoryLibraryPage {
|
||||||
const sourceIds: Record<string, string[]> = {};
|
const sourceIds: Record<string, string[]> = {};
|
||||||
const sourceTime: Record<
|
const sourceTime: Record<
|
||||||
@@ -97,6 +102,7 @@ function pageFor(
|
|||||||
total,
|
total,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
|
...cursor,
|
||||||
source_ids_by_memory: sourceIds,
|
source_ids_by_memory: sourceIds,
|
||||||
source_time_by_memory: sourceTime,
|
source_time_by_memory: sourceTime,
|
||||||
search_window: null,
|
search_window: null,
|
||||||
@@ -107,7 +113,11 @@ function pageFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
|
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 {
|
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);
|
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]);
|
vi.mocked(api.getModelProfiles).mockResolvedValueOnce([profile]);
|
||||||
const { result } = renderHook(useModelProfiles);
|
const { result } = renderHook(useModelProfiles);
|
||||||
await waitFor(() => expect(result.current.profiles).toHaveLength(1));
|
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[]>();
|
const delayed = deferred<ModelProfile[]>();
|
||||||
vi.mocked(api.getModelProfiles).mockReturnValueOnce(delayed.promise);
|
vi.mocked(api.getModelProfiles).mockReturnValueOnce(delayed.promise);
|
||||||
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(probeResult(updated));
|
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(receipt);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.probeProfile(profile.profile_id);
|
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 () => {
|
await act(async () => {
|
||||||
delayed.resolve([updated]);
|
delayed.resolve([updated]);
|
||||||
});
|
});
|
||||||
expect(result.current.probeFor(result.current.profiles[0])).toEqual(
|
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
|
||||||
probeResult(updated),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
updateModelProfile,
|
updateModelProfile,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { modelErrorText } from "./model-error-text";
|
import { modelErrorText } from "./model-error-text";
|
||||||
|
import { profileFromProbe } from "./model-profile-readiness";
|
||||||
import {
|
import {
|
||||||
clearPendingModelCreation,
|
clearPendingModelCreation,
|
||||||
readPendingModelCreation,
|
readPendingModelCreation,
|
||||||
@@ -314,12 +315,7 @@ export function useModelProfiles() {
|
|||||||
})]: result,
|
})]: result,
|
||||||
}));
|
}));
|
||||||
setProfiles((previous) =>
|
setProfiles((previous) =>
|
||||||
previous.map((item) =>
|
previous.map((item) => profileFromProbe(item, result) ?? item),
|
||||||
item.profile_id === result.profile_id &&
|
|
||||||
item.revision === result.profile_revision
|
|
||||||
? { ...item, capabilities: result.capabilities }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(reason) =>
|
(reason) =>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
||||||
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||||
|
|
||||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
|
||||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
|
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
|
||||||
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
|
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
|
||||||
|
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
|
||||||
|
|
||||||
## 1. Log level 设什么
|
## 1. Log level 设什么
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
|
|||||||
|
|
||||||
## 2. 日志在哪里
|
## 2. 日志在哪里
|
||||||
|
|
||||||
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
|
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
|
||||||
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
|
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
|
||||||
Core 日志默认位于:
|
Core 日志默认位于:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
|
|||||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
||||||
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
||||||
|
|
||||||
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||||
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
||||||
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
|
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
|
||||||
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
|
|
||||||
|
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
|
||||||
|
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
|
||||||
|
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
|
||||||
|
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
|
||||||
|
|
||||||
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
||||||
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
|
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
|
||||||
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
|
再 chmod 失败留下伪成功文件的窗口。
|
||||||
|
|
||||||
## 覆盖与节流
|
## 覆盖与节流
|
||||||
|
|
||||||
|
|||||||
@@ -412,16 +412,18 @@ mode. A change rebuilds the SDK handles the same way. Contract:
|
|||||||
|
|
||||||
The App's product rules are:
|
The App's product rules are:
|
||||||
|
|
||||||
- A new installation explicitly chooses lite or pro. Choosing lite persists
|
- A new installation explicitly chooses lite, pro or ultra. Choosing lite
|
||||||
the mode and completes onboarding without a generation model. Choosing pro
|
persists the mode and completes onboarding without a generation model.
|
||||||
opens model setup and completes onboarding only after activation succeeds.
|
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,
|
- Lite always ingests authorized imported and live conversations into L1 raw,
|
||||||
even when model sedimentation is paused. That pause controls generation-model
|
even when model sedimentation is paused. That pause controls generation-model
|
||||||
work in pro/ultra; it never turns raw conversation storage off.
|
work in pro/ultra; it never turns raw conversation storage off.
|
||||||
- Existing installations keep the default pro behaviour; an absent
|
- Existing installations keep the default pro behaviour; an absent
|
||||||
`memory_mode` is never inferred from model availability.
|
`memory_mode` is never inferred from model availability.
|
||||||
- Ultra is enabled only from Settings. Its nightly schedule is on by default
|
- Ultra's nightly schedule is configured during first-run selection or later
|
||||||
at 03:00 local time, and Settings also exposes a manual evolve action.
|
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
|
- 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
|
pro/ultra. Higher-mode memories remain stored while lite is active but are
|
||||||
neither shown nor served by lite search.
|
neither shown nor served by lite search.
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export interface ConnectSourcesResponse {
|
|||||||
request_id?: string | null;
|
request_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OnboardingModeIntent = "lite" | "pro";
|
export type OnboardingModeIntent = "lite" | "pro" | "ultra";
|
||||||
export type OnboardingSetupStep = "review" | "mode" | "model";
|
export type OnboardingSetupStep = "review" | "mode" | "model";
|
||||||
|
|
||||||
export interface OnboardingProgressUpdate {
|
export interface OnboardingProgressUpdate {
|
||||||
@@ -510,6 +510,10 @@ export interface MemoryLibraryPage {
|
|||||||
total: number;
|
total: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: 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. */
|
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
|
||||||
source_ids_by_memory?: Record<string, string[]>;
|
source_ids_by_memory?: Record<string, string[]>;
|
||||||
/** When each returned memory's cited conversation happened (D96). `import`
|
/** When each returned memory's cited conversation happened (D96). `import`
|
||||||
@@ -709,6 +713,9 @@ export interface SdkRawPage {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
unit?: "raw" | "turn";
|
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). */
|
/** A time range was applied to this page in Core (the SDK list has none). */
|
||||||
time_filtered?: boolean;
|
time_filtered?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,12 @@ describe("English catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses CLDR English singular and plural forms", () => {
|
it("uses CLDR English singular and plural forms", () => {
|
||||||
|
expect(translateCount("en", "sdk.raw.total", 1, { count: 1 })).toBe(
|
||||||
|
"1 original conversation",
|
||||||
|
);
|
||||||
|
expect(translateCount("en", "sdk.raw.total", 2, { count: 2 })).toBe(
|
||||||
|
"2 original conversations",
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
translateCount("en", "calls.headline.found", 1, {
|
translateCount("en", "calls.headline.found", 1, {
|
||||||
host: "Cursor",
|
host: "Cursor",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export const currentChineseMessages = {
|
|||||||
"sdk.raw.searchFailed": "原始对话搜索失败,请重试。",
|
"sdk.raw.searchFailed": "原始对话搜索失败,请重试。",
|
||||||
"sdk.raw.recent": "最近原始对话",
|
"sdk.raw.recent": "最近原始对话",
|
||||||
"sdk.raw.empty": "还没有原始对话。连接来源后,对话会显示在这里。",
|
"sdk.raw.empty": "还没有原始对话。连接来源后,对话会显示在这里。",
|
||||||
|
"sdk.raw.total": "{count} 条原始对话",
|
||||||
|
"sdk.raw.total.one": "{count} 条原始对话",
|
||||||
"sdk.raw.found": "找到 {count} 条",
|
"sdk.raw.found": "找到 {count} 条",
|
||||||
"sdk.raw.found.one": "找到 {count} 条",
|
"sdk.raw.found.one": "找到 {count} 条",
|
||||||
|
|
||||||
@@ -134,6 +136,7 @@ export const currentChineseMessages = {
|
|||||||
"selectedMemory.save": "保存修改",
|
"selectedMemory.save": "保存修改",
|
||||||
"selectedMemory.author.user": "你",
|
"selectedMemory.author.user": "你",
|
||||||
"selectedMemory.author.agent": "助手",
|
"selectedMemory.author.agent": "助手",
|
||||||
|
"selectedMemory.author.tool": "工具",
|
||||||
"selectedMemory.sourceUnavailable": "来源权限已变化或原文不可用。",
|
"selectedMemory.sourceUnavailable": "来源权限已变化或原文不可用。",
|
||||||
"selectedMemory.related": "对应记忆",
|
"selectedMemory.related": "对应记忆",
|
||||||
"selectedMemory.viewMemory": "查看记忆",
|
"selectedMemory.viewMemory": "查看记忆",
|
||||||
@@ -222,6 +225,8 @@ export const currentEnglishMessages: Record<
|
|||||||
"sdk.raw.recent": "Recent original conversations",
|
"sdk.raw.recent": "Recent original conversations",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"No original conversations yet. Connect a source to see conversations here.",
|
"No original conversations yet. Connect a source to see conversations here.",
|
||||||
|
"sdk.raw.total": "{count} original conversations",
|
||||||
|
"sdk.raw.total.one": "{count} original conversation",
|
||||||
"sdk.raw.found": "{count} results",
|
"sdk.raw.found": "{count} results",
|
||||||
"sdk.raw.found.one": "{count} result",
|
"sdk.raw.found.one": "{count} result",
|
||||||
|
|
||||||
@@ -289,6 +294,7 @@ export const currentEnglishMessages: Record<
|
|||||||
"selectedMemory.save": "Save changes",
|
"selectedMemory.save": "Save changes",
|
||||||
"selectedMemory.author.user": "You",
|
"selectedMemory.author.user": "You",
|
||||||
"selectedMemory.author.agent": "Assistant",
|
"selectedMemory.author.agent": "Assistant",
|
||||||
|
"selectedMemory.author.tool": "Tool",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Source permissions changed or the original text is unavailable.",
|
"Source permissions changed or the original text is unavailable.",
|
||||||
"selectedMemory.related": "Related memory",
|
"selectedMemory.related": "Related memory",
|
||||||
@@ -384,6 +390,8 @@ export const currentSpanishMessages: Record<
|
|||||||
"sdk.raw.recent": "Conversaciones originales recientes",
|
"sdk.raw.recent": "Conversaciones originales recientes",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Todavía no hay conversaciones originales. Conecta una fuente para verlas aquí.",
|
"Todavía no hay conversaciones originales. Conecta una fuente para verlas aquí.",
|
||||||
|
"sdk.raw.total": "{count} conversaciones originales",
|
||||||
|
"sdk.raw.total.one": "{count} conversación original",
|
||||||
"sdk.raw.found": "{count} resultados",
|
"sdk.raw.found": "{count} resultados",
|
||||||
"sdk.raw.found.one": "{count} resultado",
|
"sdk.raw.found.one": "{count} resultado",
|
||||||
|
|
||||||
@@ -452,6 +460,7 @@ export const currentSpanishMessages: Record<
|
|||||||
"selectedMemory.save": "Guardar cambios",
|
"selectedMemory.save": "Guardar cambios",
|
||||||
"selectedMemory.author.user": "Tú",
|
"selectedMemory.author.user": "Tú",
|
||||||
"selectedMemory.author.agent": "Asistente",
|
"selectedMemory.author.agent": "Asistente",
|
||||||
|
"selectedMemory.author.tool": "Herramienta",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Los permisos del origen cambiaron o el texto no está disponible.",
|
"Los permisos del origen cambiaron o el texto no está disponible.",
|
||||||
"selectedMemory.related": "Memoria correspondiente",
|
"selectedMemory.related": "Memoria correspondiente",
|
||||||
|
|||||||
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
|
|||||||
"Keep syncing, processing and providing memories without opening the app.",
|
"Keep syncing, processing and providing memories without opening the app.",
|
||||||
导出日志: "Export logs",
|
导出日志: "Export logs",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Package local diagnostic logs into a file to share with support.",
|
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
|
||||||
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
|
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"The background service will continue syncing, processing and providing memories.",
|
"The background service will continue syncing, processing and providing memories.",
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ const modeChineseMessages = {
|
|||||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
"正在检查或切换向量模型,完成后才能选择记忆模式。",
|
||||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||||
"模式切换未能确认,仍显示上次确认的模式。":
|
"模式切换未能确认,仍显示上次确认的模式。":
|
||||||
"模式切换未能确认,仍显示上次确认的模式。",
|
"模式选择未能确认,仍显示上次确认的模式。",
|
||||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||||
@@ -71,7 +71,7 @@ const modeChineseMessages = {
|
|||||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
|
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
|
||||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||||
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
|
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
|
||||||
每天自动整合: "每天自动整合",
|
每天自动整合: "每天自动整合",
|
||||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||||
@@ -83,10 +83,10 @@ const modeChineseMessages = {
|
|||||||
"上次整合:{time}": "上次整合:{time}",
|
"上次整合:{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}",
|
"上次整合:{time}": "Last consolidated: {time}",
|
||||||
尚未整合: "Not yet consolidated",
|
尚未整合: "Not yet consolidated",
|
||||||
立即整合: "Consolidate now",
|
立即整合: "Consolidate now",
|
||||||
"切换到 {mode}?": "Switch to {mode}?",
|
"切换到 {mode}?": "Choose {mode}?",
|
||||||
"切换到 {mode}": "Switch to {mode}",
|
"切换到 {mode}": "Choose {mode}",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"Original conversations are kept in every mode.",
|
"Original conversations are kept in every mode.",
|
||||||
切换模式: "Switch mode",
|
切换模式: "Confirm choice",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"Save original conversations · No generation model calls · Search original conversations",
|
"Save original conversations · No generation model calls · Search original conversations",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -485,11 +485,11 @@ export const modeSpanishMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Última consolidación: {time}",
|
"上次整合:{time}": "Última consolidación: {time}",
|
||||||
尚未整合: "Aún sin consolidar",
|
尚未整合: "Aún sin consolidar",
|
||||||
立即整合: "Consolidar ahora",
|
立即整合: "Consolidar ahora",
|
||||||
"切换到 {mode}?": "¿Cambiar a {mode}?",
|
"切换到 {mode}?": "¿Elegir {mode}?",
|
||||||
"切换到 {mode}": "Cambiar a {mode}",
|
"切换到 {mode}": "Elegir {mode}",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"Las conversaciones originales se conservan en todos los modos.",
|
"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",
|
"Guarda conversaciones originales · Sin llamadas al modelo generativo · Busca conversaciones originales",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -682,10 +682,10 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "上次整合:{time}",
|
"上次整合:{time}": "上次整合:{time}",
|
||||||
尚未整合: "尚未整合",
|
尚未整合: "尚未整合",
|
||||||
立即整合: "立即整合",
|
立即整合: "立即整合",
|
||||||
"切换到 {mode}?": "切換到{mode}?",
|
"切换到 {mode}?": "選擇{mode}?",
|
||||||
"切换到 {mode}": "切換到{mode}",
|
"切换到 {mode}": "選擇{mode}",
|
||||||
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
||||||
切换模式: "切換模式",
|
切换模式: "確認選擇",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"儲存原始對話 · 不呼叫生成模型 · 搜尋原始對話",
|
"儲存原始對話 · 不呼叫生成模型 · 搜尋原始對話",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -833,10 +833,10 @@ export const modeJapaneseMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "前回の統合:{time}",
|
"上次整合:{time}": "前回の統合:{time}",
|
||||||
尚未整合: "まだ統合されていません",
|
尚未整合: "まだ統合されていません",
|
||||||
立即整合: "今すぐ統合",
|
立即整合: "今すぐ統合",
|
||||||
"切换到 {mode}?": "{mode} に切り替えますか?",
|
"切换到 {mode}?": "{mode} を選択しますか?",
|
||||||
"切换到 {mode}": "{mode} に切り替える",
|
"切换到 {mode}": "{mode} を選択",
|
||||||
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
||||||
切换模式: "モードを切り替える",
|
切换模式: "選択を確定",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"元の会話を保存 · 生成モデルを呼び出さない · 元の会話を検索",
|
"元の会話を保存 · 生成モデルを呼び出さない · 元の会話を検索",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -1033,11 +1033,11 @@ export const modeGermanMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
|
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
|
||||||
尚未整合: "Noch nicht konsolidiert",
|
尚未整合: "Noch nicht konsolidiert",
|
||||||
立即整合: "Jetzt konsolidieren",
|
立即整合: "Jetzt konsolidieren",
|
||||||
"切换到 {mode}?": "Zu {mode} wechseln?",
|
"切换到 {mode}?": "{mode} auswählen?",
|
||||||
"切换到 {mode}": "Zu {mode} wechseln",
|
"切换到 {mode}": "{mode} auswählen",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
||||||
切换模式: "Modus wechseln",
|
切换模式: "Auswahl bestätigen",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"Originalunterhaltungen speichern · Keine Aufrufe des generativen Modells · Originalunterhaltungen durchsuchen",
|
"Originalunterhaltungen speichern · Keine Aufrufe des generativen Modells · Originalunterhaltungen durchsuchen",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -1237,11 +1237,11 @@ export const modePortugueseMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Última consolidação: {time}",
|
"上次整合:{time}": "Última consolidação: {time}",
|
||||||
尚未整合: "Ainda não consolidado",
|
尚未整合: "Ainda não consolidado",
|
||||||
立即整合: "Consolidar agora",
|
立即整合: "Consolidar agora",
|
||||||
"切换到 {mode}?": "Mudar para {mode}?",
|
"切换到 {mode}?": "Escolher {mode}?",
|
||||||
"切换到 {mode}": "Mudar para {mode}",
|
"切换到 {mode}": "Escolher {mode}",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"As conversas originais são mantidas em todos os modos.",
|
"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",
|
"Guardar conversas originais · Sem chamadas ao modelo generativo · Pesquisar conversas originais",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -1443,11 +1443,11 @@ export const modeFrenchMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Dernière consolidation : {time}",
|
"上次整合:{time}": "Dernière consolidation : {time}",
|
||||||
尚未整合: "Pas encore consolidé",
|
尚未整合: "Pas encore consolidé",
|
||||||
立即整合: "Consolider maintenant",
|
立即整合: "Consolider maintenant",
|
||||||
"切换到 {mode}?": "Passer à {mode} ?",
|
"切换到 {mode}?": "Choisir {mode} ?",
|
||||||
"切换到 {mode}": "Passer à {mode}",
|
"切换到 {mode}": "Choisir {mode}",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"Les conversations originales sont conservées dans tous les modes.",
|
"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",
|
"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}",
|
"上次整合:{time}": "마지막 통합: {time}",
|
||||||
尚未整合: "아직 통합되지 않음",
|
尚未整合: "아직 통합되지 않음",
|
||||||
立即整合: "지금 통합",
|
立即整合: "지금 통합",
|
||||||
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
|
"切换到 {mode}?": "{mode}을(를) 선택할까요?",
|
||||||
"切换到 {mode}": "{mode}(으)로 전환",
|
"切换到 {mode}": "{mode} 선택",
|
||||||
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
||||||
切换模式: "모드 전환",
|
切换模式: "선택 확인",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"원본 대화 저장 · 생성 모델 호출 없음 · 원본 대화 검색",
|
"원본 대화 저장 · 생성 모델 호출 없음 · 원본 대화 검색",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -1844,11 +1844,11 @@ export const modeRussianMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Последнее объединение: {time}",
|
"上次整合:{time}": "Последнее объединение: {time}",
|
||||||
尚未整合: "Ещё не объединялись",
|
尚未整合: "Ещё не объединялись",
|
||||||
立即整合: "Объединить сейчас",
|
立即整合: "Объединить сейчас",
|
||||||
"切换到 {mode}?": "Переключиться на {mode}?",
|
"切换到 {mode}?": "Выбрать {mode}?",
|
||||||
"切换到 {mode}": "Переключиться на {mode}",
|
"切换到 {mode}": "Выбрать {mode}",
|
||||||
"原始对话在所有模式下都会保留。":
|
"原始对话在所有模式下都会保留。":
|
||||||
"Исходные диалоги сохраняются во всех режимах.",
|
"Исходные диалоги сохраняются во всех режимах.",
|
||||||
切换模式: "Переключить режим",
|
切换模式: "Подтвердить выбор",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"Сохранять исходные диалоги · Не вызывать генеративную модель · Искать в исходных диалогах",
|
"Сохранять исходные диалоги · Не вызывать генеративную модель · Искать в исходных диалогах",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
@@ -2048,10 +2048,10 @@ export const modeIndonesianMessages: ModeMessages = {
|
|||||||
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
|
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
|
||||||
尚未整合: "Belum dikonsolidasikan",
|
尚未整合: "Belum dikonsolidasikan",
|
||||||
立即整合: "Konsolidasikan sekarang",
|
立即整合: "Konsolidasikan sekarang",
|
||||||
"切换到 {mode}?": "Beralih ke {mode}?",
|
"切换到 {mode}?": "Pilih {mode}?",
|
||||||
"切换到 {mode}": "Beralih ke {mode}",
|
"切换到 {mode}": "Pilih {mode}",
|
||||||
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
||||||
切换模式: "Beralih mode",
|
切换模式: "Konfirmasi pilihan",
|
||||||
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
|
||||||
"Simpan percakapan asli · Tanpa panggilan model generatif · Cari percakapan asli",
|
"Simpan percakapan asli · Tanpa panggilan model generatif · Cari percakapan asli",
|
||||||
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
|
|||||||
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
|
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
|
||||||
导出日志: "Exportar registros",
|
导出日志: "Exportar registros",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Empaqueta los registros locales en un archivo para su diagnóstico.",
|
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
|
||||||
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
|
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
|
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
|
||||||
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
|
|||||||
const chineseOverrides: Partial<
|
const chineseOverrides: Partial<
|
||||||
Record<keyof typeof settingsSpanishMessages, string>
|
Record<keyof typeof settingsSpanishMessages, string>
|
||||||
> = {
|
> = {
|
||||||
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
|
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
|
||||||
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
|
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
|
||||||
appSettingsTitle: "{name} 应用设置",
|
appSettingsTitle: "{name} 应用设置",
|
||||||
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
|
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ describe("GUI messages", () => {
|
|||||||
"上次整合:11/3/2026, 4:00:00 PM",
|
"上次整合:11/3/2026, 4:00:00 PM",
|
||||||
"Last consolidated: 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;
|
] as const;
|
||||||
for (const [source, expected] of cases) {
|
for (const [source, expected] of cases) {
|
||||||
expect(isMessageKey(source)).toBe(true);
|
expect(isMessageKey(source)).toBe(true);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const germanMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "Letzte Originalunterhaltungen",
|
"sdk.raw.recent": "Letzte Originalunterhaltungen",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Noch keine Originalunterhaltungen. Verbinde eine Quelle, damit sie hier erscheinen.",
|
"Noch keine Originalunterhaltungen. Verbinde eine Quelle, damit sie hier erscheinen.",
|
||||||
|
"sdk.raw.total": "{count} Originalunterhaltungen",
|
||||||
|
"sdk.raw.total.one": "{count} Originalunterhaltung",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"Der Basisspeicher ist aktiv. Neue Unterhaltungen werden als Originale gespeichert und erzeugen keine erweiterten Erinnerungen. Vorhandene erweiterte Erinnerungen bleiben gespeichert, werden aber nicht angezeigt oder abgerufen.",
|
"Der Basisspeicher ist aktiv. Neue Unterhaltungen werden als Originale gespeichert und erzeugen keine erweiterten Erinnerungen. Vorhandene erweiterte Erinnerungen bleiben gespeichert, werden aber nicht angezeigt oder abgerufen.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -158,6 +160,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "Speichern",
|
"selectedMemory.save": "Speichern",
|
||||||
"selectedMemory.author.user": "Sie",
|
"selectedMemory.author.user": "Sie",
|
||||||
"selectedMemory.author.agent": "Assistent",
|
"selectedMemory.author.agent": "Assistent",
|
||||||
|
"selectedMemory.author.tool": "Tool",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Quellberechtigungen haben sich geändert oder der Originaltext ist nicht verfügbar.",
|
"Quellberechtigungen haben sich geändert oder der Originaltext ist nicht verfügbar.",
|
||||||
"selectedMemory.related": "Zugehörige Erinnerung",
|
"selectedMemory.related": "Zugehörige Erinnerung",
|
||||||
@@ -1065,7 +1068,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
|||||||
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
|
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
|
||||||
导出日志: "Protokolle exportieren",
|
导出日志: "Protokolle exportieren",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
|
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
|
||||||
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
|
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
|
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export const frenchMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "Conversations originales récentes",
|
"sdk.raw.recent": "Conversations originales récentes",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Aucune conversation originale pour le moment. Connectez une source pour les afficher ici.",
|
"Aucune conversation originale pour le moment. Connectez une source pour les afficher ici.",
|
||||||
|
"sdk.raw.total": "{count} conversations originales",
|
||||||
|
"sdk.raw.total.one": "{count} conversation originale",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"La mémoire de base est active. Les nouvelles conversations sont conservées telles quelles et ne créent pas de mémoires avancées. Les mémoires avancées existantes restent enregistrées, mais ne sont ni affichées ni rappelées.",
|
"La mémoire de base est active. Les nouvelles conversations sont conservées telles quelles et ne créent pas de mémoires avancées. Les mémoires avancées existantes restent enregistrées, mais ne sont ni affichées ni rappelées.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -162,6 +164,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "Enregistrer",
|
"selectedMemory.save": "Enregistrer",
|
||||||
"selectedMemory.author.user": "Vous",
|
"selectedMemory.author.user": "Vous",
|
||||||
"selectedMemory.author.agent": "Assistant",
|
"selectedMemory.author.agent": "Assistant",
|
||||||
|
"selectedMemory.author.tool": "Outil",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Les autorisations de la source ont changé ou le texte original est indisponible.",
|
"Les autorisations de la source ont changé ou le texte original est indisponible.",
|
||||||
"selectedMemory.related": "Mémoire associée",
|
"selectedMemory.related": "Mémoire associée",
|
||||||
@@ -1078,7 +1081,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
|||||||
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
|
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
|
||||||
导出日志: "Exporter les journaux",
|
导出日志: "Exporter les journaux",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
|
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
|
||||||
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
|
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
|
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "Percakapan asli terbaru",
|
"sdk.raw.recent": "Percakapan asli terbaru",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Belum ada percakapan asli. Hubungkan sumber agar percakapan tampil di sini.",
|
"Belum ada percakapan asli. Hubungkan sumber agar percakapan tampil di sini.",
|
||||||
|
"sdk.raw.total": "{count} percakapan asli",
|
||||||
|
"sdk.raw.total.one": "{count} percakapan asli",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"Memori dasar aktif. Percakapan baru disimpan sebagai catatan asli dan tidak membuat memori lanjutan. Memori lanjutan yang ada tetap tersimpan, tetapi tidak ditampilkan atau digunakan untuk recall.",
|
"Memori dasar aktif. Percakapan baru disimpan sebagai catatan asli dan tidak membuat memori lanjutan. Memori lanjutan yang ada tetap tersimpan, tetapi tidak ditampilkan atau digunakan untuk recall.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -151,6 +153,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "Simpan perubahan",
|
"selectedMemory.save": "Simpan perubahan",
|
||||||
"selectedMemory.author.user": "Anda",
|
"selectedMemory.author.user": "Anda",
|
||||||
"selectedMemory.author.agent": "Asisten",
|
"selectedMemory.author.agent": "Asisten",
|
||||||
|
"selectedMemory.author.tool": "Alat",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Izin sumber berubah atau teks asli tidak tersedia.",
|
"Izin sumber berubah atau teks asli tidak tersedia.",
|
||||||
"selectedMemory.related": "Memori terkait",
|
"selectedMemory.related": "Memori terkait",
|
||||||
@@ -1048,7 +1051,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
|||||||
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
|
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
|
||||||
导出日志: "Ekspor log",
|
导出日志: "Ekspor log",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
|
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
|
||||||
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
|
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
|
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "最近の元の会話",
|
"sdk.raw.recent": "最近の元の会話",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"元の会話はまだありません。ソースを接続すると、ここに表示されます。",
|
"元の会話はまだありません。ソースを接続すると、ここに表示されます。",
|
||||||
|
"sdk.raw.total": "元の会話 {count} 件",
|
||||||
|
"sdk.raw.total.one": "元の会話 {count} 件",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"基本メモリを使用中です。新しい会話は原文として保存され、高度なメモリは生成されません。既存の高度なメモリは保持されますが、現在は表示も呼び出しもされません。",
|
"基本メモリを使用中です。新しい会話は原文として保存され、高度なメモリは生成されません。既存の高度なメモリは保持されますが、現在は表示も呼び出しもされません。",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -152,6 +154,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "変更を保存",
|
"selectedMemory.save": "変更を保存",
|
||||||
"selectedMemory.author.user": "あなた",
|
"selectedMemory.author.user": "あなた",
|
||||||
"selectedMemory.author.agent": "アシスタント",
|
"selectedMemory.author.agent": "アシスタント",
|
||||||
|
"selectedMemory.author.tool": "ツール",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"ソースの権限が変更されたか、原文を利用できません。",
|
"ソースの権限が変更されたか、原文を利用できません。",
|
||||||
"selectedMemory.related": "関連する記憶",
|
"selectedMemory.related": "関連する記憶",
|
||||||
@@ -1045,7 +1048,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
|||||||
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
|
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
|
||||||
导出日志: "ログを書き出す",
|
导出日志: "ログを書き出す",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
|
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
|
||||||
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
|
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
|
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const koreanMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "최근 원본 대화",
|
"sdk.raw.recent": "최근 원본 대화",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"아직 원본 대화가 없습니다. 소스를 연결하면 여기에 표시됩니다.",
|
"아직 원본 대화가 없습니다. 소스를 연결하면 여기에 표시됩니다.",
|
||||||
|
"sdk.raw.total": "원본 대화 {count}개",
|
||||||
|
"sdk.raw.total.one": "원본 대화 {count}개",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"기본 메모리를 사용 중입니다. 새 대화는 원본 기록으로 저장되며 고급 메모리를 만들지 않습니다. 기존 고급 메모리는 보존되지만 현재 표시되거나 회상에 사용되지 않습니다.",
|
"기본 메모리를 사용 중입니다. 새 대화는 원본 기록으로 저장되며 고급 메모리를 만들지 않습니다. 기존 고급 메모리는 보존되지만 현재 표시되거나 회상에 사용되지 않습니다.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -149,6 +151,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "변경 사항 저장",
|
"selectedMemory.save": "변경 사항 저장",
|
||||||
"selectedMemory.author.user": "나",
|
"selectedMemory.author.user": "나",
|
||||||
"selectedMemory.author.agent": "어시스턴트",
|
"selectedMemory.author.agent": "어시스턴트",
|
||||||
|
"selectedMemory.author.tool": "도구",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"출처 권한이 변경되었거나 원문을 사용할 수 없습니다.",
|
"출처 권한이 변경되었거나 원문을 사용할 수 없습니다.",
|
||||||
"selectedMemory.related": "관련 기억",
|
"selectedMemory.related": "관련 기억",
|
||||||
@@ -1021,7 +1024,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
|||||||
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
|
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
|
||||||
导出日志: "로그 내보내기",
|
导出日志: "로그 내보내기",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
|
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
|
||||||
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
|
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
|
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "Conversas originais recentes",
|
"sdk.raw.recent": "Conversas originais recentes",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Ainda não há conversas originais. Ligue uma origem para as ver aqui.",
|
"Ainda não há conversas originais. Ligue uma origem para as ver aqui.",
|
||||||
|
"sdk.raw.total": "{count} conversas originais",
|
||||||
|
"sdk.raw.total.one": "{count} conversa original",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"A memória básica está ativa. As novas conversas são guardadas como registos originais e não criam memórias avançadas. As memórias avançadas existentes continuam guardadas, mas não são mostradas nem usadas na recuperação.",
|
"A memória básica está ativa. As novas conversas são guardadas como registos originais e não criam memórias avançadas. As memórias avançadas existentes continuam guardadas, mas não são mostradas nem usadas na recuperação.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -159,6 +161,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "Salvar alterações",
|
"selectedMemory.save": "Salvar alterações",
|
||||||
"selectedMemory.author.user": "Você",
|
"selectedMemory.author.user": "Você",
|
||||||
"selectedMemory.author.agent": "Assistente",
|
"selectedMemory.author.agent": "Assistente",
|
||||||
|
"selectedMemory.author.tool": "Ferramenta",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"As permissões da fonte mudaram ou o texto original não está disponível.",
|
"As permissões da fonte mudaram ou o texto original não está disponível.",
|
||||||
"selectedMemory.related": "Memória relacionada",
|
"selectedMemory.related": "Memória relacionada",
|
||||||
@@ -1069,7 +1072,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
|||||||
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
|
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
|
||||||
导出日志: "Exportar logs",
|
导出日志: "Exportar logs",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
|
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
|
||||||
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
|
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
|
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export const russianMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.recent": "Недавние исходные диалоги",
|
"sdk.raw.recent": "Недавние исходные диалоги",
|
||||||
"sdk.raw.empty":
|
"sdk.raw.empty":
|
||||||
"Исходных диалогов пока нет. Подключите источник, и они появятся здесь.",
|
"Исходных диалогов пока нет. Подключите источник, и они появятся здесь.",
|
||||||
|
"sdk.raw.total": "Исходных диалогов: {count}",
|
||||||
|
"sdk.raw.total.one": "{count} исходный диалог",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"Включена базовая память. Новые диалоги сохраняются в исходном виде и не создают расширенные воспоминания. Существующие расширенные воспоминания сохраняются, но не показываются и не используются при поиске.",
|
"Включена базовая память. Новые диалоги сохраняются в исходном виде и не создают расширенные воспоминания. Существующие расширенные воспоминания сохраняются, но не показываются и не используются при поиске.",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -155,6 +157,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "Сохранить изменения",
|
"selectedMemory.save": "Сохранить изменения",
|
||||||
"selectedMemory.author.user": "Вы",
|
"selectedMemory.author.user": "Вы",
|
||||||
"selectedMemory.author.agent": "Ассистент",
|
"selectedMemory.author.agent": "Ассистент",
|
||||||
|
"selectedMemory.author.tool": "Инструмент",
|
||||||
"selectedMemory.sourceUnavailable":
|
"selectedMemory.sourceUnavailable":
|
||||||
"Разрешения источника изменились, или исходный текст недоступен.",
|
"Разрешения источника изменились, или исходный текст недоступен.",
|
||||||
"selectedMemory.related": "Связанная запись памяти",
|
"selectedMemory.related": "Связанная запись памяти",
|
||||||
@@ -1053,7 +1056,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
|||||||
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
|
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
|
||||||
导出日志: "Экспорт журналов",
|
导出日志: "Экспорт журналов",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
|
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
|
||||||
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
|
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
|
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
|||||||
"sdk.raw.searchFailed": "原始對話搜尋失敗,請重試。",
|
"sdk.raw.searchFailed": "原始對話搜尋失敗,請重試。",
|
||||||
"sdk.raw.recent": "最近原始對話",
|
"sdk.raw.recent": "最近原始對話",
|
||||||
"sdk.raw.empty": "還沒有原始對話。連接來源後,對話會顯示在這裡。",
|
"sdk.raw.empty": "還沒有原始對話。連接來源後,對話會顯示在這裡。",
|
||||||
|
"sdk.raw.total": "{count} 則原始對話",
|
||||||
|
"sdk.raw.total.one": "{count} 則原始對話",
|
||||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||||
"目前使用基礎記憶。新對話會保留為原始記錄,不會產生進階記憶;過去的進階記憶仍會保留,但目前不會顯示或參與記憶召回。",
|
"目前使用基礎記憶。新對話會保留為原始記錄,不會產生進階記憶;過去的進階記憶仍會保留,但目前不會顯示或參與記憶召回。",
|
||||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||||
@@ -139,6 +141,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
|||||||
"selectedMemory.save": "儲存修改",
|
"selectedMemory.save": "儲存修改",
|
||||||
"selectedMemory.author.user": "你",
|
"selectedMemory.author.user": "你",
|
||||||
"selectedMemory.author.agent": "助理",
|
"selectedMemory.author.agent": "助理",
|
||||||
|
"selectedMemory.author.tool": "工具",
|
||||||
"selectedMemory.sourceUnavailable": "來源權限已變化或原文不可用。",
|
"selectedMemory.sourceUnavailable": "來源權限已變化或原文不可用。",
|
||||||
"selectedMemory.related": "對應記憶",
|
"selectedMemory.related": "對應記憶",
|
||||||
"selectedMemory.viewMemory": "檢視記憶",
|
"selectedMemory.viewMemory": "檢視記憶",
|
||||||
@@ -951,7 +954,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
|||||||
"不需開啟視窗,也能持續同步、整理和提供記憶。",
|
"不需開啟視窗,也能持續同步、整理和提供記憶。",
|
||||||
导出日志: "匯出日誌",
|
导出日志: "匯出日誌",
|
||||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||||
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
|
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
|
||||||
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
|
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
|
||||||
"后台服务会继续同步、整理和提供记忆。":
|
"后台服务会继续同步、整理和提供记忆。":
|
||||||
"背景服務會繼續同步、整理和提供記憶。",
|
"背景服務會繼續同步、整理和提供記憶。",
|
||||||
|
|||||||
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
|
|||||||
expect(all).not.toContain("/Users/");
|
expect(all).not.toContain("/Users/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("optionally includes every regular file from the local logs directory", async () => {
|
||||||
|
const dataDir = await root();
|
||||||
|
const outputDir = await root();
|
||||||
|
const logs = path.join(dataDir, "logs");
|
||||||
|
await mkdir(logs, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(logs, "diagnostic-core.jsonl"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
schema_version: 1,
|
||||||
|
timestamp: "2026-10-05T01:00:00.000Z",
|
||||||
|
level: "error",
|
||||||
|
service: "core",
|
||||||
|
component: "core.database",
|
||||||
|
event: "database.error",
|
||||||
|
outcome: "failed",
|
||||||
|
session_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
error_code: "database_locked",
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(logs, "core.log"),
|
||||||
|
"raw private conversation and full local path\n",
|
||||||
|
);
|
||||||
|
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
|
||||||
|
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
|
||||||
|
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
|
||||||
|
await writeFile(path.join(logs, "empty.log"), "");
|
||||||
|
await writeFile(
|
||||||
|
path.join(logs, "large.log"),
|
||||||
|
Buffer.alloc(256 * 1024, 120),
|
||||||
|
);
|
||||||
|
const outside = path.join(await root(), "outside.log");
|
||||||
|
await writeFile(outside, "must not be followed\n");
|
||||||
|
await symlink(outside, path.join(logs, "linked.log"));
|
||||||
|
const target = path.join(outputDir, "MemHub.zip");
|
||||||
|
|
||||||
|
const result = await exportDiagnosticZip({
|
||||||
|
dataDir,
|
||||||
|
target,
|
||||||
|
productVersion: "3800",
|
||||||
|
includeRawLogs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
|
||||||
|
const files = unzipSync(await readFile(target));
|
||||||
|
expect(Object.keys(files).sort()).toEqual([
|
||||||
|
"README.txt",
|
||||||
|
"diagnostics/events.jsonl",
|
||||||
|
"logs/.hidden log",
|
||||||
|
"logs/core.log",
|
||||||
|
"logs/diagnostic-core.jsonl",
|
||||||
|
"logs/empty.log",
|
||||||
|
"logs/large.log",
|
||||||
|
"logs/service-error.log.1",
|
||||||
|
"logs/服务 日志.log",
|
||||||
|
"summary.json",
|
||||||
|
]);
|
||||||
|
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
|
||||||
|
"raw private conversation",
|
||||||
|
);
|
||||||
|
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
|
||||||
|
"原始日志可能包含会话正文",
|
||||||
|
);
|
||||||
|
expect(files["logs/linked.log"]).toBeUndefined();
|
||||||
|
expect(files["logs/empty.log"]).toHaveLength(0);
|
||||||
|
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
|
||||||
|
if (process.platform !== "win32")
|
||||||
|
expect((await lstat(target)).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
||||||
const dataDir = await root();
|
const dataDir = await root();
|
||||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { once } from "node:events";
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
chmodSync,
|
chmodSync,
|
||||||
constants,
|
constants,
|
||||||
|
createWriteStream,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
appendFile,
|
appendFile,
|
||||||
chmod,
|
chmod,
|
||||||
|
type FileHandle,
|
||||||
link,
|
link,
|
||||||
lstat,
|
lstat,
|
||||||
mkdir,
|
mkdir,
|
||||||
@@ -25,7 +28,7 @@ import {
|
|||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { strToU8, zipSync } from "fflate";
|
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
|
||||||
|
|
||||||
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
||||||
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||||
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
markers?: DiagnosticMarker[];
|
markers?: DiagnosticMarker[];
|
||||||
summary?: Record<string, unknown>;
|
summary?: Record<string, unknown>;
|
||||||
|
includeRawLogs?: boolean;
|
||||||
}): Promise<{ filename: string; file_count: number }> {
|
}): Promise<{ filename: string; file_count: number }> {
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
const home = options.home ?? os.homedir();
|
const home = options.home ?? os.homedir();
|
||||||
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
|
|||||||
"MemHub diagnostics",
|
"MemHub diagnostics",
|
||||||
"",
|
"",
|
||||||
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
|
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
|
||||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
...(options.includeRawLogs
|
||||||
|
? [
|
||||||
|
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
|
||||||
|
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||||
|
]),
|
||||||
...(markers.length
|
...(markers.length
|
||||||
? [
|
? [
|
||||||
"",
|
"",
|
||||||
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
|
|||||||
const directory = path.dirname(options.target);
|
const directory = path.dirname(options.target);
|
||||||
await mkdir(directory, { recursive: true });
|
await mkdir(directory, { recursive: true });
|
||||||
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
|
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
|
||||||
|
const rawLogs = options.includeRawLogs
|
||||||
|
? await openRawLogSnapshots(options.dataDir)
|
||||||
|
: [];
|
||||||
try {
|
try {
|
||||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
if (options.includeRawLogs)
|
||||||
mode: 0o600,
|
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
|
||||||
flag: "wx",
|
else
|
||||||
});
|
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||||
|
mode: 0o600,
|
||||||
|
flag: "wx",
|
||||||
|
});
|
||||||
await chmod(temporary, 0o600);
|
await chmod(temporary, 0o600);
|
||||||
await publishTemporaryFile(temporary, options.target);
|
await publishTemporaryFile(temporary, options.target);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await rm(temporary, { force: true }).catch(() => undefined);
|
await rm(temporary, { force: true }).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await Promise.all(
|
||||||
|
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
filename: path.basename(options.target),
|
filename: path.basename(options.target),
|
||||||
file_count: Object.keys(archive).length,
|
file_count: Object.keys(archive).length + rawLogs.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawLogSnapshot {
|
||||||
|
name: string;
|
||||||
|
handle: FileHandle;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
|
||||||
|
const directory = diagnosticDirectory(dataDir);
|
||||||
|
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||||
|
try {
|
||||||
|
directoryInfo = await lstat(directory);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||||
|
fail("diagnostic_directory_unsafe");
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const snapshots: RawLogSnapshot[] = [];
|
||||||
|
try {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isSymbolicLink()) continue;
|
||||||
|
if (
|
||||||
|
entry.name === "." ||
|
||||||
|
entry.name === ".." ||
|
||||||
|
[...entry.name].some((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return (
|
||||||
|
character === "/" || character === "\\" || code < 32 || code === 127
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fail("diagnostic_log_name_unsafe");
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
const before = await lstat(target).catch(() => null);
|
||||||
|
if (!before?.isFile() || before.isSymbolicLink()) continue;
|
||||||
|
const handle = await open(
|
||||||
|
target,
|
||||||
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
let retained = false;
|
||||||
|
try {
|
||||||
|
const opened = await handle.stat();
|
||||||
|
if (
|
||||||
|
!opened.isFile() ||
|
||||||
|
opened.dev !== before.dev ||
|
||||||
|
opened.ino !== before.ino
|
||||||
|
)
|
||||||
|
fail("diagnostic_file_unsafe");
|
||||||
|
snapshots.push({ name: entry.name, handle, size: opened.size });
|
||||||
|
retained = true;
|
||||||
|
} finally {
|
||||||
|
if (!retained) await handle.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const after = await lstat(directory);
|
||||||
|
if (
|
||||||
|
!after.isDirectory() ||
|
||||||
|
after.isSymbolicLink() ||
|
||||||
|
after.dev !== directoryInfo.dev ||
|
||||||
|
after.ino !== directoryInfo.ino
|
||||||
|
)
|
||||||
|
fail("diagnostic_directory_unsafe");
|
||||||
|
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all(
|
||||||
|
snapshots.map((file) => file.handle.close().catch(() => undefined)),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStreamingDiagnosticZip(
|
||||||
|
target: string,
|
||||||
|
archive: Record<string, Uint8Array>,
|
||||||
|
rawLogs: RawLogSnapshot[],
|
||||||
|
): Promise<void> {
|
||||||
|
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
|
||||||
|
let outputError: Error | null = null;
|
||||||
|
let backpressure = Promise.resolve();
|
||||||
|
const finished = new Promise<void>((resolve, reject) => {
|
||||||
|
output.once("finish", resolve);
|
||||||
|
output.once("error", (error) => {
|
||||||
|
outputError = error;
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// The producer may still be reading a source file when the output stream
|
||||||
|
// fails. Attach a rejection handler immediately; the awaited promise below
|
||||||
|
// still carries the same error into the export's fail-closed cleanup.
|
||||||
|
void finished.catch(() => undefined);
|
||||||
|
const zip = new Zip((error, chunk, final) => {
|
||||||
|
if (error) {
|
||||||
|
outputError = error;
|
||||||
|
output.destroy(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunk.length > 0 && !output.write(chunk))
|
||||||
|
backpressure = once(output, "drain").then(() => undefined);
|
||||||
|
if (final) output.end();
|
||||||
|
});
|
||||||
|
const ensureWritable = async () => {
|
||||||
|
await backpressure;
|
||||||
|
if (outputError) throw outputError;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (const [name, content] of Object.entries(archive)) {
|
||||||
|
await ensureWritable();
|
||||||
|
const input = new ZipPassThrough(name);
|
||||||
|
zip.add(input);
|
||||||
|
input.push(content, true);
|
||||||
|
}
|
||||||
|
for (const file of rawLogs) {
|
||||||
|
await ensureWritable();
|
||||||
|
const input = new ZipPassThrough(`logs/${file.name}`);
|
||||||
|
zip.add(input);
|
||||||
|
let position = 0;
|
||||||
|
while (position < file.size) {
|
||||||
|
await ensureWritable();
|
||||||
|
const buffer = Buffer.allocUnsafe(
|
||||||
|
Math.min(64 * 1024, file.size - position),
|
||||||
|
);
|
||||||
|
const { bytesRead } = await file.handle.read(
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
buffer.length,
|
||||||
|
position,
|
||||||
|
);
|
||||||
|
if (bytesRead < 1) fail("diagnostic_file_changed");
|
||||||
|
position += bytesRead;
|
||||||
|
input.push(buffer.subarray(0, bytesRead), position === file.size);
|
||||||
|
}
|
||||||
|
if (file.size === 0) input.push(new Uint8Array(), true);
|
||||||
|
}
|
||||||
|
await ensureWritable();
|
||||||
|
zip.end();
|
||||||
|
await finished;
|
||||||
|
} catch (error) {
|
||||||
|
zip.terminate();
|
||||||
|
output.destroy(error instanceof Error ? error : undefined);
|
||||||
|
await finished.catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
|
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
|
||||||
const directory = diagnosticDirectory(dataDir);
|
const directory = diagnosticDirectory(dataDir);
|
||||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||||
|
|||||||
Reference in new issue
Block a user