Compare commits

..
Author SHA1 Message Date
kristinheandCursor e7d82ee5e8 fix(web): expand settings controls to content width
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:54:55 +08:00
kristinhe1111 63e009bf6c Merge pull request #307 from Tencent-Hunyuan/fix/lite-home-saving-copy
fix(memhub): keep home status copy compact
2026-11-04 16:26:27 +08:00
kristinhe1111 d4cfd1bb74 Merge pull request #309 from Tencent-Hunyuan/fix/memhub-durable-drain-liveness
fix(memhub): keep durable write drain alive
2026-11-04 16:26:08 +08:00
kristinhe1111 2822cbf368 Merge pull request #310 from Tencent-Hunyuan/fix/memhub-inline-model-config
fix(memhub): inline model configuration panels
2026-11-04 16:25:47 +08:00
kristinhe1111 8d23347e1a Merge pull request #312 from Tencent-Hunyuan/fix/memory-mode-confirm-copy
fix(memhub): clarify memory mode switch effects
2026-11-04 16:25:22 +08:00
kristinhe1111 8ae0d1889e Merge pull request #315 from Tencent-Hunyuan/fix/ultra-topic-hover-only
fix(memhub): make Ultra topic nodes hover-only
2026-11-04 16:24:58 +08:00
kristinhe1111 2479de22eb Merge pull request #316 from Tencent-Hunyuan/fix/memhub-lite-turn-view
fix(memhub): show Lite raw conversations by turn
2026-11-04 16:24:44 +08:00
kristinheandCursor 265a22d612 fix(memhub): show lite raw conversations by turn
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:57 +08:00
kristinheandCursor 93d0c64490 style(memhub): format graph topic changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:30 +08:00
kristinheandCursor 1589ae31bc fix(memhub): make topic nodes hover-only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:06:56 +08:00
kristinheandCursor 38ad2292d1 fix(memhub): clarify memory mode switch effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:58:03 +08:00
kristinheandCursor 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
kristinheandCursor 0953c5af35 fix(memhub): keep durable write drain alive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:22:29 +08:00
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
kristinhe1111 b58e95fdae Merge pull request #305 from Tencent-Hunyuan/chore/memhub-build-40
chore(release): MemHub 1.0.6, build 40
2026-11-04 13:27:01 +08:00
kristinheandCursor 42b577f6f8 chore(release): bump MemHub build to 40
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:16:08 +08:00
kristinhe1111 2554fb3b96 Merge pull request #304 from Tencent-Hunyuan/feat/memhub-onboarding-mode-model
feat(memhub): 重构冷启动记忆模式与模型配置
2026-11-04 13:12:38 +08:00
kristinheandCursor e32dc987c2 feat(memhub): redesign onboarding mode setup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 12:00:46 +08:00
kristinhe1111 598e83525e Merge pull request #298 from Tencent-Hunyuan/feat/memhub-mode-projection
feat(memhub): project home and settings by memory mode
2026-11-04 09:29:48 +08:00
88 changed files with 5801 additions and 1948 deletions

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 39, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 40, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
+24 -5
View File
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
layers: 可选。pro/ultra 下只召回指定层级(字符串或
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
强制只召回 L1_RAW。
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
与记忆关系图重排(graph_rerank / episode 扩展),working/
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
intention_limit=intention_limit, created_after=created_after,
reader=reader,
workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
embed_override=embed_override,
query_embedding=query_embedding,
rerank=rerank,
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
statuses: Optional[List[Any]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
status_filter=[MemoryStatus.ACTIVE],
status_filter=status_filter,
only_latest=False,
created_after=created_after,
)
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
"status": MemoryStatus.ACTIVE.value,
"status": node.status.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
)
if statuses is not None:
from .models.memory import MemoryLayer
requested_layers = self._normalize_layers(layers)
if scene_all or (
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
):
raise ValueError(
"statuses is supported only for explicit L1_RAW search"
)
if scene_all:
return await self._search_all_scenes(
query, queries=queries, search_mode=search_mode,
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
statuses=statuses,
)
read_req = ReadRequest(
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 39),GitHub 仓库为
(build 40),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+191 -21
View File
@@ -264,7 +264,13 @@ from one_memory.model_request_audit import (
ModelRequestAuditStore,
)
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.onboarding import (
OnboardingCompletedError,
OnboardingProgressUpdate,
OnboardingState,
OnboardingStore,
)
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -599,24 +605,37 @@ class SdkRawItem(HySdkRawRecord):
role: str | None = None
content_hash: str
created: datetime | None = None
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
@classmethod
def of(cls, record: HySdkRawRecord) -> "SdkRawItem":
def of(
cls, record: HySdkRawRecord, turn: RawDisplayTurn | None = None
) -> "SdkRawItem":
content = turn.content if turn is not None else record.content
raw_messages = (
turn.messages if turn is not None else raw_turn_messages(record.content)
)
messages = tuple(
SdkRawMessage(role=role, content=text)
for role, text in raw_turn_messages(record.content)
for role, text in raw_messages
)
roles = {message.role for message in messages}
return cls(
**record.model_dump(),
**record.model_dump(exclude={"content"}),
content=content,
messages=messages,
role=next(iter(roles)) if len(roles) == 1 and "" not in roles else None,
content_hash="sha256:" + hashlib.sha256(record.content.encode("utf-8")).hexdigest(),
content_hash="sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest(),
created=(
datetime.fromtimestamp(record.gmt_created, tz=UTC)
if record.gmt_created is not None
else None
),
turn_id=turn.turn_id if turn is not None else None,
turn_index=turn.turn_index if turn is not None else None,
turn_count=turn.turn_count if turn is not None else None,
)
@@ -629,9 +648,9 @@ class SdkRawPage(BaseModel):
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
#: Set when a time filter was applied to this page in Core (the SDK list
#: has none): `items` is the page's rows inside the range, `total` stays
#: the SDK's count before that filter.
unit: Literal["raw", "turn"] = "raw"
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -2526,6 +2545,21 @@ def create_app(
def get_onboarding() -> OnboardingState:
return _onboarding_store(app).get()
@app.api_route(
"/api/v1/onboarding/progress",
methods=["PATCH", "PUT"],
response_model=OnboardingState,
dependencies=protected,
)
async def update_onboarding_progress(
update: OnboardingProgressUpdate,
) -> OnboardingState:
"""Persist only resumable onboarding choices; secrets are never accepted."""
try:
return await asyncio.to_thread(_onboarding_store(app).update_progress, update)
except OnboardingCompletedError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.post(
"/api/v1/onboarding/complete",
response_model=OnboardingState,
@@ -4421,6 +4455,16 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
raw_turn_cache: dict[
tuple[object, ...],
tuple[
int,
tuple[tuple[str, str | None], ...],
tuple[tuple[HySdkRawRecord, RawDisplayTurn], ...],
],
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4430,6 +4474,7 @@ def create_app(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4443,7 +4488,10 @@ def create_app(
The SDK lists raw only when asked for `layers=["l1_raw"]`, and only
active rows unless told otherwise. Pro shadows a raw once extraction
succeeds, so a raw page reads active and shadow (`statuses` narrows
it). `source` and `session_id` (a conversation) may repeat or be comma
it). `unit=turn` projects storage batches into stable product turns;
the first read builds an append-only in-memory index and later polls
only probe the latest SDK page unless it changed. `source` and
`session_id` (a conversation) may repeat or be comma
separated. `after`/`before` (unix seconds, against the conversation
time, else the stored time) have no SDK list filter: they cut this
page in Core and mark it `time_filtered`.
@@ -4457,24 +4505,145 @@ def create_app(
sessions = split_csv(session_id)
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
query = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
include_sources=sources or None,
exclude_sources=excluded or None,
session_ids=sessions or None,
offset=offset,
limit=limit,
order=order,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": order if unit == "raw" else "desc",
}
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=offset,
limit=limit,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
records = page.raw
else:
cache_key = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if len(raw_turn_cache_locks) > 64:
for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
if (
stale_key not in raw_turn_cache
and not stale_lock.locked()
):
raw_turn_cache_locks.pop(stale_key, None)
lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(
value, limit=500, offset=0
)
signature = tuple(
(record.raw_id, record.status) for record in first_page.raw
)
cached = raw_turn_cache.get(cache_key)
if (
cached is not None
and cached[0] == first_page.total
and cached[1] == signature
):
projected_turns = cached[2]
break
records_list = list(first_page.raw)
raw_offset = len(first_page.raw)
raw_total = first_page.total
while first_page.raw and raw_offset < raw_total:
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=500,
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=500, offset=raw_offset
)
records_list.extend(raw_page.raw)
raw_offset += len(raw_page.raw)
if not raw_page.raw:
break
verify_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
verify = HySdkRawPage.from_memory_list(
verify_value, limit=500, offset=0
)
verify_signature = tuple(
(record.raw_id, record.status) for record in verify.raw
)
if (
verify.total != first_page.total
or verify_signature != signature
):
continue
projected_turns = tuple(
(record, turn)
for record in records_list
for turn in reversed(raw_display_turns(record))
)
raw_turn_cache[cache_key] = (
first_page.total,
signature,
projected_turns,
)
while len(raw_turn_cache) > 32:
oldest = next(iter(raw_turn_cache))
raw_turn_cache.pop(oldest)
old_lock = raw_turn_cache_locks.get(oldest)
if old_lock is not None and not old_lock.locked():
raw_turn_cache_locks.pop(oldest, None)
break
else:
raise RuntimeError("hy_memory_raw_snapshot_busy")
if cache_key not in raw_turn_cache and not lock.locked():
raw_turn_cache_locks.pop(cache_key, None)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
records = page.raw
time_filtered = after is not None or before is not None
if unit == "turn":
turns = projected_turns
if time_filtered:
turns = tuple(
(record, turn)
for record, turn in turns
if (at := record.memory_at or record.gmt_created) is not None
and (after is None or at >= after)
and (before is None or at < before)
)
if order == "asc":
turns = tuple(reversed(turns))
selected = turns[offset : offset + limit]
return SdkRawPage(
user_id=user_id,
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected),
total=len(turns),
offset=offset,
limit=limit,
unit="turn",
time_filtered=time_filtered,
)
if time_filtered:
records = tuple(
record
@@ -4489,6 +4658,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
#: Product callers can request one item per turn instead of SDK raw batches.
raw_unit: Literal["raw", "turn"] = "raw"
#: Whose memories to search. The App never sends it and reads `local`.
user_id: UserId = DEFAULT_USER_ID
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
memory_at: datetime | None = None
score: float = Field(ge=0, le=1)
truncated: bool = False
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
class SdkMemoryContextItem(BaseModel):
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
parse_sdk_search,
)
from one_memory.imports.storage import EvidenceStore
from one_memory.raw_turns import raw_display_turns
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
@@ -120,6 +121,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
if sources is None:
return ()
candidate_limit = (
min(value.limit * 10, 200) if value.raw_unit == "turn" else value.limit
)
raw = await asyncio.to_thread(
client.search,
value.query,
@@ -127,7 +131,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"user_ids": [value.user_id],
"agent_ids": [],
"include_sources": sources,
"limit": value.limit,
"limit": candidate_limit,
"min_score": 0.0,
"schema_min_score": 0.0,
"schema_limit": 0,
@@ -135,27 +139,59 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
"statuses": ["active", "shadow"],
**raw_lane_overrides(value),
},
)
hits = tuple(
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
)
return tuple(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
)
for hit in hits
)
items: list[SdkRawContextItem] = []
for hit in hits:
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if turns:
query = value.query.casefold()
lexical = tuple(turn for turn in turns if query in turn.content.casefold())
# A batch-level embedding cannot identify which contained
# turn matched semantically. Return only turns grounded by
# the query text instead of assigning every sibling the
# batch score and showing unrelated conversations.
for turn in lexical:
items.append(
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=turn.content,
source=hit.source,
session_id=hit.session_id,
memory_at=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
)
)
else:
items.append(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
)
)
if len(items) >= value.limit:
break
return tuple(items[: value.limit])
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
# lite extracts nothing: its search answers raw turns, which the
@@ -3261,11 +3261,9 @@ class DurableWriteDrain:
"durable_write_drain_job_ceiling_exceeded job_id=%s",
job_id,
)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_work_ceiling_exceeded",
owner=self.owner,
)
except Exception as error:
logger.error(
@@ -3274,11 +3272,9 @@ class DurableWriteDrain:
type(error).__name__,
)
self._record_database_error(error)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_executor_error",
owner=self.owner,
)
finally:
execution.cancel()
@@ -3287,6 +3283,36 @@ class DurableWriteDrain:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _settle_worker_error(self, job_id: str, *, error_code: str) -> None:
"""Best-effort settlement must not kill the long-lived drain.
The row still has a durable lease when this fallback runs. If the
single SQLite writer is busy, waiting and retrying here only extends
the same contention that made the executor fail. Leave the row where
it is instead: lease recovery later moves a never-invoked job back to
pending and a possibly-invoked job to unknown, preserving the
no-blind-retry boundary.
Only transient lock/busy refusals are deferred. A malformed query or
other programming error still escapes to the outer per-job fence.
"""
try:
await in_the_background(
self.queue.settle_worker_error,
job_id,
error_code=error_code,
owner=self.owner,
)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
self._sample_database_failure("settle_worker_error", error)
logger.warning(
"durable_write_worker_settlement_deferred job_id=%s error_type=%s",
job_id,
type(error).__name__,
)
async def _execute_with_ceiling(self, job_id: str) -> None:
ceiling = (
await in_the_background(self.work_ceiling)
@@ -3324,7 +3350,26 @@ class DurableWriteDrain:
finished = {task for task in active if task.done()}
active.difference_update(finished)
for task in finished:
await task
try:
await task
except asyncio.CancelledError:
raise
except _LeaseRenewalFatalError:
# Renewal is the ownership fence. If it is broken,
# continuing could let another worker reclaim a job
# while this process is still executing it.
raise
except Exception as error:
# A job task is a fault-containment boundary. Its row
# retains the durable lease and is recovered according
# to hy_invoked; killing this only consumer would strand
# every unrelated write behind it.
self._record_database_error(error)
logger.error(
"durable_write_drain_job_task_failed task_name=%s error_type=%s",
task.get_name(),
type(error).__name__,
)
if self.budget is not None and work_deadline is not None:
now = _time.monotonic()
# Always admit the first unit of a cycle even when the
@@ -3349,7 +3394,12 @@ class DurableWriteDrain:
else ()
)
for job_id in ids:
active.add(asyncio.create_task(self._run_claimed_job(job_id)))
active.add(
asyncio.create_task(
self._run_claimed_job(job_id),
name=f"durable-write:{job_id}",
)
)
executed = len(ids)
self._database_failure_streaks.pop("claim", None)
except asyncio.CancelledError:
@@ -3190,6 +3190,51 @@ async def _061_write_job_retries(cursor: Any) -> None:
await cursor.execute(statement)
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
"""Append resumable onboarding choices without rewriting migration 12."""
if dialect == "sqlite":
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN setup_step TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id TEXT NULL;
"""
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
ALTER TABLE onboarding_state
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
"""
async def _062_onboarding_progress(cursor: Any) -> None:
if _dialect(cursor) == "sqlite":
existing = await cursor.columns("onboarding_state")
statements = _split_statements(onboarding_progress_ddl("sqlite"))
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
await cursor.execute(statement)
return
for statement in _split_statements(onboarding_progress_ddl("mysql")):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_column(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3490,4 +3535,10 @@ MIGRATIONS: tuple[Migration, ...] = (
("memory_note_requests",),
),
Migration(61, "write_job_retries", _061_write_job_retries),
Migration(
ONBOARDING_PROGRESS_MIGRATION_VERSION,
"onboarding_progress",
_062_onboarding_progress,
("onboarding_state",),
),
)
+171 -32
View File
@@ -4,9 +4,10 @@ from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, field_validator
from one_memory.database import (
take_the_write_lock_first,
@@ -14,11 +15,25 @@ from one_memory.database import (
from one_memory.database_access import main_database
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
from one_memory.memory_server.migrations import onboarding_state_ddl
from one_memory.memory_server.sql_compat import execute, is_mysql
from one_memory.memory_server.migrations import (
onboarding_progress_ddl,
onboarding_state_ddl,
)
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
from one_memory.memory_server.store_binding import store_for
SCHEMA = onboarding_state_ddl("sqlite")
ONBOARDING_COLUMNS = (
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
)
#: The start/complete upserts, per engine.
#:
@@ -51,11 +66,13 @@ START_MYSQL = """
updated_at = VALUES(updated_at)
"""
COMPLETE_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_INSERT_SQLITE = (
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
RESET_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = ? WHERE singleton = 1"
)
RESET_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = %s WHERE singleton = 1"
)
ENSURE_PROGRESS_SQLITE = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,?) "
"ON CONFLICT(singleton) DO NOTHING"
)
ENSURE_PROGRESS_MYSQL = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
)
class OnboardingState(BaseModel):
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
class OnboardingProgressUpdate(BaseModel):
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@field_validator("model_profile_id")
@classmethod
def validate_model_profile_id(cls, value: str | None) -> str | None:
if value is None:
return None
try:
if str(UUID(value)) != value:
raise ValueError
except (ValueError, TypeError, AttributeError):
raise ValueError("invalid_model_profile_id") from None
return value
class OnboardingCompletedError(RuntimeError):
pass
class OnboardingStore:
@@ -103,12 +163,31 @@ class OnboardingStore:
"""
with self._connect(initialize=True) as connection:
execute(connection, is_mysql(self._store), SCHEMA)
if not is_mysql(self._store):
existing = {
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
}
statements = tuple(
statement.strip()
for statement in onboarding_progress_ddl("sqlite").split(";")
if statement.strip()
)
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
connection.execute(statement)
def get(self) -> OnboardingState:
mysql = is_mysql(self._store)
with self._connect() as connection:
row = execute(
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
connection,
mysql,
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
"FROM onboarding_state WHERE singleton = 1",
).fetchone()
if row is None:
return OnboardingState(
@@ -116,8 +195,11 @@ class OnboardingStore:
selected_source_ids=(),
started_at=None,
completed_at=None,
mode_intent=None,
setup_step=None,
model_profile_id=None,
)
return self._to_state(row)
return self._to_state(row, mysql=mysql)
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
now = datetime.now(UTC).isoformat()
@@ -150,6 +232,43 @@ class OnboardingStore:
)
return self.get()
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
fields = tuple(
field
for field in ("mode_intent", "setup_step", "model_profile_id")
if field in update.model_fields_set
)
if not fields:
state = self.get()
if state.status == "completed":
raise OnboardingCompletedError("onboarding_completed")
return state
mysql = is_mysql(self._store)
placeholder = "%s" if mysql else "?"
assignments = [f"{field} = {placeholder}" for field in fields]
assignments.append(f"updated_at = {placeholder}")
values = [getattr(update, field) for field in fields]
now = datetime.now(UTC).isoformat()
values.append(now)
with self._connect(writing=True) as connection:
execute(
connection,
mysql,
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
(now,),
)
updated = execute(
connection,
mysql,
"UPDATE onboarding_state SET "
+ ", ".join(assignments)
+ " WHERE singleton = 1 AND status != 'completed'",
tuple(values),
)
if updated.rowcount != 1:
raise OnboardingCompletedError("onboarding_completed")
return self.get()
def reset(self) -> OnboardingState:
"""Return onboarding to its first-run state, touching nothing else.
@@ -207,33 +326,53 @@ class OnboardingStore:
yield connection
@staticmethod
def _to_state(row: Any) -> OnboardingState:
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
`SELECT *` makes the column order part of the contract here, which is
why the query is `SELECT *` and the indices are named: relying on
`row["..."]` would only work on SQLite, and relying on position alone
without saying which column is which would be unreadable.
Timestamps are a further engine difference: SQLite hands back the stored
string, MySQL hands back a `datetime`. Accepting both is not padding —
parsing a `datetime` raises, and treating a string as one would be
silently wrong.
"""
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
raw_ids = json.loads(row[2])
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
if mysql:
if not isinstance(row, tuple):
raise ValueError("onboarding_row_shape_invalid")
elif not isinstance(row, sqlite3.Row):
raise ValueError("onboarding_row_shape_invalid")
if len(row) != len(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
if mysql:
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
else:
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
if set(values) != set(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
raw_ids = json.loads(values["selected_source_ids_json"])
return OnboardingState(
status=row[1],
status=values["status"],
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
started_at=_as_datetime(row[3]),
completed_at=_as_datetime(row[4]),
started_at=_as_datetime(values["started_at"], mysql=mysql),
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
mode_intent=values["mode_intent"],
setup_step=values["setup_step"],
model_profile_id=values["model_profile_id"],
)
def _as_datetime(value: object) -> datetime | None:
"""SQLite stores these as text; MySQL returns `datetime` objects."""
if value is None or value == "":
def _as_datetime(
value: object,
*,
mysql: bool,
nullable: bool = True,
) -> datetime | None:
"""Validate the engine's timestamp shape and normalize it to UTC."""
if value is None:
if not nullable:
raise ValueError("onboarding_datetime_invalid")
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value))
if mysql:
if not isinstance(value, datetime):
raise ValueError("onboarding_datetime_invalid")
parsed = value
else:
if not isinstance(value, str) or value == "":
raise ValueError("onboarding_datetime_invalid")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
@@ -0,0 +1,62 @@
"""Turn-level read projection for SDK raw batches.
HY keeps several complete conversation turns in one L1 raw node so model
writes stay batched. Product readers must not expose that storage batch as
one conversation item: this module gives every contained turn a stable
identity while retaining the parent raw id for traceability.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
@dataclass(frozen=True)
class RawDisplayTurn:
turn_id: str
turn_index: int
turn_count: int
content: str
messages: tuple[tuple[str, str], ...]
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
"""Split one stored raw batch into user-opened turns.
A user message opens a new turn; assistant/tool/system messages stay with
that user message until the next user message. Legacy content with no user
marker remains one turn so it never disappears from the product.
"""
messages = raw_turn_messages(record.content)
grouped: list[list[tuple[str, str]]] = []
for message in messages:
role, _ = message
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
if not grouped or (role == "user" and has_user):
grouped.append([message])
else:
grouped[-1].append(message)
if not grouped:
grouped = [[("", record.content)]]
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
count = len(grouped)
return tuple(
RawDisplayTurn(
turn_id=f"raw-turn:{digest}:{index}",
turn_index=index,
turn_count=count,
content=_turn_content(turn),
messages=tuple(turn),
)
for index, turn in enumerate(grouped)
)
def _turn_content(messages: list[tuple[str, str]]) -> str:
return "\n".join(
f"[{role}]: {content}" if role else content for role, content in messages
)
@@ -4138,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
assert queue.get("idem-1").state == COMPLETED
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
"""The incident shape: executor and its fallback both lose the writer.
The first row keeps its lease for normal reclaim; an unrelated row must
still run instead of being stranded behind a dead drain task.
"""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def execute(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("executor failed")
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
def locked_settlement(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert queue.get("first").state == RUNNING
assert queue.get("second").state == COMPLETED
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
tmp_path, monkeypatch, caplog
) -> None:
"""A programming error stays visible without taking down the consumer."""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def run_job(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("unexpected job task failure")
drain.stop()
drain = DurableWriteDrain(
queue,
execute=_never_execute,
max_concurrent_sources=1,
)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert any(
"durable_write_drain_job_task_failed" in message.getMessage()
and "error_type=RuntimeError" in message.getMessage()
for message in caplog.records
)
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
"""A late settle must not overwrite an outcome that already happened.
@@ -4980,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
asyncio.run(drain.run_one())
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request())
drain = DurableWriteDrain(
queue,
execute=lambda _job_id: asyncio.sleep(0.03),
lease=timedelta(seconds=1),
renew_every=timedelta(milliseconds=10),
)
monkeypatch.setattr(
drain,
"renew",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
sqlite3.OperationalError("database schema is corrupt")
),
)
with pytest.raises(RuntimeError):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
InMemoryMigrationStore,
MigrationResult,
MigrationSchemaMismatchError,
SqliteMigrationStore,
ledger_sql,
migrate,
)
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
path = tmp_path / "old-onboarding.sqlite3"
with sqlite3.connect(path) as database:
database.execute(
"""
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
database.execute(
"INSERT INTO onboarding_state VALUES "
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
)
migration = next(item for item in MIGRATIONS if item.version == 62)
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
assert result.applied == (62,)
with sqlite3.connect(path) as database:
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
row = database.execute(
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
).fetchone()
assert columns == [
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
]
assert row == (None, None, None)
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
class DuplicateColumnError(Exception):
pass
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
self.applied: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
raise DuplicateColumnError(1060, "duplicate column")
self.applied.append(statement)
cursor = Cursor()
migration = next(item for item in MIGRATIONS if item.version == 62)
asyncio.run(migration.apply(cursor))
assert len(cursor.attempted) == 3
assert len(cursor.applied) == 1
assert "model_profile_id" in cursor.applied[0]
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
@@ -35,6 +35,12 @@ def test_profile_uses_one_model_and_keeps_key_out_of_database(tmp_path) -> None:
)
assert profile.has_api_key is True
assert profile.embedding_provider_kind is ProviderKind.LOCAL_ONNX
assert profile.embedding_model == "bge-small-zh-v1.5"
assert profile.embedding_dimensions == 512
assert profile.timeout_seconds == 240
assert profile.max_concurrency == 4
assert profile.context_window is None
assert "api_key" not in profile.model_dump()
assert API_KEY.encode() not in database.read_bytes()
assert secrets.resolve(store.secret_ref(profile.profile_id) or "") == API_KEY
@@ -1,13 +1,17 @@
"""Onboarding endpoints manage UI state without touching SDK memory."""
import asyncio
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.onboarding import OnboardingStore
def _app(tmp_path: Path):
@@ -63,13 +67,29 @@ def test_reset_returns_to_first_run_without_touching_sdk_data(tmp_path: Path) ->
sdk_marker = tmp_path / "data" / "hy-memory" / "governed-v2" / "memory.marker"
sdk_marker.parent.mkdir(parents=True, exist_ok=True)
sdk_marker.write_text("keep-sdk-memory", encoding="utf-8")
progress = await client.patch(
"/api/v1/onboarding/progress",
json={
"mode_intent": "pro",
"setup_step": "model",
"model_profile_id": "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
},
)
assert progress.status_code == 200
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
completed = await client.post("/api/v1/onboarding/complete")
assert completed.status_code == 200
assert completed.json()["mode_intent"] is None
assert completed.json()["setup_step"] is None
assert completed.json()["model_profile_id"] is None
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
assert reset.json()["completed_at"] is None
assert reset.json()["mode_intent"] is None
assert reset.json()["setup_step"] is None
assert reset.json()["model_profile_id"] is None
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
assert sdk_marker.read_text(encoding="utf-8") == "keep-sdk-memory"
@@ -92,6 +112,7 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
async def unavailable() -> dict[str, object]:
raise RuntimeError("hy_sdk_unavailable")
@@ -102,3 +123,197 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
assert finished.json()["status"] == "completed"
asyncio.run(scenario())
def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
patched = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "pro", "setup_step": "mode"},
)
assert patched.status_code == 200
assert patched.json()["mode_intent"] == "pro"
assert patched.json()["setup_step"] == "mode"
assert patched.json()["model_profile_id"] is None
profile_id = "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
replaced = await client.put(
"/api/v1/onboarding/progress",
json={"setup_step": "model", "model_profile_id": profile_id},
)
assert replaced.status_code == 200
assert replaced.json()["mode_intent"] == "pro"
assert replaced.json()["setup_step"] == "model"
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
asyncio.run(scenario())
def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
secret = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite", "api_key": "must-not-be-stored"},
)
invalid_id = await client.put(
"/api/v1/onboarding/progress",
json={"model_profile_id": "not-a-profile-id"},
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
)
assert secret.status_code == 422
assert "must-not-be-stored" not in secret.text
assert invalid_id.status_code == 422
assert invalid_mode.status_code == 422
state = (await client.get("/api/v1/onboarding")).json()
assert state["mode_intent"] is None
assert state["model_profile_id"] is None
asyncio.run(scenario())
def test_progress_row_adapter_accepts_mysql_tuple_shape() -> None:
started_at = datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
updated_at = datetime(2026, 11, 4, 1, 1)
state = OnboardingStore._to_state(
(
1,
"importing",
'["cursor"]',
started_at,
None,
updated_at,
"lite",
"review",
None,
),
mysql=True,
)
assert state.selected_source_ids == ("cursor",)
assert state.started_at == started_at
assert state.mode_intent == "lite"
assert state.setup_step == "review"
def test_progress_row_adapter_accepts_real_sqlite_row_and_text_datetime() -> None:
with sqlite3.connect(":memory:") as connection:
connection.row_factory = sqlite3.Row
row = connection.execute(
"SELECT 1 AS singleton, 'importing' AS status, '[\"cursor\"]' "
"AS selected_source_ids_json, '2026-11-04T01:00:00+00:00' AS started_at, "
"NULL AS completed_at, '2026-11-04T01:01:00+00:00' AS updated_at, "
"'pro' AS mode_intent, 'model' AS setup_step, "
"'9be6d465-4cf2-4c72-93f7-15f3c012bc80' AS model_profile_id"
).fetchone()
assert row is not None
state = OnboardingStore._to_state(row, mysql=False)
assert state.started_at == datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
assert state.mode_intent == "pro"
assert state.setup_step == "model"
def test_progress_row_adapter_rejects_wrong_length() -> None:
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state((1, "importing"), mysql=True)
def test_progress_row_adapter_rejects_wrong_row_types() -> None:
values = (
1,
"importing",
"[]",
None,
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(list(values), mysql=True)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(values, mysql=False)
def test_progress_row_adapter_rejects_wrong_engine_datetime_shape() -> None:
with pytest.raises(ValueError, match="onboarding_datetime_invalid"):
OnboardingStore._to_state(
(
1,
"importing",
"[]",
"2026-11-04T01:00:00+00:00",
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
),
mysql=True,
)
def test_runtime_sqlite_upgrade_does_not_record_progress_migration(tmp_path: Path) -> None:
database = tmp_path / "old.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO schema_migrations VALUES (61, 'write_job_retries', '2026-11-03');
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
);
"""
)
OnboardingStore(database)
with sqlite3.connect(database) as connection:
recorded = connection.execute(
"SELECT name FROM schema_migrations WHERE version=62"
).fetchone()
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")}
assert recorded is None
assert {"mode_intent", "setup_step", "model_profile_id"} <= columns
def test_progress_rejects_updates_after_completion(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
populated = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite"},
)
empty = await client.put("/api/v1/onboarding/progress", json={})
assert populated.status_code == 409
assert populated.json()["detail"] == "onboarding_completed"
assert empty.status_code == 409
state = (await client.get("/api/v1/onboarding")).json()
assert state["status"] == "completed"
assert state["mode_intent"] is None
asyncio.run(scenario())
+58 -2
View File
@@ -42,7 +42,10 @@ RAW_ROWS = {
"local": [
{
"memory_id": "raw-1",
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
"content": (
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
),
"status": "shadow",
"session_id": "s-1",
"source": "hermes",
@@ -347,6 +350,33 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_raw_context_search_can_return_turns_instead_of_storage_batches(
tmp_path, monkeypatch
) -> None:
async def scenario(app, sdk, client):
response = await client.post(
"/api/v2/context/search",
json={
"query": "绿茶",
"kinds": ["raw"],
"raw_unit": "turn",
"limit": 20,
},
)
assert response.status_code == 200, response.text
raw_one = [
item for item in response.json()["items"] if item["raw_id"] == "raw-1"
]
assert len(raw_one) == 1
assert raw_one[0]["turn_index"] == 1
assert "我喜欢绿茶" in raw_one[0]["content"]
assert "我叫费天翔" not in raw_one[0]["content"]
assert sdk.last("search")["statuses"] == ["active", "shadow"]
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -574,7 +604,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
assert first["status"] == "shadow"
assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
assert [message["role"] for message in first["messages"]] == [
"user",
"assistant",
"user",
"assistant",
]
assert first["messages"][0]["content"] == "我叫费天翔"
assert first["role"] is None
expected = hashlib.sha256(first["content"].encode()).hexdigest()
@@ -584,6 +619,27 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
assert second["role"] == "user"
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert turns["unit"] == "turn" and turns["total"] == 3
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in first_turns] == [1, 0]
assert len({item["turn_id"] for item in first_turns}) == 2
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
ascending = (
await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
).json()
ascending_first = [
item for item in ascending["items"] if item["raw_id"] == "raw-1"
]
assert [item["turn_index"] for item in ascending_first] == [0, 1]
await client.get(
"/api/v2/raw",
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
@@ -16,7 +16,7 @@ from one_memory.imports.connections import SourceConnectionStore
from one_memory.imports.grants import SourceGrantStore
from one_memory.imports.jobs import ImportJobStore
from one_memory.imports.sources.registry import build_source_catalog
from one_memory.onboarding import OnboardingStore
from one_memory.onboarding import OnboardingProgressUpdate, OnboardingStore
@pytest.mark.parametrize("entry", ["connect", "authorize"])
@@ -371,7 +371,14 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
database = tmp_path / "data.sqlite"
grants = SourceGrantStore(database, home=home)
jobs = ImportJobStore(database)
OnboardingStore(database)
onboarding = OnboardingStore(database)
onboarding.update_progress(
OnboardingProgressUpdate(
mode_intent="pro",
setup_step="model",
model_profile_id="9be6d465-4cf2-4c72-93f7-15f3c012bc80",
)
)
store = SourceConnectionStore(grants, jobs, home=home)
catalog = build_source_catalog(home=home, environment={})
cutoff = datetime(2026, 10, 13, 5, 0, tzinfo=UTC)
@@ -391,3 +398,7 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
assert receipt.accepted_source_ids == ("claude-code", "codex")
assert {job.history_after for job in receipt.jobs} == {cutoff}
assert store.get("bounded-first-import") == receipt
state = onboarding.get()
assert state.mode_intent == "pro"
assert state.setup_step == "model"
assert state.model_profile_id == "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
@@ -1235,7 +1235,9 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
/>,
);
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
@@ -239,7 +239,7 @@ it("emits controlled connection drafts only and passes continuation without comp
screen.getByRole<HTMLInputElement>("checkbox", { name: "一键接入应用" })
.checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(next).toHaveBeenCalledWith("settings");
});
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -147,7 +147,7 @@ it("explains that one large harness limits every first-import source to three da
it("continues to step3 while sample GET is pending without calling completion APIs", () => {
api.receipt.mockReturnValue(new Promise(() => {}));
const { props } = setup();
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(props.onContinue).toHaveBeenCalledWith("settings");
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
});
+2 -4
View File
@@ -373,7 +373,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
{text("配置模型,沉淀记忆")}
{text("选择记忆模式")}
</R6Button>
)}
</div>
@@ -393,9 +393,7 @@ export function FirstImportReview({
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
@@ -0,0 +1,345 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { ReactNode, Ref } from "react";
import { OptionMenu } from "./OptionMenu";
import { PROVIDER_PRESETS } from "./provider-presets";
export interface GenerationModelValues {
provider_kind: ProviderKind;
model: string;
base_url: string;
timeout_seconds: string;
max_concurrency: string;
context_window: string;
disable_thinking: boolean;
}
export type GenerationModelField = keyof GenerationModelValues | "api_key";
export type GenerationModelErrors = Partial<
Record<GenerationModelField, string>
>;
export const GENERATION_PROTOCOLS: readonly {
value: ProviderKind;
label: string;
supported: boolean;
}[] = [
{
value: "openai_compatible",
label: "OpenAI-compatible",
supported: true,
},
{ value: "ollama", label: "Ollama(本机)", supported: true },
{ value: "anthropic", label: "Anthropic(暂不支持)", supported: false },
{ value: "hy_default", label: "HY 托管(当前不可用)", supported: false },
];
export function presetForGeneration(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(preset) =>
preset.provider === profile.provider_kind &&
endpointKey(preset.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
export function generationProviderLabel(provider: ProviderKind): string {
return (
GENERATION_PROTOCOLS.find((item) => item.value === provider)?.label ??
provider
);
}
export function generationSummary(
presetId: string,
provider: ProviderKind,
model: string,
text: (value: string) => string,
): string {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId);
return `${preset ? text(preset.label) : text(generationProviderLabel(provider))} · ${
model.trim() || text("模型待填写")
}`;
}
export function validGenerationEndpoint(value: string): boolean {
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
export function validateGenerationModel(
values: GenerationModelValues,
options: {
apiKey: string;
keyRequired: boolean;
requireRuntimeValues?: boolean;
},
): GenerationModelErrors {
const errors: GenerationModelErrors = {};
if (!values.model.trim()) errors.model = "请填写实际可用的模型 ID。";
if (!validGenerationEndpoint(values.base_url))
errors.base_url = "请填写有效的模型接口地址。";
if (
options.apiKey.trim() &&
/^(?:[a-z][a-z\d+.-]*:\/\/|www\.|\/\/)/i.test(options.apiKey.trim())
)
errors.api_key = "这里需要 API Key,不是网址。请填写服务方提供的密钥。";
else if (options.keyRequired && !options.apiKey.trim())
errors.api_key = "请填写 API Key。";
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const raw = values[key].trim();
if (!raw && !options.requireRuntimeValues) return;
const value = Number(raw);
if (!raw || !Number.isInteger(value) || value < min || value > max)
errors[key] = "onboardingIntegerRange";
};
integer("timeout_seconds", 1, 600);
integer("max_concurrency", 1, 32);
if (values.context_window.trim()) integer("context_window", 1024, 10_000_000);
return errors;
}
export function ProviderPresetField({
id,
labelledBy,
ariaLabel,
describedBy,
value,
disabled,
text,
includePlaceholder = false,
onChange,
}: {
id?: string;
labelledBy?: string;
ariaLabel?: string;
describedBy?: string;
value: string;
disabled?: boolean;
text: (value: string) => string;
includePlaceholder?: boolean;
onChange(value: string): void;
}) {
return (
<OptionMenu
id={id}
labelledBy={labelledBy}
ariaLabel={ariaLabel}
describedBy={describedBy}
value={value}
disabled={disabled}
options={[
...(includePlaceholder && !value
? [{ value: "", label: text("选择服务商"), disabled: true }]
: []),
...PROVIDER_PRESETS.map((preset) => ({
value: preset.id,
label: text(preset.label),
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={onChange}
/>
);
}
export function GenerationTechnicalDisclosure({
open,
disabled,
controls,
text,
onChange,
children,
}: {
open: boolean;
disabled?: boolean;
controls: string;
text: (value: string) => string;
onChange(open: boolean): void;
children: ReactNode;
}) {
return (
<section className="generation-technical-disclosure">
<button
type="button"
className="generation-technical-toggle"
aria-expanded={open}
aria-controls={controls}
disabled={disabled}
onClick={() => onChange(!open)}
>
{text("配置详情与高级设置")}
{open ? (
<ChevronUp size={16} aria-hidden="true" />
) : (
<ChevronDown size={16} aria-hidden="true" />
)}
</button>
<div id={controls} className="generation-technical-fields" hidden={!open}>
{children}
</div>
</section>
);
}
export function GenerationTechnicalFields({
idPrefix,
values,
errors = {},
disabled,
text,
modelRef,
onChange,
}: {
idPrefix: string;
values: GenerationModelValues;
errors?: GenerationModelErrors;
disabled?: boolean;
text: (value: string) => string;
modelRef?: Ref<HTMLInputElement>;
onChange(patch: Partial<GenerationModelValues>): void;
}) {
const field = (
key:
| "model"
| "base_url"
| "timeout_seconds"
| "max_concurrency"
| "context_window",
label: string,
options: {
type?: string;
min?: number;
max?: number;
placeholder?: string;
ref?: Ref<HTMLInputElement>;
} = {},
) => {
const error = errors[key];
const inputId = `${idPrefix}-${key}`;
return (
<label className="generation-model-field" htmlFor={inputId}>
<span>{text(label)}</span>
<input
ref={options.ref}
id={inputId}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
inputMode={options.type === "url" ? "url" : undefined}
autoComplete="off"
spellCheck={false}
value={values[key]}
disabled={disabled}
min={options.min}
max={options.max}
maxLength={key === "model" ? 256 : undefined}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
onChange={(event) => onChange({ [key]: event.target.value })}
/>
{error ? (
<small id={`${inputId}-error`} className="generation-model-error">
{text(error)}
</small>
) : null}
</label>
);
};
return (
<>
<label
className="generation-model-field"
htmlFor={`${idPrefix}-protocol`}
>
<span>{text("协议")}</span>
<select
id={`${idPrefix}-protocol`}
name="provider"
aria-label={text("协议")}
value={values.provider_kind}
disabled={disabled}
onChange={(event) =>
onChange({ provider_kind: event.target.value as ProviderKind })
}
>
{GENERATION_PROTOCOLS.map((protocol) => (
<option
key={protocol.value}
value={protocol.value}
disabled={!protocol.supported}
>
{text(protocol.label)}
</option>
))}
</select>
</label>
{field("model", "生成模型名", {
placeholder: "例如:gpt-4o-mini…",
ref: modelRef,
})}
{field("base_url", "Base URL", { type: "url" })}
<div className="generation-runtime-fields">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
placeholder: "自动 / 可选",
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
placeholder: "自动 / 可选",
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10_000_000,
placeholder: "自动 / 可选",
})}
</div>
<small className="generation-runtime-note">
{text("留空使用 Core 默认值。")}
</small>
<label className="generation-thinking">
<input
name="disable-thinking"
type="checkbox"
checked={values.disable_thinking}
disabled={disabled}
onChange={(event) =>
onChange({ disable_thinking: event.target.checked })
}
/>
{text("关闭思考模式")}
</label>
</>
);
}
function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
+17 -1
View File
@@ -6,7 +6,11 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryGraph3D } from "./MemoryGraph3D";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
afterEach(cleanup);
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+58 -24
View File
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const { locale, text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.filter((node) => node.node_type === "source")
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
}
};
const selectable: THREE.Mesh[] = [];
const hittable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
selectable.push(mesh);
hittable.push(mesh);
visuals.push({
mesh,
material,
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(selectable, false)[0];
return raycaster.intersectObjects(hittable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
setHovered(
node
? {
label: node.label,
label: graphNodeTooltip(node, topicPrefixRef.current),
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
</div>
) : null}
{available && (
+7 -7
View File
@@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("replaces a topic detail with an off-page graph memory detail", async () => {
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => {
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
+3 -5
View File
@@ -684,6 +684,9 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -708,11 +711,6 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
afterEach(cleanup);
const modes = [
{ mode: "lite" as const, title: "Lite", description: "Raw conversations" },
{ mode: "pro" as const, title: "Pro", description: "Extracted memories" },
];
it("supports native radio keyboard selection and reports the chosen control", async () => {
const changed = vi.fn();
function Harness() {
const [selected, setSelected] = useState<"lite" | "pro">("lite");
return (
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
selected={selected}
onChange={(mode, control) => {
changed(mode, control);
setSelected(mode as "lite" | "pro");
}}
/>
);
}
const user = userEvent.setup();
render(<Harness />);
const lite = screen.getByRole("radio", { name: /Lite/ });
lite.focus();
await user.keyboard("{ArrowDown}");
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /Pro/ });
expect(pro.checked).toBe(true);
expect(changed).toHaveBeenCalledWith("pro", pro);
});
it("exposes current and recommended badges while respecting per-mode disabled state", () => {
render(
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
current="lite"
selected="lite"
currentBadge="Current"
recommended={{ mode: "pro", badge: "Recommended" }}
disabled={(mode) => mode === "pro"}
onChange={vi.fn()}
/>,
);
expect(screen.getByText("Current")).toBeTruthy();
expect(screen.getByText("Recommended")).toBeTruthy();
expect(screen.getByRole("radio", { name: /Pro/ })).toHaveProperty(
"disabled",
true,
);
});
@@ -0,0 +1,84 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { type ReactNode, useId } from "react";
import "./r6-controls.css";
export type MemoryModeChoice = {
mode: MemoryPipelineMode;
title: ReactNode;
description: ReactNode;
};
export function MemoryModeChoiceList({
modes,
current,
selected,
disabled = false,
onChange,
currentBadge,
recommended,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
modes: readonly MemoryModeChoice[];
current?: MemoryPipelineMode;
selected: MemoryPipelineMode | null;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
currentBadge?: ReactNode;
recommended?: {
mode: MemoryPipelineMode;
badge: ReactNode;
};
"aria-label": string;
"aria-busy"?: boolean;
}) {
const name = useId();
const isDisabled = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className="memory-mode-choice-list"
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{modes.map((choice) => {
const checked = selected === choice.mode;
const unavailable = isDisabled(choice.mode);
return (
<label
key={choice.mode}
className="memory-mode-choice"
data-selected={checked || undefined}
data-current={current === choice.mode || undefined}
data-disabled={unavailable || undefined}
>
<input
type="radio"
name={name}
value={choice.mode}
checked={checked}
disabled={unavailable}
onChange={(event) => onChange(choice.mode, event.currentTarget)}
/>
<span className="memory-mode-choice-copy">
<span className="memory-mode-choice-heading">
<strong>{choice.title}</strong>
{recommended?.mode === choice.mode ? (
<span className="memory-mode-choice-badge">
{recommended.badge}
</span>
) : null}
</span>
<small>{choice.description}</small>
</span>
{current === choice.mode && currentBadge ? (
<span className="memory-mode-choice-current">{currentBadge}</span>
) : null}
</label>
);
})}
</div>
);
}
+54 -28
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
@@ -107,6 +86,19 @@ afterEach(() => {
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
});
it("confirms the data effects before switching to lite", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
@@ -214,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
@@ -225,6 +231,8 @@ 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.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
});
@@ -235,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
expect(
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
).toBeTruthy();
});
it("does not show automatic consolidation when switching from lite to pro", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
});
it("focuses the Evolve settings after switching to ultra", async () => {
+28 -52
View File
@@ -25,6 +25,7 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -84,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
@@ -212,10 +215,10 @@ export function MemoryModeSettings({
focusModeWhenReady.current = null;
document
.querySelector<HTMLInputElement>(
`input[name="${id}-mode"][value="${target}"]`,
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [busy, id, pendingMode, state?.mode]);
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
@@ -368,53 +371,26 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<div
className="r6-mode-choice-list"
role="radiogroup"
<MemoryModeChoiceList
aria-label={text("当前模式")}
aria-busy={state === null}
>
{(["lite", "pro", "ultra"] as const).map((mode) => {
const checked = state?.mode === mode;
return (
<label
key={mode}
className="r6-mode-choice"
data-current={checked || undefined}
data-disabled={
!state ||
busy ||
lifecycleBusy ||
(state?.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown) ||
undefined
}
>
<input
type="radio"
name={`${id}-mode`}
value={mode}
checked={checked}
disabled={
!state ||
busy ||
lifecycleBusy ||
(state?.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(event) => void choose(mode, event.currentTarget)}
/>
<span className="r6-mode-choice-copy">
<strong>{text(MODE_COPY[mode].title)}</strong>
<small>{text(MODE_COPY[mode].description)}</small>
</span>
{checked ? (
<span className="r6-mode-current">{text("当前模式")}</span>
) : null}
</label>
);
})}
</div>
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
current={state?.mode}
selected={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
+89 -1
View File
@@ -387,6 +387,67 @@ it("pages and filters the raw conversation data plane in lite", async () => {
);
});
it("selects and renders one turn when two turns share a raw storage batch", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一问\n[assistant]: 第一答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一问" },
{ role: "assistant", content: "第一答" },
],
},
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 第二问\n[assistant]: 第二答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第二问" },
{ role: "assistant", content: "第二答" },
],
},
],
total: 2,
limit: 20,
offset: 0,
unit: "turn",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
await user.click(await screen.findByRole("button", { name: /第二问/ }));
await waitFor(() => {
const detail = document.querySelector(".map-dialogue-reading");
expect(detail?.textContent).toContain("第二问");
expect(detail?.textContent).toContain("第二答");
expect(detail?.textContent).not.toContain("第一问");
expect(detail?.textContent).not.toContain("第一答");
});
});
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
@@ -482,6 +543,27 @@ it("opens a search hit with the complete role transcript", async () => {
items: [
{
raw_id: "raw-search",
turn_id: "raw-turn:search:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一轮问题\n[assistant]: 第一轮回答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一轮问题" },
{ role: "assistant", content: "第一轮回答" },
],
},
{
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 完整问题\n[assistant]: 完整回答",
user_id: "local",
agent_id: "all-apps",
@@ -496,7 +578,7 @@ it("opens a search hit with the complete role transcript", async () => {
],
},
],
total: 1,
total: 2,
limit: 500,
offset: 0,
}
@@ -506,6 +588,9 @@ it("opens a search hit with the complete role transcript", async () => {
{
kind: "raw",
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "被截断的搜索摘要",
source: "cursor",
session_id: "session-search",
@@ -525,6 +610,9 @@ it("opens a search hit with the complete role transcript", async () => {
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
expect(
document.querySelector(".map-dialogue-reading")?.textContent,
).not.toContain("第一轮问题");
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: "cursor",
+9 -3
View File
@@ -61,6 +61,7 @@ import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
@@ -277,8 +278,11 @@ export function MemorySpace({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
for (const item of items) next.set(item.raw_id, item);
next.set(
rawRecordId(selectedRawRef.current),
selectedRawRef.current,
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
};
const [page, graph] = lite
@@ -717,7 +721,9 @@ export function MemorySpace({
offset,
limit: 500,
});
const full = page.items.find((item) => item.raw_id === memoryId);
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
+336 -16
View File
@@ -72,6 +72,7 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
@@ -224,6 +225,7 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
@@ -279,6 +281,7 @@ describe("ModelSettings", () => {
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
@@ -576,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -666,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -687,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -850,6 +854,190 @@ describe("ModelSettings", () => {
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -900,6 +1088,51 @@ describe("ModelSettings", () => {
);
});
it("keeps required generation fields mounted when technical details collapse", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
const modelField =
document.querySelector<HTMLInputElement>('[name="model"]');
const baseUrlField =
document.querySelector<HTMLInputElement>('[name="base_url"]');
expect(modelField).toBeTruthy();
expect(baseUrlField).toBeTruthy();
expect(modelField?.closest("[hidden]")).toBeTruthy();
expect(baseUrlField?.closest("[hidden]")).toBeTruthy();
});
it("reopens collapsed generation details and focuses the first invalid field", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
const modelField = await screen.findByLabelText("生成模型名");
expect(
screen
.getByRole("button", { name: "配置详情与高级设置" })
.getAttribute("aria-expanded"),
).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(modelField));
expect(create).not.toHaveBeenCalled();
});
it("clears inherited runtime values when an edit changes protocol and creates with Core defaults", async () => {
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "60");
await userEvent.selectOptions(screen.getByLabelText("协议"), "ollama");
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(create).toHaveBeenCalledOnce();
expect(create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
expect(create.mock.calls[0][0]).not.toHaveProperty("max_concurrency");
expect(create.mock.calls[0][0]).not.toHaveProperty("context_window");
expect(update).not.toHaveBeenCalled();
});
it("shows field-local Chinese required errors for mouse and keyboard submission", async () => {
const user = userEvent.setup();
render(<ModelSettings />);
@@ -999,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -1022,7 +1257,7 @@ describe("ModelSettings", () => {
);
});
it("后端激活不了的协议根本不出现在下拉里", () => {
it("后端激活不了的协议显示但不可选择", () => {
render(<ModelSettings />);
const providerSelect = screen.getByLabelText("协议") as HTMLSelectElement;
@@ -1035,12 +1270,16 @@ describe("ModelSettings", () => {
);
expect(labels.some((text) => text.startsWith("Ollama(本机)"))).toBe(true);
// ModelProfileStore.activate() 对这两个 provider 抛
// model_provider_not_supported。之前它们以 disabled 的形态留在下拉里,
// 等于把两条死路摆在明面上让人去读懂括号里的「暂不支持」。
expect(labels.some((text) => text.includes("Anthropic"))).toBe(false);
expect(labels.some((text) => text.includes("HY Memory"))).toBe(false);
expect(providerSelect.options.length).toBe(2);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("Anthropic"),
)?.disabled,
).toBe(true);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("HY 托管"),
)?.disabled,
).toBe(true);
});
it("已保存的配置不印内置向量模型", () => {
@@ -1229,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
+356 -169
View File
@@ -11,12 +11,22 @@ import {
type FormEvent,
type Ref,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type GenerationModelValues,
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
@@ -95,8 +105,11 @@ const PROVIDERS: Array<{
type FieldName =
| "model"
| "base-url"
| "api-key"
| "base_url"
| "api_key"
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "embedding-model"
| "embedding-base-url";
@@ -127,11 +140,11 @@ function probeFieldErrors(
: undefined;
};
if (!result.capabilities.endpoint_reachable) {
errors["base-url"] = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
errors.base_url = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
}
if (!result.capabilities.model_available) {
if (stages.get("model")?.code === "unauthorized") {
errors["api-key"] = FIELD_PROBE_TEXT.unauthorized;
errors.api_key = FIELD_PROBE_TEXT.unauthorized;
} else {
errors.model = text("model") ?? FIELD_PROBE_TEXT.model_not_found;
}
@@ -147,19 +160,6 @@ function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
function presetForProfile(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(item) =>
item.provider === profile.provider_kind &&
endpointKey(item.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
function validEndpoint(value: string): boolean {
try {
const url = new URL(value);
@@ -239,7 +239,8 @@ export function ModelSettings({
focusSection?: ModelSettingsFocusSection;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const { text, t } = useOnboardingTranslation();
const fieldId = useId();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -260,6 +261,10 @@ export function ModelSettings({
const [embeddingModel, setEmbeddingModel] = useState("");
const [apiKey, setApiKey] = useState("");
const [disableThinking, setDisableThinking] = useState(true);
const [technicalOpen, setTechnicalOpen] = useState(true);
const [timeoutSeconds, setTimeoutSeconds] = useState("");
const [maxConcurrency, setMaxConcurrency] = useState("");
const [contextWindow, setContextWindow] = useState("");
const [customOpen, setCustomOpen] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
const [editing, setEditing] = useState<ModelProfile | null>(null);
@@ -273,12 +278,24 @@ export function ModelSettings({
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const [switchRefreshCompleted, setSwitchRefreshCompleted] = useState<
string | null
>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
const queuedOpen = useRef<ModelOpenIntent | null>(null);
const savedCard = useRef<HTMLElement>(null);
const formEl = useRef<HTMLFormElement>(null);
const modelInput = useRef<HTMLInputElement>(null);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
useEffect(() => {
alive.current = true;
return () => {
@@ -297,10 +314,11 @@ export function ModelSettings({
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupPhase("activating");
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
void models.refreshProfiles().then(() => {
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
});
onProfilesChanged?.();
return;
}
@@ -312,6 +330,7 @@ export function ModelSettings({
);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}
}, [
embeddingSwitch.current?.last_finished,
@@ -320,17 +339,78 @@ export function ModelSettings({
switchJobId,
switchTarget,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
finished?.job_id !== switchJobId ||
finished.state !== "done" ||
switchRefreshCompleted !== switchJobId
)
return;
const active = models.profiles.find(
(profile) =>
profile.profile_id === switchTarget.profile_id &&
profile.revision >= switchTarget.revision &&
profile.is_active,
);
if (!active) {
setSetupPhase(null);
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
return;
}
if (active.name !== PRESET_PROFILE_NAME) setSaved(active);
setSetupPhase(null);
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
onSetupComplete?.();
}, [
embeddingSwitch.current?.last_finished,
models.profiles,
onSetupComplete,
switchJobId,
switchRefreshCompleted,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
useLayoutEffect(() => {
const first = Object.keys(fields).find((key) => fields[key as FieldName]);
if (advancedEmbedding && first?.startsWith("embedding-")) {
if (!first) return;
if (
[
"model",
"base_url",
"api_key",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first)
) {
if (!technicalOpen && first !== "api_key") {
setTechnicalOpen(true);
return;
}
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus();
?.focus({ preventScroll: true });
} else if (advancedEmbedding && first.startsWith("embedding-")) {
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus({ preventScroll: true });
}
}, [advancedEmbedding, fields]);
}, [advancedEmbedding, fields, technicalOpen]);
useEffect(() => {
if (technicalOpen && !model.trim())
modelInput.current?.focus({ preventScroll: true });
}, [technicalOpen, model]);
const selected =
PROVIDERS.find((item) => item.kind === provider) ?? PROVIDERS[0];
const presetProfiles = models.profiles.filter(
@@ -348,6 +428,7 @@ export function ModelSettings({
saved);
const editorLocked =
saving ||
switchJobId !== null ||
models.busy !== null ||
!!models.pendingCreation ||
!!models.pendingProbe ||
@@ -377,6 +458,14 @@ export function ModelSettings({
nextModel.trim()
? `${providerLabel(nextProvider)} · ${nextModel.trim()}`.slice(0, 128)
: "";
const clearInheritedRuntimeForNewProfile = (nextProvider: ProviderKind) => {
if (!editing || nextProvider === editing.provider_kind) return;
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setDisableThinking(true);
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
@@ -389,10 +478,17 @@ export function ModelSettings({
setName(profile.name);
setNameEdited(true);
setProvider(profile.provider_kind);
setProviderPreset(presetForProfile(profile));
setProviderPreset(presetForGeneration(profile));
setBaseUrl(profile.base_url ?? "");
setModel(profile.model);
setDisableThinking(profile.disable_thinking);
setTimeoutSeconds(String(profile.timeout_seconds));
setMaxConcurrency(String(profile.max_concurrency));
setContextWindow(
profile.context_window === null ? "" : String(profile.context_window),
);
setTechnicalOpen(true);
technicalTouched.current.clear();
setApiKey("");
setAdvancedEmbedding(
profile.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND &&
@@ -430,6 +526,11 @@ export function ModelSettings({
setModel("");
setApiKey("");
setDisableThinking(true);
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setTechnicalOpen(true);
technicalTouched.current.clear();
setAdvancedEmbedding(false);
setEmbeddingMode("local");
setEmbeddingModel("");
@@ -659,11 +760,37 @@ export function ModelSettings({
return;
const errors: Partial<Record<FieldName, string>> = {};
if (focusSection !== "embedding") {
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
const generationErrors = validateGenerationModel(
{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
},
{ apiKey, keyRequired },
);
if (generationErrors.model) errors.model = "请填写生成模型名。";
if (generationErrors.base_url)
errors.base_url = "请填写完整的 http:// 或 https:// 接口地址。";
if (generationErrors.api_key) errors.api_key = generationErrors.api_key;
if (generationErrors.timeout_seconds)
errors.timeout_seconds = t("onboardingIntegerRange", {
min: 1,
max: 600,
});
if (generationErrors.max_concurrency)
errors.max_concurrency = t("onboardingIntegerRange", {
min: 1,
max: 32,
});
if (generationErrors.context_window)
errors.context_window = t("onboardingIntegerRange", {
min: 1024,
max: 10_000_000,
});
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (!embeddingModel.trim())
@@ -676,6 +803,19 @@ export function ModelSettings({
setSetupError(null);
const first = Object.keys(errors)[0];
if (first) {
if (
[
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first) &&
!technicalOpen
) {
setTechnicalOpen(true);
return;
}
if (first.startsWith("embedding-") && !advancedEmbedding) {
setAdvancedEmbedding(true);
return;
@@ -702,6 +842,20 @@ export function ModelSettings({
...(provider === "hy_default" ? {} : { base_url: baseUrl }),
...(apiKey ? { api_key: apiKey } : {}),
};
if (
technicalTouched.current.has("timeout_seconds") &&
timeoutSeconds.trim()
)
input.timeout_seconds = Number(timeoutSeconds);
if (
technicalTouched.current.has("max_concurrency") &&
maxConcurrency.trim()
)
input.max_concurrency = Number(maxConcurrency);
if (technicalTouched.current.has("context_window"))
input.context_window = contextWindow.trim()
? Number(contextWindow)
: null;
try {
const patch: ModelProfilePatch = editing
? { expected_revision: editing.revision }
@@ -715,6 +869,21 @@ export function ModelSettings({
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
if (
technicalTouched.current.has("timeout_seconds") &&
input.timeout_seconds !== editing.timeout_seconds
)
patch.timeout_seconds = input.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
input.max_concurrency !== editing.max_concurrency
)
patch.max_concurrency = input.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
input.context_window !== editing.context_window
)
patch.context_window = input.context_window;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (embeddingModel !== editing.embedding_model)
@@ -743,6 +912,9 @@ export function ModelSettings({
if (!alive.current) return;
if (created) {
if (thenSetup) {
// Retrying after probe, activation, or switch confirmation failed
// must update this saved revision instead of creating a duplicate.
setEditing(created);
// The probe decides whether this form closes. A rejected key or a
// failed endpoint leaves the filled-in form on screen with the
// reason; only a passing test returns to the saved card.
@@ -971,8 +1143,14 @@ export function ModelSettings({
</div>
<h3>{savedProfile.name}</h3>
<p>
{savedProfile.model} · {text(endpointLabel(savedProfile.base_url))}{" "}
· {text("版本")} {savedProfile.revision}
{generationSummary(
presetForGeneration(savedProfile),
savedProfile.provider_kind,
savedProfile.model,
text,
)}{" "}
· {text(endpointLabel(savedProfile.base_url))} · {text("版本")}{" "}
{savedProfile.revision}
</p>
{savedProfile.credential_state === "unavailable" && (
<p role="status">
@@ -1057,29 +1235,34 @@ export function ModelSettings({
<>
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
<ProviderPresetField
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
text={text}
onChange={(value) => {
const next = PROVIDER_PRESETS.find(
(item) => item.id === value,
);
setProviderPreset(value);
if (!next) return;
if (!next) {
clearInheritedRuntimeForNewProfile("openai_compatible");
setProvider("openai_compatible");
setBaseUrl("");
setModel("");
setApiKey("");
setTechnicalOpen(true);
if (!nameEdited) setName("");
return;
}
clearInheritedRuntimeForNewProfile(next.provider);
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
setTechnicalOpen(true);
if (editing) {
setName(next.label);
setNameEdited(true);
@@ -1096,34 +1279,6 @@ export function ModelSettings({
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
@@ -1143,34 +1298,70 @@ export function ModelSettings({
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
<GenerationTechnicalDisclosure
open={technicalOpen}
disabled={formLocked}
controls="model-generation-technical"
text={text}
onChange={setTechnicalOpen}
>
<GenerationTechnicalFields
idPrefix="model-generation"
values={{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
}}
errors={{
model: fields.model,
base_url: fields.base_url,
timeout_seconds: fields.timeout_seconds,
max_concurrency: fields.max_concurrency,
context_window: fields.context_window,
}}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
text={text}
modelRef={modelInput}
onChange={(patch: Partial<GenerationModelValues>) => {
if (patch.provider_kind !== undefined) {
clearInheritedRuntimeForNewProfile(patch.provider_kind);
setProvider(patch.provider_kind);
setProviderPreset("custom");
if (!nameEdited)
setName(suggestedName(model, patch.provider_kind));
}
if (patch.model !== undefined) {
setModel(patch.model);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(patch.model));
}
if (patch.base_url !== undefined) {
setBaseUrl(patch.base_url);
setProviderPreset("custom");
}
if (patch.timeout_seconds !== undefined) {
setTimeoutSeconds(patch.timeout_seconds);
technicalTouched.current.add("timeout_seconds");
}
if (patch.max_concurrency !== undefined) {
setMaxConcurrency(patch.max_concurrency);
technicalTouched.current.add("max_concurrency");
}
if (patch.context_window !== undefined) {
setContextWindow(patch.context_window);
technicalTouched.current.add("context_window");
}
if (patch.disable_thinking !== undefined) {
setDisableThinking(patch.disable_thinking);
technicalTouched.current.add("disable_thinking");
}
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</label>
</GenerationTechnicalDisclosure>
</>
)}
{/* Disclosure only changes visibility; mode determines what is saved. */}
@@ -1199,20 +1390,37 @@ export function ModelSettings({
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-mode-label`}
htmlFor={`${fieldId}-embedding-mode`}
>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
</label>
<OptionMenu
id={`${fieldId}-embedding-mode`}
labelledBy={`${fieldId}-embedding-mode-label`}
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{
value: "local",
label: text("本地内置(推荐)"),
},
{
value: "custom",
label: text("自定义搜索模型"),
},
]}
onChange={(value) => {
setEmbeddingMode(value as "local" | "custom");
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
@@ -1220,11 +1428,8 @@ export function ModelSettings({
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
/>
</div>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
@@ -1257,25 +1462,38 @@ export function ModelSettings({
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-provider-label`}
htmlFor={`${fieldId}-embedding-provider`}
>
{text("向量模型来源")}
</label>
<OptionMenu
id={`${fieldId}-embedding-provider`}
labelledBy={`${fieldId}-embedding-provider-label`}
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{ value: "ollama", label: text("本机 Ollama") },
{
value: "openai_compatible",
label: text(
"同源 OpenAI-compatible(共用 API Key)",
),
},
]}
onChange={(value) => {
const next = value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
/>
</div>
<label>
{text("向量模型 Base URL")}
<input
@@ -1337,40 +1555,13 @@ export function ModelSettings({
)}
{focusSection !== "embedding" && (
<>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
name="api_key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
aria-describedby={`model-key-help${fields.api_key ? " model-key-error" : ""}`}
aria-invalid={!!fields.api_key}
type="password"
autoComplete="off"
required={keyRequired}
@@ -1388,22 +1579,12 @@ export function ModelSettings({
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
{fields.api_key && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
{text(fields.api_key)}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
</>
)}
<div className="form-submit">
@@ -1475,8 +1656,13 @@ export function ModelSettings({
<div>
<strong>{profile.name}</strong>
<p>
{text(providerLabel(profile.provider_kind))} ·{" "}
{profile.model} ·
{generationSummary(
presetForGeneration(profile),
profile.provider_kind,
profile.model,
text,
)}{" "}
·
{/* Core fills in the built-in vector model on every profile,
* so printing it unconditionally put a line about
* bge-small-zh-v1.5 under a profile whose owner never chose
@@ -1561,13 +1747,14 @@ export function ModelSettings({
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSwitchRefreshCompleted(null);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
@@ -16,6 +16,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
@@ -49,6 +52,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
@@ -130,7 +136,7 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -159,7 +165,7 @@ it("lets an empty first-time user enter without granting or importing a source",
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -182,7 +188,7 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -31,6 +31,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
const models = {
pendingProbe: false,
@@ -109,6 +112,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
beforeEach(() => {
delete window.oneMemory;
@@ -265,7 +271,7 @@ it("translates import review and preserves independent permission disclosure", a
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
expect(
@@ -295,20 +301,20 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
name: "Elegir el modo de memoria",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -416,7 +422,7 @@ it("translates completion recovery live without repeating completion writes", as
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "Continuar" }));
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -439,9 +445,14 @@ it("translates model configuration, validation and advanced fields without activ
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
name: "Configurar el modelo de organización de recuerdos",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", {
name: "Detalles y configuración avanzada",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
@@ -452,6 +463,42 @@ it("translates model configuration, validation and advanced fields without activ
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。",
"No se completó la actualización del índice. Se sigue usando el modelo de búsqueda anterior. Comprueba la conexión y vuelve a intentarlo.",
],
[
"上次模型测试结果仍待核对,请稍后重试。",
"El resultado de la última prueba del modelo aún debe comprobarse. Vuelve a intentarlo más tarde.",
],
[
"模型写入暂时关闭。请等待索引维护完成后重试。",
"La escritura del modelo está temporalmente pausada. Espera a que termine el mantenimiento del índice y vuelve a intentarlo.",
],
[
"模型未能确认启用,请在设置中核对。",
"No se pudo confirmar que el modelo esté habilitado. Compruébalo en Configuración.",
],
[
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。",
"La configuración guardada del modelo no se puede leer temporalmente. No se creará un duplicado. Vuelve a actualizar.",
],
])("recognizes controller message key %s", async (key, translated) => {
render(
<LanguageProvider>
<OnboardingModelController
sourceNames={[]}
flowError={key}
onBack={vi.fn()}
/>
</LanguageProvider>,
);
expect(await screen.findByText(translated)).toBeTruthy();
expect(screen.queryByText(key)).toBeNull();
});
it.each([
[
"completed",
@@ -32,6 +32,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -74,6 +77,9 @@ const state: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
let sources: SourceResponse[];
let jobs: ImportJob[];
@@ -146,6 +152,17 @@ function noUnrequestedWrites() {
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
blockers: [],
} as never);
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
vi.stubGlobal("crypto", webcrypto);
delete window.oneMemory;
@@ -302,7 +319,7 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -354,7 +371,7 @@ it("partial acceptance preserves the successful job and failure through model-st
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -21,6 +21,9 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
@@ -41,6 +44,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
+365 -19
View File
@@ -9,6 +9,7 @@ import type {
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -17,6 +18,10 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./api";
import {
confirmedMemoryModeSnapshot,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { readConnectionPreferences } from "./connection-preferences";
import { OnboardingFlow, PLUGIN_CONSENT_KEY } from "./OnboardingFlow";
import {
@@ -27,6 +32,9 @@ import {
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const planMemoryMode = vi.fn();
const putOnboardingProgress = vi.fn();
const patchOnboardingProgress = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -48,6 +56,10 @@ vi.mock("./api", () => ({
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
planMemoryMode: (...args: unknown[]) => planMemoryMode(...args),
putOnboardingProgress: (...args: unknown[]) => putOnboardingProgress(...args),
patchOnboardingProgress: (...args: unknown[]) =>
patchOnboardingProgress(...args),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -62,20 +74,33 @@ vi.mock("./OnboardingModelController", () => ({
OnboardingModelController: ({
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken,
}: {
onBack(): void;
onSkip(): void;
onEnabled(): void;
onSkip?(): void;
onProfilePersisted(profileId: string): void;
onModelActiveConfirmed(token: number, profileId: string): void;
generationToken: number;
}) => (
<section aria-label="模型步骤边界">
<button type="button" onClick={onBack}>
返回导入
</button>
<button type="button" onClick={onEnabled}>
<button
type="button"
onClick={() => {
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"9be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
模型启用回执已确认
</button>
<button type="button" onClick={onSkip}>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
</section>
@@ -87,6 +112,9 @@ const onboarding: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
const health = {
status: "ok" as const,
@@ -184,13 +212,13 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "模型启用回执已确认" }));
}
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 user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
@@ -225,6 +253,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
completeOnboarding.mockResolvedValue({
@@ -232,6 +261,27 @@ describe("OnboardingFlow S1.3", () => {
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
patchOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
@@ -371,7 +421,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
const continueButton = await screen.findByRole("button", {
name: "配置模型,沉淀记忆",
name: "选择记忆模式",
});
expect(continueButton).toHaveProperty("disabled", false);
await openModelStep(user);
@@ -695,13 +745,47 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(controller.refresh).toHaveBeenCalledOnce();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
it("shows the compact Lite and Pro mode step with contextual actions", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
const modeStep = screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]');
expect(modeStep?.getAttribute("aria-current")).toBe("step");
await user.click(screen.getByRole("radio", { name: /lite/i }));
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
@@ -735,7 +819,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(lite.checked).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
});
@@ -760,7 +844,7 @@ describe("OnboardingFlow S1.3", () => {
true,
);
await user.click(lite);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存。你的选择已保留,请重试。",
@@ -769,13 +853,277 @@ describe("OnboardingFlow S1.3", () => {
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
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 user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode.mock.calls.map(([mode]) => mode)).toEqual([
"pro",
"lite",
]);
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "选择记忆模式" }),
).toBeTruthy(),
);
expect(confirmedMemoryModeSnapshot().revision).toBe(revisionBeforePut);
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("serializes model and back progress writes so setup mode wins", async () => {
let resolveModelProgress!: (value: OnboardingState) => void;
putOnboardingProgress
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "model",
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveModelProgress = resolve;
}),
)
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "mode",
});
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(2));
await user.click(screen.getByRole("button", { name: "返回导入" }));
expect(putOnboardingProgress).toHaveBeenCalledTimes(2);
await act(async () =>
resolveModelProgress({
...onboarding,
mode_intent: "pro",
setup_step: "model",
}),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(3));
expect(putOnboardingProgress).toHaveBeenLastCalledWith({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
});
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("moves focus to the mode title on entry and after returning from model", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
});
await waitFor(() => expect(document.activeElement).toBe(returnedTitle));
});
it("treats completed progress conflicts as terminal instead of retryable failures", async () => {
putOnboardingProgress.mockRejectedValueOnce(
Object.assign(new Error("completed"), {
response: {
status: 409,
data: { detail: "onboarding_completed" },
},
}),
);
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 user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("persists a late profile receipt after leaving model but fences late activation", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
await user.click(screen.getByRole("button", { name: "返回导入" }));
fireEvent.click(lateReceipt);
await waitFor(() =>
expect(patchOnboardingProgress).toHaveBeenCalledWith({
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("restores a persisted Pro model step directly after restart", async () => {
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "pro",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "选择记忆模式" })).toBeNull();
});
it("keeps the Pro choice retryable when progress persistence fails", async () => {
putOnboardingProgress.mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
});
it("returns from skipped pro setup to an explicit mode choice", async () => {
const user = userEvent.setup();
const controller = imports();
@@ -792,11 +1140,11 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
expect(screen.getByRole("heading", { name: "选择记忆方式" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -1749,7 +2097,7 @@ describe("OnboardingFlow S1.3", () => {
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "配置模型,沉淀记忆" });
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
@@ -2336,9 +2684,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(host.disabled).toBe(true);
expect(host.checked).toBe(false);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
+357 -83
View File
@@ -15,7 +15,15 @@ import {
useState,
} from "react";
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
import {
apiErrorCode,
completeOnboarding,
getMemoryMode,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
import {
commitConfirmedMemoryMode,
@@ -32,6 +40,7 @@ import {
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
@@ -51,6 +60,28 @@ const SCOPE_CHANGED =
"来源位置、身份或状态已变化,请重新选择并核对。尚未提交本次授权。";
type LocationCheck = "idle" | "checking" | "ready" | "failed";
function isCompletedProgressConflict(reason: unknown): boolean {
if (apiErrorCode(reason) === "onboarding_completed") return true;
if (!reason || typeof reason !== "object" || !("response" in reason))
return false;
const response = (
reason as {
response?: { status?: unknown; data?: { detail?: unknown } };
}
).response;
return (
response?.status === 409 && response.data?.detail === "onboarding_completed"
);
}
function isGenerationInvalid(reason: unknown): boolean {
return (
reason instanceof Error &&
(reason.message === "onboarding_generation_invalid" ||
reason.message === "onboarding_progress_generation_invalid")
);
}
function defaultConnectionDraft(
sources: SourceResponse[],
sourceIds: readonly string[],
@@ -93,19 +124,37 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
: "review";
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
"review",
resumedStep,
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
null,
state.mode_intent,
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modeError, setModeError] = useState<string | null>(null);
const modeChoiceTouched = useRef(false);
const [visitedSetup, setVisitedSetup] = useState(false);
const [visitedModel, setVisitedModel] = useState(false);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
const modeChoiceTouched = useRef(state.mode_intent !== null);
const [visitedSetup, setVisitedSetup] = useState(resumedStep !== "review");
const [visitedModel, setVisitedModel] = useState(resumedStep === "model");
const [modelGenerationToken, setModelGenerationToken] = useState(
resumedStep === "model" ? 1 : 0,
);
const modelGeneration = useRef(resumedStep === "model" ? 1 : 0);
const intentRef = useRef<OnboardingMemoryMode | null>(state.mode_intent);
const setupStepRef = useRef<"review" | "mode" | "model">(resumedStep);
const modelProfileId = useRef<string | null>(state.model_profile_id);
const modeWriteQueue = useRef<Promise<void>>(Promise.resolve());
const progressWriteQueue = useRef<Promise<void>>(Promise.resolve());
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
{ hostSourceIds: [] },
@@ -434,6 +483,93 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
intentRef.current = nextIntent;
setupStepRef.current = nextStep;
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
},
[],
);
const queueModeWrite = useCallback(
(mode: OnboardingMemoryMode, allowed: () => boolean = () => true) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof setMemoryMode>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<Awaited<ReturnType<typeof setMemoryMode>>>(
(resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
},
);
modeWriteQueue.current = modeWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_generation_invalid"));
return;
}
try {
resolveResult(await setMemoryMode(mode));
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const queueProgressWrite = useCallback(
(
operation: () => ReturnType<typeof putOnboardingProgress>,
allowed: () => boolean = () => true,
) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof putOnboardingProgress>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<
Awaited<ReturnType<typeof putOnboardingProgress>>
>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
progressWriteQueue.current = progressWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_progress_generation_invalid"));
return;
}
try {
resolveResult(await operation());
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const persistFinish = useCallback(
async (destination: OnboardingDestination) => {
setExitDestination(destination);
@@ -453,6 +589,8 @@ export function OnboardingFlow({
: "onboarding_complete_result_unknown",
});
if (alive.current) {
modelGeneration.current += 1;
setupStepRef.current = "review";
setCompletionFailed(true);
setSetupStep("review");
// A definite refusal means it was not saved; anything else (timeout,
@@ -486,13 +624,19 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
setSetupStep("mode");
setModeFocusToken((value) => value + 1);
}
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
setSetupStep("mode");
},
[],
[leaveModelStep, modeChoice],
);
const saveModeChoice = useCallback(async () => {
@@ -507,21 +651,132 @@ export function OnboardingFlow({
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const changed = await setMemoryMode(modeChoice);
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
if (modeChoice === "lite") {
await leave(modeDestination);
if (modeChoice === "pro") {
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
return;
}
setVisitedModel(true);
setSetupStep("model");
} catch {
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
leaveModelStep("lite", "mode");
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "lite",
setup_step: "mode",
model_profile_id: null,
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
}
}, [leave, modeChoice, modeDestination, modeSaving]);
}, [
enterModelStep,
leave,
leaveModelStep,
modeChoice,
modeDestination,
modeSaving,
finishOnce,
queueModeWrite,
queueProgressWrite,
]);
const persistModelProfile = useCallback(
async (profileId: string) => {
modelProfileId.current = profileId;
try {
await queueProgressWrite(() =>
patchOnboardingProgress({ model_profile_id: profileId }),
);
} catch (reason) {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("模型配置已保存,但恢复进度未能保存。请重试当前步骤。");
}
},
[queueProgressWrite],
);
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
},
[
finishOnce,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
if (flight.current || locked) return;
@@ -769,8 +1024,9 @@ export function OnboardingFlow({
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆方式")}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
@@ -778,7 +1034,20 @@ export function OnboardingFlow({
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("返回导入")}
@@ -794,7 +1063,15 @@ export function OnboardingFlow({
}
onClick={() => void saveModeChoice()}
>
{text(modeSaving ? "正在保存…" : "继续")}
{text(
modeSaving
? "正在保存…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
}
@@ -804,68 +1081,40 @@ export function OnboardingFlow({
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<h2 id="onboarding-memory-mode-title">
{text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<fieldset
className="onboarding-r6-mode-options"
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={[
{
mode: "lite",
title: "Lite",
description: text(
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
),
},
{
mode: "pro",
title: "Pro",
description: text(
"使用模型理解对话,提取并整理可持续生长的记忆。",
),
},
]}
selected={modeChoice}
disabled={modeSaving || busy === "leaving"}
>
<legend className="onboarding-r6-sr-only">
{text("记忆模式")}
</legend>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "pro"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="pro"
checked={modeChoice === "pro"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("pro");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>pro</strong>
<span>{text("推荐")}</span>
</span>
<span>
{text("使用模型理解对话,提取并整理可持续生长的记忆。")}
</span>
</span>
</label>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "lite"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="lite"
checked={modeChoice === "lite"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("lite");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>lite</strong>
</span>
<span>
{text("无需模型,只保存并检索原始对话,不生成提炼记忆。")}
</span>
</span>
</label>
</fieldset>
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
setModeError(null);
}}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
@@ -877,11 +1126,24 @@ export function OnboardingFlow({
{visitedModel && (
<div hidden={setupStep !== "model"}>
<OnboardingModelController
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
flowError={modeError}
connectionAlreadyApplied={
connectionApplication === "applied" ||
connectionApplication === "partial"
}
onEnabled={() => void leave("sources")}
onProfilePersisted={(profileId) =>
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)
@@ -893,8 +1155,20 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setSetupStep("mode")}
onSkip={() => setSetupStep("mode")}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("恢复进度未能保存。你的选择已保留,请重试。");
});
}}
/>
</div>
)}
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { models } = vi.hoisted(() => ({
const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
models: {
profiles: [
{
@@ -26,73 +26,392 @@ const { models } = vi.hoisted(() => ({
is_active: true,
},
],
pendingProbe: false,
pendingProbe: null as null | {
operationId: string;
profileId: string;
inputRevision: number;
},
probeStorageFailed: false,
pendingCreation: false,
creationMissing: null,
creationStorageFailed: false,
refreshing: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
activate: vi.fn(),
reconcileCreation: vi.fn(),
discardMissingCreation: vi.fn(),
},
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: null,
getModelProfiles: vi.fn(),
embedding: {
current: null as null | {
writes_paused: boolean;
last_finished?: { job_id: string; state: string };
},
plan: null,
busy: null,
errorCode: null,
refreshFailed: false,
supported: false,
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
},
step: { current: null as Record<string, unknown> | null },
}));
vi.mock("./api", () => ({
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => embedding,
}));
vi.mock("./OnboardingModelStep", () => ({
OnboardingModelStep: (props: {
probe?: { message: string } | null;
enableActionLabel?: string;
onSaveAndEnable(): Promise<void>;
}) => (
<section>
<p>{props.probe?.message}</p>
<button type="button" onClick={() => void props.onSaveAndEnable()}>
{props.enableActionLabel}
</button>
</section>
),
OnboardingModelStep: (props: Record<string, unknown>) => {
step.current = props;
const probe = props.probe as { message: string } | null | undefined;
return (
<section>
<p>{probe?.message}</p>
<p>{String(props.error ?? "")}</p>
<button
type="button"
onClick={() => void (props.onSaveAndEnable as () => Promise<void>)()}
>
{String(props.enableActionLabel ?? "")}
</button>
<button
type="button"
disabled={!props.canTest}
onClick={() => void (props.onTest as () => Promise<void>)()}
>
{String(props.testActionLabel ?? "测试模型连通性")}
</button>
{props.onRecoverCreation ? (
<button
type="button"
onClick={() =>
void (props.onRecoverCreation as () => Promise<void>)()
}
>
核对创建结果
</button>
) : null}
</section>
);
},
}));
function currentStep(): Record<string, unknown> {
if (!step.current) throw new Error("onboarding_model_step_not_rendered");
return step.current;
}
const activeProfile = { ...models.profiles[0] };
beforeEach(() => {
models.profiles = [{ ...activeProfile }];
models.pendingCreation = false;
models.pendingProbe = null;
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
step.current = null;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
it("does not claim or auto-complete an arbitrary active profile without an onboarding id", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onEnabled={enabled}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(currentStep().profile).toBeNull();
expect(enabled).not.toHaveBeenCalled();
expect(models.activate).not.toHaveBeenCalled();
});
it("resumes and confirms the active profile explicitly owned by onboarding", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe("active-profile"),
);
expect(models.create).not.toHaveBeenCalled();
expect(active).not.toHaveBeenCalled();
});
it("recovers a pending creation and persists its profile id", async () => {
models.profiles = [];
models.pendingCreation = true;
models.reconcileCreation.mockResolvedValueOnce(activeProfile);
const persisted = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onProfilePersisted={persisted}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对创建结果" }),
);
await waitFor(() => expect(persisted).toHaveBeenCalledWith("active-profile"));
expect(models.create).not.toHaveBeenCalled();
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
profileId: "active-profile",
inputRevision: 3,
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: true }],
});
getModelProfiles.mockResolvedValueOnce([inactive]);
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对测试结果" }),
);
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
await screen.findByText("模型写入暂时关闭。请等待索引维护完成后重试。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
expect(active).not.toHaveBeenCalled();
});
it("does not confirm a late embedding gate opening after generation invalidation", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
let current = true;
const view = render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await screen.findByText("当前模型已经启用,可以继续完成首次设置。");
current = false;
embedding.current = {
writes_paused: false,
last_finished: { job_id: "late-job", state: "done" },
};
view.rerender(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() => expect(active).not.toHaveBeenCalled());
});
it("creates a cold-start profile without embedding or Core-owned runtime defaults", async () => {
models.profiles = [];
const created = {
...activeProfile,
profile_id: "created-profile",
is_active: false,
timeout_seconds: 240,
};
models.create.mockResolvedValueOnce(created);
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
api_key: "test-key",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create).toHaveBeenCalledOnce();
const input = models.create.mock.calls[0][0];
expect(input).toMatchObject({
provider_kind: "openai_compatible",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "test-key",
});
for (const key of [
"embedding_provider_kind",
"embedding_model",
"embedding_base_url",
"embedding_dimensions",
"timeout_seconds",
"max_concurrency",
"context_window",
])
expect(input).not.toHaveProperty(key);
});
it("clears inherited runtime display on provider switch and creates with Core defaults", async () => {
models.profiles = [];
models.create.mockResolvedValueOnce({
...activeProfile,
profile_id: "ollama-profile",
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
timeout_seconds: 240,
is_active: false,
});
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
timeout_seconds: "90",
});
});
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
provider_id: "ollama",
});
});
expect(
(currentStep().draft as { timeout_seconds: string }).timeout_seconds,
).toBe("");
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "qwen2.5",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create.mock.calls[0][0]).toMatchObject({
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
});
expect(models.create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
});
it("updates generation without overwriting an existing custom embedding", async () => {
const custom = {
...activeProfile,
is_active: false,
embedding_provider_kind: "ollama" as const,
embedding_model: "nomic-embed-text",
embedding_base_url: null,
embedding_dimensions: 768,
};
models.profiles = [custom];
models.update.mockResolvedValueOnce({ ...custom, model: "model-2" });
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={custom.profile_id}
onBack={vi.fn()}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(custom.profile_id),
);
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "model-2",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.update).toHaveBeenCalledWith(custom.profile_id, {
expected_revision: custom.revision,
model: "model-2",
});
});
+281 -108
View File
@@ -2,10 +2,15 @@ import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -31,8 +36,8 @@ const initial: OnboardingModelDraft = {
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "90",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
};
@@ -57,15 +62,23 @@ export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken = 1,
isGenerationCurrent = () => true,
initialProfileId = null,
flowError = null,
connectionAlreadyApplied = false,
}: {
sourceNames: readonly string[];
importedConversationCount?: number;
onBack(): void;
onSkip?(): void;
onEnabled?(): void;
onProfilePersisted?(profileId: string): void;
onModelActiveConfirmed?(token: number, ownerProfileId: string): void;
generationToken?: number;
isGenerationCurrent?(token: number): boolean;
initialProfileId?: string | null;
flowError?: string | null;
connectionAlreadyApplied?: boolean;
}) {
const { text, t } = useOnboardingTranslation();
@@ -90,36 +103,72 @@ export function OnboardingModelController({
);
const flight = useRef(false);
const draftTouched = useRef(false);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
const autoConfirmed = useRef<string | null>(null);
useEffect(() => {
const active = (models.profiles ?? []).find((profile) => profile.is_active);
if (!active || saved || draftTouched.current) return;
setSaved(active);
const profile = initialProfileId
? (models.profiles ?? []).find(
(candidate) => candidate.profile_id === initialProfileId,
)
: undefined;
if (!profile || saved || draftTouched.current) return;
setSaved(profile);
setDraft((current) => ({
...current,
name: active.name,
provider_kind: active.provider_kind,
model: active.model,
base_url: active.base_url ?? "",
disable_thinking: active.disable_thinking,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
disable_thinking: profile.disable_thinking,
embedding_mode:
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
profile.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
active.embedding_provider_kind === "ollama"
profile.embedding_provider_kind === "ollama"
? "ollama"
: "openai_compatible",
embedding_model: active.embedding_model ?? "",
embedding_base_url: active.embedding_base_url ?? "",
timeout_seconds: String(active.timeout_seconds),
max_concurrency: String(active.max_concurrency),
embedding_model: profile.embedding_model ?? "",
embedding_base_url: profile.embedding_base_url ?? "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
active.context_window === null ? "" : String(active.context_window),
profile.context_window === null ? "" : String(profile.context_window),
}));
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [models.profiles, saved]);
if (profile.is_active)
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [initialProfileId, models.profiles, saved]);
useEffect(() => {
if (
!saved?.is_active ||
!isGenerationCurrent(generationToken) ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
autoConfirmed.current = key;
onModelActiveConfirmed?.(generationToken, saved.profile_id);
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
@@ -133,18 +182,34 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
}, [
embeddingSwitch.current?.last_finished,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
for (const key of [
"timeout_seconds",
"max_concurrency",
"context_window",
"disable_thinking",
] as const) {
if (key in patch) technicalTouched.current.add(key);
}
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
const preset = PROVIDER_PRESETS.find(
(item) => item.id === patch.provider_id,
@@ -154,9 +219,14 @@ export function OnboardingModelController({
provider_kind: preset?.provider ?? "openai_compatible",
base_url: preset?.baseUrl ?? "",
model: preset?.model ?? "",
name: preset?.label ?? "",
name: preset?.label ?? "自定义兼容服务",
api_key: "",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
disable_thinking: true,
};
technicalTouched.current.clear();
setSaved(null);
setAdvanced(!preset?.model);
}
@@ -167,82 +237,42 @@ export function OnboardingModelController({
}
function input(): ModelProfileInput | null {
const next: Partial<Record<keyof OnboardingModelDraft, string>> = {};
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const value = Number(draft[key]);
if (
!draft[key].trim() ||
!Number.isInteger(value) ||
value < min ||
value > max
)
next[key] = "onboardingIntegerRange";
return value;
};
const timeout = integer("timeout_seconds", 1, 600);
const concurrency = integer("max_concurrency", 1, 32);
const context = draft.context_window.trim()
? integer("context_window", 1024, 10_000_000)
: null;
if (!draft.name.trim()) next.name = "请填写配置名称。";
if (!draft.model.trim()) next.model = "请填写实际可用的模型 ID。";
try {
const url = new URL(draft.base_url);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error();
} catch {
next.base_url = "请填写有效的模型接口地址。";
}
if (
draft.provider_kind !== "ollama" &&
!draft.api_key.trim() &&
!saved?.has_api_key
)
next.api_key = "请填写 API Key。";
if (draft.embedding_mode === "custom" && !draft.embedding_model.trim())
next.embedding_model = "请填写向量模型 ID。";
const next: Partial<Record<keyof OnboardingModelDraft, string>> =
validateGenerationModel(draft, {
apiKey: draft.api_key,
keyRequired:
draft.provider_kind !== "ollama" &&
!(saved?.has_configured_api_key ?? saved?.has_api_key),
});
if (Object.keys(next).length) {
setFields(next);
setAdvanced(true);
if (Object.keys(next).some((key) => key !== "api_key" && key !== "name"))
setAdvanced(true);
return null;
}
return {
const value: ModelProfileInput = {
name: draft.name.trim(),
model: draft.model.trim(),
provider_kind: draft.provider_kind,
base_url: draft.base_url.trim(),
api_key: draft.api_key.trim() || undefined,
timeout_seconds: timeout,
max_concurrency: concurrency,
context_window: context,
disable_thinking: draft.disable_thinking,
embedding_provider_kind:
draft.embedding_mode === "local"
? "local_onnx"
: draft.embedding_provider_kind,
embedding_model:
draft.embedding_mode === "local"
? "bge-small-zh-v1.5"
: draft.embedding_model.trim(),
// Core no longer lets the embedding endpoint follow the generation
// one, so an empty field is written as the endpoint it means.
embedding_base_url:
draft.embedding_mode === "local"
? null
: draft.embedding_base_url.trim() ||
(draft.embedding_provider_kind === "ollama"
? "http://127.0.0.1:11434"
: draft.base_url.trim()),
embedding_dimensions: draft.embedding_mode === "local" ? 512 : null,
};
if (
technicalTouched.current.has("timeout_seconds") &&
draft.timeout_seconds.trim()
)
value.timeout_seconds = Number(draft.timeout_seconds);
if (
technicalTouched.current.has("max_concurrency") &&
draft.max_concurrency.trim()
)
value.max_concurrency = Number(draft.max_concurrency);
if (technicalTouched.current.has("context_window"))
value.context_window = draft.context_window.trim()
? Number(draft.context_window)
: null;
return value;
}
async function save(test: boolean) {
@@ -252,27 +282,66 @@ export function OnboardingModelController({
flight.current = true;
setError(null);
setPhase("saving");
const owner = generationToken;
try {
const { provider_kind: providerKind, ...patch } = value;
const { provider_kind: providerKind } = value;
const patch: ModelProfilePatch = {
expected_revision: saved?.revision ?? 0,
};
if (saved) {
if (value.name !== saved.name) patch.name = value.name;
if (value.model !== saved.model) patch.model = value.model;
if (value.base_url !== saved.base_url) patch.base_url = value.base_url;
if (draft.api_key.trim()) patch.api_key = draft.api_key.trim();
if (
technicalTouched.current.has("disable_thinking") &&
value.disable_thinking !== saved.disable_thinking
)
patch.disable_thinking = value.disable_thinking;
if (
technicalTouched.current.has("timeout_seconds") &&
value.timeout_seconds !== saved.timeout_seconds
)
patch.timeout_seconds = value.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
value.max_concurrency !== saved.max_concurrency
)
patch.max_concurrency = value.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
value.context_window !== saved.context_window
)
patch.context_window = value.context_window;
}
const profile =
saved && saved.provider_kind === providerKind
? await models.update(saved.profile_id, {
...patch,
expected_revision: saved.revision,
})
? await models.update(saved.profile_id, patch)
: await models.create(value);
if (!profile) {
setError("配置结果尚未确认,请在模型设置中核对后重试。");
return;
}
onProfilePersisted?.(profile.profile_id);
setSaved(profile);
setDraft((current) => ({ ...current, api_key: "" }));
if (!test) return;
technicalTouched.current.clear();
setDraft((current) => ({
...current,
api_key: "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null ? "" : String(profile.context_window),
disable_thinking: profile.disable_thinking,
}));
if (!test || !isGenerationCurrent(owner)) return;
setPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
@@ -289,12 +358,61 @@ export function OnboardingModelController({
}
}
async function testModel() {
const pending = models.pendingProbe;
if (!pending) {
await save(true);
return;
}
if (
flight.current ||
!saved ||
pending.profileId !== saved.profile_id ||
!isGenerationCurrent(generationToken)
)
return;
const owner = generationToken;
flight.current = true;
setError(null);
setPhase("testing");
try {
const result = await models.probeProfile(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === saved.profile_id,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
message: passed
? "模型连接测试通过,可以启用。"
: "连接未通过,请核对模型、密钥和接收方。",
});
} catch {
setError("上次模型测试结果仍待核对,请稍后重试。");
} finally {
flight.current = false;
setPhase("idle");
}
}
async function enable() {
if (flight.current || probe?.state !== "passed" || !saved) return;
if (saved.is_active) {
onEnabled?.();
if (
embeddingSwitch.current?.writes_paused ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current)
) {
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
return;
}
const owner = generationToken;
flight.current = true;
setPhase("enabling");
setError(null);
@@ -306,6 +424,7 @@ export function OnboardingModelController({
saved.profile_id,
saved.revision,
);
if (!isGenerationCurrent(owner)) return;
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
@@ -313,9 +432,10 @@ export function OnboardingModelController({
return;
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onEnabled?.();
onModelActiveConfirmed?.(owner, active.profile_id);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -350,8 +470,17 @@ export function OnboardingModelController({
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
canTest={
!models.probeStorageFailed &&
(!models.pendingProbe ||
models.pendingProbe.profileId === saved?.profile_id)
}
testActionLabel={models.pendingProbe ? "核对测试结果" : undefined}
canSaveDraft={
!models.pendingCreation &&
!models.creationStorageFailed &&
(!initialProfileId || !!saved)
}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
@@ -368,7 +497,17 @@ export function OnboardingModelController({
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
error={
(error ? text(error) : null) ??
(flowError ? text(flowError) : null) ??
(embeddingSwitch.current?.writes_paused
? text("模型写入暂时关闭。请等待索引维护完成后重试。")
: null) ??
(initialProfileId && !saved && !models.refreshing
? text("已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。")
: null) ??
(models.error ? text(models.error) : null)
}
probe={probe}
profile={
saved
@@ -393,10 +532,44 @@ export function OnboardingModelController({
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onTest={testModel}
onSaveAndEnable={enable}
onRecoverCreation={
models.pendingCreation
? async () => {
const profile = await models.reconcileCreation();
if (!profile) return;
onProfilePersisted?.(profile.profile_id);
technicalTouched.current.clear();
setSaved(profile);
setDraft((current) => ({
...current,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
api_key: "",
disable_thinking: profile.disable_thinking,
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null
? ""
: String(profile.context_window),
}));
}
: undefined
}
onDiscardMissingCreation={
models.creationMissing
? async () => {
await models.discardMissingCreation();
}
: undefined
}
onBack={onBack}
onSkip={onSkip}
focusToken={generationToken}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
+163 -381
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { guiMessages, SUPPORTED_LOCALES } from "@one-memory/i18n";
import {
act,
cleanup,
@@ -6,9 +7,8 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
OnboardingModelStep,
@@ -20,417 +20,199 @@ afterEach(() => {
localStorage.clear();
});
it.each(["es", "zh-Hans"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const p = props();
p.onSkip = vi.fn();
let finish!: () => void;
p.onTest = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const labels =
locale === "es"
? [
"Omitir por ahora",
"Probar conexión",
"Confirmar y habilitar",
"Probando el modelo…",
]
: ["跳过,先进入应用", "测试模型连通性", "确认启用", "正在测试模型…"];
const draw = (probe?: OnboardingModelStepProps["probe"]) => (
<LanguageProvider>
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={probe}
advancedOpen
/>
</LanguageProvider>
);
const view = render(draw());
const skip = screen.getByRole("button", { name: labels[0] });
if (locale === "es") {
expect(
document.getElementById(skip.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("Aplicaciones");
}
fireEvent.click(skip);
expect(p.onSkip).toHaveBeenCalledOnce();
expect(p.onBack).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: labels[1] }));
expect(screen.getByRole("button", { name: labels[3] })).toHaveProperty(
"disabled",
true,
);
expect(skip).toHaveProperty("disabled", true);
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
await act(async () => finish());
view.rerender(draw({ state: "failed", message: "synthetic failure" }));
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
expect(p.onDraftChange).not.toHaveBeenCalled();
view.rerender(draw({ state: "passed", message: "synthetic success" }));
fireEvent.click(screen.getByRole("button", { name: labels[2] }));
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(
screen.getByText(
locale === "es"
? /La prueba de conexión solo envía/
: /测试连接只发送测试内容/,
),
).toBeTruthy();
},
);
function props(): OnboardingModelStepProps {
return {
draft: {
provider_id: "fixture",
name: "My profile",
provider_id: "deepseek",
name: "DeepSeek",
provider_kind: "openai_compatible",
model: "user-model",
base_url: "https://example.invalid/v1",
api_key: "secret",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "",
disable_thinking: true,
embedding_mode: "local",
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "240",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
},
providers: [
{ id: "fixture", label: "用户服务", keyRequired: true, supported: true },
{
id: "deepseek",
label: "DeepSeek",
keyRequired: true,
supported: true,
},
{
id: "ollama",
label: "Ollama(本机)",
keyRequired: false,
supported: true,
},
],
advancedOpen: false,
onAdvancedChange: vi.fn(),
onDraftChange: vi.fn(),
phase: "idle",
capability: { saveAndEnable: false },
capability: { saveAndEnable: true },
canTest: true,
canSaveDraft: true,
canSaveAndEnable: true,
sourceNames: ["Source A"],
importedConversationCount: 37,
generationRecipient: "用户服务 / 生成",
embeddingRecipient: "本机 / 向量",
canSaveAndEnable: false,
sourceNames: ["Cursor", "Codex"],
importedConversationCount: 12,
generationRecipient: "https://api.deepseek.com",
embeddingRecipient: "本机",
onTest: vi.fn().mockResolvedValue(undefined),
onSaveAndEnable: vi.fn().mockResolvedValue(undefined),
onSaveDraft: vi.fn().mockResolvedValue(undefined),
onBack: vi.fn(),
onActionError: vi.fn(),
};
}
it("renders step3 without engineering-only recipients, with unavailable enable failing closed", () => {
const p = props();
render(<OnboardingModelStep {...p} />);
expect(
screen.getByRole("heading", { name: "选好模型,让记忆开始沉淀" }),
).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("沉淀记忆");
fireEvent.click(screen.getByLabelText("模型服务商"));
expect(screen.getByRole("option", { name: "用户服务" })).toBeTruthy();
expect(screen.queryByRole("option", { name: "DeepSeek" })).toBeNull();
expect(screen.queryByText("用户服务 / 生成")).toBeNull();
expect(screen.queryByText("本机 / 向量")).toBeNull();
expect(screen.getByText("Source A")).toBeTruthy();
expect(screen.getAllByText(/当前不能在此启用模型/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.queryByRole("checkbox", { name: /同意|允许发送/ })).toBeNull();
});
it("keeps a real multi-source summary compact", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
sourceNames={["WorkBuddy", "Cursor", "Codex", "Claude Code", "Pi"]}
/>,
);
expect(
screen.getByText("WorkBuddy · Cursor · Codex 等 5 个应用"),
).toBeTruthy();
expect(screen.queryByText("Pi")).toBeNull();
});
it("guides first-run keychain authorization when a saved key is unreadable", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
profile={{
profile_id: "saved-profile",
revision: 1,
has_configured_api_key: true,
credential_state: "unavailable",
}}
/>,
);
expect(screen.getByText(/需要允许 MemHub 访问钥匙串/)).toBeTruthy();
expect(screen.getByText(/始终允许/)).toBeTruthy();
});
it("draft edits and advanced mode are controlled; return never clears or saves values", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "new-secret" },
});
expect(p.onDraftChange).toHaveBeenCalledWith({ api_key: "new-secret" });
expect((screen.getByLabelText("API Key") as HTMLInputElement).value).toBe(
"secret",
);
fireEvent.click(screen.getByRole("button", { name: "配置详情与高级设置" }));
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
fireEvent.click(screen.getByRole("button", { name: "返回服务商选择" }));
expect(p.onAdvancedChange).toHaveBeenLastCalledWith(false);
expect(
screen.getByRole("heading", { name: "配置详情,按需要调整" }),
).toBeTruthy();
expect((screen.getByLabelText("超时(秒)") as HTMLInputElement).max).toBe(
"600",
);
expect((screen.getByLabelText("最大并发") as HTMLInputElement).max).toBe(
"32",
);
expect((screen.getByLabelText("上下文窗口") as HTMLInputElement).min).toBe(
"1024",
);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.draft.api_key).toBe("secret");
});
it("keeps skip available while an uncertain write is reconciled", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
inputsLocked
capability={{ saveAndEnable: true }}
enableActionLabel="核对提交状态"
/>,
);
expect((screen.getByLabelText("配置名称") as HTMLInputElement).disabled).toBe(
true,
);
expect(
(
screen.getByRole("button", {
name: "跳过,先进入应用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledOnce();
expect(
(screen.getByRole("button", { name: "核对提交状态" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
it.each(["testing", "saving", "enabling"] as const)(
"%s locks controlled actions and fields",
(phase) => {
const p = props();
render(<OnboardingModelStep {...p} phase={phase} />);
describe("OnboardingModelStep", () => {
it.each(SUPPORTED_LOCALES)("renders named controls in %s", (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const title = guiMessages[locale]["配置记忆整理模型"];
const back = guiMessages[locale]["返回选择模式"];
if (!title || !back) throw new Error(`missing ${locale} model copy`);
render(
<LanguageProvider>
<OnboardingModelStep {...props()} />
</LanguageProvider>,
);
expect(
(screen.getByLabelText("API Key") as HTMLInputElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).not.toHaveBeenCalled();
if (phase === "saving")
expect(screen.queryByRole("status", { name: "模型提交状态" })).toBeNull();
else
expect(screen.getByRole("status", { name: "模型提交状态" })).toBeTruthy();
},
);
it("one in-flight callback across double click, errors stay owner-controlled without exposing thrown secrets", async () => {
const p = props();
let reject!: (e: Error) => void;
p.onSaveAndEnable = vi.fn(
() =>
new Promise<void>((_, r) => {
reject = r;
screen.getByRole("heading", {
name: title,
}),
);
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "passed", message: "测试通过" }}
/>,
);
const button = screen.getByRole("button", { name: "确认启用" });
fireEvent.click(button);
fireEvent.click(button);
expect(p.onSaveAndEnable).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
await act(async () => reject(new Error("SECRET_TOKEN")));
expect(p.onActionError).toHaveBeenCalledWith("save-and-enable");
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false),
);
});
it("renders field errors with links and preserves custom embedding in advanced mode", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
error="配置已变化,请核对。"
fieldErrors={{ api_key: "密钥未通过验证" }}
/>,
);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("密钥未通过验证");
view.rerender(
<OnboardingModelStep
{...p}
advancedOpen
draft={{
...p.draft,
embedding_mode: "custom",
embedding_model: "saved-vector",
embedding_base_url: "http://127.0.0.1:11434",
}}
/>,
);
expect(
(screen.getByLabelText("向量模型名称") as HTMLInputElement).value,
).toBe("saved-vector");
expect(screen.queryByRole("button", { name: "收起高级配置" })).toBeNull();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
).toBeTruthy();
expect(
screen.getByRole("button", {
name: back,
}),
).toBeTruthy();
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
});
it("discloses that testing does not send historical conversations", () => {
render(<OnboardingModelStep {...props()} />);
it("keeps step 3 on memory mode in a generation-only column", () => {
render(<OnboardingModelStep {...props()} />);
expect(
screen.getByRole("heading", { name: "配置记忆整理模型" }),
).toBeTruthy();
expect(screen.getByText("智能记忆 Pro · 配置记忆整理")).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("记忆模式");
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
expect(screen.queryByText("本次沉淀")).toBeNull();
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
expect(
document.querySelector(
".onboarding-model-layout--single-column-600" +
'[data-responsive="single-column-600"]',
),
).toBeTruthy();
});
expect(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
expect(screen.getByText(/启用后会使用此模型整理/)).toBeTruthy();
});
it("focuses the page title when entering the model substep", async () => {
render(<OnboardingModelStep {...props()} focusToken={7} />);
const title = screen.getByRole("heading", {
name: "配置记忆整理模型",
});
await waitFor(() => expect(document.activeElement).toBe(title));
});
it("does not expose an extra backlog selector in the first-run model page", () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.getByText(/先测试模型连通性/)).toBeTruthy();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
});
it("separates connectivity testing from final enable", async () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
expect(screen.getByRole("button", { name: "测试模型连通性" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
expect(screen.getByRole("button", { name: "确认启用" })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: "测试模型连通性" }));
await act(async () => {});
expect(p.onTest).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("uses back, test, and enable actions without a skip path", async () => {
const p = props();
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "返回选择模式" }));
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await act(async () => {});
expect(p.onBack).toHaveBeenCalledOnce();
expect(p.onTest).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /跳过/ })).toBeNull();
expect(screen.getByRole("button", { name: "启用并进入" })).toHaveProperty(
"disabled",
true,
);
});
it("requires owner validation as well as policy capability and truthful recipients", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
canSaveAndEnable={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
view.rerender(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
generationRecipient=""
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("renders shared technical generation fields without embedding", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const mountedModel =
document.querySelector<HTMLInputElement>('[name="model"]');
expect(mountedModel).toBeTruthy();
expect(mountedModel?.closest("[hidden]")).toBeTruthy();
const disclosure = screen.getByRole("button", {
name: "配置详情与高级设置",
});
expect(disclosure.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(disclosure);
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("协议")).toBeTruthy();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
"deepseek-flash",
);
expect(screen.getByLabelText("Base URL")).toBeTruthy();
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
expect(screen.getByLabelText("最大并发")).toBeTruthy();
expect(screen.getByLabelText("上下文窗口")).toBeTruthy();
expect(screen.getByLabelText("关闭思考模式")).toBeTruthy();
expect(screen.getByText("留空使用 Core 默认值。")).toBeTruthy();
expect(screen.queryByText(/向量模型/)).toBeNull();
});
it("opens the advanced design state with a route back to provider selection", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const toggle = screen.getByRole("button", { name: "配置详情与高级设置" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(
document.getElementById(toggle.getAttribute("aria-controls") ?? ""),
).toBeTruthy();
toggle.focus();
fireEvent.click(toggle);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByRole("button", { name: "返回服务商选择" })).toBeTruthy();
expect(screen.getByLabelText("API Key")).toBeTruthy();
expect(
screen.getByText("预设已填好,只有自定义服务或特殊需求才需要修改。"),
).toBeTruthy();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
it("expands technical details and focuses the first invalid required field", async () => {
const p = props();
p.draft = { ...p.draft, model: "" };
p.fieldErrors = { model: "请填写实际可用的模型 ID。" };
const view = render(<OnboardingModelStep {...p} />);
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(true));
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("生成模型名")),
);
});
it("uses the failure-state title and omits consent and scope-management UI", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "failed", message: "synthetic failure" }}
/>,
);
expect(
screen.getByRole("heading", { name: "连接未通过,配置已保留" }),
).toBeTruthy();
expect(screen.getByText(/连接未通过,请检查 API Key/)).toBeTruthy();
expect(screen.getByText("synthetic failure")).toBeTruthy();
expect(screen.queryByText("调整处理范围")).toBeNull();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
});
it("links field errors and keeps secrets out of callback errors", async () => {
const p = props();
p.fieldErrors = { api_key: "请填写 API Key。" };
p.canSaveAndEnable = true;
p.probe = { state: "passed", message: "模型连接测试通过,可以启用。" };
p.onSaveAndEnable = vi.fn().mockRejectedValue(new Error("SECRET_TOKEN"));
render(<OnboardingModelStep {...p} />);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("请填写 API Key");
const enable = screen.getByRole("button", { name: "启用并进入" });
fireEvent.click(enable);
fireEvent.click(enable);
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
});
it("keeps the first-run model form free of a second consent strip", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
draft={{ ...p.draft, embedding_mode: "custom" }}
/>,
);
const fields = screen.getByRole("region", { name: "模型参数" });
expect(within(fields).getByLabelText("向量 Base URL")).toBeTruthy();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
expect(screen.queryByText(/通过后自动启用/)).toBeNull();
expect(screen.queryByText(p.generationRecipient)).toBeNull();
expect(screen.queryByText(p.embeddingRecipient)).toBeNull();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
it("keeps recovery controls while embedding UI remains hidden", async () => {
const p = props();
p.onRecoverCreation = vi.fn().mockResolvedValue(undefined);
p.onDiscardMissingCreation = vi.fn().mockResolvedValue(undefined);
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "核对创建结果" }));
fireEvent.click(
screen.getByRole("button", { name: "放弃旧记录,重新配置" }),
);
await act(async () => {});
expect(p.onRecoverCreation).toHaveBeenCalledOnce();
expect(p.onDiscardMissingCreation).toHaveBeenCalledOnce();
});
});
+6 -615
View File
@@ -1,11 +1,4 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
/** Strings preserve partial numeric edits. The owner validates and maps to API payloads. */
export interface OnboardingModelDraft {
@@ -16,6 +9,7 @@ export interface OnboardingModelDraft {
base_url: string;
api_key: string;
disable_thinking: boolean;
/** Retained in controller state only for persisted-profile recovery; cold start never renders or writes it. */
embedding_mode: "local" | "custom";
embedding_provider_kind: "openai_compatible" | "ollama";
embedding_model: string;
@@ -24,13 +18,16 @@ export interface OnboardingModelDraft {
max_concurrency: string;
context_window: string;
}
export interface OnboardingModelProvider {
id: string;
label: string;
keyRequired: boolean;
supported: boolean;
}
type Action = "test" | "save-draft" | "save-and-enable";
export interface OnboardingModelStepProps {
draft: OnboardingModelDraft;
providers: readonly OnboardingModelProvider[];
@@ -41,7 +38,6 @@ export interface OnboardingModelStepProps {
capability: { saveAndEnable: boolean; reason?: string };
canTest: boolean;
canSaveDraft: boolean;
/** Owner validation/current revision gate; absence fails closed. */
canSaveAndEnable?: boolean;
enableActionLabel?: string;
testActionLabel?: string;
@@ -66,613 +62,8 @@ export interface OnboardingModelStepProps {
onRecoverCreation?(): Promise<void>;
onDiscardMissingCreation?(): Promise<void>;
onBack(): void;
onSkip?(): void;
/** Owner must preserve receipts/unknown results and supply a safe display error. */
focusToken?: number;
onActionError?(action: Action): void;
}
export function OnboardingModelStep(props: OnboardingModelStepProps) {
const { text, t, locale } = useOnboardingTranslation();
const { draft, advancedOpen, onDraftChange, fieldErrors = {}, phase } = props;
const id = useId();
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-Hans" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const locked = phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
// biome-ignore lint/correctness/useExhaustiveDependencies: Restore only after the controlled mode change has rendered its replacement toggle.
useEffect(() => {
if (!restoreToggleFocus.current) return;
restoreToggleFocus.current = false;
if (fields.current) fields.current.scrollTop = 0;
advancedToggle.current?.focus({ preventScroll: true });
}, [advancedOpen]);
function changeAdvanced(open: boolean) {
restoreToggleFocus.current = true;
props.onAdvancedChange(open);
}
const provider = props.providers.find(
(item) => item.id === draft.provider_id,
);
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
!!props.embeddingRecipient.trim() &&
provider?.supported;
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "save-and-enable" && !canEnable) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-draft" && !props.canSaveDraft) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test"
? props.onTest()
: action === "save-draft"
? props.onSaveDraft()
: props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
function field(
key: keyof OnboardingModelDraft,
label: string,
options: {
type?: string;
min?: number;
max?: number;
maxLength?: number;
required?: boolean;
placeholder?: string;
} = {},
) {
const error = fieldErrors[key];
return (
<label className="onboarding-model-field" htmlFor={`${id}-${key}`}>
<span>{text(label)}</span>
<input
id={`${id}-${key}`}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
autoComplete="off"
spellCheck={false}
value={String(draft[key])}
disabled={fieldsLocked}
aria-invalid={!!error}
aria-describedby={error ? `${id}-${key}-error` : undefined}
min={options.min}
max={options.max}
maxLength={options.maxLength}
required={options.required}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
onChange={(event) => {
if (!flight.current && !fieldsLocked)
onDraftChange({ [key]: event.target.value });
}}
/>
{error && (
<small id={`${id}-${key}-error`} className="onboarding-model-error">
{text(error)}
</small>
)}
</label>
);
}
const displayedPhase =
phase !== "idle"
? phase
: pending === "test"
? "testing"
: pending === "save-draft"
? "saving"
: pending
? "enabling"
: "idle";
const connectionFailed = props.probe?.state === "failed";
const pageTitle = advancedOpen
? "配置详情,按需要调整"
: connectionFailed
? "连接未通过,配置已保留"
: "选好模型,让记忆开始沉淀";
const connectionReceipt = props.connectionSummary?.includes("已启用")
? "连接设置已保存\n成功项保留,不重复执行。"
: props.connectionSummary === "持续同步 · 一键接入"
? "已选择:持续同步 · 一键接入"
: props.connectionSummary;
return (
<div
className="onboarding-model-r6"
data-integration={
props.capability.saveAndEnable
? "capability-provided"
: "not-integrated"
}
>
<R6ColdStartFrame
step={3}
eyebrow={text("沉淀记忆 · 使用你选择的模型")}
title={text(pageTitle)}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
{skipDestination && (
<span id={`${id}-skip-destination`} hidden>
{skipDestination}
</span>
)}
<R6Button
aria-describedby={
skipDestination ? `${id}-skip-destination` : undefined
}
disabled={locked}
onClick={() => {
if (!flight.current && !locked)
(props.onSkip ?? props.onBack)();
}}
>
{text("跳过,先进入应用")}
</R6Button>
<R6Button
disabled={locked || !props.canTest}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用"),
)}
</R6Button>
</div>
</>
}
>
<div className="onboarding-model-layout">
<section
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={advancedOpen}
>
<section
ref={fields}
id={`${id}-fields`}
className="onboarding-model-fields"
aria-label={text("模型参数")}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The parameter pane needs keyboard scrolling.
tabIndex={0}
>
{!advancedOpen ? (
<>
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>
{text("模型服务商")}
</span>
<OptionMenu
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={draft.provider_id}
disabled={fieldsLocked}
options={[
...(!provider
? [{ value: "", label: text("选择服务商") }]
: []),
...props.providers.map((item) => ({
value: item.id,
label: text(item.label),
disabled: !item.supported,
})),
]}
onChange={(value) => {
if (!flight.current)
onDraftChange({ provider_id: value });
}}
/>
</label>
<div className="onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
</div>
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={false}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(true)}
>
{text("配置详情与高级设置")}{" "}
<ChevronDown size={16} aria-hidden="true" />
</button>
<p className="onboarding-model-profile">
{provider ? text(provider.label) : text("服务商待选择")} ·{" "}
{draft.model || text("模型待填写")}
<br />
{text(safeEndpoint(draft.base_url))} ·{" "}
{draft.embedding_mode === "local"
? text("本地向量模型")
: draft.embedding_model || text("向量模型待填写")}{" "}
·{" "}
{text(draft.disable_thinking ? "思考已关闭" : "思考未关闭")}
</p>
</>
) : (
<>
<div className="onboarding-model-advanced-head">
<p>
{text("预设已填好,只有自定义服务或特殊需求才需要修改。")}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={true}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(false)}
>
{text("返回服务商选择")}{" "}
<ChevronUp size={16} aria-hidden="true" />
</button>
</div>
<div className="onboarding-model-advanced-grid">
{field("name", "配置名称", { maxLength: 128 })}
{field("model", "模型名称", {
required: true,
maxLength: 256,
})}
<div className="onboarding-model-wide">
{field("base_url", "Base URL", {
type: "url",
required: true,
})}
</div>
<div className="onboarding-model-wide onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
</div>
<label className="onboarding-model-field">
<span>{text("协议")}</span>
<select
aria-label={text("协议")}
value={draft.provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
provider_kind: e.target.value as ProviderKind,
});
}}
>
<option value="openai_compatible">
OpenAI-compatible
</option>
<option value="ollama">Ollama</option>
<option value="anthropic" disabled>
{text("Anthropic(暂不支持)")}
</option>
<option value="hy_default" disabled>
{text("HY 托管(当前不可用)")}
</option>
</select>
</label>
<label className="onboarding-model-field">
<span>{text("向量模型")}</span>
<select
aria-label={text("向量模型")}
value={draft.embedding_mode}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_mode: e.target.value as
| "local"
| "custom",
});
}}
>
<option value="local">
{text("本地 ONNX · 内置")}
</option>
<option value="custom">{text("自定义向量")}</option>
</select>
</label>
<div className="onboarding-model-numbers">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10000000,
placeholder: "自动 / 可选",
})}
</div>
<label className="onboarding-model-thinking">
<input
type="checkbox"
checked={draft.disable_thinking}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
disable_thinking: e.target.checked,
});
}}
/>
{text("关闭思考模式")}
</label>
<p className="onboarding-model-vector-note">
{text(
"自定义向量配置可展开;远程向量服务须遵守凭据边界。",
)}
<br />
{text("切换服务商或模型后,需要重新测试并启用。")}
</p>
{draft.embedding_mode === "custom" && (
<>
{field("embedding_model", "向量模型名称", {
required: true,
maxLength: 256,
})}
<label className="onboarding-model-field">
<span>{text("向量协议")}</span>
<select
aria-label={text("向量协议")}
value={draft.embedding_provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_provider_kind: e.target.value as
| "ollama"
| "openai_compatible",
});
}}
>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 Key)")}
</option>
<option value="ollama">
{text("本机 Ollama")}
</option>
</select>
</label>
<div className="onboarding-model-wide">
{field("embedding_base_url", "向量 Base URL", {
type: "url",
required: true,
})}
<small>
{text(
"远程向量地址须与生成服务同源;本机地址仍需校验。",
)}
</small>
</div>
</>
)}
</div>
</>
)}
</section>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation && (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
)}
{props.onDiscardMissingCreation && (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
)}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe && (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
)}
{(props.error || callbackFailed) && (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
)}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("本次沉淀")}
data-full-height={advancedOpen || connectionFailed}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the full permission disclosure.
tabIndex={0}
>
<div className="onboarding-model-summary-heading">
<p>{text("本次沉淀")}</p>
<h2>{text("让记忆从这里开始")}</h2>
</div>
<div className="onboarding-model-connections">
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<small>
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
</small>
</div>
<div className="onboarding-model-summary-status">
{connectionReceipt && (
<small className="onboarding-model-connection-receipt">
{text(connectionReceipt)}
</small>
)}
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
<br />
{text("相关对话会发送给所选服务商。")}
</small>
<small>
{text(
"测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。",
)}
</small>
</div>
{!props.capability.saveAndEnable && (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
)}
</aside>
</div>
</R6ColdStartFrame>
</div>
);
}
function safeEndpoint(value: string): string {
try {
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
return "接口地址待核对";
return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
} catch {
return "接口地址待核对";
}
}
export { OnboardingModelStepNext as OnboardingModelStep } from "./OnboardingModelStepNext";
@@ -0,0 +1,355 @@
import { useEffect, useId, useRef, useState } from "react";
import {
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
} from "./GenerationModelPrimitives";
import type {
OnboardingModelDraft,
OnboardingModelStepProps,
} from "./OnboardingModelStep";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
type Action = "test" | "save-and-enable";
export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
const { text, t } = useOnboardingTranslation();
const id = useId();
const modelRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLElement>(null);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
const provider = props.providers.find(
(candidate) => candidate.id === props.draft.provider_id,
);
const locked = props.phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
provider?.supported;
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
const firstError = (
[
"api_key",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
] as const
).find((key) => props.fieldErrors?.[key]);
const technicalError = firstError && firstError !== "api_key";
if (technicalError && !props.advancedOpen) {
props.onAdvancedChange(true);
return;
}
if (firstError && (!technicalError || props.advancedOpen)) {
formRef.current
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
?.focus({ preventScroll: true });
return;
}
if (props.advancedOpen && !props.draft.model.trim())
modelRef.current?.focus({ preventScroll: true });
}, [
props.advancedOpen,
props.draft.model,
props.fieldErrors,
props.onAdvancedChange,
]);
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-and-enable" && !canEnable) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test" ? props.onTest() : props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
const displayedPhase =
props.phase !== "idle"
? props.phase
: pending === "test"
? "testing"
: pending
? "enabling"
: "idle";
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
const runtimeErrors = Object.fromEntries(
Object.entries(props.fieldErrors ?? {}).filter(([key]) =>
[
"provider_kind",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(key),
),
) as Partial<Record<keyof OnboardingModelDraft, string>>;
return (
<div className="onboarding-model-r6">
<R6ColdStartFrame
step={3}
eyebrow={text("智能记忆 Pro · 配置记忆整理")}
title={text("配置记忆整理模型")}
focusToken={props.focusToken}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
<R6Button
disabled={locked}
onClick={() => {
if (!flight.current && !locked) props.onBack();
}}
>
{text("返回选择模式")}
</R6Button>
<R6Button
disabled={locked || !props.canTest || !provider?.supported}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试连接"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "启用并进入"),
)}
</R6Button>
</div>
</>
}
>
<div
className="onboarding-model-layout onboarding-model-layout--single-column-600"
data-responsive="single-column-600"
>
<section
ref={formRef}
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={props.advancedOpen}
>
<div className="onboarding-model-group">
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>{text("模型服务商")}</span>
<ProviderPresetField
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={props.draft.provider_id}
disabled={fieldsLocked}
text={text}
onChange={(provider_id) =>
props.onDraftChange({ provider_id })
}
/>
</label>
<label
className="onboarding-model-field onboarding-model-key"
htmlFor={`${id}-api-key`}
>
<span>
API Key{provider?.keyRequired ? "" : text("(可选)")}
</span>
<input
id={`${id}-api-key`}
name="api_key"
aria-label={`API Key${provider?.keyRequired ? "" : text("(可选)")}`}
type="password"
autoComplete="off"
value={props.draft.api_key}
disabled={fieldsLocked}
required={
provider?.keyRequired &&
!props.profile?.has_configured_api_key
}
aria-invalid={!!props.fieldErrors?.api_key}
aria-describedby={
props.fieldErrors?.api_key
? `${id}-api-key-error`
: `${id}-api-key-help`
}
placeholder={text(
props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
)}
onChange={(event) =>
props.onDraftChange({ api_key: event.target.value })
}
/>
<small id={`${id}-api-key-help`}>
{text(
props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</small>
{props.fieldErrors?.api_key ? (
<small
id={`${id}-api-key-error`}
className="generation-model-error"
>
{text(props.fieldErrors.api_key)}
</small>
) : null}
</label>
<div className="onboarding-model-default-summary">
<span>{text("默认模型")}</span>
<strong>
{generationSummary(
props.draft.provider_id,
props.draft.provider_kind,
props.draft.model,
text,
)}
</strong>
</div>
<div className="onboarding-model-disclosure">
<p>
{text("相关对话会发送给所选服务商。")}
{sourceSummary ? ` ${sourceSummary}` : ""}
</p>
<p>
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</p>
</div>
<GenerationTechnicalDisclosure
open={props.advancedOpen}
disabled={fieldsLocked}
controls={`${id}-technical`}
text={text}
onChange={props.onAdvancedChange}
>
<GenerationTechnicalFields
idPrefix={`${id}-generation`}
values={props.draft}
errors={runtimeErrors}
disabled={fieldsLocked}
text={text}
modelRef={modelRef}
onChange={(patch) =>
props.onDraftChange(patch as Partial<OnboardingModelDraft>)
}
/>
</GenerationTechnicalDisclosure>
</div>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation ? (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
) : null}
{props.onDiscardMissingCreation ? (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
) : null}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe ? (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
) : null}
{props.error || callbackFailed ? (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
) : null}
{!props.capability.saveAndEnable ? (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
) : null}
</div>
</section>
</div>
</R6ColdStartFrame>
</div>
);
}
+1 -1
View File
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
if (item.value !== props.value) props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
+14 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
import { LanguageSelector } from "./LanguageSelector";
import { R6LocalStorage, R6Stepper } from "./R6Controls";
import "./onboarding-r6.css";
@@ -9,13 +9,20 @@ export function R6ColdStartFrame({
eyebrow,
children,
footer,
focusToken,
}: {
step: 1 | 2 | 3;
title: string;
eyebrow: string;
children: ReactNode;
footer: ReactNode;
focusToken?: number;
}) {
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focusToken === undefined) return;
titleRef.current?.focus({ preventScroll: true });
}, [focusToken]);
return (
<main className="onboarding-r6" aria-label={title}>
{/* The source (143:3007 / 143:3008) puts a mark + "MemHub" on the
@@ -33,7 +40,12 @@ export function R6ColdStartFrame({
<header className="onboarding-r6-head">
<div>
<p>{eyebrow}</p>
<h1>{title}</h1>
<h1
ref={titleRef}
tabIndex={focusToken === undefined ? undefined : -1}
>
{title}
</h1>
</div>
<R6Stepper active={step} />
</header>
+1 -1
View File
@@ -40,7 +40,7 @@ export function R6Stepper({ active }: { active: 1 | 2 | 3 }) {
return (
<nav className="r6-stepper" aria-label={text("首次设置进度")}>
<ol>
{["导入历史", "连接应用", "沉淀记忆"].map((label, index) => (
{["导入历史", "连接应用", "记忆模式"].map((label, index) => (
<li
key={label}
aria-current={active === index + 1 ? "step" : undefined}
+47 -5
View File
@@ -21,6 +21,9 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
const editProfileAction = vi.fn(() => true);
const addProfileAction = vi.fn(() => true);
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -112,8 +115,8 @@ vi.mock("./ModelSettings", async () => {
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement(
@@ -304,6 +307,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
editProfileAction.mockReturnValueOnce(false);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
@@ -321,10 +325,13 @@ it("opens the model area even when a pending probe fences the editor, so recover
expect(alert.textContent).toContain("待核对");
// What changed is that the recovery entry no longer hides with the form it
// is supposed to unlock.
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
expect(
within(generationLane).getByRole("button", { name: "核对测试结果" }),
).toBeTruthy();
});
it("reaches the same recovery entry when no model has been configured yet", async () => {
addProfileAction.mockReturnValueOnce(false);
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
@@ -368,11 +375,46 @@ it("shows separate compact lanes and opens the requested presentation", async ()
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
expect(await within(embeddingLane).findByText("embedding")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(1);
expect(editProfileAction).toHaveBeenLastCalledWith(
expect.objectContaining({ profile_id: "profile-1" }),
);
expect(embeddingLane.dataset.expanded).toBe("true");
expect(
within(embeddingLane)
.getByRole("button", { name: "收起" })
.getAttribute("aria-expanded"),
).toBe("true");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
expect(await within(generationLane).findByText("generation")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(generationLane.dataset.expanded).toBe("true");
expect(within(embeddingLane).queryByText("embedding")).toBeNull();
await user.click(
within(generationLane).getByRole("button", { name: "收起" }),
);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(true);
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+187 -82
View File
@@ -78,6 +78,7 @@ export function R6Settings({
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -101,6 +102,10 @@ export function R6Settings({
const [sedimentationUnavailable, setSedimentationUnavailable] =
useState(false);
const modelActions = useRef<ModelSettingsActions>(null);
const configurationStarted = useRef(false);
const configurationStartedFocus = useRef<ModelSettingsFocusSection | null>(
null,
);
const quitPanel = useRef<HTMLElement>(null);
const quitOpener = useRef<HTMLElement | null>(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
@@ -224,18 +229,49 @@ export function R6Settings({
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
if (
configurationStarted.current &&
configurationStartedFocus.current === focusSection
) {
setConfigurationFocus(focusSection);
setConfigurationOpen(true);
return;
}
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
configurationStarted.current = true;
configurationStartedFocus.current = focusSection;
setConfigurationOpen(true);
setConfigurationRequest((value) => value + 1);
}
// The focused editor can move between the two lanes. Fill it only after
// React has mounted it in the requested lane, otherwise an edit sent to the
// old lane would be lost during the move.
// biome-ignore lint/correctness/useExhaustiveDependencies: configurationRequest is the explicit intent trigger.
useEffect(() => {
if (!configurationOpen || configurationRequest === 0) return;
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
// The area opens even when the editor refuses: the only recovery entry
// lives inside it, and hiding both is what locked people out of their own
// settings. They needed the form open to reach “核对测试结果”, and that
// button to clear the pending record that kept the form shut. The fence
// itself is untouched (see ModelSettings): refusing still returns false.
setConfigurationOpen(true);
// The area stays open even when the editor refuses: its recovery entry is
// the only way to reconcile the pending operation.
if (!opened) setConfigurationRefusal(true);
}, [configurationRequest]);
function toggleConfiguration(focusSection: ModelSettingsFocusSection) {
if (configurationOpen && configurationFocus === focusSection) {
setConfigurationOpen(false);
return;
}
openConfiguration(focusSection);
}
function completeConfiguration() {
configurationStarted.current = false;
configurationStartedFocus.current = null;
setConfigurationRefusal(false);
setConfigurationOpen(false);
}
async function quitApplication() {
@@ -384,67 +420,156 @@ export function R6Settings({
</div>
)}
<div className="r6-model-overview">
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "generation"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
aria-expanded={
configurationOpen && configurationFocus === "generation"
}
aria-controls={
configurationFocus === "generation"
? `${id}-generation-configuration`
: undefined
}
onClick={() => toggleConfiguration("generation")}
>
{configurationOpen && configurationFocus === "generation"
? t("wb.collapse")
: text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{configurationFocus === "generation" && (
<section
id={`${id}-generation-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "embedding"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !==
"local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
{currentModel && (
</strong>
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
aria-expanded={
configurationOpen && configurationFocus === "embedding"
}
aria-controls={
configurationFocus === "embedding"
? `${id}-embedding-configuration`
: undefined
}
onClick={() => toggleConfiguration("embedding")}
>
{configurationOpen && configurationFocus === "embedding"
? t("wb.collapse")
: text("配置")}
</R6Button>
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
{configurationFocus === "embedding" && (
<section
id={`${id}-embedding-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
@@ -456,13 +581,6 @@ export function R6Settings({
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
@@ -499,19 +617,6 @@ export function R6Settings({
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
/>
</section>
</section>
<section
id={`${id}-panel-connections`}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
function declaration(file: string, selector: string): string {
const css = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-choice-list",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
});
+5 -1
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
import { rawRecordId } from "./sdk-raw-map";
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
@@ -121,7 +122,7 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
) : null}
<ol>
{items.map((item) => (
<li key={item.raw_id}>
<li key={rawRecordId(item)}>
<p>{item.content}</p>
<small>
{item.source} · {item.session_id}
@@ -140,6 +141,9 @@ function asContextItem(item: SdkRawRecord): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
turn_id: item.turn_id,
turn_index: item.turn_index,
turn_count: item.turn_count,
content: item.content,
source: item.source,
session_id: item.session_id,
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { rawRecordTime } from "./sdk-raw-map";
import { rawRecordId, rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
@@ -50,7 +50,7 @@ export function SelectedRawDetail({
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
<small>{authorLabel(message.role, t)}</small>
<p>{message.content}</p>
</li>
+2
View File
@@ -32,6 +32,7 @@ it("lists raw conversations for the selected application", async () => {
session_id: undefined,
offset: 20,
limit: 20,
unit: "turn",
},
signal,
});
@@ -49,6 +50,7 @@ it("keeps the application filter when searching raw conversations", async () =>
query: "release plan",
kinds: ["raw"],
include_sources: ["claude-code"],
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
+25 -1
View File
@@ -37,6 +37,7 @@ import {
type ModelProfile,
type ModelProfileInput,
type ModelProfilePatch,
type OnboardingProgressUpdate,
type OnboardingState,
type SdkMemoryKind,
type SdkMemoryPage,
@@ -478,6 +479,26 @@ export async function getOnboarding(): Promise<OnboardingState> {
return response.data;
}
export async function patchOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.patch<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function putOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.put<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function completeOnboarding(
traceId?: string,
): Promise<OnboardingState> {
@@ -965,16 +986,18 @@ export async function getSdkRaw({
sessionId,
offset = 0,
limit = 100,
unit = "turn",
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
unit?: "raw" | "turn";
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, session_id: sessionId, offset, limit },
params: { source, session_id: sessionId, offset, limit, unit },
signal,
});
return response.data;
@@ -990,6 +1013,7 @@ export async function searchSdkRaw(
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
+15 -2
View File
@@ -132,12 +132,17 @@ button svg {
content: "";
position: absolute;
right: 18px;
top: 22px;
top: 50%;
width: 8px;
height: 8px;
border-right: 1.5px solid #4e6478;
border-bottom: 1.5px solid #4e6478;
transform: rotate(45deg);
transform: translateY(-65%) rotate(45deg);
transform-origin: 50% 50%;
transition: transform 150ms ease-out;
}
.om-option-menu-button[aria-expanded="true"]::after {
transform: translateY(-35%) rotate(225deg);
}
.om-option-menu-button:disabled {
color: #66727b;
@@ -981,6 +986,14 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
+4 -3
View File
@@ -38,7 +38,7 @@ const base = {
describe("home view model", () => {
it.each([
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
["lite", "raw", "apps.rawConversations", "apps.rawSavingCompact"],
["pro", "memory", "apps.memories", "apps.running"],
["ultra", "memory", "apps.memories", "apps.running"],
] as const)(
@@ -109,7 +109,7 @@ describe("home view model", () => {
} as never,
});
expect(result.recentMetric).toEqual({
valueKey: "apps.rawCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity).toMatchObject({
@@ -158,9 +158,10 @@ describe("home view model", () => {
projectionState: "ready",
});
expect(result.recentMetric).toEqual({
valueKey: "apps.memoryCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
expect(result.activity.kind).toBe("empty");
});
+3 -10
View File
@@ -40,11 +40,7 @@ export interface HomeViewModel {
emptyExcerpt: boolean;
} | null;
recentMetric: {
valueKey:
| "apps.checkingRecent"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| null;
valueKey: "apps.checkingRecent" | "apps.recentEmptyCompact" | null;
detailKey:
| "apps.rawSaved"
| "apps.saved"
@@ -120,7 +116,7 @@ export function buildHomeViewModel(input: {
const sedimenting = input.sedimentation?.enabled;
const processingLabel =
!generationRequired || mode === "lite"
? "apps.rawSaving"
? "apps.rawSavingCompact"
: sedimenting === true
? "apps.running"
: sedimenting === false
@@ -179,10 +175,7 @@ export function buildHomeViewModel(input: {
}
: state === "ready" || state === "stale"
? {
valueKey:
plane === "raw"
? ("apps.rawCheckedEmpty" as const)
: ("apps.memoryCheckedEmpty" as const),
valueKey: "apps.recentEmptyCompact" as const,
detailKey: "apps.checkedRecent" as const,
}
: {
+75
View File
@@ -119,6 +119,81 @@
margin: 0;
}
.model-settings .generation-technical-disclosure {
grid-column: 1 / -1;
border-block: 1px solid var(--bd, #d3d9e0);
padding-block: 4px;
}
.model-settings .generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
color: #4e6478;
}
.model-settings .generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-block: 12px;
}
.model-settings .generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
}
.model-settings .generation-model-field:has([name="base-url"]),
.model-settings .generation-runtime-fields,
.model-settings .generation-thinking {
grid-column: 1 / -1;
}
.model-settings .generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.model-settings .generation-technical-fields[hidden] {
display: none;
}
.model-settings .generation-runtime-note {
grid-column: 1 / -1;
color: #63707b;
font-size: 12px;
line-height: 1.5;
}
.model-settings .generation-thinking {
display: flex;
align-items: center;
gap: 8px;
}
.model-settings .generation-model-error {
color: #96453e;
}
@media (max-width: 600px) {
.model-settings .generation-technical-fields,
.model-settings .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.model-settings .generation-technical-toggle,
.model-settings .generation-model-field :is(input, select) {
transition: none;
}
}
.model-settings
:is(
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select
):focus-visible {
outline: 2px solid #4e6478;
outline-offset: 3px;
}
.model-settings .model-saved-card .source-actions,
.model-settings .preset-profile .source-actions {
flex-wrap: wrap;
+189
View File
@@ -587,3 +587,192 @@
) {
transition: none;
}
/* Generation-only cold start. Embedding remains a separate settings lane. */
.onboarding-model-r6 .onboarding-r6-head > div {
width: min(760px, 100%);
}
.onboarding-model-layout {
display: block;
width: min(760px, 100%);
height: auto;
margin-inline: auto;
}
.onboarding-model-form {
overflow: visible;
}
.onboarding-model-group {
display: grid;
gap: 20px;
padding: 24px;
border: 1px solid #d8e2e8;
border-radius: 14px;
background: #fff;
}
.onboarding-model-provider,
.onboarding-model-key {
margin-top: 0;
}
.onboarding-model-field {
gap: 7px;
}
.onboarding-model-field :is(input, select),
.onboarding-model-field .om-option-menu-button,
.generation-model-field :is(input, select) {
width: 100%;
min-width: 0;
height: 44px;
padding: 9px 12px;
border: 1px solid #c8d3db;
border-radius: 8px;
background: #fff;
color: #262a2e;
font:
400 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
}
.onboarding-model-field small,
.generation-model-field small {
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.onboarding-model-default-summary {
display: grid;
gap: 6px;
padding: 14px 0;
border-block: 1px solid #e4eaee;
}
.onboarding-model-default-summary span,
.onboarding-model-disclosure {
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.onboarding-model-default-summary strong {
color: #33424d;
font-size: 14px;
font-weight: 600;
}
.onboarding-model-disclosure {
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
border-top: 1px solid #e4eaee;
padding-top: 4px;
}
.generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border: 0;
background: transparent;
color: #4e6478;
font:
500 13px / 21px "Noto Sans SC",
"PingFang SC",
sans-serif;
}
.generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-top: 12px;
}
.generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base-url"]),
.generation-runtime-fields,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.generation-technical-fields[hidden] {
display: none;
}
.generation-runtime-note {
grid-column: 1 / -1;
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.generation-thinking {
display: flex;
min-height: 32px;
align-items: center;
gap: 9px;
color: #416e5c;
font-size: 13px;
}
.generation-model-error {
color: #a24646 !important;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
}
.onboarding-model-feedback {
margin-top: 8px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: auto;
}
.onboarding-model-form {
display: block;
overflow: visible;
}
}
@media (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
padding: 16px;
border-radius: 10px;
}
.onboarding-model-layout--single-column-600 .generation-technical-fields,
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-footer-actions .r6-button,
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.generation-technical-toggle,
.generation-model-field :is(input, select) {
transition: none;
}
}
[data-native-reduce-motion="true"]
:is(
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select
) {
transition: none;
}
.onboarding-model-r6
:is(
.om-option-menu-button,
.generation-technical-toggle,
.generation-model-field input,
.generation-model-field select,
.onboarding-model-field input
):focus-visible {
outline: 2px solid #557c93;
outline-offset: 3px;
}
+19 -79
View File
@@ -76,7 +76,6 @@
text-overflow: ellipsis;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
@@ -582,89 +581,36 @@
.onboarding-r6-mode-intro {
text-align: center;
}
.onboarding-r6-mode-intro h2 {
margin: 0;
color: #263947;
font-size: 20px;
line-height: 30px;
}
.onboarding-r6-mode-intro p {
margin: 8px 0 0;
margin: 0;
color: #637381;
font-size: 14px;
line-height: 22px;
}
.onboarding-r6-mode-options {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.onboarding-r6-mode-card {
min-height: 136px;
padding: 20px;
border: 1px solid #c9d5de;
border-radius: 10px;
.onboarding-r6-mode .memory-mode-choice-list {
background: rgb(255 255 255 / 72%);
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 14px;
transition:
border-color var(--r6-fast, 140ms),
background var(--r6-fast, 140ms),
box-shadow var(--r6-fast, 140ms);
}
.onboarding-r6-mode-card:hover {
border-color: #8299aa;
background: rgb(255 255 255 / 76%);
.onboarding-r6-mode .memory-mode-choice {
min-height: 82px;
padding: 15px 18px;
}
.onboarding-r6-mode-card[data-selected="true"] {
border-color: #557c93;
background: #edf3f6;
box-shadow: 0 0 0 2px rgb(85 124 147 / 10%);
.onboarding-r6-mode .memory-mode-choice-copy {
gap: 4px;
}
.onboarding-r6-mode-card:has(input:focus-visible) {
outline: 2px solid #557c93;
outline-offset: 3px;
.onboarding-r6-mode .memory-mode-choice-copy strong {
font-size: 16px;
line-height: 25px;
}
.onboarding-r6-mode-card input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 4px 0 0;
accent-color: #557c93;
.onboarding-r6-mode .memory-mode-choice-copy small {
font-size: 13px;
line-height: 22px;
}
.onboarding-r6-mode-copy {
min-width: 0;
color: #5f6e79;
font-size: 14px;
line-height: 23px;
display: flex;
flex-direction: column;
gap: 10px;
}
.onboarding-r6-mode-heading {
display: flex;
align-items: center;
gap: 9px;
}
.onboarding-r6-mode-heading strong {
color: #263947;
font-size: 17px;
line-height: 26px;
}
.onboarding-r6-mode-heading > span {
padding: 2px 8px;
border-radius: 999px;
color: #41677e;
background: #dceaf2;
font-size: 11px;
line-height: 18px;
font-weight: 600;
.onboarding-r6-mode-ultra-note {
margin: -6px 0 0;
color: #637381;
font-size: 12px;
line-height: 20px;
text-align: center;
}
.onboarding-r6-mode-error {
min-height: 20px;
@@ -811,12 +757,6 @@
.onboarding-r6-body {
padding: 24px 20px;
}
.onboarding-r6-mode-options {
grid-template-columns: 1fr;
}
.onboarding-r6-mode-card {
min-height: 0;
}
.onboarding-r6-discovery {
height: auto;
display: flex;
+113 -1
View File
@@ -11,6 +11,109 @@
--r6-fast: 140ms;
}
.memory-mode-choice-list {
min-width: 0;
overflow: hidden;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.memory-mode-choice {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 74px;
padding: 12px 16px;
color: var(--r6-text);
cursor: pointer;
transition:
background-color var(--r6-fast),
border-color var(--r6-fast);
}
.memory-mode-choice + .memory-mode-choice {
border-top: 1px solid #d8e2ea;
}
.memory-mode-choice:hover:not([data-disabled="true"]) {
background: #f6f9fa;
}
.memory-mode-choice[data-selected="true"] {
background: #edf3f6;
}
.memory-mode-choice[data-disabled="true"] {
color: #71808b;
cursor: default;
}
.memory-mode-choice:focus-within {
outline: 2px solid var(--r6-focus);
outline-offset: -2px;
}
.memory-mode-choice input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--r6-action);
}
.memory-mode-choice input:focus-visible {
outline: 0;
}
.memory-mode-choice-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.memory-mode-choice-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.memory-mode-choice-copy strong {
color: var(--r6-text);
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.memory-mode-choice-copy small {
color: var(--r6-muted);
font-size: 12px;
line-height: 20px;
overflow-wrap: normal;
word-break: normal;
}
.memory-mode-choice-badge {
padding: 1px 7px;
border-radius: 999px;
color: #41677e;
background: #dceaf2;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
.memory-mode-choice-current {
align-self: start;
color: #405e75;
font-size: 12px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
@@ -340,6 +443,7 @@ button.r6-switch:focus-visible,
.r6-button-spinner {
animation: none;
}
.memory-mode-choice,
button.r6-button,
.r6-switch-track,
.r6-switch-thumb {
@@ -366,9 +470,17 @@ button.r6-switch:focus-visible,
backdrop-filter: none;
}
[data-native-reduce-motion="true"]
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
:is(.memory-mode-choice, .r6-button, .r6-switch-track, .r6-switch-thumb) {
transition: none;
}
@media (max-width: 700px) {
.memory-mode-choice {
grid-template-columns: 20px minmax(0, 1fr);
}
.memory-mode-choice-current {
grid-column: 2;
}
}
[data-native-reduce-motion="true"] :is(.r6-stepper li, .r6-stepper li::after) {
transition: none;
}
+54 -78
View File
@@ -66,7 +66,6 @@
text-overflow: ellipsis;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
@@ -228,70 +227,9 @@
font-size: 13px;
line-height: 22px;
}
.r6-mode-choice-list {
max-width: 760px;
overflow: hidden;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.r6-mode-choice {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 74px;
padding: 12px 16px;
color: #304757;
cursor: pointer;
}
.r6-mode-choice + .r6-mode-choice {
border-top: 1px solid #d8e2ea;
}
.r6-mode-choice[data-current="true"] {
background: #edf3f6;
}
.r6-mode-choice[data-disabled="true"] {
color: #71808b;
cursor: default;
}
.r6-mode-choice:focus-within {
outline: 2px solid var(--r6-focus);
outline-offset: -2px;
}
.r6-mode-choice input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--r6-action);
}
.r6-mode-choice input:focus-visible {
outline: 0;
}
.r6-mode-choice-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.r6-mode-choice-copy strong {
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.r6-mode-choice-copy small {
color: #5e6e7a;
font-size: 12px;
line-height: 20px;
overflow-wrap: normal;
word-break: normal;
}
.r6-mode-current {
align-self: start;
color: #405e75;
font-size: 12px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
.r6-memory-mode-settings .memory-mode-choice-list {
width: 100%;
max-width: none;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -544,7 +482,8 @@
text-wrap: pretty;
}
.r6-settings .connection-fallback .custom-harness-name {
max-width: 560px;
width: 100%;
max-width: none;
gap: 8px;
margin: 0 0 14px;
color: #4f6373;
@@ -658,6 +597,12 @@
background: #fff;
}
.r6-settings .r6-model-lane {
min-height: 108px;
}
.r6-settings .r6-model-lane[data-expanded="true"] {
background: #f8fafb;
}
.r6-settings .r6-model-lane-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -701,7 +646,7 @@
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
@@ -752,8 +697,9 @@
line-height: 28px;
}
.r6-settings .r6-model-owner {
padding-top: 24px;
padding: 24px 20px;
border-top: 1px solid #d8e2ea;
background: #f8fafb;
}
.r6-settings .r6-model-owner[hidden] {
display: none;
@@ -909,7 +855,6 @@
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
@@ -928,6 +873,39 @@
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-option-label {
display: block;
margin: 0;
}
.model-option-menu-panel {
padding: 6px;
border-color: var(--r6-control-edge, #c0ccd5);
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.model-option-menu-panel [role="option"] {
min-height: 40px;
padding: 8px 10px;
color: #304757;
font-size: 14px;
line-height: 22px;
}
.model-option-menu-panel [role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"]:hover:not(:disabled) {
color: #304757;
background: #e9f0f4;
}
.model-option-menu-panel [role="option"]:active:not(:disabled) {
background: #d4e2ea;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
@@ -946,7 +924,8 @@
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
.r6-settings .model-advanced label,
.r6-settings .model-advanced .model-form-field {
display: flex;
flex-direction: column;
gap: 8px;
@@ -1050,24 +1029,21 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
.r6-settings .r6-model-lane-header {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
justify-self: start;
}
.r6-settings .r6-model-owner {
padding: 20px 16px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.r6-mode-choice {
grid-template-columns: 20px minmax(0, 1fr);
}
.r6-mode-current {
grid-column: 2;
}
.r6-evolve-setting,
.r6-evolve-time,
.r6-mode-confirm-effects > div {
+24
View File
@@ -76,4 +76,28 @@ describe("raw map projection", () => {
history: [],
});
});
it("uses a turn identity while retaining the parent raw id", () => {
const turn = {
...record,
raw_id: "raw-batch",
turn_id: "raw-turn:batch:1",
turn_index: 1,
turn_count: 2,
content: "[user]: second question\n[assistant]: second answer",
messages: [
{ role: "user", content: "second question" },
{ role: "assistant", content: "second answer" },
],
};
expect(
rawLibraryPage({ items: [turn], total: 2, offset: 0, limit: 20 }),
).toMatchObject({
items: [{ memory_id: "raw-turn:batch:1", content: turn.content }],
source_ids_by_memory: { "raw-turn:batch:1": ["cursor"] },
});
expect(rawDetail(turn).memory.memory_id).toBe("raw-turn:batch:1");
expect(turn.raw_id).toBe("raw-batch");
});
});
+9 -4
View File
@@ -9,6 +9,10 @@ import type {
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
}
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
@@ -43,7 +47,7 @@ function sourceOf(record: RawMapRecord): string {
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
const created = rawRecordTime(record) ?? "";
return {
memory_id: record.raw_id,
memory_id: rawRecordId(record),
user_id: "user_id" in record ? record.user_id : "local",
kind: "chat",
status: "confirmed",
@@ -75,17 +79,18 @@ function pageFor(
{ at: string; basis: "conversation" | "import" }
> = {};
for (const record of records) {
sourceIds[record.raw_id] = [sourceOf(record)];
const recordId = rawRecordId(record);
sourceIds[recordId] = [sourceOf(record)];
const conversationAt = isoTime(record.memory_at);
const importedAt =
"gmt_created" in record ? isoTime(record.gmt_created) : "";
if (conversationAt)
sourceTime[record.raw_id] = {
sourceTime[recordId] = {
at: conversationAt,
basis: "conversation",
};
else if (importedAt)
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
sourceTime[recordId] = { at: importedAt, basis: "import" };
}
return {
items: records.map(rawMapMemory),
-1
View File
@@ -97,7 +97,6 @@
border-color: transparent;
}
.selected-source-filter .om-option-menu-button::after {
top: 18px;
right: 4px;
}
.selected-source-filter details {
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 39)的发行目标如下:
MemHub `v1.0.6`(build 40)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -12,7 +12,7 @@ memory route. `user_id` is optional (default `local`) and must match
| Mode | SDK client | What happens to a conversation | What search reads |
|---|---|---|---|
| `lite` | `mode="lite"` | Stored as one raw turn with one embedding. No model call; no generation model needs to be configured. | Raw turns only (the SDK forces `L1_RAW`). |
| `lite` | `mode="lite"` | Stored in embedded L1 raw batches. No model call; no generation model needs to be configured. The product page projects each batch back into individual conversation turns. | Raw turns only (the SDK forces `L1_RAW`). |
| `pro` (default) | `mode="pro"` | Stored as raw, then a model extracts memories (System 1). The raw becomes SHADOW once extraction succeeds. | Extracted memories (facts, profile, intentions). |
| `ultra` | `mode="ultra"` | Same as pro, and new facts are also projected into the knowledge graph. Every night, evolve runs System 2 for every user. | Same as pro, plus the graph-backed lanes below. |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 39,
"build_number": 40,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+5
View File
@@ -87,7 +87,12 @@ export interface ContextSearchResponse {
export interface SdkRawContextItem {
kind: "raw";
/** Parent SDK L1 raw storage batch. */
raw_id: string;
/** Stable product identity of one turn inside `raw_id`, when requested. */
turn_id?: string | null;
turn_index?: number | null;
turn_count?: number | null;
content: string;
source: string;
session_id: string;
@@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";
import {
CORE_SERVICE,
type HomeProjectionV1,
type ModelProfileInput,
type OnboardingProgressUpdate,
type OnboardingState,
type RecallReceipt,
} from "./index";
@@ -70,4 +73,40 @@ describe("Core contract", () => {
};
expect(projection.latest?.type).toBe("raw");
});
it("types resumable onboarding progress without secret fields", () => {
const update: OnboardingProgressUpdate = {
mode_intent: "pro",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
};
const state: OnboardingState = {
status: "not_started",
selected_source_ids: [],
started_at: null,
completed_at: null,
...update,
mode_intent: update.mode_intent ?? null,
setup_step: update.setup_step ?? null,
model_profile_id: update.model_profile_id ?? null,
};
// @ts-expect-error onboarding progress does not carry credentials.
const secret: OnboardingProgressUpdate = { api_key: "must-not-compile" };
expect(state.setup_step).toBe("model");
expect(secret).toEqual({ api_key: "must-not-compile" });
});
it("allows Core to own omitted embedding and runtime defaults", () => {
const input: ModelProfileInput = {
name: "DeepSeek",
provider_kind: "openai_compatible",
base_url: "https://api.deepseek.com",
model: "deepseek-flash",
};
expect(input.embedding_model).toBeUndefined();
expect(input.timeout_seconds).toBeUndefined();
expect(input.max_concurrency).toBeUndefined();
expect(input.context_window).toBeUndefined();
});
});
+18
View File
@@ -259,11 +259,23 @@ export interface ConnectSourcesResponse {
request_id?: string | null;
}
export type OnboardingModeIntent = "lite" | "pro";
export type OnboardingSetupStep = "review" | "mode" | "model";
export interface OnboardingProgressUpdate {
mode_intent?: OnboardingModeIntent | null;
setup_step?: OnboardingSetupStep | null;
model_profile_id?: string | null;
}
export interface OnboardingState {
status: "not_started" | "importing" | "completed";
selected_source_ids: string[];
started_at: string | null;
completed_at: string | null;
mode_intent: OnboardingModeIntent | null;
setup_step: OnboardingSetupStep | null;
model_profile_id: string | null;
}
export interface ImportJob {
@@ -663,7 +675,12 @@ export interface SdkUserProfileResponse {
}
export interface SdkRawRecord {
/** Parent SDK L1 raw storage batch. */
raw_id: string;
/** Stable product identity of one turn inside `raw_id`, when unit=turn. */
turn_id?: string | null;
turn_index?: number | null;
turn_count?: number | null;
content: string;
user_id: string;
agent_id: string;
@@ -691,6 +708,7 @@ export interface SdkRawPage {
total: number;
limit: number;
offset: number;
unit?: "raw" | "turn";
/** A time range was applied to this page in Core (the SDK list has none). */
time_filtered?: boolean;
}
@@ -9,6 +9,7 @@ export const applicationsChineseMessages = {
"apps.memories": "已沉淀记忆",
"apps.rawConversations": "原始对话",
"apps.rawSaving": "正在保存原始对话",
"apps.rawSavingCompact": "正在保存",
"apps.rawSavingStatus": "保存状态",
"apps.rawSavingDetail": "原始对话持续保存,不需要生成模型",
"apps.rawNoExcerpt": "这条对话没有可显示的用户原文",
@@ -17,6 +18,7 @@ export const applicationsChineseMessages = {
"apps.rawEmpty": "还没有保存的原始对话",
"apps.rawCheckedEmpty": "已核对,暂无可显示的原始对话更新",
"apps.memoryCheckedEmpty": "已核对,暂无可显示的记忆更新",
"apps.recentEmptyCompact": "暂无更新",
"apps.rawWaiting": "原始对话保存后会显示在这里",
"apps.checkedRecent": "已核对当前内容",
"apps.currentMode": "当前模式",
@@ -149,6 +151,7 @@ export const applicationsSpanishMessages: Record<
"apps.memories": "Memorias guardadas",
"apps.rawConversations": "Conversaciones originales",
"apps.rawSaving": "Guardando",
"apps.rawSavingCompact": "Guardando",
"apps.rawSavingStatus": "Estado de guardado",
"apps.rawSavingDetail":
"Las conversaciones originales se guardan sin un modelo generativo",
@@ -160,6 +163,7 @@ export const applicationsSpanishMessages: Record<
"Comprobado: no hay actualizaciones de conversaciones para mostrar",
"apps.memoryCheckedEmpty":
"Comprobado: no hay actualizaciones de memorias para mostrar",
"apps.recentEmptyCompact": "Sin novedades",
"apps.rawWaiting":
"Las conversaciones originales aparecerán aquí cuando se guarden",
"apps.checkedRecent": "Contenido actual comprobado",
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。",
"已经处理完的旧对话不会补做;原始对话全部保留。",
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
"知识图谱",
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
"已有记忆会在后台加入知识图谱,期间仍可使用。",
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"每天自动整合会停止,已经生成的数据不会删除。",
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。",
] as const;
@@ -1277,6 +1277,16 @@ const baseEnglishMessages = {
"Connection failed. Check the model, key, and recipient.",
"配置或测试结果暂未确认,请核对后重试。":
"Could not confirm the configuration or test result. Check before retrying.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"The index update did not complete. The previous search model is still in use. Check the connection and try again.",
"上次模型测试结果仍待核对,请稍后重试。":
"The previous model test result still needs review. Try again later.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"Model writes are temporarily paused. Wait for index maintenance to finish, then try again.",
"模型未能确认启用,请在设置中核对。":
"Could not confirm that the model was enabled. Check it in Settings.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"The saved model configuration is temporarily unavailable. No duplicate will be created. Try refreshing again.",
所选连接已启用: "Selected app connections are enabled",
本次未选择应用连接: "No app connection selected this time",
尚未填写接收方: "No recipient entered",
@@ -1335,6 +1345,7 @@ const baseEnglishMessages = {
"apps.memories": "Saved memories",
"apps.rawConversations": "Original conversations",
"apps.rawSaving": "Saving",
"apps.rawSavingCompact": "Saving",
"apps.rawSavingStatus": "Saving status",
"apps.rawSavingDetail":
"Original conversations are saved without a generation model",
@@ -1345,6 +1356,7 @@ const baseEnglishMessages = {
"apps.rawCheckedEmpty":
"Checked: no original-conversation updates to display",
"apps.memoryCheckedEmpty": "Checked: no memory updates to display",
"apps.recentEmptyCompact": "No updates",
"apps.rawWaiting": "Original conversations will appear here when saved",
"apps.checkedRecent": "Current content checked",
"apps.currentMode": "Current mode",
@@ -24,6 +24,8 @@ const ENGLISH_EQUIVALENT_ALLOWED = new Set<MessageKey>([
const keys = Object.keys(chineseMessages) as MessageKey[];
const rawSavingStatusKey = "apps.rawSavingStatus" satisfies MessageKey;
const rawSavingCompactKey = "apps.rawSavingCompact" satisfies MessageKey;
const recentEmptyCompactKey = "apps.recentEmptyCompact" satisfies MessageKey;
const rawSavingStatusTranslations = {
en: "Saving status",
@@ -39,6 +41,23 @@ const rawSavingStatusTranslations = {
id: "Status penyimpanan",
} as const satisfies Record<(typeof SUPPORTED_LOCALES)[number], string>;
const compactHomeTranslations = {
en: ["Saving", "No updates"],
"zh-Hans": ["正在保存", "暂无更新"],
"zh-Hant": ["儲存中", "暫無更新"],
ja: ["保存中", "更新なし"],
es: ["Guardando", "Sin novedades"],
de: ["Wird gespeichert", "Keine Updates"],
pt: ["Salvando", "Sem atualizações"],
fr: ["Enregistrement", "Aucune mise à jour"],
ko: ["저장 중", "업데이트 없음"],
ru: ["Сохранение", "Нет обновлений"],
id: ["Menyimpan", "Belum ada pembaruan"],
} as const satisfies Record<
(typeof SUPPORTED_LOCALES)[number],
readonly [string, string]
>;
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
const catalog = guiMessages[locale];
@@ -100,6 +119,14 @@ describe("applications catalog", () => {
rawSavingStatusTranslations[locale],
);
});
it("keeps large home metric copy compact in all 11 locales", () => {
for (const locale of SUPPORTED_LOCALES) {
const [saving, empty] = compactHomeTranslations[locale];
expect(guiMessages[locale][rawSavingCompactKey]).toBe(saving);
expect(guiMessages[locale][recentEmptyCompactKey]).toBe(empty);
}
});
});
describe("embedding switch catalog", () => {
@@ -137,6 +164,34 @@ describe("embedding switch catalog", () => {
});
});
describe("onboarding model controller catalog", () => {
const controllerKeys = [
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。",
"上次模型测试结果仍待核对,请稍后重试。",
"模型写入暂时关闭。请等待索引维护完成后重试。",
"模型未能确认启用,请在设置中核对。",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。",
] as const satisfies readonly MessageKey[];
it("is complete in all 11 locales", () => {
for (const locale of SUPPORTED_LOCALES)
for (const key of controllerKeys)
expect(
Object.hasOwn(guiMessages[locale], key) &&
Boolean(guiMessages[locale][key]?.trim()),
`${locale} ${key}`,
).toBe(true);
});
it("has no Simplified Chinese fallback outside zh-Hans", () => {
for (const locale of SUPPORTED_LOCALES) {
if (locale === "zh-Hans") continue;
for (const key of controllerKeys)
expect(guiMessages[locale][key], `${locale} ${key}`).not.toBe(key);
}
});
});
describe("fallback", () => {
it("falls back to English when a locale lacks a key", () => {
const key = "languageTitle";
+178 -1
View File
@@ -1,7 +1,21 @@
const modeChineseMessages = {
"智能记忆 Pro · 配置记忆整理": "智能记忆 Pro · 配置记忆整理",
配置记忆整理模型: "配置记忆整理模型",
返回选择模式: "返回选择模式",
启用并进入: "启用并进入",
默认模型: "默认模型",
"留空使用 Core 默认值。": "留空使用 Core 默认值。",
"模式未能保存。你的选择已保留,请重试。":
"模式未能保存。你的选择已保留,请重试。",
选择记忆方式: "选择记忆方式",
选择记忆模式: "选择记忆模式",
"之后可在设置中更改。": "之后可在设置中更改。",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra 可在完成首次设置后到设置页开启。",
"使用 Lite 进入": "使用 Lite 进入",
继续配置模型: "继续配置模型",
"下一步选择记忆模式;之后仍可在设置中更改。":
"下一步选择记忆模式;之后仍可在设置中更改。",
"最后一步 · 决定如何处理对话": "最后一步 · 选择记忆模式",
返回导入: "返回导入",
继续: "继续",
@@ -33,7 +47,7 @@ const modeChineseMessages = {
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
"每天自动整合会停止,已经生成的数据不会删除。":
"自动整合会停止,已有数据保留。",
"每天自动整合会停止;已生成的整合结果继续保留。",
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
"已有高级记忆将在后台加入知识图谱。",
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
@@ -83,6 +97,7 @@ const modeChineseMessages = {
模型调用与费用: "模型调用与费用",
搜索范围: "搜索范围",
已有数据: "已有数据",
知识图谱: "知识图谱",
切换后: "切换后",
"之后只保存原始对话,不再生成新记忆。":
"之后只保存原始对话,不再生成新记忆。",
@@ -173,9 +188,24 @@ export type ModeMessageKey = keyof typeof modeChineseMessages;
type ModeMessages = Record<ModeMessageKey, string>;
export const modeEnglishMessages: ModeMessages = {
"智能记忆 Pro · 配置记忆整理":
"Smart memory Pro · Configure memory organization",
配置记忆整理模型: "Configure the memory organization model",
返回选择模式: "Back to mode selection",
启用并进入: "Enable and continue",
默认模型: "Default model",
"留空使用 Core 默认值。": "Leave blank to use Core defaults.",
"模式未能保存。你的选择已保留,请重试。":
"Could not save the mode. Your selection is retained; please retry.",
选择记忆方式: "Choose how memory works",
选择记忆模式: "Choose memory mode",
"之后可在设置中更改。": "You can change this later in Settings.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra can be enabled in Settings after initial setup.",
"使用 Lite 进入": "Enter with Lite",
继续配置模型: "Continue to model setup",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Next, choose a memory mode. You can change it later in Settings.",
"最后一步 · 决定如何处理对话":
"Final step · Choose how conversations are handled",
返回导入: "Back to import",
@@ -266,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
模型调用与费用: "Model calls and cost",
搜索范围: "Search scope",
已有数据: "Existing data",
知识图谱: "Knowledge graph",
切换后: "After switching",
"之后只保存原始对话,不再生成新记忆。":
"New conversations are saved as original text only; no new memories are created.",
@@ -360,9 +391,25 @@ export const modeEnglishMessages: ModeMessages = {
};
export const modeSpanishMessages: ModeMessages = {
"智能记忆 Pro · 配置记忆整理":
"Memoria inteligente Pro · Configurar la organización",
配置记忆整理模型: "Configurar el modelo de organización de recuerdos",
返回选择模式: "Volver a elegir el modo",
启用并进入: "Habilitar y continuar",
默认模型: "Modelo predeterminado",
"留空使用 Core 默认值。":
"Déjalo vacío para usar los valores predeterminados de Core.",
"模式未能保存。你的选择已保留,请重试。":
"No se pudo guardar el modo. Tu selección se conserva; reinténtalo.",
选择记忆方式: "Elegir cómo funciona la memoria",
选择记忆模式: "Elegir el modo de memoria",
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
"Ultra 可在完成首次设置后到设置页开启。":
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
"使用 Lite 进入": "Entrar con Lite",
继续配置模型: "Continuar con la configuración del modelo",
"下一步选择记忆模式;之后仍可在设置中更改。":
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
"最后一步 · 决定如何处理对话":
"Último paso · Decide cómo tratar las conversaciones",
返回导入: "Volver a la importación",
@@ -453,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
模型调用与费用: "Llamadas al modelo y coste",
搜索范围: "Ámbito de búsqueda",
已有数据: "Datos existentes",
知识图谱: "Grafo de conocimiento",
切换后: "Después del cambio",
"之后只保存原始对话,不再生成新记忆。":
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
@@ -548,9 +596,23 @@ export const modeSpanishMessages: ModeMessages = {
export const modeTraditionalChineseMessages: ModeMessages = {
...modeChineseMessages,
"智能记忆 Pro · 配置记忆整理": "智慧記憶 Pro · 設定記憶整理",
配置记忆整理模型: "設定記憶整理模型",
返回选择模式: "返回選擇模式",
启用并进入: "啟用並繼續",
默认模型: "預設模型",
"留空使用 Core 默认值。": "留空以使用 Core 預設值。",
"模式未能保存。你的选择已保留,请重试。":
"模式無法儲存。你的選擇已保留,請重試。",
选择记忆方式: "選擇記憶方式",
选择记忆模式: "選擇記憶模式",
"之后可在设置中更改。": "之後可在設定中更改。",
"Ultra 可在完成首次设置后到设置页开启。":
"完成首次設定後,可到設定頁開啟 Ultra。",
"使用 Lite 进入": "使用 Lite 進入",
继续配置模型: "繼續設定模型",
"下一步选择记忆模式;之后仍可在设置中更改。":
"下一步選擇記憶模式;之後仍可在設定中更改。",
"最后一步 · 决定如何处理对话": "最後一步 · 決定如何處理對話",
返回导入: "返回匯入",
继续: "繼續",
@@ -634,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
模型调用与费用: "模型呼叫與費用",
搜索范围: "搜尋範圍",
已有数据: "現有資料",
知识图谱: "知識圖譜",
切换后: "切換後",
"之后只保存原始对话,不再生成新记忆。":
"之後只儲存原始對話,不再產生新記憶。",
@@ -721,9 +784,23 @@ export const modeTraditionalChineseMessages: ModeMessages = {
export const modeJapaneseMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理": "スマートメモリ Pro · 記憶の整理を設定",
配置记忆整理模型: "記憶整理モデルを設定",
返回选择模式: "モード選択に戻る",
启用并进入: "有効にして続行",
默认模型: "デフォルトモデル",
"留空使用 Core 默认值。": "空欄の場合は Core の既定値を使用します。",
"模式未能保存。你的选择已保留,请重试。":
"モードを保存できませんでした。選択内容は保持されています。もう一度お試しください。",
选择记忆方式: "記憶方法を選択",
选择记忆模式: "記憶モードを選択",
"之后可在设置中更改。": "後から設定で変更できます。",
"Ultra 可在完成首次设置后到设置页开启。":
"初期設定の完了後、設定画面で Ultra を有効にできます。",
"使用 Lite 进入": "Lite で開始",
继续配置模型: "モデル設定に進む",
"下一步选择记忆模式;之后仍可在设置中更改。":
"次に記憶モードを選択します。後から設定で変更できます。",
"最后一步 · 决定如何处理对话": "最後のステップ · 会話の処理方法を選択",
返回导入: "インポートに戻る",
继续: "続ける",
@@ -770,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
模型调用与费用: "モデル呼び出しと料金",
搜索范围: "検索範囲",
已有数据: "既存データ",
知识图谱: "ナレッジグラフ",
切换后: "切り替え後",
"之后只保存原始对话,不再生成新记忆。":
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
@@ -903,9 +981,25 @@ export const modeJapaneseMessages: ModeMessages = {
export const modeGermanMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理":
"Smart Memory Pro · Erinnerungsorganisation konfigurieren",
配置记忆整理模型: "Modell zur Erinnerungsorganisation konfigurieren",
返回选择模式: "Zurück zur Modusauswahl",
启用并进入: "Aktivieren und fortfahren",
默认模型: "Standardmodell",
"留空使用 Core 默认值。":
"Leer lassen, um die Core-Standardwerte zu verwenden.",
"模式未能保存。你的选择已保留,请重试。":
"Der Modus konnte nicht gespeichert werden. Ihre Auswahl bleibt erhalten. Bitte erneut versuchen.",
选择记忆方式: "Erinnerungsmodus wählen",
选择记忆模式: "Erinnerungsmodus auswählen",
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
"使用 Lite 进入": "Mit Lite starten",
继续配置模型: "Weiter zur Modelleinrichtung",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Wählen Sie als Nächstes einen Erinnerungsmodus. Sie können ihn später in den Einstellungen ändern.",
"最后一步 · 决定如何处理对话":
"Letzter Schritt · Verarbeitung der Unterhaltungen wählen",
返回导入: "Zurück zum Import",
@@ -954,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
模型调用与费用: "Modellaufrufe und Kosten",
搜索范围: "Suchbereich",
已有数据: "Vorhandene Daten",
知识图谱: "Wissensgraph",
切换后: "Nach dem Wechsel",
"之后只保存原始对话,不再生成新记忆。":
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
@@ -1091,9 +1186,24 @@ export const modeGermanMessages: ModeMessages = {
export const modePortugueseMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理":
"Memória inteligente Pro · Configurar organização",
配置记忆整理模型: "Configurar o modelo de organização de memórias",
返回选择模式: "Voltar à seleção de modo",
启用并进入: "Ativar e continuar",
默认模型: "Modelo padrão",
"留空使用 Core 默认值。": "Deixe em branco para usar os padrões do Core.",
"模式未能保存。你的选择已保留,请重试。":
"Não foi possível guardar o modo. A sua escolha foi mantida; tente novamente.",
选择记忆方式: "Escolher o modo de memória",
选择记忆模式: "Escolher o modo de memória",
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
"Ultra 可在完成首次设置后到设置页开启。":
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
"使用 Lite 进入": "Entrar com Lite",
继续配置模型: "Continuar para configurar o modelo",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Em seguida, escolha um modo de memória. Pode alterá-lo mais tarde nas Definições.",
"最后一步 · 决定如何处理对话":
"Último passo · Decida como tratar as conversas",
返回导入: "Voltar à importação",
@@ -1142,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
模型调用与费用: "Chamadas ao modelo e custos",
搜索范围: "Âmbito da pesquisa",
已有数据: "Dados existentes",
知识图谱: "Grafo de conhecimento",
切换后: "Após a mudança",
"之后只保存原始对话,不再生成新记忆。":
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
@@ -1279,9 +1390,26 @@ export const modePortugueseMessages: ModeMessages = {
export const modeFrenchMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理":
"Mémoire intelligente Pro · Configurer l’organisation",
配置记忆整理模型: "Configurer le modèle d’organisation des mémoires",
返回选择模式: "Retour au choix du mode",
启用并进入: "Activer et continuer",
默认模型: "Modèle par défaut",
"留空使用 Core 默认值。":
"Laissez vide pour utiliser les valeurs par défaut du Core.",
"模式未能保存。你的选择已保留,请重试。":
"Impossible d’enregistrer le mode. Votre choix est conservé. Réessayez.",
选择记忆方式: "Choisir le mode de mémoire",
选择记忆模式: "Choisir le mode de mémoire",
"之后可在设置中更改。":
"Vous pourrez le modifier plus tard dans les réglages.",
"Ultra 可在完成首次设置后到设置页开启。":
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
"使用 Lite 进入": "Entrer avec Lite",
继续配置模型: "Continuer vers la configuration du modèle",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Choisissez ensuite un mode de mémoire. Vous pourrez le modifier plus tard dans les réglages.",
"最后一步 · 决定如何处理对话":
"Dernière étape · Choisissez le traitement des conversations",
返回导入: "Retour à l’importation",
@@ -1330,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
模型调用与费用: "Appels au modèle et coût",
搜索范围: "Périmètre de recherche",
已有数据: "Données existantes",
知识图谱: "Graphe de connaissances",
切换后: "Après le changement",
"之后只保存原始对话,不再生成新记忆。":
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
@@ -1467,9 +1596,23 @@ export const modeFrenchMessages: ModeMessages = {
export const modeKoreanMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理": "스마트 메모리 Pro · 메모리 정리 구성",
配置记忆整理模型: "메모리 정리 모델 구성",
返回选择模式: "모드 선택으로 돌아가기",
启用并进入: "활성화하고 계속",
默认模型: "기본 모델",
"留空使用 Core 默认值。": "비워 두면 Core 기본값을 사용합니다.",
"模式未能保存。你的选择已保留,请重试。":
"모드를 저장하지 못했습니다. 선택은 유지됩니다. 다시 시도하세요.",
选择记忆方式: "메모리 방식 선택",
选择记忆模式: "메모리 모드 선택",
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
"Ultra 可在完成首次设置后到设置页开启。":
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
"使用 Lite 进入": "Lite로 시작",
继续配置模型: "모델 설정 계속",
"下一步选择记忆模式;之后仍可在设置中更改。":
"다음으로 메모리 모드를 선택하세요. 나중에 설정에서 변경할 수 있습니다.",
"最后一步 · 决定如何处理对话": "마지막 단계 · 대화 처리 방식 선택",
返回导入: "가져오기로 돌아가기",
继续: "계속",
@@ -1516,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
模型调用与费用: "모델 호출 및 비용",
搜索范围: "검색 범위",
已有数据: "기존 데이터",
知识图谱: "지식 그래프",
切换后: "전환 후",
"之后只保存原始对话,不再生成新记忆。":
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
@@ -1649,9 +1793,24 @@ export const modeKoreanMessages: ModeMessages = {
export const modeRussianMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理": "Умная память Pro · Настройка организации",
配置记忆整理模型: "Настроить модель организации памяти",
返回选择模式: "Назад к выбору режима",
启用并进入: "Включить и продолжить",
默认模型: "Модель по умолчанию",
"留空使用 Core 默认值。":
"Оставьте пустым, чтобы использовать значения Core по умолчанию.",
"模式未能保存。你的选择已保留,请重试。":
"Не удалось сохранить режим. Ваш выбор сохранён. Повторите попытку.",
选择记忆方式: "Выберите режим памяти",
选择记忆模式: "Выберите режим памяти",
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra можно включить в настройках после завершения первоначальной настройки.",
"使用 Lite 进入": "Войти с Lite",
继续配置模型: "Перейти к настройке модели",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Далее выберите режим памяти. Позже его можно изменить в настройках.",
"最后一步 · 决定如何处理对话":
"Последний шаг · Выберите способ обработки диалогов",
返回导入: "Назад к импорту",
@@ -1700,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
模型调用与费用: "Вызовы модели и расходы",
搜索范围: "Область поиска",
已有数据: "Существующие данные",
知识图谱: "Граф знаний",
切换后: "После переключения",
"之后只保存原始对话,不再生成新记忆。":
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
@@ -1836,9 +1996,25 @@ export const modeRussianMessages: ModeMessages = {
export const modeIndonesianMessages: ModeMessages = {
...modeEnglishMessages,
"智能记忆 Pro · 配置记忆整理":
"Memori cerdas Pro · Konfigurasi pengorganisasian",
配置记忆整理模型: "Konfigurasikan model pengorganisasian memori",
返回选择模式: "Kembali ke pilihan mode",
启用并进入: "Aktifkan dan lanjutkan",
默认模型: "Model bawaan",
"留空使用 Core 默认值。":
"Biarkan kosong untuk menggunakan nilai bawaan Core.",
"模式未能保存。你的选择已保留,请重试。":
"Mode tidak dapat disimpan. Pilihan Anda tetap tersimpan; coba lagi.",
选择记忆方式: "Pilih cara kerja memori",
选择记忆模式: "Pilih mode memori",
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
"使用 Lite 进入": "Masuk dengan Lite",
继续配置模型: "Lanjutkan ke konfigurasi model",
"下一步选择记忆模式;之后仍可在设置中更改。":
"Selanjutnya, pilih mode memori. Anda dapat mengubahnya nanti di Pengaturan.",
"最后一步 · 决定如何处理对话":
"Langkah terakhir · Tentukan cara memproses percakapan",
返回导入: "Kembali ke impor",
@@ -1886,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
模型调用与费用: "Panggilan model dan biaya",
搜索范围: "Cakupan pencarian",
已有数据: "Data yang ada",
知识图谱: "Graf pengetahuan",
切换后: "Setelah beralih",
"之后只保存原始对话,不再生成新记忆。":
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
@@ -313,6 +313,16 @@ export const onboardingSpanishMessages = {
"La conexión falló. Comprueba el modelo, la clave y el destinatario.",
"配置或测试结果暂未确认,请核对后重试。":
"El resultado de la configuración o la prueba aún no está confirmado. Compruébalo antes de reintentar.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"No se completó la actualización del índice. Se sigue usando el modelo de búsqueda anterior. Comprueba la conexión y vuelve a intentarlo.",
"上次模型测试结果仍待核对,请稍后重试。":
"El resultado de la última prueba del modelo aún debe comprobarse. Vuelve a intentarlo más tarde.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"La escritura del modelo está temporalmente pausada. Espera a que termine el mantenimiento del índice y vuelve a intentarlo.",
"模型未能确认启用,请在设置中核对。":
"No se pudo confirmar que el modelo esté habilitado. Compruébalo en Configuración.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"La configuración guardada del modelo no se puede leer temporalmente. No se creará un duplicado. Vuelve a actualizar.",
自定义兼容服务: "Servicio compatible personalizado",
所选连接已启用: "Se habilitaron las conexiones seleccionadas",
本次未选择应用连接: "No se seleccionaron conexiones de aplicaciones",
@@ -1473,6 +1473,16 @@ export const germanMessages: Record<MessageKey, string> = {
"Verbindung fehlgeschlagen. Prüfen Sie Modell, Schlüssel und Empfänger.",
"配置或测试结果暂未确认,请核对后重试。":
"Konfiguration oder Testergebnis konnte nicht bestätigt werden. Prüfen Sie sie vor dem erneuten Versuch.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"Die Indexaktualisierung wurde nicht abgeschlossen. Das bisherige Suchmodell wird weiterhin verwendet. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
"上次模型测试结果仍待核对,请稍后重试。":
"Das Ergebnis des letzten Modelltests muss noch geprüft werden. Versuchen Sie es später erneut.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"Modellschreibvorgänge sind vorübergehend pausiert. Warten Sie, bis die Indexwartung abgeschlossen ist, und versuchen Sie es erneut.",
"模型未能确认启用,请在设置中核对。":
"Die Aktivierung des Modells konnte nicht bestätigt werden. Prüfen Sie dies in den Einstellungen.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"Die gespeicherte Modellkonfiguration kann vorübergehend nicht gelesen werden. Es wird kein Duplikat erstellt. Aktualisieren Sie erneut.",
所选连接已启用: "Ausgewählte App-Verbindungen sind aktiviert",
本次未选择应用连接: "Diesmal keine App-Verbindung ausgewählt",
尚未填写接收方: "Kein Empfänger eingegeben",
@@ -1534,6 +1544,7 @@ export const germanMessages: Record<MessageKey, string> = {
"apps.memories": "Gespeicherte Erinnerungen",
"apps.rawConversations": "Originalunterhaltungen",
"apps.rawSaving": "Wird gespeichert",
"apps.rawSavingCompact": "Wird gespeichert",
"apps.rawSavingStatus": "Speicherstatus",
"apps.rawSavingDetail":
"Originalunterhaltungen werden ohne generatives Modell gespeichert",
@@ -1545,6 +1556,7 @@ export const germanMessages: Record<MessageKey, string> = {
"Geprüft: keine anzeigbaren Aktualisierungen der Originalunterhaltungen",
"apps.memoryCheckedEmpty":
"Geprüft: keine anzeigbaren Erinnerungsaktualisierungen",
"apps.recentEmptyCompact": "Keine Updates",
"apps.rawWaiting": "Gespeicherte Originalunterhaltungen erscheinen hier",
"apps.checkedRecent": "Aktueller Inhalt geprüft",
"apps.currentMode": "Aktueller Modus",
@@ -1493,6 +1493,16 @@ export const frenchMessages: Record<MessageKey, string> = {
"Échec de la connexion. Vérifiez le modèle, la clé et le destinataire.",
"配置或测试结果暂未确认,请核对后重试。":
"Impossible de confirmer la configuration ou le résultat du test. Vérifiez avant de réessayer.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"La mise à jour de l’index ne s’est pas terminée. Le modèle de recherche précédent reste utilisé. Vérifiez la connexion et réessayez.",
"上次模型测试结果仍待核对,请稍后重试。":
"Le résultat du dernier test du modèle doit encore être vérifié. Réessayez plus tard.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"Les écritures du modèle sont temporairement suspendues. Attendez la fin de la maintenance de l’index, puis réessayez.",
"模型未能确认启用,请在设置中核对。":
"Impossible de confirmer que le modèle a été activé. Vérifiez dans les Paramètres.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"La configuration de modèle enregistrée est temporairement inaccessible. Aucun doublon ne sera créé. Réessayez d’actualiser.",
所选连接已启用: "Les connexions des applications sélectionnées sont activées",
本次未选择应用连接:
"Aucune connexion d’application sélectionnée cette fois-ci",
@@ -1555,6 +1565,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"apps.memories": "Mémoires enregistrées",
"apps.rawConversations": "Conversations d’origine",
"apps.rawSaving": "Enregistrement",
"apps.rawSavingCompact": "Enregistrement",
"apps.rawSavingStatus": "État de l’enregistrement",
"apps.rawSavingDetail":
"Les conversations d’origine sont enregistrées sans modèle génératif",
@@ -1566,6 +1577,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"Vérifié : aucune mise à jour de conversation à afficher",
"apps.memoryCheckedEmpty":
"Vérifié : aucune mise à jour de mémoire à afficher",
"apps.recentEmptyCompact": "Aucune mise à jour",
"apps.rawWaiting":
"Les conversations d’origine enregistrées apparaîtront ici",
"apps.checkedRecent": "Contenu actuel vérifié",
@@ -1451,6 +1451,16 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Koneksi gagal. Periksa model, kunci, dan penerima.",
"配置或测试结果暂未确认,请核对后重试。":
"Hasil konfigurasi atau pengujian belum terkonfirmasi. Periksa sebelum mencoba lagi.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"Pembaruan indeks belum selesai. Model pencarian sebelumnya masih digunakan. Periksa koneksi lalu coba lagi.",
"上次模型测试结果仍待核对,请稍后重试。":
"Hasil pengujian model sebelumnya masih perlu diperiksa. Coba lagi nanti.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"Penulisan model dijeda sementara. Tunggu hingga pemeliharaan indeks selesai, lalu coba lagi.",
"模型未能确认启用,请在设置中核对。":
"Tidak dapat memastikan bahwa model telah diaktifkan. Periksa di Pengaturan.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"Konfigurasi model yang tersimpan untuk sementara tidak dapat dibaca. Duplikat tidak akan dibuat. Coba muat ulang lagi.",
所选连接已启用: "Koneksi aplikasi terpilih diaktifkan",
本次未选择应用连接: "Tidak ada koneksi aplikasi yang dipilih kali ini",
尚未填写接收方: "Penerima belum diisi",
@@ -1509,6 +1519,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"apps.memories": "Memori tersimpan",
"apps.rawConversations": "Percakapan asli",
"apps.rawSaving": "Menyimpan",
"apps.rawSavingCompact": "Menyimpan",
"apps.rawSavingStatus": "Status penyimpanan",
"apps.rawSavingDetail": "Percakapan asli disimpan tanpa model generatif",
"apps.rawNoExcerpt": "Tidak ada kutipan pengguna yang dapat ditampilkan",
@@ -1519,6 +1530,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Diperiksa: tidak ada pembaruan percakapan untuk ditampilkan",
"apps.memoryCheckedEmpty":
"Diperiksa: tidak ada pembaruan memori untuk ditampilkan",
"apps.recentEmptyCompact": "Belum ada pembaruan",
"apps.rawWaiting": "Percakapan asli yang disimpan akan muncul di sini",
"apps.checkedRecent": "Konten saat ini telah diperiksa",
"apps.currentMode": "Mode saat ini",
@@ -1447,6 +1447,16 @@ export const japaneseMessages: Record<MessageKey, string> = {
"接続に失敗しました。モデル、キー、送信先を確認してください。",
"配置或测试结果暂未确认,请核对后重试。":
"設定またはテストの結果を確認できませんでした。確認してから再試行してください。",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"インデックスの更新が完了しませんでした。以前の検索モデルが引き続き使用されています。接続を確認して再試行してください。",
"上次模型测试结果仍待核对,请稍后重试。":
"前回のモデルテスト結果をまだ確認できていません。しばらくしてから再試行してください。",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"モデルへの書き込みは一時停止されています。インデックスのメンテナンスが完了してから再試行してください。",
"模型未能确认启用,请在设置中核对。":
"モデルが有効になったことを確認できませんでした。設定で確認してください。",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"保存済みのモデル設定を一時的に読み込めません。重複して作成されることはありません。もう一度更新してください。",
所选连接已启用: "選択したアプリの接続が有効になりました",
本次未选择应用连接: "今回はアプリの接続が選択されていません",
尚未填写接收方: "送信先未入力",
@@ -1506,6 +1516,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.memories": "保存済みの記憶",
"apps.rawConversations": "元の会話",
"apps.rawSaving": "保存中",
"apps.rawSavingCompact": "保存中",
"apps.rawSavingStatus": "保存状態",
"apps.rawSavingDetail": "生成モデルを使わず元の会話を保存します",
"apps.rawNoExcerpt": "表示できるユーザーの原文がありません",
@@ -1514,6 +1525,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "保存済みの元の会話はまだありません",
"apps.rawCheckedEmpty": "確認済み:表示できる元の会話の更新はありません",
"apps.memoryCheckedEmpty": "確認済み:表示できる記憶の更新はありません",
"apps.recentEmptyCompact": "更新なし",
"apps.rawWaiting": "保存された元の会話がここに表示されます",
"apps.checkedRecent": "現在の内容を確認済み",
"apps.currentMode": "現在のモード",
@@ -1414,6 +1414,16 @@ export const koreanMessages: Record<MessageKey, string> = {
"연결에 실패했습니다. 모델, 키, 수신처를 확인하세요.",
"配置或测试结果暂未确认,请核对后重试。":
"구성 또는 테스트 결과를 확인하지 못했습니다. 다시 시도하기 전에 확인하세요.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"인덱스 업데이트가 완료되지 않았습니다. 이전 검색 모델을 계속 사용합니다. 연결을 확인한 후 다시 시도하세요.",
"上次模型测试结果仍待核对,请稍后重试。":
"이전 모델 테스트 결과를 아직 확인해야 합니다. 잠시 후 다시 시도하세요.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"모델 쓰기가 일시 중지되었습니다. 인덱스 유지 관리가 완료된 후 다시 시도하세요.",
"模型未能确认启用,请在设置中核对。":
"모델이 활성화되었는지 확인하지 못했습니다. 설정에서 확인하세요.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"저장된 모델 구성을 일시적으로 읽을 수 없습니다. 중복으로 생성되지 않습니다. 다시 새로 고쳐 보세요.",
所选连接已启用: "선택한 앱 연결이 활성화되었습니다",
本次未选择应用连接: "이번에 선택한 앱 연결 없음",
尚未填写接收方: "수신처가 입력되지 않음",
@@ -1470,6 +1480,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.memories": "저장된 기억",
"apps.rawConversations": "원본 대화",
"apps.rawSaving": "저장 중",
"apps.rawSavingCompact": "저장 중",
"apps.rawSavingStatus": "저장 상태",
"apps.rawSavingDetail": "생성 모델 없이 원본 대화를 저장합니다",
"apps.rawNoExcerpt": "표시할 수 있는 사용자 원문이 없습니다",
@@ -1478,6 +1489,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "저장된 원본 대화가 아직 없습니다",
"apps.rawCheckedEmpty": "확인됨: 표시할 원본 대화 업데이트가 없습니다",
"apps.memoryCheckedEmpty": "확인됨: 표시할 메모리 업데이트가 없습니다",
"apps.recentEmptyCompact": "업데이트 없음",
"apps.rawWaiting": "저장된 원본 대화가 여기에 표시됩니다",
"apps.checkedRecent": "현재 내용 확인됨",
"apps.currentMode": "현재 모드",
@@ -1470,6 +1470,16 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Falha na conexão. Verifique o modelo, a chave e o destinatário.",
"配置或测试结果暂未确认,请核对后重试。":
"Não foi possível confirmar a configuração ou o resultado do teste. Verifique antes de tentar novamente.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"A atualização do índice não foi concluída. O modelo de busca anterior continua em uso. Verifique a conexão e tente novamente.",
"上次模型测试结果仍待核对,请稍后重试。":
"O resultado do último teste do modelo ainda precisa ser verificado. Tente novamente mais tarde.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"As gravações do modelo estão temporariamente pausadas. Aguarde a conclusão da manutenção do índice e tente novamente.",
"模型未能确认启用,请在设置中核对。":
"Não foi possível confirmar que o modelo foi ativado. Verifique nas Configurações.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"A configuração de modelo salva está temporariamente indisponível. Nenhuma duplicata será criada. Tente atualizar novamente.",
所选连接已启用: "As conexões dos apps selecionados estão ativadas",
本次未选择应用连接: "Nenhuma conexão de app selecionada desta vez",
尚未填写接收方: "Nenhum destinatário informado",
@@ -1531,6 +1541,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"apps.memories": "Memórias salvas",
"apps.rawConversations": "Conversas originais",
"apps.rawSaving": "Salvando",
"apps.rawSavingCompact": "Salvando",
"apps.rawSavingStatus": "Status de salvamento",
"apps.rawSavingDetail":
"As conversas originais são salvas sem um modelo generativo",
@@ -1542,6 +1553,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Verificado: não há atualizações de conversas para mostrar",
"apps.memoryCheckedEmpty":
"Verificado: não há atualizações de memórias para mostrar",
"apps.recentEmptyCompact": "Sem atualizações",
"apps.rawWaiting": "As conversas originais salvas aparecerão aqui",
"apps.checkedRecent": "Conteúdo atual verificado",
"apps.currentMode": "Modo atual",
@@ -1454,6 +1454,16 @@ export const russianMessages: Record<MessageKey, string> = {
"Подключение не удалось. Проверьте модель, ключ и получателя.",
"配置或测试结果暂未确认,请核对后重试。":
"Не удалось подтвердить конфигурацию или результат теста. Проверьте их перед повторной попыткой.",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"Обновление индекса не завершено. По-прежнему используется прежняя модель поиска. Проверьте подключение и повторите попытку.",
"上次模型测试结果仍待核对,请稍后重试。":
"Результат последнего теста модели ещё нужно проверить. Повторите попытку позже.",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"Запись модели временно приостановлена. Дождитесь завершения обслуживания индекса и повторите попытку.",
"模型未能确认启用,请在设置中核对。":
"Не удалось подтвердить включение модели. Проверьте это в настройках.",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"Сохранённая конфигурация модели временно недоступна. Дубликат создан не будет. Попробуйте обновить снова.",
所选连接已启用: "Выбранные подключения приложений включены",
本次未选择应用连接: "Подключения приложений не выбраны",
尚未填写接收方: "Получатель не указан",
@@ -1514,6 +1524,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.memories": "Сохранённые записи памяти",
"apps.rawConversations": "Исходные разговоры",
"apps.rawSaving": "Сохранение",
"apps.rawSavingCompact": "Сохранение",
"apps.rawSavingStatus": "Статус сохранения",
"apps.rawSavingDetail":
"Исходные разговоры сохраняются без генеративной модели",
@@ -1524,6 +1535,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.rawCheckedEmpty":
"Проверено: нет обновлений исходных диалогов для показа",
"apps.memoryCheckedEmpty": "Проверено: нет обновлений памяти для показа",
"apps.recentEmptyCompact": "Нет обновлений",
"apps.rawWaiting": "Сохранённые исходные диалоги появятся здесь",
"apps.checkedRecent": "Текущее содержимое проверено",
"apps.currentMode": "Текущий режим",
@@ -1323,6 +1323,15 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"連線未通過,請核對模型、金鑰和接收方。",
"配置或测试结果暂未确认,请核对后重试。":
"設定或測試結果暫未確認,請核對後重試。",
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。":
"索引更新未完成,仍在使用原本的搜尋模型。請檢查連線後重試。",
"上次模型测试结果仍待核对,请稍后重试。":
"上次模型測試結果仍待核對,請稍後重試。",
"模型写入暂时关闭。请等待索引维护完成后重试。":
"模型寫入暫時關閉。請等待索引維護完成後重試。",
"模型未能确认启用,请在设置中核对。": "未能確認模型已啟用,請在設定中核對。",
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。":
"已儲存的模型設定暫時無法讀取,不會重複建立。請重試重新整理。",
所选连接已启用: "所選連線已啟用",
本次未选择应用连接: "本次未選擇 App 連線",
尚未填写接收方: "尚未填寫接收方",
@@ -1379,6 +1388,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.memories": "已建立的記憶",
"apps.rawConversations": "原始對話",
"apps.rawSaving": "儲存中",
"apps.rawSavingCompact": "儲存中",
"apps.rawSavingStatus": "儲存狀態",
"apps.rawSavingDetail": "原始對話會持續儲存,不需要生成模型",
"apps.rawNoExcerpt": "這段對話沒有可顯示的使用者原文",
@@ -1387,6 +1397,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "還沒有儲存的原始對話",
"apps.rawCheckedEmpty": "已核對,暫無可顯示的原始對話更新",
"apps.memoryCheckedEmpty": "已核對,暫無可顯示的記憶更新",
"apps.recentEmptyCompact": "暫無更新",
"apps.rawWaiting": "原始對話儲存後會顯示在這裡",
"apps.checkedRecent": "已核對目前內容",
"apps.currentMode": "目前模式",
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
});
it("isolates the old-Core matrix in one helper", () => {
it.each([
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
[
"lite",
"ultra",
[
"earlier_raw_not_extracted",
"lite_search_stops",
"existing_memories_added_to_graph",
],
],
["pro", "lite", ["higher_mode_memories_hidden"]],
["pro", "ultra", ["existing_memories_added_to_graph"]],
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
] as const)(
"keeps the old-Core %s to %s effects aligned with the plan route",
(current, target, effects) => {
expect(
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
).toEqual(effects);
},
);
it("keeps the old-Core model blocker and target capabilities", () => {
expect(
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
).toMatchObject({
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
graph_enabled: true,
},
blockers: ["active_model_required"],
effects: expect.arrayContaining([
"earlier_raw_not_extracted",
"existing_memories_added_to_graph",
]),
});
});
});
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
options: { hasActiveModel: boolean },
): MemoryModePlan {
const effects: MemoryModeChange["effects"] = [];
if (target === "lite") {
effects.push("higher_mode_memories_hidden");
if (current === "ultra") effects.push("evolve_stopped");
} else if (current === "lite") {
const rank: Record<MemoryPipelineMode, number> = {
lite: 0,
pro: 1,
ultra: 2,
};
if (current !== target && current === "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
}
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
if (current !== target && current === "ultra") effects.push("evolve_stopped");
if (target === "ultra" && current !== "ultra")
effects.push("existing_memories_added_to_graph");
return {
+41 -2
View File
@@ -27,6 +27,7 @@ class RawStore:
exclude_sources,
layers,
limit,
status_filter,
**_kwargs,
):
rows = []
@@ -41,11 +42,18 @@ class RawStore:
continue
if node.layer not in layers:
continue
if node.status not in status_filter:
continue
rows.append({"node": node, "score": 0.9})
return rows[:limit]
def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
def raw_node(
raw_id: str,
session_id: str,
source: str,
status: MemoryStatus = MemoryStatus.ACTIVE,
) -> MemoryNode:
return MemoryNode(
node_id=raw_id,
user_id="u1",
@@ -55,7 +63,7 @@ def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
layer=MemoryLayer.L1_RAW,
content=f"content {raw_id}",
source_type=SourceType.EXPLICIT,
status=MemoryStatus.ACTIVE,
status=status,
is_latest=True,
embedding=[0.1, 0.2],
)
@@ -102,6 +110,7 @@ async def test_existing_search_contract_can_explicitly_search_raw():
request_id="search-1",
t0=0.0,
query_embedding=[0.1, 0.2],
statuses=None,
)
assert [item["memory_id"] for item in result["memories"]["normal"]] == [
@@ -109,3 +118,33 @@ async def test_existing_search_contract_can_explicitly_search_raw():
]
assert result["memories"]["normal"][0]["layer"] == "l1_raw"
assert result["memories"]["normal"][0]["source"] == "claude"
@pytest.mark.asyncio
async def test_explicit_raw_search_can_include_shadow_rows():
client = HyMemoryClient.__new__(HyMemoryClient)
client._vector_store = RawStore(
[raw_node("raw-shadow", "s1", "cursor", MemoryStatus.SHADOW)]
)
client._embed_service = AsyncMock()
client._embed_service.embed.return_value = [0.1, 0.2]
result = await HyMemoryClient._search_explicit_raw_layer.__wrapped__(
client,
"content",
user_ids=["u1"],
agent_ids=["a1"],
session_ids=[],
include_sources=[],
exclude_sources=[],
limit=5,
min_score=0.0,
created_after=None,
request_id="search-shadow",
t0=0.0,
query_embedding=[0.1, 0.2],
statuses=["active", "shadow"],
)
assert result["memories"]["normal"][0]["memory_id"] == "raw-shadow"
assert result["memories"]["normal"][0]["status"] == "shadow"