Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265a22d612 |
No files matched your search
+24
-5
@@ -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(
|
||||
|
||||
@@ -270,6 +270,7 @@ from one_memory.onboarding import (
|
||||
OnboardingState,
|
||||
OnboardingStore,
|
||||
)
|
||||
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns
|
||||
from one_memory.recall import (
|
||||
GovernedRecallTarget,
|
||||
RecallReceipt,
|
||||
@@ -604,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -634,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
|
||||
|
||||
|
||||
@@ -4441,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,
|
||||
@@ -4450,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
|
||||
@@ -4463,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`.
|
||||
@@ -4477,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
|
||||
@@ -4509,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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -72,7 +72,6 @@ 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;
|
||||
@@ -225,7 +224,6 @@ vi.mock("./use-model-profiles", () => ({
|
||||
activate,
|
||||
remove,
|
||||
update,
|
||||
refreshProfiles,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
@@ -281,7 +279,6 @@ 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",
|
||||
@@ -579,8 +576,7 @@ describe("ModelSettings", () => {
|
||||
name: "高级:自行配置向量模型",
|
||||
});
|
||||
await user.click(disclosure);
|
||||
await user.click(screen.getByLabelText("向量配置模式"));
|
||||
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
|
||||
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
|
||||
await user.type(
|
||||
screen.getByLabelText("向量模型 Base URL"),
|
||||
@@ -670,9 +666,9 @@ describe("ModelSettings", () => {
|
||||
];
|
||||
render(<ModelSettings />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
await userEvent.click(screen.getByLabelText("向量配置模式"));
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: "本地内置(推荐)" }),
|
||||
await userEvent.selectOptions(
|
||||
screen.getByLabelText("向量配置模式"),
|
||||
"local",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
expect(update).toHaveBeenCalledWith(profile.profile_id, {
|
||||
@@ -691,9 +687,9 @@ describe("ModelSettings", () => {
|
||||
name: "高级:自行配置向量模型",
|
||||
});
|
||||
await userEvent.click(disclosure);
|
||||
await userEvent.click(screen.getByLabelText("向量配置模式"));
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: "自定义搜索模型" }),
|
||||
await userEvent.selectOptions(
|
||||
screen.getByLabelText("向量配置模式"),
|
||||
"custom",
|
||||
);
|
||||
await userEvent.click(disclosure);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
|
||||
@@ -854,190 +850,6 @@ 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();
|
||||
@@ -1232,16 +1044,14 @@ describe("ModelSettings", () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
|
||||
);
|
||||
await user.click(screen.getByLabelText("向量配置模式"));
|
||||
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
|
||||
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.click(screen.getByLabelText("向量模型来源"));
|
||||
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
|
||||
await user.type(
|
||||
screen.getByLabelText("向量模型 Base URL"),
|
||||
"http://127.0.0.1:11434",
|
||||
@@ -1468,87 +1278,6 @@ 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("把一张预置卡设为当前模型", () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type FormEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
@@ -240,7 +238,6 @@ export function ModelSettings({
|
||||
onSetupComplete?(): void;
|
||||
} = {}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const fieldId = useId();
|
||||
const models = useModelProfiles();
|
||||
const embeddingSwitch = useEmbeddingSwitch();
|
||||
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
|
||||
@@ -278,9 +275,6 @@ 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);
|
||||
@@ -314,11 +308,10 @@ export function ModelSettings({
|
||||
return;
|
||||
settledSwitchJob.current = finished.job_id;
|
||||
if (finished.state === "done") {
|
||||
setSetupPhase("activating");
|
||||
void models.refreshProfiles();
|
||||
setSetupError(null);
|
||||
void models.refreshProfiles().then(() => {
|
||||
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
|
||||
});
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
onProfilesChanged?.();
|
||||
return;
|
||||
}
|
||||
@@ -330,7 +323,6 @@ export function ModelSettings({
|
||||
);
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
setSwitchRefreshCompleted(null);
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
@@ -339,45 +331,6 @@ 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]);
|
||||
@@ -428,7 +381,6 @@ export function ModelSettings({
|
||||
saved);
|
||||
const editorLocked =
|
||||
saving ||
|
||||
switchJobId !== null ||
|
||||
models.busy !== null ||
|
||||
!!models.pendingCreation ||
|
||||
!!models.pendingProbe ||
|
||||
@@ -912,9 +864,6 @@ 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.
|
||||
@@ -1390,37 +1339,20 @@ export function ModelSettings({
|
||||
)}
|
||||
{focusSection === "embedding" || advancedEmbedding ? (
|
||||
<>
|
||||
<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`}
|
||||
<label>
|
||||
{text(
|
||||
focusSection === "embedding"
|
||||
? "搜索模型"
|
||||
: "向量配置模式",
|
||||
)}
|
||||
<select
|
||||
name="embedding-mode"
|
||||
value={embeddingMode}
|
||||
disabled={formLocked}
|
||||
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");
|
||||
onChange={(event) => {
|
||||
setEmbeddingMode(
|
||||
event.target.value as "local" | "custom",
|
||||
);
|
||||
setEmbeddingModeChanged(true);
|
||||
setFields((previous) => ({
|
||||
...previous,
|
||||
@@ -1428,8 +1360,11 @@ export function ModelSettings({
|
||||
"embedding-base-url": undefined,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<option value="local">{text("本地内置(推荐)")}</option>
|
||||
<option value="custom">{text("自定义搜索模型")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<small>{text("技术类型:Embedding")}</small>
|
||||
{embeddingMode === "custom" ? (
|
||||
<>
|
||||
@@ -1462,38 +1397,25 @@ export function ModelSettings({
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<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`}
|
||||
<label>
|
||||
{text("向量模型来源")}
|
||||
<select
|
||||
name="embedding-provider"
|
||||
value={embeddingProvider}
|
||||
disabled={formLocked}
|
||||
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
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as
|
||||
| "openai_compatible"
|
||||
| "ollama";
|
||||
setEmbeddingProvider(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<option value="ollama">{text("本机 Ollama")}</option>
|
||||
<option value="openai_compatible">
|
||||
{text("同源 OpenAI-compatible(共用 API Key)")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{text("向量模型 Base URL")}
|
||||
<input
|
||||
@@ -1747,14 +1669,13 @@ 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?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
|
||||
function choose(index: number) {
|
||||
const item = props.options[index];
|
||||
if (!item || item.disabled) return;
|
||||
if (item.value !== props.value) props.onChange(item.value);
|
||||
props.onChange(item.value);
|
||||
setOpen(false);
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ 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");
|
||||
@@ -115,8 +112,8 @@ vi.mock("./ModelSettings", async () => {
|
||||
}) => {
|
||||
useImperativeHandle(controlsRef, () => ({
|
||||
edit: () => false,
|
||||
editProfile: editProfileAction,
|
||||
add: addProfileAction,
|
||||
editProfile: () => false,
|
||||
add: () => false,
|
||||
test: () => Promise.resolve(undefined),
|
||||
}));
|
||||
return createElement(
|
||||
@@ -307,7 +304,6 @@ 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: "模型配置" }));
|
||||
@@ -325,13 +321,10 @@ 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(
|
||||
within(generationLane).getByRole("button", { name: "核对测试结果" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.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()} />);
|
||||
@@ -375,46 +368,11 @@ 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(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");
|
||||
expect(screen.getByText("embedding")).toBeTruthy();
|
||||
await user.click(
|
||||
within(generationLane).getByRole("button", { name: "配置" }),
|
||||
);
|
||||
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();
|
||||
expect(screen.getByText("generation")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the switch unusable when the state cannot be read", async () => {
|
||||
|
||||
@@ -78,7 +78,6 @@ 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,
|
||||
);
|
||||
@@ -102,10 +101,6 @@ 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;
|
||||
@@ -229,49 +224,18 @@ 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 stays open even when the editor refuses: its recovery entry is
|
||||
// the only way to reconcile the pending operation.
|
||||
// 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);
|
||||
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() {
|
||||
@@ -420,156 +384,67 @@ export function R6Settings({
|
||||
</div>
|
||||
)}
|
||||
<div className="r6-model-overview">
|
||||
<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
|
||||
: "本地内置(推荐)",
|
||||
<div className="r6-model-lane">
|
||||
<div className="r6-model-lane-copy">
|
||||
<h3>{text("记忆整理")}</h3>
|
||||
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
|
||||
<strong>
|
||||
{currentModel?.name ??
|
||||
text(
|
||||
profiles === null
|
||||
? "正在读取模型…"
|
||||
: modelError
|
||||
? "模型状态暂不可读"
|
||||
: "尚未配置模型",
|
||||
)}
|
||||
</strong>
|
||||
</strong>
|
||||
{currentModel && (
|
||||
<small>
|
||||
{text(
|
||||
currentModel?.embedding_provider_kind &&
|
||||
currentModel.embedding_provider_kind !== "local_onnx"
|
||||
? `Embedding · ${currentModel.embedding_provider_kind}`
|
||||
: profiles !== null && !currentModel
|
||||
? "Embedding · 配置记忆整理后可自定义"
|
||||
: "Embedding · 本机处理,无需额外配置",
|
||||
)}
|
||||
LLM · {currentModel.model} ·{" "}
|
||||
{text(safeEndpoint(currentModel.base_url))}
|
||||
</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>
|
||||
{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>
|
||||
<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
|
||||
: "本地内置(推荐)",
|
||||
)}
|
||||
<ModelSettings
|
||||
compactSingle
|
||||
focusSection={configurationFocus}
|
||||
controlsRef={modelActions}
|
||||
onProfilesChanged={() =>
|
||||
setModelVersion((value) => value + 1)
|
||||
}
|
||||
onSetupComplete={completeConfiguration}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
{modelError && (
|
||||
<R6StateRecovery
|
||||
@@ -581,6 +456,13 @@ 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>
|
||||
@@ -617,6 +499,19 @@ 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`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -986,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;
|
||||
@@ -1011,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,
|
||||
},
|
||||
|
||||
@@ -132,17 +132,12 @@ button svg {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 50%;
|
||||
top: 22px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1.5px solid #4e6478;
|
||||
border-bottom: 1.5px solid #4e6478;
|
||||
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);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.om-option-menu-button:disabled {
|
||||
color: #66727b;
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
}
|
||||
.onboarding-r6-titlebar
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.r6-settings
|
||||
@@ -595,12 +596,6 @@
|
||||
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;
|
||||
@@ -644,7 +639,7 @@
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header > .r6-button {
|
||||
.r6-settings .r6-model-lane > .r6-button {
|
||||
min-width: 72px;
|
||||
}
|
||||
.r6-settings .r6-model-recipient {
|
||||
@@ -695,9 +690,8 @@
|
||||
line-height: 28px;
|
||||
}
|
||||
.r6-settings .r6-model-owner {
|
||||
padding: 24px 20px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
background: #f8fafb;
|
||||
}
|
||||
.r6-settings .r6-model-owner[hidden] {
|
||||
display: none;
|
||||
@@ -853,6 +847,7 @@
|
||||
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"] {
|
||||
@@ -871,39 +866,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -922,8 +884,7 @@
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-settings .model-advanced label,
|
||||
.r6-settings .model-advanced .model-form-field {
|
||||
.r6-settings .model-advanced label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -1027,17 +988,14 @@
|
||||
padding: 24px 20px;
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header {
|
||||
.r6-settings .r6-model-lane {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header > .r6-button {
|
||||
.r6-settings .r6-model-lane > .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;
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
.selected-source-filter .om-option-menu-button::after {
|
||||
top: 18px;
|
||||
right: 4px;
|
||||
}
|
||||
.selected-source-filter details {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -675,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;
|
||||
@@ -703,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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user