Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b74f91bc | ||
|
|
b36452d110 | ||
|
|
a8b92d21e1 | ||
|
|
e58a7c0d14 | ||
|
|
1663c9424e | ||
|
|
fba9d8cf26 | ||
|
|
f754f8bc0f | ||
|
|
2155c4c773 | ||
|
|
18e2fca34d | ||
|
|
88feb0adca | ||
|
|
d2e9cdaf06 | ||
|
|
8277aaee51 | ||
|
|
65550621e0 |
No files matched your search
+9
-1
@@ -4,6 +4,14 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
explicitly choose lite or pro during onboarding; pro completes only after a
|
||||
generation model is active, while ultra remains an advanced Settings choice.
|
||||
Settings shows mode effects, graph backfill, the default 03:00 evolve
|
||||
schedule and manual evolve. The memory page shows original conversations in
|
||||
lite and advanced memories in pro/ultra. Existing installs remain pro unless
|
||||
the user changes them, and embedding maintenance blocks mode/evolve changes.
|
||||
|
||||
## Hy Memory 2.0.13 — 2026-11-02
|
||||
|
||||
- A model failure during a write now reports failure and stores
|
||||
@@ -75,7 +83,7 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 38, 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,
|
||||
|
||||
+1
-1
@@ -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 37),GitHub 仓库为
|
||||
(build 38),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
@@ -740,6 +740,8 @@ class MemoryModeState(BaseModel):
|
||||
mode: MemoryMode
|
||||
#: The saved setting (default pro).
|
||||
configured_mode: MemoryMode
|
||||
#: True once a person chose a mode, including explicitly choosing pro.
|
||||
explicitly_configured: bool
|
||||
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
|
||||
forced_by_env: bool
|
||||
modes: tuple[MemoryMode, ...] = MEMORY_MODES
|
||||
@@ -1440,10 +1442,15 @@ def create_app(
|
||||
service=app.state.evolve_service,
|
||||
schedule=lambda: _config_store(app).evolve_schedule(),
|
||||
users_with_memories=lambda: _users_with_memories(app),
|
||||
lifecycle_lock=app.state.memory_mode_lock,
|
||||
lifecycle_open=lambda: (
|
||||
_embedding_writes_open(app) and not app.state.memory_graph_backfill.running
|
||||
),
|
||||
)
|
||||
app.state.memory_graph_backfill = MemoryGraphBackfill(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
state=app.state.evolve_state_store,
|
||||
lifecycle_open=lambda: _embedding_writes_open(app),
|
||||
)
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
@@ -1842,6 +1849,16 @@ def create_app(
|
||||
name="one-memory-durable-write-drain",
|
||||
)
|
||||
|
||||
async def resume_graph_backfill() -> None:
|
||||
saved = app.state.evolve_state_store.graph_build()
|
||||
if saved.state != "running":
|
||||
return
|
||||
if _memory_mode(app) != "ultra":
|
||||
app.state.evolve_state_store.set_graph_build(GraphBuildState())
|
||||
return
|
||||
users = await asyncio.to_thread(_known_user_ids, app)
|
||||
app.state.memory_graph_backfill.recover(users)
|
||||
|
||||
async def start_evolve_schedule() -> None:
|
||||
# Idle outside ultra: each tick asks the mode first.
|
||||
app.state.evolve_scheduler.start()
|
||||
@@ -1930,6 +1947,7 @@ def create_app(
|
||||
# served from the live space throughout.
|
||||
if sdk:
|
||||
await step("embedding_spaces", resume_embedding_spaces)
|
||||
await step("graph_backfill", resume_graph_backfill)
|
||||
await step("write_drain", start_write_drain)
|
||||
if sdk:
|
||||
await step("evolve_schedule", start_evolve_schedule)
|
||||
@@ -4693,13 +4711,19 @@ def create_app(
|
||||
added to the graph once, in the background (`graph_build`). No raw is
|
||||
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
|
||||
"""
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
|
||||
raise HTTPException(status_code=409, detail="graph_store_unavailable")
|
||||
store = _config_store(app)
|
||||
async with app.state.memory_mode_lock:
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
previous_effective = _memory_mode(app)
|
||||
if request.mode != previous_effective and (
|
||||
_evolve_service(app).running() or app.state.memory_graph_backfill.running
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
|
||||
previous = store.memory_mode()
|
||||
if previous != request.mode or "memory_mode" not in store.document().model_fields_set:
|
||||
if previous != request.mode or not store.memory_mode_explicitly_configured():
|
||||
await asyncio.to_thread(store.set_memory_mode, request.mode)
|
||||
effective = _memory_mode(app)
|
||||
backfill: bool | None = None
|
||||
@@ -4862,14 +4886,18 @@ def create_app(
|
||||
in flight and holds their next writes until it ends, and repeats
|
||||
while the SDK says there is more to do (up to 20 rounds).
|
||||
"""
|
||||
try:
|
||||
return _evolve_service(app).start(
|
||||
request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
max_batches=request.max_batches,
|
||||
)
|
||||
except EvolveModeError:
|
||||
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
|
||||
async with app.state.memory_mode_lock:
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
if app.state.memory_graph_backfill.running:
|
||||
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
|
||||
try:
|
||||
return _evolve_service(app).start(
|
||||
request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
max_batches=request.max_batches,
|
||||
)
|
||||
except EvolveModeError:
|
||||
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
|
||||
|
||||
@app.get(
|
||||
"/api/v2/evolve",
|
||||
@@ -6178,9 +6206,11 @@ def _memory_mode(app: FastAPI) -> MemoryMode:
|
||||
|
||||
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
|
||||
mode = _memory_mode(app)
|
||||
store = _config_store(app)
|
||||
return MemoryModeState(
|
||||
mode=mode,
|
||||
configured_mode=_config_store(app).memory_mode(),
|
||||
configured_mode=store.memory_mode(),
|
||||
explicitly_configured=store.memory_mode_explicitly_configured(),
|
||||
forced_by_env=graph_store_enabled(),
|
||||
graph_build=app.state.evolve_state_store.graph_build(),
|
||||
)
|
||||
@@ -6612,6 +6642,12 @@ def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
|
||||
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
|
||||
|
||||
|
||||
def _refuse_pipeline_change_during_embedding_maintenance(app: FastAPI) -> None:
|
||||
"""Keep mode/evolve lifecycle changes out of embedding maintenance."""
|
||||
if not _embedding_writes_open(app):
|
||||
raise HTTPException(status_code=409, detail="embedding_maintenance_in_progress")
|
||||
|
||||
|
||||
_EMBEDDING_PATCH_FIELDS = frozenset(
|
||||
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
|
||||
)
|
||||
|
||||
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
|
||||
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
||||
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
# One lifecycle lock orders mode/evolve/backfill and embedding
|
||||
# maintenance. Without it each endpoint could pass its own preflight
|
||||
# and then begin concurrently.
|
||||
async with request.app.state.memory_mode_lock:
|
||||
evolve = request.app.state.evolve_service
|
||||
backfill = request.app.state.memory_graph_backfill
|
||||
if evolve.running() or backfill.running:
|
||||
return refusal("memory_lifecycle_busy")
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
@router.get("/api/v1/embedding-switches/current")
|
||||
|
||||
@@ -465,12 +465,16 @@ class EvolveScheduler:
|
||||
service: EvolveService,
|
||||
schedule: Callable[[], EvolveSchedule],
|
||||
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
||||
lifecycle_lock: asyncio.Lock | None = None,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
||||
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.schedule = schedule
|
||||
self.users_with_memories = users_with_memories
|
||||
self.lifecycle_lock = lifecycle_lock
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self.check_seconds = check_seconds
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
@@ -492,7 +496,15 @@ class EvolveScheduler:
|
||||
if not evolve_due(now, schedule, last, anchor):
|
||||
continue
|
||||
try:
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
if self.lifecycle_lock is None:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
else:
|
||||
async with self.lifecycle_lock:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
except EvolveModeError:
|
||||
break
|
||||
await self.service.wait(receipt.run_id)
|
||||
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
|
||||
*,
|
||||
coordinator: EvolveCoordinator,
|
||||
state: EvolveStateStore,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.state = state
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
|
||||
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
||||
return state
|
||||
|
||||
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
|
||||
"""Resume an interrupted backfill without repeating completed users."""
|
||||
if self.running:
|
||||
return self.state.graph_build()
|
||||
saved = self.state.graph_build()
|
||||
if saved.state != "running":
|
||||
return saved
|
||||
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
|
||||
pending = tuple(
|
||||
user_id
|
||||
for user_id in users
|
||||
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
|
||||
)
|
||||
resumed = saved.model_copy(deep=True)
|
||||
for user_id in pending:
|
||||
resumed.users[user_id] = GraphBuildUser()
|
||||
if not pending:
|
||||
resumed.state = "succeeded"
|
||||
resumed.finished_at = self._now()
|
||||
self.state.set_graph_build(resumed)
|
||||
return resumed
|
||||
resumed.state = "running"
|
||||
resumed.finished_at = None
|
||||
self.state.set_graph_build(resumed)
|
||||
self._task = asyncio.create_task(
|
||||
self._run(pending), name="one-memory-graph-backfill-recovery"
|
||||
)
|
||||
return resumed
|
||||
|
||||
async def wait(self) -> None:
|
||||
if self._task is not None:
|
||||
await asyncio.shield(self._task)
|
||||
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
|
||||
async def _run(self, users: tuple[str, ...]) -> None:
|
||||
state = self.state.graph_build()
|
||||
for user_id in users:
|
||||
while not self.lifecycle_open():
|
||||
await asyncio.sleep(0.2)
|
||||
state.users[user_id] = GraphBuildUser(state="running")
|
||||
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
||||
try:
|
||||
|
||||
@@ -153,6 +153,11 @@ class ConfigStore:
|
||||
with self._lock:
|
||||
return self._document.memory_mode
|
||||
|
||||
def memory_mode_explicitly_configured(self) -> bool:
|
||||
"""Whether a person chose the mode, including choosing the default."""
|
||||
with self._lock:
|
||||
return "memory_mode" in self._document.model_fields_set
|
||||
|
||||
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
||||
if mode not in MEMORY_MODES:
|
||||
raise ValueError("invalid_memory_mode")
|
||||
|
||||
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
|
||||
backend.store.put(
|
||||
builtin_base(),
|
||||
512,
|
||||
{"m1": ("memory", "local")},
|
||||
model="bge-small-zh-v1.5",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
plan = (
|
||||
await client.post(
|
||||
"/api/v1/embedding-switches/plan",
|
||||
json={"profile_id": profile.profile_id},
|
||||
)
|
||||
).json()
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
response = await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": plan["plan_token"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
assert app.state.embedding_switch.writes_open() is True
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_over_the_api(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
|
||||
EvolveScheduler,
|
||||
EvolveService,
|
||||
EvolveStateStore,
|
||||
GraphBuildState,
|
||||
GraphBuildUser,
|
||||
MemoryGraphBackfill,
|
||||
evolve_due,
|
||||
)
|
||||
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
assert effective_memory_mode(store.memory_mode()) == "pro"
|
||||
store.set_memory_mode("lite")
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
store.set_memory_mode("max") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
|
||||
)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
|
||||
store.set_memory_mode("pro")
|
||||
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
|
||||
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
|
||||
|
||||
|
||||
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
@@ -624,6 +643,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
|
||||
assert state.user("wife").last_result["trigger"] == "scheduled"
|
||||
|
||||
|
||||
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=0)
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.anchor(_at(1, 12))
|
||||
open_gate = [False]
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
memory_mode=lambda: "ultra",
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
|
||||
async def users():
|
||||
return ("local",)
|
||||
|
||||
async def scenario():
|
||||
scheduler = EvolveScheduler(
|
||||
service=service,
|
||||
schedule=EvolveSchedule,
|
||||
users_with_memories=users,
|
||||
lifecycle_lock=asyncio.Lock(),
|
||||
lifecycle_open=lambda: open_gate[0],
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
assert await scheduler.tick() == ()
|
||||
assert sdk.calls == []
|
||||
open_gate[0] = True
|
||||
assert await scheduler.tick() == ("local",)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
@@ -643,6 +694,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.set_graph_build(
|
||||
GraphBuildState(
|
||||
state="running",
|
||||
started_at=_at(1, 3),
|
||||
users={
|
||||
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
|
||||
"wife": GraphBuildUser(state="running"),
|
||||
},
|
||||
)
|
||||
)
|
||||
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
|
||||
|
||||
async def scenario():
|
||||
resumed = backfill.recover(["local", "wife"])
|
||||
assert resumed.state == "running"
|
||||
assert resumed.users["local"].state == "succeeded"
|
||||
assert resumed.users["wife"].state == "pending"
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["wife"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
gate = [False]
|
||||
checked = asyncio.Event()
|
||||
|
||||
def lifecycle_open() -> bool:
|
||||
checked.set()
|
||||
return gate[0]
|
||||
|
||||
backfill = MemoryGraphBackfill(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
lifecycle_open=lifecycle_open,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
backfill.start(["local"])
|
||||
await checked.wait()
|
||||
assert sdk.graph_builds == []
|
||||
assert state.graph_build().state == "running"
|
||||
assert state.graph_build().users["local"].state == "pending"
|
||||
gate[0] = True
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["local"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -701,6 +809,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert current["mode"] == current["configured_mode"] == "pro"
|
||||
assert current["explicitly_configured"] is False
|
||||
assert current["forced_by_env"] is False
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
||||
|
||||
@@ -708,6 +817,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
assert lite.status_code == 200, lite.text
|
||||
body = lite.json()
|
||||
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
||||
assert body["explicitly_configured"] is True
|
||||
assert body["effects"] == ["higher_mode_memories_hidden"]
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
||||
assert _sdk_client_handle(app).memory_mode() == "lite"
|
||||
@@ -741,6 +851,65 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
switch = app.state.embedding_switch
|
||||
|
||||
switch._scan_hold = True
|
||||
current = await client.get("/api/v1/settings/memory-mode")
|
||||
assert current.status_code == 200
|
||||
assert current.json()["explicitly_configured"] is False
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
assert app.state.config_store.memory_mode_explicitly_configured() is False
|
||||
|
||||
switch._scan_hold = False
|
||||
switch._open_cache = True
|
||||
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
|
||||
switch._open_cache = False
|
||||
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||
assert explicitly_pro.status_code == 200
|
||||
assert explicitly_pro.json()["explicitly_configured"] is True
|
||||
assert explicitly_pro.json()["effects"] == []
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
|
||||
@@ -37,7 +37,41 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
|
||||
vi.mock("./api", () => ({
|
||||
getOnboarding,
|
||||
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
getEvolveOverview: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
}),
|
||||
getMemoryProcessing: vi.fn().mockResolvedValue({
|
||||
embedding_switch: null,
|
||||
}),
|
||||
setMemoryMode: vi.fn(),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
startEvolve: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
getSources: vi.fn().mockResolvedValue(sources),
|
||||
getCurrentEmbeddingSwitch: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("unsupported")),
|
||||
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
|
||||
...mutations,
|
||||
}));
|
||||
vi.mock("./use-import-sources", () => ({
|
||||
@@ -119,7 +153,7 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
|
||||
).toBeNull();
|
||||
noModelWrites();
|
||||
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "记忆模式与模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
|
||||
noModelWrites();
|
||||
@@ -188,7 +222,7 @@ it("keeps an open model draft while switching settings groups", async () => {
|
||||
"unsaved draft",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "记忆模式与模型" }));
|
||||
expect(window.location.search).toBe("?view=settings");
|
||||
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
|
||||
"value",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useApplicationsLanguage } from "./applications-language";
|
||||
import { CallHistory } from "./CallHistory";
|
||||
import { DebugLogPanel } from "./DebugLogPanel";
|
||||
import { DesktopFrame } from "./DesktopFrame";
|
||||
import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HealthPanel } from "./HealthPanel";
|
||||
import { ImportProgressStrip } from "./ImportProgressStrip";
|
||||
@@ -309,6 +310,10 @@ function AppContent() {
|
||||
ref={content}
|
||||
data-view={displayedView}
|
||||
>
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={() => navigate("settings")}
|
||||
onDiagnostics={() => navigate("health")}
|
||||
/>
|
||||
{/* First run hands the user in here while the import is still going.
|
||||
* Without this the work would be invisible from every screen except
|
||||
* the one they were no longer required to sit on. */}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const cancelCurrentSwitch = vi.fn();
|
||||
let current: EmbeddingSwitchCurrent | null = null;
|
||||
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
useEmbeddingSwitch: () => ({
|
||||
current,
|
||||
busy: null,
|
||||
cancelCurrentSwitch,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
deleteRetiredEmbeddingSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbeddingSwitchConfirmDialog,
|
||||
EmbeddingSwitchGlobalStatus,
|
||||
} from "./EmbeddingSwitchExperience";
|
||||
|
||||
const NOW = "2026-11-03T09:00:00Z";
|
||||
const space = (id: string | null, model: string) => ({
|
||||
space_id: id,
|
||||
model,
|
||||
dims: model === "old-search" ? 512 : 1024,
|
||||
collection_base: id ? "memory" : "",
|
||||
vectors: id ? 8421 : null,
|
||||
});
|
||||
const plan: EmbeddingSwitchPlan = {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token",
|
||||
source: space("space-old", "old-search"),
|
||||
target: space(null, "new-search"),
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [
|
||||
{
|
||||
space_id: "space-old",
|
||||
collection_base: "memory",
|
||||
dims: 512,
|
||||
vectors: 8421,
|
||||
unmerged: null,
|
||||
},
|
||||
],
|
||||
total_vectors: 8421,
|
||||
users: 1,
|
||||
graph_vectors: null,
|
||||
estimate_s: 120,
|
||||
requests: 43,
|
||||
disk_bytes_needed: 72 * 1024 * 1024,
|
||||
disk_bytes_free: 1024 * 1024 * 1024,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
};
|
||||
const job: EmbeddingSwitchJob = {
|
||||
job_id: "job-1",
|
||||
kind: "switch",
|
||||
state: "reembedding",
|
||||
source: space("space-old", "old-search"),
|
||||
target: space("space-new", "new-search"),
|
||||
stray_space_ids: [],
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "vectors",
|
||||
done: 4884,
|
||||
total: 8421,
|
||||
percent: 58,
|
||||
embedded: 4884,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: "memory",
|
||||
source_index: 0,
|
||||
sources: 1,
|
||||
rate_per_s: 90,
|
||||
eta_s: 52,
|
||||
updated_at: NOW,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: null,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
current = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("explains the index update in user terms before exposing technical details", async () => {
|
||||
const onStart = vi.fn();
|
||||
render(
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={plan}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={onStart}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("8,421 条")).toBeTruthy();
|
||||
expect(screen.getByText("约 2 分钟")).toBeTruthy();
|
||||
expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy();
|
||||
await userEvent.click(screen.getByText("查看技术详情"));
|
||||
expect(screen.getByText("512 → 1024")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "开始更新索引" }));
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks start when Core reports a prerequisite", () => {
|
||||
render(
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={{ ...plan, blockers: ["insufficient_disk_space"] }}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/剩余空间不足/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a running switch visible and cancellable outside settings", async () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job,
|
||||
last_finished: null,
|
||||
};
|
||||
render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={vi.fn()}
|
||||
onDiagnostics={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("正在更新记忆索引")).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"58",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消更新" }));
|
||||
expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows startup index inspection without blocking the rest of the app", () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "scanning",
|
||||
writes_paused: true,
|
||||
scan_state: "scanning",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: null,
|
||||
};
|
||||
render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={vi.fn()}
|
||||
onDiagnostics={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy();
|
||||
expect(screen.getByText(/搜索仍可使用/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains rollback safety and gives recovery actions", async () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job,
|
||||
last_finished: null,
|
||||
};
|
||||
const onConfigureSearch = vi.fn();
|
||||
const onDiagnostics = vi.fn();
|
||||
const view = render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={onConfigureSearch}
|
||||
onDiagnostics={onDiagnostics}
|
||||
/>,
|
||||
);
|
||||
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: {
|
||||
...job,
|
||||
state: "rolled_back",
|
||||
error_code: "embedding_service_failed",
|
||||
progress: { ...job.progress, phase: "done" },
|
||||
finished_at: NOW,
|
||||
},
|
||||
};
|
||||
view.rerender(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={onConfigureSearch}
|
||||
onDiagnostics={onDiagnostics}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy());
|
||||
await userEvent.click(screen.getByRole("button", { name: "检查连接" }));
|
||||
expect(onConfigureSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import { ChevronDown, CircleAlert, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteRetiredEmbeddingSpace } from "./api";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import "./embedding-switch.css";
|
||||
|
||||
function count(value: number | null | undefined) {
|
||||
return new Intl.NumberFormat().format(value ?? 0);
|
||||
}
|
||||
|
||||
function duration(seconds: number | null) {
|
||||
if (seconds === null) return "暂时无法估算";
|
||||
if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`;
|
||||
return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`;
|
||||
}
|
||||
|
||||
function size(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
return `${Math.ceil(bytes / 1024 / 1024)} MB`;
|
||||
}
|
||||
|
||||
const blockerCopy: Record<string, string> = {
|
||||
embedding_probe_required: "请先测试新的搜索模型连接。",
|
||||
embedding_dimensions_unknown:
|
||||
"还无法确认新搜索模型的索引规格,请重新测试连接。",
|
||||
embedding_switch_requires_migration:
|
||||
"旧版本数据仍在升级,请等待升级完成后再更换。",
|
||||
sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。",
|
||||
insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。",
|
||||
embedding_switch_running: "另一次索引更新正在进行。",
|
||||
memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。",
|
||||
};
|
||||
|
||||
const failureCopy: Record<string, string> = {
|
||||
writes_did_not_drain: "等待正在进行的记忆整理结束超时。",
|
||||
embedding_service_failed: "搜索模型服务连续请求失败。",
|
||||
embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。",
|
||||
embedding_verification_failed: "新的记忆索引没有通过完整性校验。",
|
||||
target_profile_changed: "更新期间搜索模型配置发生了变化。",
|
||||
embedding_switch_cancelled: "索引更新已取消。",
|
||||
model_secret_unavailable: "无法读取搜索服务的 API Key。",
|
||||
secure_storage_unavailable: "系统安全存储暂时不可用。",
|
||||
embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。",
|
||||
};
|
||||
|
||||
export function EmbeddingSwitchConfirmDialog({
|
||||
plan,
|
||||
busy,
|
||||
errorCode,
|
||||
onCancel,
|
||||
onStart,
|
||||
}: {
|
||||
plan: EmbeddingSwitchPlan;
|
||||
busy: boolean;
|
||||
errorCode: string | null;
|
||||
onCancel(): void;
|
||||
onStart(): void;
|
||||
}) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const blockers = plan.blockers.map(
|
||||
(code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const opener =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
panel.current
|
||||
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
|
||||
?.focus();
|
||||
return () => {
|
||||
if (opener?.isConnected) opener.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="r6-settings-dialog embedding-switch-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="embedding-switch-title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !busy) onCancel();
|
||||
if (event.key !== "Tab" || !panel.current) return;
|
||||
const controls = [
|
||||
...panel.current.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
|
||||
),
|
||||
];
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="embedding-switch-title">更新记忆搜索索引?</h2>
|
||||
<p>
|
||||
不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="embedding-switch-impact">
|
||||
<div>
|
||||
<dt>已有记忆</dt>
|
||||
<dd>
|
||||
{count(plan.total_vectors)} 条
|
||||
{plan.users && plan.users > 1
|
||||
? ` · ${count(plan.users)} 位用户`
|
||||
: ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>预计耗时</dt>
|
||||
<dd>{duration(plan.estimate_s)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>远程请求</dt>
|
||||
<dd>
|
||||
{plan.target_local
|
||||
? "不会产生"
|
||||
: plan.requests === null
|
||||
? "按服务商计费"
|
||||
: `约 ${count(plan.requests)} 次`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="embedding-switch-safety">
|
||||
<strong>更新期间仍可搜索记忆</strong>
|
||||
<p>
|
||||
MemHub
|
||||
会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。
|
||||
</p>
|
||||
<strong>失败不会影响已有数据</strong>
|
||||
<p>
|
||||
只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details className="embedding-switch-details">
|
||||
<summary>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
查看技术详情
|
||||
</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>当前搜索模型</dt>
|
||||
<dd>{plan.source.model ?? "旧版本索引"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>新的搜索模型</dt>
|
||||
<dd>{plan.target.model ?? "待确认"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>索引维度</dt>
|
||||
<dd>
|
||||
{plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>额外磁盘空间</dt>
|
||||
<dd>{size(plan.disk_bytes_needed)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>旧索引保留</dt>
|
||||
<dd>{plan.retention_days} 天</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
{blockers.length > 0 ? (
|
||||
<div className="embedding-switch-alert" role="alert">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
<span>{blockers.join(" ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{errorCode ? (
|
||||
<div className="embedding-switch-alert" role="alert">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
<span>
|
||||
{errorCode === "embedding_switch_plan_stale"
|
||||
? "记忆状态发生了变化,请重新检查更换影响。"
|
||||
: "索引更新暂时无法开始,请稍后重试。"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={onCancel}>
|
||||
暂不更换
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
disabled={blockers.length > 0}
|
||||
onClick={onStart}
|
||||
>
|
||||
开始更新索引
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(job: EmbeddingSwitchJob) {
|
||||
if (job.kind === "merge") return "正在整理旧版本留下的搜索索引";
|
||||
if (job.state === "preparing") return "正在准备更新记忆索引";
|
||||
if (job.state === "reembedding") return "正在更新记忆索引";
|
||||
if (job.state === "verifying") return "正在校验新的记忆索引";
|
||||
if (job.state === "committing") return "正在启用新的搜索模型";
|
||||
if (job.state === "failed") return "更新没有完成,正在恢复原设置";
|
||||
return "记忆索引更新";
|
||||
}
|
||||
|
||||
function canCancel(job: EmbeddingSwitchJob) {
|
||||
return ["preparing", "reembedding", "verifying"].includes(job.state);
|
||||
}
|
||||
|
||||
function ActiveSwitchBanner({
|
||||
current,
|
||||
onCancel,
|
||||
cancelling,
|
||||
}: {
|
||||
current: EmbeddingSwitchCurrent;
|
||||
onCancel(): void;
|
||||
cancelling: boolean;
|
||||
}) {
|
||||
const job = current.job;
|
||||
if (!job && current.phase !== "scanning") return null;
|
||||
const percent = job?.progress.percent ?? 0;
|
||||
return (
|
||||
<aside className="embedding-switch-strip" aria-live="polite">
|
||||
<div className="embedding-switch-strip-copy">
|
||||
<strong>{job ? jobTitle(job) : "正在检查本机的记忆搜索索引"}</strong>
|
||||
<span>
|
||||
{job?.state === "failed"
|
||||
? "原来的搜索模型仍在使用,记忆没有丢失。"
|
||||
: job
|
||||
? `${Math.round(percent)}%${
|
||||
job.progress.eta_s === null
|
||||
? ""
|
||||
: ` · ${duration(job.progress.eta_s)}`
|
||||
}`
|
||||
: "完成前暂不整理新记忆,搜索仍可使用。"}
|
||||
</span>
|
||||
</div>
|
||||
{job ? (
|
||||
<div
|
||||
className="embedding-switch-progress"
|
||||
role="progressbar"
|
||||
aria-label="记忆索引更新进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
>
|
||||
<span style={{ width: `${Math.max(2, percent)}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{job && canCancel(job) ? (
|
||||
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
|
||||
取消更新
|
||||
</R6Button>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddingSwitchGlobalStatus({
|
||||
onConfigureSearch,
|
||||
onDiagnostics,
|
||||
}: {
|
||||
onConfigureSearch(): void;
|
||||
onDiagnostics(): void;
|
||||
}) {
|
||||
const controller = useEmbeddingSwitch();
|
||||
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [cleanupError, setCleanupError] = useState(false);
|
||||
const previousJob = useRef<string | null>(null);
|
||||
const alive = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
alive.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const open = controller.current?.job?.job_id ?? null;
|
||||
if (
|
||||
previousJob.current &&
|
||||
!open &&
|
||||
controller.current?.last_finished?.job_id === previousJob.current
|
||||
) {
|
||||
setRecent(controller.current.last_finished);
|
||||
}
|
||||
previousJob.current = open;
|
||||
}, [controller.current]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controller.current ? (
|
||||
<ActiveSwitchBanner
|
||||
current={controller.current}
|
||||
cancelling={controller.busy === "cancel"}
|
||||
onCancel={() => void controller.cancelCurrentSwitch()}
|
||||
/>
|
||||
) : null}
|
||||
{controller.refreshFailed && controller.supported === true ? (
|
||||
<aside className="embedding-switch-outcome is-error" role="status">
|
||||
<div>
|
||||
<strong>索引状态暂时无法读取</strong>
|
||||
<span>当前显示可能不是最新状态,可以继续使用已有记忆。</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
|
||||
重新读取
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{controller.current &&
|
||||
["failed", "unavailable"].includes(controller.current.scan_state) ? (
|
||||
<aside className="embedding-switch-outcome is-error" role="alert">
|
||||
<div>
|
||||
<strong>旧版本搜索索引检查未完成</strong>
|
||||
<span>
|
||||
{controller.current.writes_paused
|
||||
? "新的记忆整理暂时暂停,已有记忆仍可搜索。"
|
||||
: "记忆整理已经恢复;可以继续使用,诊断中保留了失败原因。"}
|
||||
</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
查看诊断
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{recent?.state === "done" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<strong>搜索模型已更换</strong>
|
||||
<span>暂停期间保存的新对话正在整理成记忆。</span>
|
||||
</div>
|
||||
<div className="embedding-switch-outcome-actions">
|
||||
{recent.source.space_id ? (
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
pending={cleaning}
|
||||
onClick={() => {
|
||||
if (!recent.source.space_id) return;
|
||||
setCleaning(true);
|
||||
setCleanupError(false);
|
||||
void deleteRetiredEmbeddingSpace(recent.source.space_id)
|
||||
.then(() => {
|
||||
if (alive.current) setRecent(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive.current) setCleanupError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive.current) setCleaning(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
清理旧索引
|
||||
</R6Button>
|
||||
) : null}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label="关闭完成提示"
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{cleanupError ? (
|
||||
<p className="embedding-switch-cleanup-error" role="alert">
|
||||
旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。
|
||||
</p>
|
||||
) : null}
|
||||
{recent?.state === "rolled_back" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome is-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div>
|
||||
<strong>索引更新没有完成</strong>
|
||||
<span>
|
||||
{failureCopy[recent.error_code ?? ""]
|
||||
? `${failureCopy[recent.error_code ?? ""]} 仍在使用原来的搜索模型,记忆没有丢失。`
|
||||
: "仍在使用原来的搜索模型,记忆没有丢失。"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="embedding-switch-outcome-actions">
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
查看诊断
|
||||
</R6Button>
|
||||
<R6Button variant="quiet" onClick={onConfigureSearch}>
|
||||
检查连接
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label="关闭失败提示"
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkMemoryScene,
|
||||
SdkRawContextItem,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import type { GuiLocale } from "@one-memory/i18n";
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HandoffBar } from "./HandoffBar";
|
||||
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
|
||||
import { MemoryGraph3D } from "./MemoryGraph3D";
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SelectedRawDetail } from "./SelectedRawDetail";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { useMediaPreference } from "./use-media-preference";
|
||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||
@@ -89,6 +93,8 @@ type Props = {
|
||||
id?: string;
|
||||
arrival?: MemoryArrival;
|
||||
onViewHistory?(): void;
|
||||
contentKind?: "memory" | "raw";
|
||||
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
|
||||
libraryView?: {
|
||||
query: string;
|
||||
source: string;
|
||||
@@ -171,6 +177,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
||||
|
||||
export function MemoryMap(props: Props) {
|
||||
const { locale, text, count, plain } = useMemoryLanguage();
|
||||
const { t } = useGuiLanguage();
|
||||
const raw = props.contentKind === "raw";
|
||||
const {
|
||||
memories,
|
||||
graph: baseGraph,
|
||||
@@ -699,6 +707,7 @@ export function MemoryMap(props: Props) {
|
||||
: "";
|
||||
if (
|
||||
props.simplified &&
|
||||
!raw &&
|
||||
(!library ||
|
||||
(!library.loading &&
|
||||
!library.failed &&
|
||||
@@ -860,11 +869,15 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
aria-label={text("筛选地图记忆")}
|
||||
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
|
||||
name="map-memory-search"
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
props.simplified ? text("搜索记忆") : text("筛选记忆…")
|
||||
raw
|
||||
? t("sdk.raw.searchLabel")
|
||||
: props.simplified
|
||||
? text("搜索记忆")
|
||||
: text("筛选记忆…")
|
||||
}
|
||||
value={library ? searchDraft : search}
|
||||
maxLength={library ? 512 : undefined}
|
||||
@@ -971,7 +984,7 @@ export function MemoryMap(props: Props) {
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
aria-label={text("地图记忆列表")}
|
||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
@@ -1014,12 +1027,12 @@ export function MemoryMap(props: Props) {
|
||||
m.updated_at,
|
||||
locale,
|
||||
)}`}
|
||||
{/* D99: the conversation date alone would hide that this
|
||||
one was made today, so the generated day stays too. */}
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
{!raw ? (
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
text("已可用 · {count} 段原文", {
|
||||
@@ -1033,9 +1046,11 @@ export function MemoryMap(props: Props) {
|
||||
</ul>
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<p>
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
|
||||
</p>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1045,13 +1060,17 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale &&
|
||||
!filtered.length && (
|
||||
<p className="map-empty">
|
||||
{library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
{raw
|
||||
? library.query
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: t("sdk.raw.empty")
|
||||
: library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
</p>
|
||||
)}
|
||||
{library?.page?.search_window &&
|
||||
@@ -1151,12 +1170,20 @@ export function MemoryMap(props: Props) {
|
||||
<span className="map-kicker" hidden={props.simplified}>
|
||||
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
|
||||
</span>
|
||||
<h2>{props.simplified ? text("记忆关联") : label}</h2>
|
||||
<h2>
|
||||
{props.simplified
|
||||
? raw
|
||||
? t("memory:原始对话")
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1195,21 +1222,33 @@ export function MemoryMap(props: Props) {
|
||||
: {})}
|
||||
>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("可在沉淀记忆后查看视图")
|
||||
: library?.graphFailed || library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("记忆暂时无法读取")
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
? raw
|
||||
? t("sdk.raw.recent")
|
||||
: text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
@@ -1313,13 +1352,15 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
<h2>
|
||||
{inspectorDetail
|
||||
? text("记忆详情")
|
||||
: selectedSource
|
||||
{raw
|
||||
? t("memory:原始对话")
|
||||
: inspectorDetail
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
: selectedSource
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
</h2>
|
||||
{(activeId || visibleGroupNode) && (
|
||||
<button
|
||||
@@ -1339,7 +1380,9 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed &&
|
||||
library.source === selectedSource.source_id ? (
|
||||
<>
|
||||
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||
<p role="alert">
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
@@ -1356,10 +1399,12 @@ export function MemoryMap(props: Props) {
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||
<p role="status">
|
||||
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{text("还没有已生成的记忆")}</p>
|
||||
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
@@ -1423,6 +1468,8 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
<p role="status">{text("正在读取这条记忆…")}</p>
|
||||
</div>
|
||||
) : raw && props.rawRecord ? (
|
||||
<SelectedRawDetail record={props.rawRecord} sources={sources} />
|
||||
) : (
|
||||
<SelectedMemoryDetail
|
||||
detail={inspectorDetail}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryMode: vi.fn(),
|
||||
setMemoryMode: vi.fn(),
|
||||
getEvolveSchedule: vi.fn(),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
getEvolveOverview: vi.fn(),
|
||||
startEvolve: vi.fn(),
|
||||
getMemoryProcessing: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
function state(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
return {
|
||||
mode,
|
||||
configured_mode: mode,
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"] as Array<"lite" | "pro" | "ultra">,
|
||||
graph_build: {
|
||||
state: "idle" as const,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state(mode));
|
||||
vi.mocked(getEvolveSchedule).mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(getEvolveOverview).mockResolvedValue({
|
||||
mode,
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
|
||||
}
|
||||
|
||||
async function chooseMode(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
await user.click(picker);
|
||||
await user.click(screen.getByRole("option", { name }));
|
||||
return picker;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(setMemoryMode).mockResolvedValue({
|
||||
...state("lite"),
|
||||
previous_mode: "pro",
|
||||
effects: ["higher_mode_memories_hidden"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
expect(screen.getByText(/高级记忆会保留/)).toBeTruthy();
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||
});
|
||||
|
||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const lite = await chooseMode(user, /基础记忆(lite)/);
|
||||
|
||||
const cancel = screen.getByRole("button", { name: "取消" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "切换模式" }),
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(lite);
|
||||
});
|
||||
|
||||
it("keeps focus inside the modal while the mode change is pending", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
|
||||
const modal = screen.getByRole("dialog");
|
||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||
await user.keyboard("{Tab}");
|
||||
expect(document.activeElement).toBe(modal);
|
||||
});
|
||||
|
||||
it("routes to model setup instead of switching pro without a model", async () => {
|
||||
prepare("lite");
|
||||
const configure = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
|
||||
);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the default nightly evolve controls in ultra", async () => {
|
||||
prepare("ultra");
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("switch", { name: "每天自动整合" })).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(screen.getByLabelText("自动整合时间") as HTMLInputElement).value,
|
||||
).toBe("03:00");
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores an older refresh that finishes after a newer mode read", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
|
||||
let resolveOld!: (value: ReturnType<typeof state>) => void;
|
||||
vi.mocked(getMemoryMode)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(state("lite"));
|
||||
|
||||
const refresh = screen.getByRole("button", { name: "刷新记忆模式" });
|
||||
await user.click(refresh);
|
||||
await user.click(refresh);
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
resolveOld(state("pro"));
|
||||
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
});
|
||||
|
||||
it("does not let an older refresh undo a saved schedule change", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
enabled: false,
|
||||
time: "03:00",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
name: "每天自动整合",
|
||||
});
|
||||
|
||||
let resolveOld!: (value: { enabled: true; time: string }) => void;
|
||||
vi.mocked(getEvolveSchedule).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "刷新记忆模式" }));
|
||||
await user.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }),
|
||||
);
|
||||
resolveOld({ enabled: true, time: "03:00" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("combobox", {
|
||||
name: "当前模式",
|
||||
})) as HTMLButtonElement,
|
||||
).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("keeps lite available while model status is unknown", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={false}
|
||||
modelStateKnown={false}
|
||||
onConfigureModel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("combobox", { name: "当前模式" }));
|
||||
expect(
|
||||
screen.getByRole("option", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", false);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /深度记忆(ultra)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import type {
|
||||
EvolveOverview,
|
||||
EvolveRunReceipt,
|
||||
EvolveSchedule,
|
||||
MemoryModeChange,
|
||||
MemoryModeState,
|
||||
MemoryPipelineMode,
|
||||
} from "@one-memory/contracts";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
startEvolve,
|
||||
} from "./api";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
|
||||
const MODE_COPY: Record<
|
||||
MemoryPipelineMode,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
lite: {
|
||||
title: "基础记忆(lite)",
|
||||
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
|
||||
},
|
||||
pro: {
|
||||
title: "智能记忆(pro)",
|
||||
description: "使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
},
|
||||
ultra: {
|
||||
title: "深度记忆(ultra)",
|
||||
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
|
||||
},
|
||||
};
|
||||
|
||||
const EFFECT_COPY: Record<string, string> = {
|
||||
earlier_raw_not_extracted:
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。",
|
||||
lite_search_stops: "原始对话检索会停止,改为使用高级记忆。",
|
||||
higher_mode_memories_hidden:
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。",
|
||||
evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||
existing_memories_added_to_graph:
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。",
|
||||
graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。",
|
||||
};
|
||||
|
||||
function plannedEffects(
|
||||
current: MemoryPipelineMode,
|
||||
target: MemoryPipelineMode,
|
||||
): string[] {
|
||||
if (current === target) return [];
|
||||
const effects: string[] = [];
|
||||
if (current === "lite" && target !== "lite")
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
if (target === "lite") effects.push("higher_mode_memories_hidden");
|
||||
if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped");
|
||||
if (target === "ultra") effects.push("existing_memories_added_to_graph");
|
||||
return effects;
|
||||
}
|
||||
|
||||
export function MemoryModeSettings({
|
||||
hasActiveModel,
|
||||
modelStateKnown = true,
|
||||
onConfigureModel,
|
||||
onModeChange,
|
||||
}: {
|
||||
hasActiveModel: boolean;
|
||||
modelStateKnown?: boolean;
|
||||
onConfigureModel(): void;
|
||||
onModeChange?(mode: MemoryPipelineMode): void;
|
||||
}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const id = useId();
|
||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
|
||||
const [overview, setOverview] = useState<EvolveOverview | null>(null);
|
||||
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const refreshVersion = useRef(0);
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
busyRef.current = busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMode || !dialog.current) return;
|
||||
const panel = dialog.current;
|
||||
const controls = () =>
|
||||
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
|
||||
controls()[0]?.focus();
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busyRef.current) {
|
||||
event.preventDefault();
|
||||
setPendingMode(null);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const items = controls();
|
||||
const first = items[0];
|
||||
const last = items.at(-1);
|
||||
if (!first || !last) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
panel.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
panel.removeEventListener("keydown", handleKey);
|
||||
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
|
||||
};
|
||||
}, [pendingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingMode && busy) dialog.current?.focus();
|
||||
}, [busy, pendingMode]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
||||
await Promise.allSettled([
|
||||
getMemoryMode(),
|
||||
getEvolveSchedule(),
|
||||
getEvolveOverview(),
|
||||
getMemoryProcessing(),
|
||||
]);
|
||||
if (version !== refreshVersion.current) return;
|
||||
if (modeResult.status === "fulfilled") {
|
||||
setState(modeResult.value);
|
||||
onModeChange?.(modeResult.value.mode);
|
||||
setError("");
|
||||
} else {
|
||||
setError("记忆模式状态暂时无法读取,请重试。");
|
||||
}
|
||||
if (scheduleResult.status === "fulfilled")
|
||||
setScheduleState(scheduleResult.value);
|
||||
if (overviewResult.status === "fulfilled") {
|
||||
setOverview(overviewResult.value);
|
||||
setRun(overviewResult.value.running[0] ?? null);
|
||||
}
|
||||
if (processingResult.status === "fulfilled")
|
||||
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
||||
}, [onModeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const running =
|
||||
maintenance ||
|
||||
state?.graph_build.state === "running" ||
|
||||
run?.state === "running";
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
|
||||
|
||||
const lifecycleBusy =
|
||||
maintenance ||
|
||||
run?.state === "running" ||
|
||||
state?.graph_build.state === "running";
|
||||
|
||||
function choose(mode: MemoryPipelineMode) {
|
||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||
if (mode !== "lite" && !hasActiveModel) {
|
||||
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
|
||||
onConfigureModel();
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||
setPendingMode(mode);
|
||||
}
|
||||
|
||||
async function commitMode() {
|
||||
if (!pendingMode) return;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
|
||||
setState(changed);
|
||||
setPendingMode(null);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
code === "embedding_maintenance_in_progress"
|
||||
? "正在检查或切换向量模型,完成后才能切换记忆模式。"
|
||||
: code === "graph_store_unavailable"
|
||||
? "当前安装缺少知识图谱组件,无法开启 ultra。"
|
||||
: "模式切换未能确认,仍显示上次确认的模式。",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSchedule(update: Partial<EvolveSchedule>) {
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setScheduleState(await setEvolveSchedule(update));
|
||||
} catch {
|
||||
setError("自动整合设置未能保存,请重试。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function evolveNow() {
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const receipt = await startEvolve();
|
||||
setRun(receipt);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
code === "embedding_maintenance_in_progress"
|
||||
? "正在检查或切换向量模型,完成后才能整合记忆。"
|
||||
: "未能开始整合记忆,请重试。",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const last = overview?.users.local;
|
||||
return (
|
||||
<section
|
||||
className="r6-memory-mode-settings"
|
||||
aria-labelledby="memory-mode-title"
|
||||
>
|
||||
<div className="r6-mode-heading">
|
||||
<div>
|
||||
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={text("刷新记忆模式")}
|
||||
title={text("刷新记忆模式")}
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
<div
|
||||
className="r6-language-setting-row r6-mode-setting-row"
|
||||
aria-busy={state === null}
|
||||
>
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`}>
|
||||
{text("当前模式")}
|
||||
</label>
|
||||
<OptionMenu
|
||||
id={`${id}-select`}
|
||||
labelledBy={`${id}-label`}
|
||||
value={state?.mode ?? ""}
|
||||
disabled={!state || busy || lifecycleBusy}
|
||||
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
value: mode,
|
||||
label: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
disabled: mode !== "lite" && !modelStateKnown,
|
||||
}))}
|
||||
className="r6-language-option-menu r6-mode-option-menu"
|
||||
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
|
||||
onChange={(value) => {
|
||||
if (value === "lite" || value === "pro" || value === "ultra")
|
||||
choose(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
||||
</p>
|
||||
) : null}
|
||||
{maintenance ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
||||
</p>
|
||||
) : null}
|
||||
{state?.graph_build.state === "running" ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在把已有高级记忆加入知识图谱…")}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{state?.mode === "ultra" && schedule ? (
|
||||
<div className="r6-evolve-settings">
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("每天自动整合")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
).replace("{time}", schedule.time)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("每天自动整合")}
|
||||
checked={schedule.enabled}
|
||||
disabled={busy || maintenance}
|
||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("自动整合时间")}</h3>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
aria-label={text("自动整合时间")}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("立即整合记忆")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingMode ? (
|
||||
<div className="r6-settings-dialog-layer" role="presentation">
|
||||
<section
|
||||
ref={dialog}
|
||||
className="r6-settings-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="memory-mode-confirm-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<h2 id="memory-mode-confirm-title">
|
||||
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
|
||||
</h2>
|
||||
<ul>
|
||||
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
|
||||
(effect) => (
|
||||
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
|
||||
),
|
||||
)}
|
||||
<li>{text("原始对话在所有模式下都会保留。")}</li>
|
||||
</ul>
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
|
||||
{text("取消")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
onClick={() => void commitMode()}
|
||||
>
|
||||
{text("切换模式")}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,8 @@ const { reads } = vi.hoisted(() => ({
|
||||
sdkGraph: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
rawSearch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
@@ -82,6 +84,8 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getSdkMemoryGraph: reads.sdkGraph,
|
||||
getMemoryProcessing: reads.processing,
|
||||
getModelProfiles: reads.profiles,
|
||||
getSdkRaw: reads.raw,
|
||||
searchSdkRaw: reads.rawSearch,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryAddComposer", () => ({
|
||||
@@ -194,8 +198,130 @@ function setup() {
|
||||
completed: 1,
|
||||
});
|
||||
reads.profiles.mockResolvedValue([]);
|
||||
reads.raw.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue({
|
||||
running: 0,
|
||||
pending: 0,
|
||||
blocked_stages: [],
|
||||
completed: 1,
|
||||
pipeline: {
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
forced_by_env: false,
|
||||
evolve: {
|
||||
mode: "lite",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
last_evolve_at: null,
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
features: {},
|
||||
},
|
||||
});
|
||||
reads.raw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-1",
|
||||
content: "原始对话内容",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
source: "cursor",
|
||||
session_id: "session-1",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: "原始对话内容" }],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||
).toBe(false);
|
||||
expect(screen.getByTestId("graph-owner")).toBeTruthy();
|
||||
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "补充记忆" })).toBeNull();
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
expect(reads.raw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/当前为 lite/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not request advanced memories before the pipeline mode is known", async () => {
|
||||
setup();
|
||||
let resolveProcessing!: (value: unknown) => void;
|
||||
reads.processing.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveProcessing = resolve;
|
||||
}),
|
||||
);
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await act(async () => Promise.resolve());
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveProcessing({
|
||||
running: 0,
|
||||
pending: 0,
|
||||
blocked_stages: [],
|
||||
completed: 0,
|
||||
pipeline: {
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
forced_by_env: false,
|
||||
evolve: {
|
||||
mode: "lite",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
last_evolve_at: null,
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
features: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
await screen.findByText(/当前为 lite/);
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an added memory visible while it waits for model processing", async () => {
|
||||
setup();
|
||||
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
getSdkMemory,
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
getSdkRaw,
|
||||
manualExtract,
|
||||
searchSdkRaw,
|
||||
setExtractionWindow,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
@@ -54,6 +56,12 @@ import {
|
||||
sdkLibraryPage,
|
||||
sdkNamespace,
|
||||
} from "./sdk-memory-map";
|
||||
import {
|
||||
type RawMapRecord,
|
||||
rawDetail,
|
||||
rawLibraryPage,
|
||||
rawSearchPage,
|
||||
} from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
|
||||
@@ -109,6 +117,10 @@ export function MemorySpace({
|
||||
return isMessageKey(key) ? t(key) : text;
|
||||
};
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [selectedRaw, setSelectedRaw] = useState<RawMapRecord | null>(null);
|
||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||
selectedRawRef.current = selectedRaw;
|
||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
@@ -183,17 +195,31 @@ export function MemorySpace({
|
||||
/** The filter the shown page was read with, for the count above it. */
|
||||
settledSource: string;
|
||||
settledQuery: string;
|
||||
settledMode: "lite" | "pro" | "ultra" | null;
|
||||
}>({
|
||||
page: null,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: "",
|
||||
settledQuery: "",
|
||||
settledMode: null,
|
||||
});
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [processingFailed, setProcessingFailed] = useState(false);
|
||||
const [processingResolved, setProcessingResolved] = useState(false);
|
||||
const pipelineMode =
|
||||
processing?.pipeline?.mode ??
|
||||
(processingResolved && !processingFailed ? "pro" : null);
|
||||
const lite = pipelineMode === "lite";
|
||||
const sdkPage =
|
||||
sdkLibrary.settledMode === pipelineMode ? sdkLibrary.page : null;
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
pipelineMode,
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
@@ -213,8 +239,9 @@ export function MemorySpace({
|
||||
});
|
||||
// The page the list shows, for the namespace of a memory being opened.
|
||||
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
|
||||
sdkPageRef.current = sdkLibrary.page;
|
||||
const sdkMapVisible = simplified && active && displayedMode === "map";
|
||||
sdkPageRef.current = sdkPage;
|
||||
const sdkMapVisible =
|
||||
simplified && active && displayedMode === "map" && pipelineMode !== null;
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
// A write bumps the revision; reading it here re-runs the read at once
|
||||
@@ -224,7 +251,7 @@ export function MemorySpace({
|
||||
let controller: AbortController | null = null;
|
||||
const source = sdkLibrarySource || undefined;
|
||||
const query = sdkLibraryQuery.trim() || undefined;
|
||||
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
|
||||
const windowKey = JSON.stringify([pipelineMode, query ?? "", source ?? ""]);
|
||||
if (sdkSearchWindow.current.key !== windowKey)
|
||||
sdkSearchWindow.current = { key: windowKey, id: null };
|
||||
const read = async (first: boolean) => {
|
||||
@@ -233,42 +260,68 @@ export function MemorySpace({
|
||||
controller = request;
|
||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const [page, graph] = await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
),
|
||||
// The overlay is decoration: the list stands without it. It reads
|
||||
// the full active/source-filtered scene up to the API's visual cap.
|
||||
// Reading only the first list-page prefix made "All applications"
|
||||
// look identical to Cursor whenever newer Cursor memories occupied
|
||||
// that prefix.
|
||||
query
|
||||
? Promise.resolve({
|
||||
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);
|
||||
rawRecords.current = next;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
? await Promise.all([
|
||||
query
|
||||
? searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
}),
|
||||
Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
}),
|
||||
])
|
||||
: await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
).then(sdkLibraryPage),
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sdkSearchWindow.current.key === windowKey)
|
||||
if (!lite && sdkSearchWindow.current.key === windowKey)
|
||||
sdkSearchWindow.current = {
|
||||
key: windowKey,
|
||||
id: page.window_id ?? null,
|
||||
@@ -276,11 +329,12 @@ export function MemorySpace({
|
||||
// The window expired: the server searched again and answered page 1.
|
||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||
setSdkLibrary({
|
||||
page: sdkLibraryPage(page),
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
@@ -305,6 +359,8 @@ export function MemorySpace({
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
@@ -337,9 +393,6 @@ export function MemorySpace({
|
||||
}
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
|
||||
const [checkingSetup, setCheckingSetup] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
|
||||
@@ -408,7 +461,11 @@ export function MemorySpace({
|
||||
const update = async () => {
|
||||
try {
|
||||
const next = await getMemoryProcessing();
|
||||
if (!stopped) setProcessing(next);
|
||||
if (!stopped) {
|
||||
setProcessing(next);
|
||||
setProcessingFailed(false);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
} catch {
|
||||
// Background read failures do not invalidate the last verified state.
|
||||
}
|
||||
@@ -438,8 +495,29 @@ export function MemorySpace({
|
||||
const closeDetail = useCallback(() => {
|
||||
detailEpoch.current++;
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
setDetailLoad(null);
|
||||
}, []);
|
||||
const previousPipelineMode = useRef(pipelineMode);
|
||||
useEffect(() => {
|
||||
if (pipelineMode === null || previousPipelineMode.current === pipelineMode)
|
||||
return;
|
||||
previousPipelineMode.current = pipelineMode;
|
||||
rawRecords.current.clear();
|
||||
sdkSearchWindow.current = { key: "", id: null };
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkLibraryQuery("");
|
||||
setSdkGraph(null);
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
page: null,
|
||||
loading: true,
|
||||
failed: false,
|
||||
settledQuery: "",
|
||||
settledMode: null,
|
||||
}));
|
||||
closeDetail();
|
||||
}, [pipelineMode, closeDetail]);
|
||||
|
||||
const previousSourceKey = useRef(sourceKey);
|
||||
useEffect(() => {
|
||||
@@ -460,14 +538,23 @@ export function MemorySpace({
|
||||
* this subscriber and the background arrival poll read the same endpoint,
|
||||
* so one `fetch` per tick is the whole point of sharing the key. */
|
||||
const consumeProcessing = (value: unknown) => {
|
||||
if (!cancelled) setProcessing(value as MemoryProcessingSummary);
|
||||
if (!cancelled) {
|
||||
setProcessing(value as MemoryProcessingSummary);
|
||||
setProcessingFailed(false);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
};
|
||||
// `subscribePoll` only schedules ticks; the first read still has to happen
|
||||
// on mount. Do it once through the shared fetch so mount does not double-read
|
||||
// with the background arrival subscriber.
|
||||
void getMemoryProcessing()
|
||||
.then(consumeProcessing)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setProcessingFailed(true);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
});
|
||||
// The processing poll shares one read with the background arrival poll on
|
||||
// the same key (3s wins over 12s).
|
||||
const stopProcessing = subscribePoll({
|
||||
@@ -593,6 +680,22 @@ export function MemorySpace({
|
||||
const epoch = ++detailEpoch.current;
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
if (lite) {
|
||||
const record = rawRecords.current.get(memoryId);
|
||||
if (!record) {
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
status: "failed",
|
||||
error: t("sdk.raw.loadFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedRaw(record);
|
||||
setDetail(rawDetail(record));
|
||||
setDetailLoad(null);
|
||||
return;
|
||||
}
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
try {
|
||||
// The inspector reads the memory through v2 and renders it in place.
|
||||
@@ -733,40 +836,55 @@ export function MemorySpace({
|
||||
</h1>
|
||||
<span className="memory-loaded-count">
|
||||
{simplified && !management
|
||||
? sdkLibrary.page
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
sdkLibrary.page.total,
|
||||
{
|
||||
filter: sdkFilterLabel
|
||||
? t("memory:({filter})", { filter: sdkFilterLabel })
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkLibrary.page.items.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: // The list below says a failed read once, with its retry.
|
||||
sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
? pipelineMode === null
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
: sdkPage
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
sdkPage.total,
|
||||
{
|
||||
filter: sdkFilterLabel
|
||||
? t("memory:({filter})", {
|
||||
filter: sdkFilterLabel,
|
||||
})
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkPage.items.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: // The list below says a failed read once, with its retry.
|
||||
sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<MemoryAddComposer
|
||||
suspended={!active}
|
||||
onPendingChange={setPendingAdd}
|
||||
onSaved={() => {
|
||||
// A finished addition is a new memory: re-read the page now rather
|
||||
// than leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
void open(id);
|
||||
}}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
{!lite ? (
|
||||
<MemoryAddComposer
|
||||
suspended={!active}
|
||||
onPendingChange={setPendingAdd}
|
||||
onSaved={() => {
|
||||
// A finished addition is a new memory: re-read the page now rather
|
||||
// than leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
void open(id);
|
||||
}}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
) : null}
|
||||
{!simplified && (
|
||||
<div
|
||||
className="memory-mode-tabs"
|
||||
@@ -834,6 +952,19 @@ export function MemorySpace({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{simplified && pipelineMode ? (
|
||||
<p className="memory-mode-notice" role="status">
|
||||
{lite
|
||||
? t(
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。",
|
||||
)
|
||||
: pipelineMode === "ultra"
|
||||
? t(
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。",
|
||||
)
|
||||
: t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")}
|
||||
</p>
|
||||
) : null}
|
||||
{processing && (!simplified || managementOpen) ? (
|
||||
<details className="memory-processing-details">
|
||||
<summary>
|
||||
@@ -920,16 +1051,20 @@ export function MemorySpace({
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<ModelBackoffBanner
|
||||
processing={processing}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
<ModelPrompt
|
||||
processing={processing}
|
||||
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
{simplified && pendingAdd ? (
|
||||
{!lite ? (
|
||||
<>
|
||||
<ModelBackoffBanner
|
||||
processing={processing}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
<ModelPrompt
|
||||
processing={processing}
|
||||
hasMemories={(sdkPage?.total ?? 0) > 0}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{simplified && !lite && pendingAdd ? (
|
||||
<p className="memory-pending-add" role="status">
|
||||
<span>
|
||||
{t("memory:正在等待整理:")}
|
||||
@@ -954,7 +1089,7 @@ export function MemorySpace({
|
||||
id="memory-mode-panel-history"
|
||||
hidden={displayedMode !== "history"}
|
||||
>
|
||||
<SdkRawHistory />
|
||||
<SdkRawHistory active={active && displayedMode === "history"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -995,14 +1130,17 @@ export function MemorySpace({
|
||||
|
||||
{/* Native hiding retains the WebGL owner; its visibility observer pauses drawing. */}
|
||||
<MemoryMap
|
||||
key={simplified ? (pipelineMode ?? "unknown") : "full"}
|
||||
id="memory-mode-panel-map"
|
||||
simplified={simplified}
|
||||
contentKind={lite ? "raw" : "memory"}
|
||||
rawRecord={selectedRaw}
|
||||
libraryView={
|
||||
simplified
|
||||
? {
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkLibrary.page,
|
||||
page: sdkPage,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
@@ -1020,14 +1158,18 @@ export function MemorySpace({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={sdkLibrary.page?.items ?? []}
|
||||
hidden={
|
||||
!active ||
|
||||
displayedMode !== "map" ||
|
||||
(simplified && pipelineMode === null)
|
||||
}
|
||||
memories={sdkPage?.items ?? []}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
onViewHistory={simplified ? onConnect : () => setMode("history")}
|
||||
loaded={sdkLibrary.page !== null}
|
||||
loaded={sdkPage !== null}
|
||||
detail={detail}
|
||||
detailLoad={detailLoad}
|
||||
overlayBlocked={
|
||||
@@ -1035,7 +1177,7 @@ export function MemorySpace({
|
||||
deleteCount > 0 ||
|
||||
// Only the paged map waits for its page; the three-column surface
|
||||
// never reads one.
|
||||
(simplified && sdkLibrary.page === null)
|
||||
(simplified && sdkPage === null)
|
||||
}
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
@@ -1128,10 +1270,12 @@ export function MemorySpace({
|
||||
className="memory-library-layout"
|
||||
hidden={displayedMode !== "library"}
|
||||
>
|
||||
<SdkMemoryLibrary
|
||||
active={active}
|
||||
focusedMemoryId={sdkFocusedMemoryId}
|
||||
/>
|
||||
{!simplified || (pipelineMode !== null && !lite) ? (
|
||||
<SdkMemoryLibrary
|
||||
active={active}
|
||||
focusedMemoryId={sdkFocusedMemoryId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1350,7 +1494,7 @@ function message(reason: unknown, t: Translate): string {
|
||||
reason.message === "memory_answer_context_changed"
|
||||
? reason.message
|
||||
: apiErrorCode(reason);
|
||||
const labels: Record<string, Parameters<Translate>[0]> = {
|
||||
const labels: Record<string, string> = {
|
||||
memory_answer_context_changed:
|
||||
"memory:记忆内容或使用权限已变化,回答已停止。请重新提问。",
|
||||
memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。",
|
||||
@@ -1363,8 +1507,14 @@ function message(reason: unknown, t: Translate): string {
|
||||
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
|
||||
request_validation_failed: "memory:输入内容不符合要求。",
|
||||
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
|
||||
embedding_unavailable:
|
||||
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
|
||||
embedding_switch_in_progress:
|
||||
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
|
||||
embedding_space_profile_missing:
|
||||
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
|
||||
};
|
||||
if (code && labels[code]) return t(labels[code]);
|
||||
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
|
||||
// No code at all means the request never reached the core — usually because
|
||||
// it is still starting. Saying only "failed" left the one case that resolves
|
||||
// itself looking like the ones that do not.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
ModelProbeResult,
|
||||
ModelProfile,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
@@ -66,6 +72,10 @@ const probeProfile = vi.fn();
|
||||
const activate = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const update = vi.fn();
|
||||
const planActivation = vi.fn();
|
||||
const startPlannedSwitch = vi.fn();
|
||||
let switchPlan: EmbeddingSwitchPlan | null = null;
|
||||
let switchCurrent: EmbeddingSwitchCurrent | null = null;
|
||||
|
||||
it("explains an insufficient model balance with the required recovery", () => {
|
||||
expect(probeLabel("insufficient_balance")).toBe(
|
||||
@@ -114,6 +124,71 @@ const preset: ModelProfile = {
|
||||
has_api_key: false,
|
||||
disable_thinking: true,
|
||||
};
|
||||
const migrationPlan: EmbeddingSwitchPlan = {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "plan-token",
|
||||
source: {
|
||||
space_id: "old-space",
|
||||
model: "old-search",
|
||||
dims: 512,
|
||||
collection_base: "memory",
|
||||
vectors: 12,
|
||||
},
|
||||
target: {
|
||||
space_id: null,
|
||||
model: "new-search",
|
||||
dims: 1024,
|
||||
collection_base: "",
|
||||
vectors: null,
|
||||
},
|
||||
target_profile_id: profile.profile_id,
|
||||
target_revision: profile.revision,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [],
|
||||
total_vectors: 12,
|
||||
users: 1,
|
||||
graph_vectors: null,
|
||||
estimate_s: 10,
|
||||
requests: 1,
|
||||
disk_bytes_needed: 1024,
|
||||
disk_bytes_free: 4096,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
};
|
||||
const oldFinishedJob: EmbeddingSwitchJob = {
|
||||
job_id: "old-job",
|
||||
kind: "switch",
|
||||
state: "done",
|
||||
source: migrationPlan.source,
|
||||
target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 },
|
||||
stray_space_ids: [],
|
||||
target_profile_id: profile.profile_id,
|
||||
target_revision: profile.revision,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "done",
|
||||
done: 12,
|
||||
total: 12,
|
||||
percent: 100,
|
||||
embedded: 12,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: null,
|
||||
source_index: 0,
|
||||
sources: 1,
|
||||
rate_per_s: null,
|
||||
eta_s: 0,
|
||||
updated_at: "2026-11-03T09:00:00Z",
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: "2026-11-03T09:00:00Z",
|
||||
updated_at: "2026-11-03T09:00:01Z",
|
||||
finished_at: "2026-11-03T09:00:01Z",
|
||||
};
|
||||
|
||||
/** Re-renders on demand so a cleared journal is picked up, like a real refresh. */
|
||||
function ProbeRecoveryHarness({
|
||||
@@ -151,6 +226,19 @@ vi.mock("./use-model-profiles", () => ({
|
||||
update,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
useEmbeddingSwitch: () => ({
|
||||
current: switchCurrent,
|
||||
plan: switchPlan,
|
||||
busy: null,
|
||||
errorCode: null,
|
||||
planActivation,
|
||||
startPlannedSwitch,
|
||||
shouldUseLegacyActivation: () => false,
|
||||
clearPlan: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ModelSettings", () => {
|
||||
it("exposes distinct edit, add and test actions from the same owner without activating", async () => {
|
||||
@@ -187,8 +275,14 @@ describe("ModelSettings", () => {
|
||||
probes = {};
|
||||
pendingProbe = null;
|
||||
profiles = [profile];
|
||||
switchPlan = null;
|
||||
switchCurrent = null;
|
||||
create.mockResolvedValue(profile);
|
||||
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
|
||||
planActivation.mockResolvedValue({
|
||||
needed: false,
|
||||
reason: "same_embedding",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
|
||||
@@ -399,6 +493,62 @@ describe("ModelSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses generation without exposing or patching the search lane", async () => {
|
||||
update.mockResolvedValue(profile);
|
||||
render(<ModelSettings focusSection="generation" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy();
|
||||
expect(screen.queryByLabelText("搜索模型")).toBeNull();
|
||||
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
|
||||
"value",
|
||||
profile.model,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
|
||||
expected_revision: profile.revision,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not repair a hidden legacy search endpoint while editing generation", async () => {
|
||||
const legacy = {
|
||||
...profile,
|
||||
embedding_provider_kind: "openai_compatible" as const,
|
||||
embedding_model: "remote-search",
|
||||
embedding_base_url: null,
|
||||
};
|
||||
profiles = [legacy];
|
||||
update.mockResolvedValue(legacy);
|
||||
render(<ModelSettings focusSection="generation" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
await userEvent.type(screen.getByLabelText("生成模型名"), "-new");
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
|
||||
expected_revision: profile.revision,
|
||||
model: "memory-model-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses search on the recommended builtin without exposing or patching generation", async () => {
|
||||
update.mockResolvedValue(profile);
|
||||
render(<ModelSettings focusSection="embedding" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy();
|
||||
expect(screen.queryByLabelText("生成模型名")).toBeNull();
|
||||
expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local");
|
||||
expect(screen.getByText(/无需密钥/)).toBeTruthy();
|
||||
expect(screen.getByText("技术类型:Embedding")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
|
||||
expected_revision: profile.revision,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace an edit draft with a newer external profile revision", async () => {
|
||||
const view = render(<ModelSettings />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
@@ -661,9 +811,45 @@ describe("ModelSettings", () => {
|
||||
|
||||
expect(container.textContent).not.toContain("secret-value");
|
||||
await user.click(screen.getByRole("button", { name: "设为当前模型" }));
|
||||
expect(planActivation).toHaveBeenCalledWith("profile-1", 1);
|
||||
expect(activate).toHaveBeenCalledWith("profile-1");
|
||||
});
|
||||
|
||||
it("asks before activation when the search index must be rebuilt", async () => {
|
||||
profiles = [
|
||||
{
|
||||
...profile,
|
||||
capabilities: {
|
||||
...profile.capabilities,
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
planActivation.mockImplementationOnce(async () => {
|
||||
switchPlan = migrationPlan;
|
||||
return migrationPlan;
|
||||
});
|
||||
switchCurrent = {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: oldFinishedJob,
|
||||
};
|
||||
render(<ModelSettings />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "更新记忆搜索索引?" }),
|
||||
).toBeTruthy();
|
||||
expect(activate).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();
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import { useModelProfiles } from "./use-model-profiles";
|
||||
import "./model-setup-next.css";
|
||||
|
||||
@@ -216,6 +218,8 @@ export interface ModelSettingsActions {
|
||||
test(profileId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type ModelSettingsFocusSection = "generation" | "embedding";
|
||||
|
||||
/** What the owner asked for before the fence said no. */
|
||||
type ModelOpenIntent =
|
||||
| { kind: "add" }
|
||||
@@ -225,15 +229,18 @@ export function ModelSettings({
|
||||
controlsRef,
|
||||
onProfilesChanged,
|
||||
compactSingle = false,
|
||||
focusSection,
|
||||
onSetupComplete,
|
||||
}: {
|
||||
controlsRef?: Ref<ModelSettingsActions>;
|
||||
onProfilesChanged?(): void;
|
||||
compactSingle?: boolean;
|
||||
focusSection?: ModelSettingsFocusSection;
|
||||
onSetupComplete?(): void;
|
||||
} = {}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const models = useModelProfiles();
|
||||
const embeddingSwitch = useEmbeddingSwitch();
|
||||
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
|
||||
const [providerPreset, setProviderPreset] = useState("custom");
|
||||
const [advancedEmbedding, setAdvancedEmbedding] = useState(false);
|
||||
@@ -263,6 +270,9 @@ export function ModelSettings({
|
||||
null,
|
||||
);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
|
||||
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
|
||||
const settledSwitchJob = useRef<string | null>(null);
|
||||
const flight = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const queuedOpen = useRef<ModelOpenIntent | null>(null);
|
||||
@@ -274,6 +284,41 @@ export function ModelSettings({
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const finished = embeddingSwitch.current?.last_finished;
|
||||
if (
|
||||
!switchTarget ||
|
||||
!switchJobId ||
|
||||
!finished ||
|
||||
finished.job_id !== switchJobId ||
|
||||
settledSwitchJob.current === finished.job_id
|
||||
)
|
||||
return;
|
||||
settledSwitchJob.current = finished.job_id;
|
||||
if (finished.state === "done") {
|
||||
void models.refreshProfiles();
|
||||
setSetupError(null);
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
onProfilesChanged?.();
|
||||
return;
|
||||
}
|
||||
if (finished.state === "rolled_back") {
|
||||
setSetupError(
|
||||
finished.error_code === "embedding_switch_cancelled"
|
||||
? "索引更新已取消,仍在使用原来的搜索模型。"
|
||||
: "索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
|
||||
);
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
models.refreshProfiles,
|
||||
onProfilesChanged,
|
||||
switchJobId,
|
||||
switchTarget,
|
||||
]);
|
||||
useLayoutEffect(() => {
|
||||
if (saved) savedCard.current?.focus({ preventScroll: true });
|
||||
}, [saved]);
|
||||
@@ -452,6 +497,43 @@ export function ModelSettings({
|
||||
onProfilesChanged?.();
|
||||
}, [profileSnapshot, onProfilesChanged]);
|
||||
|
||||
async function activateWithPlan(
|
||||
profile: ModelProfile,
|
||||
result: ModelProbeResult | null,
|
||||
): Promise<boolean> {
|
||||
setSetupPhase("activating");
|
||||
const switchPlan = embeddingSwitch.shouldUseLegacyActivation()
|
||||
? null
|
||||
: await embeddingSwitch.planActivation(
|
||||
profile.profile_id,
|
||||
profile.revision,
|
||||
);
|
||||
if (!alive.current) return false;
|
||||
if (!switchPlan && !embeddingSwitch.shouldUseLegacyActivation()) {
|
||||
setSetupError("暂时无法检查搜索模型更换影响,配置已保存,请稍后重试。");
|
||||
return false;
|
||||
}
|
||||
if (switchPlan?.needed) {
|
||||
setSwitchTarget(profile);
|
||||
setSetupPhase(null);
|
||||
return false;
|
||||
}
|
||||
const active = await models.activate(profile.profile_id);
|
||||
if (!alive.current) return false;
|
||||
if (
|
||||
!active ||
|
||||
active.profile_id !== profile.profile_id ||
|
||||
!active.is_active ||
|
||||
!ready(active, result)
|
||||
) {
|
||||
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
||||
return false;
|
||||
}
|
||||
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
|
||||
onSetupComplete?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function finishSetup(profile: ModelProfile): Promise<boolean> {
|
||||
setSetupPhase("testing");
|
||||
const result = await models.probeProfile(profile.profile_id);
|
||||
@@ -487,21 +569,24 @@ export function ModelSettings({
|
||||
setFields(probeFieldErrors(result));
|
||||
return false;
|
||||
}
|
||||
setSetupPhase("activating");
|
||||
const active = await models.activate(profile.profile_id);
|
||||
if (!alive.current) return false;
|
||||
if (
|
||||
!active ||
|
||||
active.profile_id !== profile.profile_id ||
|
||||
!active.is_active ||
|
||||
!ready(active, result)
|
||||
) {
|
||||
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
|
||||
return false;
|
||||
return activateWithPlan(profile, result);
|
||||
}
|
||||
|
||||
async function activateTested(profile: ModelProfile) {
|
||||
const result = models.probeFor(profile);
|
||||
if (!isReady(profile) || flight.current || locked) return;
|
||||
flight.current = true;
|
||||
setSaving(true);
|
||||
setSetupError(null);
|
||||
try {
|
||||
await activateWithPlan(profile, result);
|
||||
} finally {
|
||||
flight.current = false;
|
||||
if (alive.current) {
|
||||
setSaving(false);
|
||||
setSetupPhase(null);
|
||||
}
|
||||
}
|
||||
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
|
||||
onSetupComplete?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeSetup(profile: ModelProfile, key?: string) {
|
||||
@@ -572,12 +657,14 @@ export function ModelSettings({
|
||||
)
|
||||
return;
|
||||
const errors: Partial<Record<FieldName, string>> = {};
|
||||
if (!model.trim()) errors.model = "请填写生成模型名。";
|
||||
if (!validEndpoint(baseUrl))
|
||||
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
|
||||
const credentialError = keyError(apiKey, keyRequired);
|
||||
if (credentialError) errors["api-key"] = credentialError;
|
||||
if (embeddingMode === "custom") {
|
||||
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;
|
||||
}
|
||||
if (focusSection !== "generation" && embeddingMode === "custom") {
|
||||
if (!embeddingModel.trim())
|
||||
errors["embedding-model"] = "请填写向量模型名。";
|
||||
if (!validEndpoint(embeddingBaseUrl))
|
||||
@@ -619,14 +706,16 @@ export function ModelSettings({
|
||||
? { expected_revision: editing.revision }
|
||||
: { expected_revision: 0 };
|
||||
if (editing) {
|
||||
if (input.name !== editing.name) patch.name = input.name;
|
||||
if (input.model !== editing.model) patch.model = input.model;
|
||||
if (input.base_url !== editing.base_url)
|
||||
patch.base_url = input.base_url;
|
||||
if (input.disable_thinking !== editing.disable_thinking)
|
||||
patch.disable_thinking = input.disable_thinking;
|
||||
if (apiKey) patch.api_key = apiKey;
|
||||
if (embeddingMode === "custom") {
|
||||
if (focusSection !== "embedding") {
|
||||
if (input.name !== editing.name) patch.name = input.name;
|
||||
if (input.model !== editing.model) patch.model = input.model;
|
||||
if (input.base_url !== editing.base_url)
|
||||
patch.base_url = input.base_url;
|
||||
if (input.disable_thinking !== editing.disable_thinking)
|
||||
patch.disable_thinking = input.disable_thinking;
|
||||
if (apiKey) patch.api_key = apiKey;
|
||||
}
|
||||
if (focusSection !== "generation" && embeddingMode === "custom") {
|
||||
if (embeddingModel !== editing.embedding_model)
|
||||
patch.embedding_model = embeddingModel;
|
||||
if (embeddingProvider !== editing.embedding_provider_kind)
|
||||
@@ -636,6 +725,7 @@ export function ModelSettings({
|
||||
if (embeddingBaseUrl !== (editing.embedding_base_url ?? ""))
|
||||
patch.embedding_base_url = embeddingBaseUrl;
|
||||
} else if (
|
||||
focusSection !== "generation" &&
|
||||
embeddingModeChanged &&
|
||||
editing.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND
|
||||
) {
|
||||
@@ -699,11 +789,23 @@ export function ModelSettings({
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="section-index">02</p>
|
||||
<h2 id="models-title">{text("记忆模型")}</h2>
|
||||
<h2 id="models-title">
|
||||
{text(
|
||||
focusSection === "generation"
|
||||
? "记忆整理"
|
||||
: focusSection === "embedding"
|
||||
? "记忆搜索"
|
||||
: "记忆模型",
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<p>
|
||||
{text(
|
||||
"只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
|
||||
focusSection === "generation"
|
||||
? "配置用于理解会话、整理记忆的模型。"
|
||||
: focusSection === "embedding"
|
||||
? "推荐使用本地内置搜索;需要连接自定义服务时再展开配置。"
|
||||
: "只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -956,326 +1058,359 @@ export function ModelSettings({
|
||||
{text("刷新配置列表")}
|
||||
</button>
|
||||
)}
|
||||
<div className="model-form-field">
|
||||
{text("服务商预设")}
|
||||
<OptionMenu
|
||||
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("自定义兼容服务") },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
const next = PROVIDER_PRESETS.find((item) => item.id === value);
|
||||
setProviderPreset(value);
|
||||
if (!next) return;
|
||||
setProvider(next.provider);
|
||||
setBaseUrl(next.baseUrl);
|
||||
setModel(next.model);
|
||||
setApiKey("");
|
||||
setDisableThinking(true);
|
||||
if (editing) {
|
||||
setName(next.label);
|
||||
setNameEdited(true);
|
||||
} else if (!nameEdited) {
|
||||
setName(suggestedName(next.model, next.provider));
|
||||
}
|
||||
setFields({});
|
||||
setSaveError(null);
|
||||
}}
|
||||
/>
|
||||
<small id="model-preset-help">
|
||||
{text(
|
||||
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
|
||||
)}
|
||||
</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
|
||||
name="name"
|
||||
aria-label={text("配置名称")}
|
||||
aria-describedby="model-name-help"
|
||||
autoComplete="off"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setNameEdited(true);
|
||||
setName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<small id="model-name-help">
|
||||
{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}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setModel(event.target.value);
|
||||
setProviderPreset("custom");
|
||||
if (!nameEdited) setName(suggestedName(event.target.value));
|
||||
}}
|
||||
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>
|
||||
{/* Disclosure only changes visibility; mode determines what is saved. */}
|
||||
<div className="model-advanced">
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
aria-expanded={advancedEmbedding}
|
||||
disabled={formLocked}
|
||||
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
|
||||
>
|
||||
{text("高级:自行配置向量模型")}
|
||||
</button>
|
||||
{advancedEmbedding ? (
|
||||
<>
|
||||
<label>
|
||||
{text("向量配置模式")}
|
||||
<select
|
||||
name="embedding-mode"
|
||||
value={embeddingMode}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setEmbeddingMode(
|
||||
event.target.value as "local" | "custom",
|
||||
);
|
||||
setEmbeddingModeChanged(true);
|
||||
setFields((previous) => ({
|
||||
...previous,
|
||||
"embedding-model": undefined,
|
||||
"embedding-base-url": undefined,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="local">{text("本地内置")}</option>
|
||||
<option value="custom">{text("自定义向量模型")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{embeddingMode === "custom" ? (
|
||||
<>
|
||||
<label>
|
||||
{text("向量模型名")}
|
||||
<input
|
||||
name="embedding-model"
|
||||
autoComplete="off"
|
||||
required
|
||||
maxLength={256}
|
||||
value={embeddingModel}
|
||||
disabled={formLocked}
|
||||
aria-invalid={!!fields["embedding-model"]}
|
||||
aria-describedby={
|
||||
fields["embedding-model"]
|
||||
? "model-embedding-error"
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) =>
|
||||
setEmbeddingModel(event.target.value)
|
||||
}
|
||||
placeholder={text("例如:text-embedding-3-small…")}
|
||||
/>
|
||||
{fields["embedding-model"] && (
|
||||
<small
|
||||
id="model-embedding-error"
|
||||
className="model-field-error"
|
||||
>
|
||||
{text(fields["embedding-model"])}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
{text("向量模型来源")}
|
||||
<select
|
||||
name="embedding-provider"
|
||||
value={embeddingProvider}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.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>
|
||||
<label>
|
||||
{text("向量模型 Base URL")}
|
||||
<input
|
||||
name="embedding-base-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
required
|
||||
value={embeddingBaseUrl}
|
||||
disabled={formLocked}
|
||||
placeholder={
|
||||
embeddingProvider === "ollama"
|
||||
? "http://127.0.0.1:11434"
|
||||
: baseUrl
|
||||
}
|
||||
aria-invalid={!!fields["embedding-base-url"]}
|
||||
aria-describedby={
|
||||
fields["embedding-base-url"]
|
||||
? "model-embedding-url-error"
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) =>
|
||||
setEmbeddingBaseUrl(event.target.value)
|
||||
}
|
||||
/>
|
||||
{fields["embedding-base-url"] && (
|
||||
<small
|
||||
id="model-embedding-url-error"
|
||||
className="model-field-error"
|
||||
>
|
||||
{text(fields["embedding-base-url"])}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
{embeddingProvider === "openai_compatible" ? (
|
||||
<small>
|
||||
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
|
||||
</small>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<small>
|
||||
{text("使用内置本地向量模型;不会发送内容给远程向量服务。")}
|
||||
{focusSection !== "embedding" && (
|
||||
<>
|
||||
<div className="model-form-field">
|
||||
{text("服务商预设")}
|
||||
<OptionMenu
|
||||
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("自定义兼容服务") },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
const next = PROVIDER_PRESETS.find(
|
||||
(item) => item.id === value,
|
||||
);
|
||||
setProviderPreset(value);
|
||||
if (!next) return;
|
||||
setProvider(next.provider);
|
||||
setBaseUrl(next.baseUrl);
|
||||
setModel(next.model);
|
||||
setApiKey("");
|
||||
setDisableThinking(true);
|
||||
if (editing) {
|
||||
setName(next.label);
|
||||
setNameEdited(true);
|
||||
} else if (!nameEdited) {
|
||||
setName(suggestedName(next.model, next.provider));
|
||||
}
|
||||
setFields({});
|
||||
setSaveError(null);
|
||||
}}
|
||||
/>
|
||||
<small id="model-preset-help">
|
||||
{text(
|
||||
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
|
||||
)}
|
||||
</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
|
||||
name="name"
|
||||
aria-label={text("配置名称")}
|
||||
aria-describedby="model-name-help"
|
||||
autoComplete="off"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setNameEdited(true);
|
||||
setName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<small id="model-name-help">
|
||||
{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}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setModel(event.target.value);
|
||||
setProviderPreset("custom");
|
||||
if (!nameEdited) setName(suggestedName(event.target.value));
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{!advancedEmbedding && embeddingMode === "custom" ? (
|
||||
<p className="model-help">
|
||||
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")} ·{" "}
|
||||
{embeddingBaseUrl || text("尚未填写地址")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<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"
|
||||
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
|
||||
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
|
||||
aria-invalid={!!fields["api-key"]}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
required={keyRequired}
|
||||
value={apiKey}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={text("保存后不会再次显示…")}
|
||||
/>
|
||||
<small id="model-key-help">
|
||||
{text(
|
||||
editing && !endpointMoved
|
||||
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
|
||||
: editing && keyRequired
|
||||
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
|
||||
: "保存在系统安全存储中,不随安装包分发。",
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{/* Disclosure only changes visibility; mode determines what is saved. */}
|
||||
{focusSection !== "generation" && (
|
||||
<div className="model-advanced">
|
||||
{focusSection === "embedding" && (
|
||||
<div className="model-search-intro">
|
||||
<strong>{text("搜索方式")}</strong>
|
||||
<p>
|
||||
{text(
|
||||
"本地内置适合大多数人:无需密钥,内容不会发送到远程向量服务。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</small>
|
||||
{fields["api-key"] && (
|
||||
<small id="model-key-error" className="model-field-error">
|
||||
{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>
|
||||
{focusSection !== "embedding" && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
aria-expanded={advancedEmbedding}
|
||||
disabled={formLocked}
|
||||
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
|
||||
>
|
||||
{text("高级:自行配置向量模型")}
|
||||
</button>
|
||||
)}
|
||||
{focusSection === "embedding" || advancedEmbedding ? (
|
||||
<>
|
||||
<label>
|
||||
{text(
|
||||
focusSection === "embedding"
|
||||
? "搜索模型"
|
||||
: "向量配置模式",
|
||||
)}
|
||||
<select
|
||||
name="embedding-mode"
|
||||
value={embeddingMode}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
setEmbeddingMode(
|
||||
event.target.value as "local" | "custom",
|
||||
);
|
||||
setEmbeddingModeChanged(true);
|
||||
setFields((previous) => ({
|
||||
...previous,
|
||||
"embedding-model": undefined,
|
||||
"embedding-base-url": undefined,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="local">{text("本地内置(推荐)")}</option>
|
||||
<option value="custom">{text("自定义搜索模型")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<small>{text("技术类型:Embedding")}</small>
|
||||
{embeddingMode === "custom" ? (
|
||||
<>
|
||||
<label>
|
||||
{text("向量模型名")}
|
||||
<input
|
||||
name="embedding-model"
|
||||
autoComplete="off"
|
||||
required
|
||||
maxLength={256}
|
||||
value={embeddingModel}
|
||||
disabled={formLocked}
|
||||
aria-invalid={!!fields["embedding-model"]}
|
||||
aria-describedby={
|
||||
fields["embedding-model"]
|
||||
? "model-embedding-error"
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) =>
|
||||
setEmbeddingModel(event.target.value)
|
||||
}
|
||||
placeholder={text("例如:text-embedding-3-small…")}
|
||||
/>
|
||||
{fields["embedding-model"] && (
|
||||
<small
|
||||
id="model-embedding-error"
|
||||
className="model-field-error"
|
||||
>
|
||||
{text(fields["embedding-model"])}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
{text("向量模型来源")}
|
||||
<select
|
||||
name="embedding-provider"
|
||||
value={embeddingProvider}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.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>
|
||||
<label>
|
||||
{text("向量模型 Base URL")}
|
||||
<input
|
||||
name="embedding-base-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
required
|
||||
value={embeddingBaseUrl}
|
||||
disabled={formLocked}
|
||||
placeholder={
|
||||
embeddingProvider === "ollama"
|
||||
? "http://127.0.0.1:11434"
|
||||
: baseUrl
|
||||
}
|
||||
aria-invalid={!!fields["embedding-base-url"]}
|
||||
aria-describedby={
|
||||
fields["embedding-base-url"]
|
||||
? "model-embedding-url-error"
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) =>
|
||||
setEmbeddingBaseUrl(event.target.value)
|
||||
}
|
||||
/>
|
||||
{fields["embedding-base-url"] && (
|
||||
<small
|
||||
id="model-embedding-url-error"
|
||||
className="model-field-error"
|
||||
>
|
||||
{text(fields["embedding-base-url"])}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
{embeddingProvider === "openai_compatible" ? (
|
||||
<small>
|
||||
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
|
||||
</small>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<small>
|
||||
{text(
|
||||
"使用内置本地向量模型;不会发送内容给远程向量服务。",
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{focusSection !== "embedding" &&
|
||||
!advancedEmbedding &&
|
||||
embeddingMode === "custom" ? (
|
||||
<p className="model-help">
|
||||
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")}{" "}
|
||||
· {embeddingBaseUrl || text("尚未填写地址")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{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"
|
||||
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
|
||||
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
|
||||
aria-invalid={!!fields["api-key"]}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
required={keyRequired}
|
||||
value={apiKey}
|
||||
disabled={formLocked}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={text("保存后不会再次显示…")}
|
||||
/>
|
||||
<small id="model-key-help">
|
||||
{text(
|
||||
editing && !endpointMoved
|
||||
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
|
||||
: editing && keyRequired
|
||||
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
|
||||
: "保存在系统安全存储中,不随安装包分发。",
|
||||
)}
|
||||
</small>
|
||||
{fields["api-key"] && (
|
||||
<small id="model-key-error" className="model-field-error">
|
||||
{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">
|
||||
{!compactSingle && (
|
||||
<button
|
||||
@@ -1397,7 +1532,7 @@ export function ModelSettings({
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked || !isReady(profile)}
|
||||
onClick={() => models.activate(profile.profile_id)}
|
||||
onClick={() => void activateTested(profile)}
|
||||
>
|
||||
{text("设为当前模型")}
|
||||
</button>
|
||||
@@ -1421,6 +1556,27 @@ export function ModelSettings({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{embeddingSwitch.plan?.needed && switchTarget ? (
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={embeddingSwitch.plan}
|
||||
busy={embeddingSwitch.busy === "start"}
|
||||
errorCode={embeddingSwitch.errorCode}
|
||||
onCancel={() => {
|
||||
embeddingSwitch.clearPlan();
|
||||
embeddingSwitch.clearError();
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
}}
|
||||
onStart={() => {
|
||||
void embeddingSwitch.startPlannedSwitch().then((job) => {
|
||||
if (!job || !alive.current) return;
|
||||
setSwitchJobId(job.job_id);
|
||||
setSetupError(null);
|
||||
onSetupComplete?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ import { completeOnboarding } from "./api";
|
||||
import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
vi.mock("./FirstImportReview", () => ({
|
||||
defaultHostSourceIds: () => [],
|
||||
FirstImportReview: ({
|
||||
@@ -121,10 +129,13 @@ 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: "继续" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -147,6 +158,8 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -168,6 +181,8 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
|
||||
@@ -25,6 +25,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getModelProfiles: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
const models = {
|
||||
pendingProbe: false,
|
||||
@@ -393,6 +399,8 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
fireEvent.click(
|
||||
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" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -26,6 +26,12 @@ vi.mock("./api", () => ({
|
||||
getImportJobs: vi.fn(),
|
||||
connectSources: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
getSourceConnection: vi.fn(),
|
||||
createImportJob: vi.fn(),
|
||||
detectSource: vi.fn(),
|
||||
@@ -161,6 +167,34 @@ beforeEach(() => {
|
||||
...state,
|
||||
status: "completed",
|
||||
});
|
||||
vi.mocked(api.getMemoryMode).mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(api.setMemoryMode).mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
previous_mode: "pro",
|
||||
effects: [],
|
||||
});
|
||||
vi.mocked(api.connectSources).mockImplementation(
|
||||
async (ids, _confirmed, options) => ({
|
||||
request_id: options?.requestId ?? null,
|
||||
@@ -267,6 +301,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
|
||||
true,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -317,6 +353,8 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,15 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
vi.mock("./FirstImportReview", () => ({
|
||||
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
|
||||
<p>已受理 {sourceIds.join(",")}</p>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from "./use-import-sources";
|
||||
|
||||
const completeOnboarding = vi.fn();
|
||||
const setMemoryMode = vi.fn();
|
||||
const getMemoryMode = vi.fn();
|
||||
const getModelProfiles = vi.fn();
|
||||
const getMemoryProcessing = vi.fn();
|
||||
const previewSourceDirectory = vi.fn();
|
||||
@@ -44,6 +46,8 @@ vi.mock("./api", () => ({
|
||||
getImportJob: vi.fn(),
|
||||
apiErrorCode: vi.fn(() => null),
|
||||
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
|
||||
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
|
||||
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
|
||||
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
|
||||
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
|
||||
previewSourceDirectory: (...args: unknown[]) =>
|
||||
@@ -176,9 +180,18 @@ async function skipWelcome(user?: ReturnType<typeof userEvent.setup>) {
|
||||
await openApps(user);
|
||||
}
|
||||
async function continueImport(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
|
||||
await openModelStep(user);
|
||||
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("radio", { name: /pro/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
}
|
||||
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
}
|
||||
function choice(name = "Claude Code") {
|
||||
return screen.getByRole<HTMLInputElement>("checkbox", {
|
||||
name: `导入 ${name} 历史`,
|
||||
@@ -218,6 +231,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
});
|
||||
setMemoryMode.mockResolvedValue({ mode: "pro" });
|
||||
getMemoryMode.mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -355,7 +374,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
name: "配置模型,沉淀记忆",
|
||||
});
|
||||
expect(continueButton).toHaveProperty("disabled", false);
|
||||
await user.click(continueButton);
|
||||
await openModelStep(user);
|
||||
const modelBoundary = screen.getByRole("region", {
|
||||
name: "模型步骤边界",
|
||||
});
|
||||
@@ -365,6 +384,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
@@ -388,9 +408,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(userEvent.setup());
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
@@ -649,9 +667,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
@@ -678,13 +694,89 @@ 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: "继续" }));
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(controller.refresh).toHaveBeenCalledOnce();
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows entering the app from step three without creating a model", async () => {
|
||||
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
|
||||
let resolveMode!: (value: {
|
||||
mode: "pro";
|
||||
configured_mode: "pro";
|
||||
explicitly_configured: true;
|
||||
}) => void;
|
||||
getMemoryMode.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveMode = 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 }));
|
||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||
await user.click(lite);
|
||||
await act(async () => {
|
||||
resolveMode({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(lite.checked).toBe(true);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
|
||||
});
|
||||
|
||||
it("retains the explicit lite choice when saving the mode fails and retries", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
setMemoryMode.mockRejectedValueOnce(new Error("offline"));
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
await user.click(lite);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"模式未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
expect(lite.checked).toBe(true);
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
it("returns from skipped pro setup to an explicit mode choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports();
|
||||
const onFinish = vi.fn();
|
||||
@@ -698,10 +790,13 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
|
||||
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: "继续" }));
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
@@ -721,6 +816,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
await choose(user);
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
for (const fn of [
|
||||
controller.connectForOnboarding,
|
||||
@@ -775,6 +871,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(choice()).toHaveProperty("disabled", false);
|
||||
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -798,6 +895,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
@@ -1868,6 +1966,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "查看未导入的应用" }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
@@ -2042,6 +2141,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
@@ -2143,9 +2243,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(readConnectionPreferences().hostSourceIds).toContain("claude");
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
|
||||
@@ -2192,9 +2290,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(insertion);
|
||||
expect(insertion.checked).toBe(false);
|
||||
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2355,9 +2451,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await waitFor(() => expect(insertion.disabled).toBe(false));
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
expect(insertion.checked).toBe(true);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
|
||||
expect(window.oneMemory?.installRecall).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { completeOnboarding } from "./api";
|
||||
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
|
||||
import { AppMark } from "./app-mark";
|
||||
import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import { isDefiniteRefusal } from "./write-outcome";
|
||||
import "./cold-start.css";
|
||||
|
||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||
type OnboardingMemoryMode = "lite" | "pro";
|
||||
|
||||
// Cold start neither reads nor writes installation intent.
|
||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||
@@ -88,7 +89,18 @@ export function OnboardingFlow({
|
||||
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modelStep, setModelStep] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
|
||||
"review",
|
||||
);
|
||||
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
|
||||
null,
|
||||
);
|
||||
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 [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
|
||||
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
|
||||
@@ -135,7 +147,7 @@ export function OnboardingFlow({
|
||||
const consideredSources = useRef(new Set<string>());
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const reviewingImport =
|
||||
visitedModel ||
|
||||
(visitedSetup && !completionFailed) ||
|
||||
accepted !== null ||
|
||||
startingImport !== null ||
|
||||
state.status === "importing";
|
||||
@@ -307,6 +319,20 @@ export function OnboardingFlow({
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void getMemoryMode().then(
|
||||
(memoryMode) => {
|
||||
if (
|
||||
alive.current &&
|
||||
!modeChoiceTouched.current &&
|
||||
memoryMode.explicitly_configured &&
|
||||
memoryMode.configured_mode !== "ultra"
|
||||
)
|
||||
setModeChoice(memoryMode.configured_mode);
|
||||
},
|
||||
() => {
|
||||
// The choice screen remains usable; saving rechecks Core.
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
@@ -421,7 +447,7 @@ export function OnboardingFlow({
|
||||
});
|
||||
if (alive.current) {
|
||||
setCompletionFailed(true);
|
||||
setModelStep(false);
|
||||
setSetupStep("review");
|
||||
// A definite refusal means it was not saved; anything else (timeout,
|
||||
// 5xx, no response) leaves the outcome unknown, and the copy must not
|
||||
// assert a failure the UI cannot prove.
|
||||
@@ -450,6 +476,44 @@ export function OnboardingFlow({
|
||||
[persistFinish],
|
||||
);
|
||||
|
||||
const requestModeChoice = useCallback(
|
||||
(destination: OnboardingDestination) => {
|
||||
if (finished.current) return;
|
||||
setModeDestination(destination === "settings" ? "sources" : destination);
|
||||
setModeError(null);
|
||||
setError(null);
|
||||
setVisitedSetup(true);
|
||||
setSetupStep("mode");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
if (
|
||||
!modeChoice ||
|
||||
modeSaving ||
|
||||
finished.current ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
)
|
||||
return;
|
||||
setModeSaving(true);
|
||||
setModeError(null);
|
||||
try {
|
||||
await setMemoryMode(modeChoice);
|
||||
if (!alive.current) return;
|
||||
if (modeChoice === "lite") {
|
||||
await leave(modeDestination);
|
||||
return;
|
||||
}
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
} catch {
|
||||
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
}
|
||||
}, [leave, modeChoice, modeDestination, modeSaving]);
|
||||
|
||||
async function checkLocations() {
|
||||
if (flight.current || locked) return;
|
||||
flight.current = true;
|
||||
@@ -636,7 +700,7 @@ export function OnboardingFlow({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
hidden={modelStep}
|
||||
hidden={setupStep !== "review"}
|
||||
data-import-submission={startingImport ? "pending" : "settled"}
|
||||
>
|
||||
<FirstImportReview
|
||||
@@ -663,6 +727,10 @@ export function OnboardingFlow({
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
if (busy === "leaving") return;
|
||||
if (completionFailed) {
|
||||
void leave(destination);
|
||||
return;
|
||||
}
|
||||
const selection = {
|
||||
hostSourceIds: hostConnectionAvailable
|
||||
? [...activeDraft.hostSourceIds]
|
||||
@@ -676,9 +744,8 @@ export function OnboardingFlow({
|
||||
setApplyTick((value) => value + 1);
|
||||
if (destination === "settings") {
|
||||
setModelSourceIds([...sourceIds]);
|
||||
setVisitedModel(true);
|
||||
setModelStep(true);
|
||||
} else void leave(destination);
|
||||
}
|
||||
requestModeChoice(destination);
|
||||
}}
|
||||
recovery={
|
||||
completionFailed && exitDestination
|
||||
@@ -690,8 +757,116 @@ export function OnboardingFlow({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{setupStep === "mode" && (
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆方式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
onClick={() => {
|
||||
setModeError(null);
|
||||
setSetupStep("review");
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(modeSaving ? "正在保存…" : "继续")}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<h2 id="onboarding-memory-mode-title">
|
||||
{text("你希望 MemHub 如何形成记忆?")}
|
||||
</h2>
|
||||
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="onboarding-r6-mode-options"
|
||||
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>
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
)}
|
||||
{visitedModel && (
|
||||
<div hidden={!modelStep}>
|
||||
<div hidden={setupStep !== "model"}>
|
||||
<OnboardingModelController
|
||||
connectionAlreadyApplied={
|
||||
connectionApplication === "applied" ||
|
||||
@@ -709,8 +884,8 @@ export function OnboardingFlow({
|
||||
(total, source) => total + source.imported_conversations,
|
||||
0,
|
||||
)}
|
||||
onBack={() => setModelStep(false)}
|
||||
onSkip={() => void leave("sources")}
|
||||
onBack={() => setSetupStep("mode")}
|
||||
onSkip={() => setSetupStep("mode")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -763,7 +938,7 @@ export function OnboardingFlow({
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={locked}
|
||||
onClick={() => void leave("sources")}
|
||||
onClick={() => requestModeChoice("sources")}
|
||||
>
|
||||
{text("稍后添加,先进入")}
|
||||
</R6Button>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { OnboardingModelController } from "./OnboardingModelController";
|
||||
|
||||
const { models } = vi.hoisted(() => ({
|
||||
models: {
|
||||
profiles: [
|
||||
{
|
||||
profile_id: "active-profile",
|
||||
revision: 3,
|
||||
name: "Current model",
|
||||
provider_kind: "openai_compatible",
|
||||
model: "model-1",
|
||||
base_url: "https://models.example/v1",
|
||||
disable_thinking: true,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_base_url: null,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
context_window: null,
|
||||
has_api_key: true,
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
pendingProbe: false,
|
||||
probeStorageFailed: false,
|
||||
pendingCreation: false,
|
||||
creationStorageFailed: false,
|
||||
error: null,
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
probeProfile: vi.fn(),
|
||||
activate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./use-model-profiles", () => ({
|
||||
useModelProfiles: () => models,
|
||||
}));
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resumes onboarding with an already active model after a restart", async () => {
|
||||
const enabled = vi.fn();
|
||||
render(
|
||||
<OnboardingModelController
|
||||
sourceNames={[]}
|
||||
onBack={vi.fn()}
|
||||
onEnabled={enabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
|
||||
).toBeTruthy();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "继续使用当前模型" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
|
||||
expect(models.activate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3,8 +3,9 @@ import type {
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
} from "@one-memory/contracts";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getModelProfiles } from "./api";
|
||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||
import {
|
||||
type OnboardingModelDraft,
|
||||
OnboardingModelStep,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import { useModelProfiles } from "./use-model-profiles";
|
||||
|
||||
const defaultPreset = PROVIDER_PRESETS.find(
|
||||
@@ -68,6 +70,7 @@ export function OnboardingModelController({
|
||||
}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const models = useModelProfiles();
|
||||
const embeddingSwitch = useEmbeddingSwitch();
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [saved, setSaved] = useState<ModelProfile | null>(null);
|
||||
@@ -82,10 +85,66 @@ export function OnboardingModelController({
|
||||
state: "passed" | "failed" | "stale";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const flight = useRef(false);
|
||||
const draftTouched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const active = (models.profiles ?? []).find((profile) => profile.is_active);
|
||||
if (!active || saved || draftTouched.current) return;
|
||||
setSaved(active);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: active.name,
|
||||
provider_kind: active.provider_kind,
|
||||
model: active.model,
|
||||
base_url: active.base_url ?? "",
|
||||
disable_thinking: active.disable_thinking,
|
||||
embedding_mode:
|
||||
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
|
||||
embedding_provider_kind:
|
||||
active.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),
|
||||
context_window:
|
||||
active.context_window === null ? "" : String(active.context_window),
|
||||
}));
|
||||
setProbe({
|
||||
state: "passed",
|
||||
message: "当前模型已经启用,可以继续完成首次设置。",
|
||||
});
|
||||
}, [models.profiles, saved]);
|
||||
|
||||
useEffect(() => {
|
||||
const finished = embeddingSwitch.current?.last_finished;
|
||||
if (
|
||||
!switchPendingJobId ||
|
||||
!finished ||
|
||||
finished.job_id !== switchPendingJobId
|
||||
)
|
||||
return;
|
||||
if (finished.state === "done") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
onEnabled?.();
|
||||
} else if (finished.state === "rolled_back") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
|
||||
}
|
||||
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
|
||||
|
||||
function edit(patch: Partial<OnboardingModelDraft>) {
|
||||
if (flight.current) return;
|
||||
draftTouched.current = true;
|
||||
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
|
||||
const preset = PROVIDER_PRESETS.find(
|
||||
(item) => item.id === patch.provider_id,
|
||||
@@ -232,11 +291,27 @@ export function OnboardingModelController({
|
||||
|
||||
async function enable() {
|
||||
if (flight.current || probe?.state !== "passed" || !saved) return;
|
||||
if (saved.is_active) {
|
||||
onEnabled?.();
|
||||
return;
|
||||
}
|
||||
flight.current = true;
|
||||
setPhase("enabling");
|
||||
setError(null);
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
try {
|
||||
const plan = embeddingSwitch.shouldUseLegacyActivation()
|
||||
? null
|
||||
: await embeddingSwitch.planActivation(
|
||||
saved.profile_id,
|
||||
saved.revision,
|
||||
);
|
||||
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
|
||||
throw new Error("embedding_switch_plan_unconfirmed");
|
||||
if (plan?.needed) {
|
||||
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
|
||||
return;
|
||||
}
|
||||
const active = await models.activate(saved.profile_id);
|
||||
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
|
||||
trace.done("succeeded");
|
||||
@@ -251,76 +326,102 @@ export function OnboardingModelController({
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingModelStep
|
||||
draft={draft}
|
||||
providers={[
|
||||
...PROVIDER_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
keyRequired: preset.provider !== "ollama",
|
||||
supported: true,
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "自定义兼容服务",
|
||||
keyRequired: true,
|
||||
supported: true,
|
||||
},
|
||||
]}
|
||||
advancedOpen={advanced}
|
||||
onAdvancedChange={setAdvanced}
|
||||
onDraftChange={edit}
|
||||
phase={phase}
|
||||
capability={{ saveAndEnable: true }}
|
||||
canSaveAndEnable={probe?.state === "passed" && !!saved}
|
||||
canTest={!models.pendingProbe && !models.probeStorageFailed}
|
||||
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
|
||||
inputsLocked={flight.current}
|
||||
fieldErrors={Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [
|
||||
key,
|
||||
value === "onboardingIntegerRange"
|
||||
? t(
|
||||
"onboardingIntegerRange",
|
||||
key === "timeout_seconds"
|
||||
? { min: 1, max: 600 }
|
||||
: key === "max_concurrency"
|
||||
? { min: 1, max: 32 }
|
||||
: { min: 1024, max: 10_000_000 },
|
||||
)
|
||||
: text(value),
|
||||
]),
|
||||
)}
|
||||
error={error ?? (models.error ? text(models.error) : null)}
|
||||
probe={probe}
|
||||
profile={
|
||||
saved
|
||||
? {
|
||||
profile_id: saved.profile_id,
|
||||
revision: saved.revision,
|
||||
has_configured_api_key:
|
||||
saved.has_configured_api_key ?? saved.has_api_key,
|
||||
credential_state: saved.credential_state,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sourceNames={sourceNames}
|
||||
importedConversationCount={importedConversationCount}
|
||||
connectionSummary={
|
||||
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
|
||||
}
|
||||
generationRecipient={draft.base_url || "尚未填写接收方"}
|
||||
embeddingRecipient={
|
||||
draft.embedding_mode === "local"
|
||||
? "本机内置向量模型"
|
||||
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
|
||||
}
|
||||
onSaveDraft={() => save(false)}
|
||||
onTest={() => save(true)}
|
||||
onSaveAndEnable={enable}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
onActionError={() => setError("操作未确认,草稿已保留。")}
|
||||
/>
|
||||
<>
|
||||
<OnboardingModelStep
|
||||
draft={draft}
|
||||
providers={[
|
||||
...PROVIDER_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
label: preset.label,
|
||||
keyRequired: preset.provider !== "ollama",
|
||||
supported: true,
|
||||
})),
|
||||
{
|
||||
id: "custom",
|
||||
label: "自定义兼容服务",
|
||||
keyRequired: true,
|
||||
supported: true,
|
||||
},
|
||||
]}
|
||||
advancedOpen={advanced}
|
||||
onAdvancedChange={setAdvanced}
|
||||
onDraftChange={edit}
|
||||
phase={phase}
|
||||
capability={{ saveAndEnable: true }}
|
||||
canSaveAndEnable={probe?.state === "passed" && !!saved}
|
||||
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
|
||||
canTest={!models.pendingProbe && !models.probeStorageFailed}
|
||||
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
|
||||
inputsLocked={flight.current}
|
||||
fieldErrors={Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [
|
||||
key,
|
||||
value === "onboardingIntegerRange"
|
||||
? t(
|
||||
"onboardingIntegerRange",
|
||||
key === "timeout_seconds"
|
||||
? { min: 1, max: 600 }
|
||||
: key === "max_concurrency"
|
||||
? { min: 1, max: 32 }
|
||||
: { min: 1024, max: 10_000_000 },
|
||||
)
|
||||
: text(value),
|
||||
]),
|
||||
)}
|
||||
error={error ?? (models.error ? text(models.error) : null)}
|
||||
probe={probe}
|
||||
profile={
|
||||
saved
|
||||
? {
|
||||
profile_id: saved.profile_id,
|
||||
revision: saved.revision,
|
||||
has_configured_api_key:
|
||||
saved.has_configured_api_key ?? saved.has_api_key,
|
||||
credential_state: saved.credential_state,
|
||||
}
|
||||
: null
|
||||
}
|
||||
sourceNames={sourceNames}
|
||||
importedConversationCount={importedConversationCount}
|
||||
connectionSummary={
|
||||
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
|
||||
}
|
||||
generationRecipient={draft.base_url || "尚未填写接收方"}
|
||||
embeddingRecipient={
|
||||
draft.embedding_mode === "local"
|
||||
? "本机内置向量模型"
|
||||
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
|
||||
}
|
||||
onSaveDraft={() => save(false)}
|
||||
onTest={() => save(true)}
|
||||
onSaveAndEnable={enable}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
onActionError={() => setError("操作未确认,草稿已保留。")}
|
||||
/>
|
||||
{embeddingSwitch.plan?.needed && saved ? (
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={embeddingSwitch.plan}
|
||||
busy={embeddingSwitch.busy === "start"}
|
||||
errorCode={embeddingSwitch.errorCode}
|
||||
onCancel={() => {
|
||||
embeddingSwitch.clearPlan();
|
||||
embeddingSwitch.clearError();
|
||||
}}
|
||||
onStart={() => {
|
||||
flight.current = true;
|
||||
setPhase("enabling");
|
||||
void embeddingSwitch.startPlannedSwitch().then((job) => {
|
||||
if (job) {
|
||||
setSwitchPendingJobId(job.job_id);
|
||||
return;
|
||||
}
|
||||
flight.current = false;
|
||||
setPhase("idle");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
|
||||
export interface OptionMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
lang?: string;
|
||||
}
|
||||
@@ -265,7 +266,14 @@ export function OptionMenu(props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleLabel}
|
||||
{selected?.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{visibleLabel}</span>
|
||||
<small>{selected.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
visibleLabel
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
@@ -298,7 +306,14 @@ export function OptionMenu(props: {
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
{item.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{item.label}</span>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Ref } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
@@ -27,7 +27,7 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
}),
|
||||
).toBeNull();
|
||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||
@@ -48,6 +48,36 @@ vi.mock("./api", () => ({
|
||||
imports_unaffected: true,
|
||||
}),
|
||||
setSedimentation: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
setMemoryMode: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
getEvolveOverview: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
}),
|
||||
startEvolve: vi.fn(),
|
||||
getMemoryProcessing: vi.fn().mockResolvedValue({
|
||||
embedding_switch: null,
|
||||
}),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
vi.mock("./ModelSettings", async () => {
|
||||
const { createElement, useImperativeHandle } = await import("react");
|
||||
@@ -55,14 +85,25 @@ vi.mock("./ModelSettings", async () => {
|
||||
// Stands in for the real component at its worst: every mutating action is
|
||||
// fenced, and the only way out is the recovery entry that lives inside the
|
||||
// same area the fence used to keep shut.
|
||||
ModelSettings: ({ controlsRef }: { controlsRef?: Ref<unknown> }) => {
|
||||
ModelSettings: ({
|
||||
controlsRef,
|
||||
focusSection,
|
||||
}: {
|
||||
controlsRef?: Ref<unknown>;
|
||||
focusSection?: "generation" | "embedding";
|
||||
}) => {
|
||||
useImperativeHandle(controlsRef, () => ({
|
||||
edit: () => false,
|
||||
editProfile: () => false,
|
||||
add: () => false,
|
||||
test: () => Promise.resolve(undefined),
|
||||
}));
|
||||
return createElement("button", { type: "button" }, "核对测试结果");
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement("span", null, focusSection),
|
||||
createElement("button", { type: "button" }, "核对测试结果"),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -97,8 +138,8 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
} as NonNullable<typeof window.oneMemory>;
|
||||
const user = userEvent.setup();
|
||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox", { name: "语言" }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(12);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
@@ -151,14 +192,20 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
|
||||
);
|
||||
// The model configuration is a different store: a failed switch must not
|
||||
// leave the page claiming the model was removed.
|
||||
expect(screen.getByRole("heading", { name: "当前模型" })).toBeTruthy();
|
||||
expect(screen.getByText("当前模型")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "修改模型" }));
|
||||
const generationLane = (
|
||||
await screen.findByRole("heading", { name: "记忆整理" })
|
||||
).closest(".r6-model-lane") as HTMLElement | null;
|
||||
if (!generationLane) throw new Error("Missing generation lane");
|
||||
await user.click(
|
||||
within(generationLane).getByRole("button", { name: "配置" }),
|
||||
);
|
||||
|
||||
// The fence is still reported: the editor really did refuse.
|
||||
const alert = await screen.findByRole("alert");
|
||||
@@ -173,9 +220,48 @@ it("reaches the same recovery entry when no model has been configured yet", asyn
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "配置模型" }));
|
||||
const generationLane = (
|
||||
await screen.findByRole("heading", { name: "记忆整理" })
|
||||
).closest(".r6-model-lane") as HTMLElement | null;
|
||||
if (!generationLane) throw new Error("Missing generation lane");
|
||||
await user.click(
|
||||
within(generationLane).getByRole("button", { name: "配置模型" }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
|
||||
const searchLane = screen
|
||||
.getByRole("heading", { name: "记忆搜索" })
|
||||
.closest(".r6-model-lane") as HTMLElement | null;
|
||||
if (!searchLane) throw new Error("Missing search lane");
|
||||
expect(
|
||||
within(searchLane).getByRole("button", { name: "配置" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(searchLane.textContent).toContain("配置记忆整理后可自定义");
|
||||
});
|
||||
|
||||
it("shows separate compact lanes and opens the requested presentation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
|
||||
const generationLane = (
|
||||
await screen.findByRole("heading", { name: "记忆整理" })
|
||||
).closest(".r6-model-lane") as HTMLElement | null;
|
||||
const embeddingLane = screen
|
||||
.getByRole("heading", { name: "记忆搜索" })
|
||||
.closest(".r6-model-lane") as HTMLElement | null;
|
||||
if (!generationLane || !embeddingLane) throw new Error("Missing model lane");
|
||||
|
||||
expect(generationLane.textContent).toContain("当前模型");
|
||||
expect(generationLane.textContent).toContain("LLM");
|
||||
expect(embeddingLane.textContent).toContain("本地内置(推荐)");
|
||||
expect(embeddingLane.textContent).toContain("Embedding");
|
||||
|
||||
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
|
||||
expect(screen.getByText("embedding")).toBeTruthy();
|
||||
await user.click(
|
||||
within(generationLane).getByRole("button", { name: "配置" }),
|
||||
);
|
||||
expect(screen.getByText("generation")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the switch unusable when the state cannot be read", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -12,7 +12,12 @@ import { getModelProfiles, setSedimentation } from "./api";
|
||||
import { ConnectionFallback } from "./ConnectionFallback";
|
||||
import { exportDiagnosticsFile } from "./diagnostics-export";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
import {
|
||||
ModelSettings,
|
||||
type ModelSettingsActions,
|
||||
type ModelSettingsFocusSection,
|
||||
} from "./ModelSettings";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
import {
|
||||
@@ -30,7 +35,7 @@ import "./r6-settings.css";
|
||||
type Group = "model" | "connections" | "help";
|
||||
|
||||
const labels: Record<Group, string> = {
|
||||
model: "模型",
|
||||
model: "记忆模式与模型",
|
||||
connections: "连接其他应用",
|
||||
help: "帮助与诊断",
|
||||
};
|
||||
@@ -67,6 +72,11 @@ export function R6Settings({
|
||||
const [configurationRefusal, setConfigurationRefusal] = useState(false);
|
||||
const [modelVersion, setModelVersion] = useState(0);
|
||||
const [configurationOpen, setConfigurationOpen] = useState(false);
|
||||
const [configurationFocus, setConfigurationFocus] =
|
||||
useState<ModelSettingsFocusSection>("generation");
|
||||
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
const [quitOpen, setQuitOpen] = useState(false);
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
const [quitError, setQuitError] = useState("");
|
||||
@@ -208,8 +218,11 @@ export function R6Settings({
|
||||
}
|
||||
}
|
||||
|
||||
function openConfiguration() {
|
||||
function openConfiguration(
|
||||
focusSection: ModelSettingsFocusSection = "generation",
|
||||
) {
|
||||
setConfigurationRefusal(false);
|
||||
setConfigurationFocus(focusSection);
|
||||
const opened = currentModel
|
||||
? modelActions.current?.editProfile(currentModel)
|
||||
: modelActions.current?.add();
|
||||
@@ -292,95 +305,141 @@ export function R6Settings({
|
||||
hidden={group !== "model"}
|
||||
className="r6-settings-model"
|
||||
>
|
||||
<h2>{text("记忆模型")}</h2>
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={currentModel !== null}
|
||||
modelStateKnown={profiles !== null && !modelError}
|
||||
onConfigureModel={() => openConfiguration("generation")}
|
||||
onModeChange={setMemoryModeState}
|
||||
/>
|
||||
<div className="r6-model-heading">
|
||||
<div>
|
||||
<h2 className="r6-model-title">{text("记忆模型")}</h2>
|
||||
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={text("刷新模型状态")}
|
||||
title={text("刷新模型状态")}
|
||||
onClick={() => setModelVersion((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
<div className="r6-model-overview">
|
||||
<p className="r6-model-caption">{text("当前模型")}</p>
|
||||
<h3 className="r6-current-model">
|
||||
{currentModel?.name ??
|
||||
text(
|
||||
profiles === null
|
||||
? "正在读取模型…"
|
||||
: modelError
|
||||
? "模型状态暂不可读"
|
||||
: "尚未配置模型",
|
||||
<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>
|
||||
{currentModel && (
|
||||
<small>
|
||||
LLM · {currentModel.model} ·{" "}
|
||||
{text(safeEndpoint(currentModel.base_url))}
|
||||
</small>
|
||||
)}
|
||||
</h3>
|
||||
{currentModel && (
|
||||
<p className="r6-model-state">
|
||||
{currentModel.model} ·{" "}
|
||||
{text(safeEndpoint(currentModel.base_url))}
|
||||
</p>
|
||||
)}
|
||||
<div className="r6-model-actions">
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={profiles === null}
|
||||
onClick={openConfiguration}
|
||||
>
|
||||
{text(currentModel ? "修改模型" : "配置模型")}
|
||||
</R6Button>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={text("刷新模型状态")}
|
||||
title={text("刷新模型状态")}
|
||||
onClick={() => setModelVersion((value) => value + 1)}
|
||||
disabled={profiles === null}
|
||||
onClick={() => openConfiguration("generation")}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{text(currentModel ? "配置" : "配置模型")}
|
||||
</R6Button>
|
||||
</div>
|
||||
{modelError && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
profiles === null
|
||||
? "模型状态未能读取,请重试。"
|
||||
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{configurationRefusal && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{currentModel && (
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("用模型沉淀记忆")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("用模型沉淀记忆")}
|
||||
checked={sedimentation === true}
|
||||
disabled={
|
||||
sedimentation === null ||
|
||||
sedimentationPending ||
|
||||
sedimentationUnavailable
|
||||
}
|
||||
onCheckedChange={(enabled) =>
|
||||
void changeSedimentation(enabled)
|
||||
}
|
||||
/>
|
||||
<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
|
||||
: "本地内置(推荐)",
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{sedimentationError && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(sedimentationError)}
|
||||
</p>
|
||||
)}
|
||||
{sedimentationUnavailable && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={profiles === null || !currentModel}
|
||||
onClick={() => openConfiguration("embedding")}
|
||||
>
|
||||
{text("配置")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
{modelError && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
profiles === null
|
||||
? "模型状态未能读取,请重试。"
|
||||
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{configurationRefusal && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{currentModel && memoryMode !== "lite" && (
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("用模型沉淀记忆")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("用模型沉淀记忆")}
|
||||
checked={sedimentation === true}
|
||||
disabled={
|
||||
sedimentation === null ||
|
||||
sedimentationPending ||
|
||||
sedimentationUnavailable
|
||||
}
|
||||
onCheckedChange={(enabled) =>
|
||||
void changeSedimentation(enabled)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sedimentationError && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(sedimentationError)}
|
||||
</p>
|
||||
)}
|
||||
{sedimentationUnavailable && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{text(
|
||||
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<section
|
||||
className="r6-model-owner"
|
||||
hidden={!configurationOpen}
|
||||
@@ -388,6 +447,7 @@ export function R6Settings({
|
||||
>
|
||||
<ModelSettings
|
||||
compactSingle
|
||||
focusSection={configurationFocus}
|
||||
controlsRef={modelActions}
|
||||
onProfilesChanged={() => setModelVersion((value) => value + 1)}
|
||||
onSetupComplete={() => setConfigurationOpen(false)}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -15,6 +21,8 @@ import { SdkRawHistory } from "./SdkRawHistory";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("localizes controls without translating original conversation fields", async () => {
|
||||
@@ -87,3 +95,150 @@ it("lists and searches SDK raw without legacy conversation APIs", async () => {
|
||||
expect(await screen.findByText("搜索结果")).toBeTruthy();
|
||||
expect(searchSdkRaw).toHaveBeenCalledWith("查询");
|
||||
});
|
||||
|
||||
it("does not call a zero-result search an empty conversation history", async () => {
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-existing",
|
||||
content: "已有原始对话",
|
||||
session_id: "session-existing",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw.mockResolvedValue([]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "没有命中");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
|
||||
expect(await screen.findByText("找到 0 条")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("还没有原始对话。连接来源后,对话会显示在这里。"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("retries the lite history read in place after a load failure", async () => {
|
||||
getSdkRaw.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-recovered",
|
||||
content: "恢复后的原始对话",
|
||||
session_id: "session-recovered",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
|
||||
|
||||
expect(await screen.findByText("恢复后的原始对话")).toBeTruthy();
|
||||
expect(getSdkRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries the failed raw search instead of reloading history", async () => {
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search-recovered",
|
||||
content: "重试后的搜索结果",
|
||||
source: "cursor",
|
||||
session_id: "session-search",
|
||||
memory_at: null,
|
||||
score: 0.8,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "恢复搜索");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
|
||||
|
||||
expect(await screen.findByText("重试后的搜索结果")).toBeTruthy();
|
||||
expect(searchSdkRaw).toHaveBeenCalledTimes(2);
|
||||
expect(getSdkRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let a history poll clear an independent search failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-old",
|
||||
content: "已有历史",
|
||||
session_id: "session-old",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw.mockRejectedValue(new Error("search offline"));
|
||||
render(<SdkRawHistory />);
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("搜索原始对话"), {
|
||||
target: { value: "失败查询" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
|
||||
});
|
||||
|
||||
it("ignores a stale initial-history failure after a newer search succeeds", async () => {
|
||||
let rejectHistory!: (reason: Error) => void;
|
||||
getSdkRaw.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectHistory = reject;
|
||||
}),
|
||||
);
|
||||
searchSdkRaw.mockResolvedValue([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-new-search",
|
||||
content: "较新的搜索结果",
|
||||
source: "cursor",
|
||||
session_id: "session-new",
|
||||
memory_at: null,
|
||||
score: 0.9,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "新查询");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
expect(await screen.findByText("较新的搜索结果")).toBeTruthy();
|
||||
await act(async () => rejectHistory(new Error("late history failure")));
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByText("较新的搜索结果")).toBeTruthy();
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { getSdkRaw, searchSdkRaw } from "./api";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
|
||||
export function SdkRawHistory() {
|
||||
export function SdkRawHistory({ active = true }: { active?: boolean }) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const translation = useRef(t);
|
||||
translation.current = t;
|
||||
@@ -11,35 +12,67 @@ export function SdkRawHistory() {
|
||||
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [errorKind, setErrorKind] = useState<"load" | "search" | null>(null);
|
||||
const errorKindRef = useRef<"load" | "search" | null>(null);
|
||||
const searchEpoch = useRef(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let current = true;
|
||||
void getSdkRaw({ limit: 100 }).then(
|
||||
(page) => {
|
||||
if (current) setRecent(page.items);
|
||||
},
|
||||
() => {
|
||||
if (current) setError(translation.current("sdk.raw.loadFailed"));
|
||||
},
|
||||
);
|
||||
const startedBeforeSearch = searchEpoch.current;
|
||||
const consume = (value: unknown) => {
|
||||
if (!current) return;
|
||||
setRecent((value as Awaited<ReturnType<typeof getSdkRaw>>).items);
|
||||
if (errorKindRef.current !== "search") {
|
||||
errorKindRef.current = null;
|
||||
setError("");
|
||||
setErrorKind(null);
|
||||
}
|
||||
};
|
||||
void getSdkRaw({ limit: 100 }).then(consume, () => {
|
||||
if (current && startedBeforeSearch === searchEpoch.current) {
|
||||
setError(translation.current("sdk.raw.loadFailed"));
|
||||
errorKindRef.current = "load";
|
||||
setErrorKind("load");
|
||||
}
|
||||
});
|
||||
const stop = subscribePoll({
|
||||
key: "sdk-raw-history",
|
||||
intervalMs: 12_000,
|
||||
fetch: () => getSdkRaw({ limit: 100 }),
|
||||
onValue: consume,
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
stop();
|
||||
};
|
||||
}, []);
|
||||
}, [active, revision]);
|
||||
|
||||
async function runSearch(value: string) {
|
||||
searchEpoch.current += 1;
|
||||
setBusy(true);
|
||||
setResults(null);
|
||||
setError("");
|
||||
errorKindRef.current = null;
|
||||
setErrorKind(null);
|
||||
try {
|
||||
setResults(await searchSdkRaw(value));
|
||||
} catch {
|
||||
setError(t("sdk.raw.searchFailed"));
|
||||
errorKindRef.current = "search";
|
||||
setErrorKind("search");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResults(await searchSdkRaw(query.trim()));
|
||||
} catch {
|
||||
setError(t("sdk.raw.searchFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
await runSearch(query.trim());
|
||||
}
|
||||
|
||||
const items = results ?? recent.map(asContextItem);
|
||||
@@ -57,12 +90,35 @@ export function SdkRawHistory() {
|
||||
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{error ? (
|
||||
<p role="alert">
|
||||
{error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (errorKind === "search" && query.trim()) {
|
||||
void runSearch(query.trim());
|
||||
} else {
|
||||
setError("");
|
||||
errorKindRef.current = null;
|
||||
setErrorKind(null);
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("connectionFallback.retry")}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<p aria-live="polite">
|
||||
{results === null
|
||||
? t("sdk.raw.recent")
|
||||
: tn("sdk.raw.found", items.length)}
|
||||
</p>
|
||||
{!error && results === null && items.length === 0 ? (
|
||||
<p>{t("sdk.raw.empty")}</p>
|
||||
) : null}
|
||||
<ol>
|
||||
{items.map((item) => (
|
||||
<li key={item.raw_id}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
SdkRawContextItem,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { clockStamp } from "./clock-stamp";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { rawRecordTime } from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
|
||||
type RawRecord = SdkRawRecord | SdkRawContextItem;
|
||||
|
||||
function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
|
||||
if (role === "user") return t("selectedMemory.author.user");
|
||||
if (role === "assistant" || role === "agent")
|
||||
return t("selectedMemory.author.agent");
|
||||
return t("selectedMemory.author.unknown");
|
||||
}
|
||||
|
||||
export function SelectedRawDetail({
|
||||
record,
|
||||
sources,
|
||||
}: {
|
||||
record: RawRecord;
|
||||
sources: SourceResponse[];
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const messages =
|
||||
"messages" in record && record.messages?.length
|
||||
? record.messages
|
||||
: [
|
||||
{
|
||||
role:
|
||||
"role" in record && typeof record.role === "string"
|
||||
? record.role
|
||||
: "unknown",
|
||||
content: record.content,
|
||||
},
|
||||
];
|
||||
const source =
|
||||
record.source === UNATTRIBUTED_SOURCE_ID
|
||||
? t("selectedMemory.sourceUnattributed")
|
||||
: sourceNameOr(
|
||||
sources,
|
||||
record.source,
|
||||
record.source || t("selectedMemory.sourceUnknown"),
|
||||
);
|
||||
return (
|
||||
<article className="map-dialogue-reading">
|
||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
||||
<ul className="map-transcript">
|
||||
{messages.map((message) => (
|
||||
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
|
||||
<small>{authorLabel(message.role, t)}</small>
|
||||
<p>{message.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", {
|
||||
source,
|
||||
time: clockStamp(rawRecordTime(record), locale),
|
||||
})}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: () => ({
|
||||
get: fake.get,
|
||||
post: fake.post,
|
||||
delete: fake.del,
|
||||
interceptors: { request: { use: vi.fn() } },
|
||||
}),
|
||||
isAxiosError: (value: unknown) =>
|
||||
typeof value === "object" && value !== null && "isAxiosError" in value,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
cancelEmbeddingSwitch,
|
||||
deleteRetiredEmbeddingSpace,
|
||||
embeddingSwitchRefusal,
|
||||
embeddingSwitchUnsupported,
|
||||
getCurrentEmbeddingSwitch,
|
||||
listEmbeddingSpaces,
|
||||
planEmbeddingSwitch,
|
||||
startEmbeddingSwitch,
|
||||
} from "./api";
|
||||
|
||||
const NOW = "2026-11-03T09:00:00Z";
|
||||
const summary = (space_id: string | null) => ({
|
||||
space_id,
|
||||
model: "embed",
|
||||
dims: 512,
|
||||
collection_base: space_id ? "memory" : "",
|
||||
vectors: space_id ? 1 : null,
|
||||
});
|
||||
const plan = {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token",
|
||||
source: summary("space-1"),
|
||||
target: summary(null),
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [
|
||||
{
|
||||
space_id: "space-1",
|
||||
collection_base: "memory",
|
||||
dims: 512,
|
||||
vectors: 1,
|
||||
unmerged: null,
|
||||
},
|
||||
],
|
||||
total_vectors: 1,
|
||||
users: 1,
|
||||
graph_vectors: null,
|
||||
estimate_s: 1,
|
||||
requests: 1,
|
||||
disk_bytes_needed: 1,
|
||||
disk_bytes_free: 2,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
} as const;
|
||||
const job = {
|
||||
job_id: "job-1",
|
||||
kind: "switch",
|
||||
state: "preparing",
|
||||
source: summary("space-1"),
|
||||
target: summary("space-2"),
|
||||
stray_space_ids: [],
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "prepare",
|
||||
done: 0,
|
||||
total: 1,
|
||||
percent: 0,
|
||||
embedded: 0,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: null,
|
||||
source_index: 0,
|
||||
sources: 1,
|
||||
rate_per_s: null,
|
||||
eta_s: null,
|
||||
updated_at: NOW,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: null,
|
||||
} as const;
|
||||
const space = {
|
||||
space_id: "space-1",
|
||||
state: "retired",
|
||||
model: "embed",
|
||||
dims: 512,
|
||||
name_dims: 512,
|
||||
collection_base: "memory",
|
||||
naming: "legacy",
|
||||
graph_dir: "graph",
|
||||
provider_kind: "local_onnx",
|
||||
profile_id: null,
|
||||
profile_revision: null,
|
||||
vectors: 1,
|
||||
created_at: NOW,
|
||||
live_since: NOW,
|
||||
retired_at: NOW,
|
||||
keep_until: NOW,
|
||||
dropped_at: null,
|
||||
} as const;
|
||||
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
describe("embedding switch API", () => {
|
||||
it("plans and starts through validated request bodies", async () => {
|
||||
fake.post
|
||||
.mockResolvedValueOnce({ data: plan })
|
||||
.mockResolvedValueOnce({ data: job });
|
||||
await expect(planEmbeddingSwitch("profile-1", 2)).resolves.toEqual(plan);
|
||||
await expect(
|
||||
startEmbeddingSwitch({
|
||||
profile_id: "profile-1",
|
||||
expected_revision: 2,
|
||||
plan_token: "token",
|
||||
requested_by: "app",
|
||||
}),
|
||||
).resolves.toEqual(job);
|
||||
expect(fake.post.mock.calls[0].slice(0, 2)).toEqual([
|
||||
"/api/v1/embedding-switches/plan",
|
||||
{ profile_id: "profile-1", expected_revision: 2 },
|
||||
]);
|
||||
expect(fake.post.mock.calls[1][0]).toBe("/api/v1/embedding-switches");
|
||||
});
|
||||
|
||||
it("reads, cancels, lists and deletes with parsed receipts", async () => {
|
||||
const current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job,
|
||||
last_finished: null,
|
||||
};
|
||||
const spaces = {
|
||||
schema_version: 1,
|
||||
live_space_id: null,
|
||||
spaces: [space],
|
||||
scanned: null,
|
||||
scan_error: null,
|
||||
};
|
||||
fake.get
|
||||
.mockResolvedValueOnce({ data: current })
|
||||
.mockResolvedValueOnce({ data: spaces });
|
||||
fake.post.mockResolvedValueOnce({
|
||||
data: { ...job, cancel_requested: true },
|
||||
});
|
||||
fake.del.mockResolvedValueOnce({
|
||||
data: {
|
||||
schema_version: 1,
|
||||
space: { ...space, state: "dropped", dropped_at: NOW },
|
||||
},
|
||||
});
|
||||
await expect(getCurrentEmbeddingSwitch()).resolves.toEqual(current);
|
||||
await expect(cancelEmbeddingSwitch("job-1")).resolves.toMatchObject({
|
||||
cancel_requested: true,
|
||||
});
|
||||
await expect(listEmbeddingSpaces()).resolves.toEqual(spaces);
|
||||
await expect(deleteRetiredEmbeddingSpace("space-1")).resolves.toMatchObject(
|
||||
{ space: { state: "dropped" } },
|
||||
);
|
||||
expect(fake.post.mock.calls[0][0]).toBe(
|
||||
"/api/v1/embedding-switches/job-1/cancel",
|
||||
);
|
||||
expect(fake.del.mock.calls[0][0]).toBe("/api/v1/embedding-spaces/space-1");
|
||||
});
|
||||
|
||||
it("keeps a replacement plan attached to a refusal", async () => {
|
||||
const refusal = Object.assign(new Error("conflict"), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { detail: "embedding_switch_plan_stale", plan },
|
||||
},
|
||||
});
|
||||
fake.post.mockRejectedValueOnce(refusal);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await planEmbeddingSwitch("profile-1", 2);
|
||||
} catch (reason) {
|
||||
caught = reason;
|
||||
}
|
||||
expect(embeddingSwitchRefusal(caught)).toEqual({
|
||||
code: "embedding_switch_plan_stale",
|
||||
plan,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed success bodies", async () => {
|
||||
fake.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: { ...job, state: "surprise" },
|
||||
last_finished: null,
|
||||
},
|
||||
});
|
||||
await expect(getCurrentEmbeddingSwitch()).rejects.toThrow(
|
||||
"embedding_switch_current_invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes an older Core route 404 from a product refusal", () => {
|
||||
const routeMissing = {
|
||||
isAxiosError: true,
|
||||
response: { status: 404, data: { detail: "Not Found" } },
|
||||
};
|
||||
const productMissing = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 404,
|
||||
data: { detail: "embedding_switch_not_found" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(embeddingSwitchUnsupported(routeMissing)).toBe(true);
|
||||
expect(embeddingSwitchUnsupported(productMissing)).toBe(false);
|
||||
});
|
||||
});
|
||||
+215
-3
@@ -2,6 +2,14 @@ import {
|
||||
CORE_SERVICE,
|
||||
type ConnectSourcesResponse,
|
||||
type DetectionResponse,
|
||||
type EmbeddingSpaceDeleteReceipt,
|
||||
type EmbeddingSpaces,
|
||||
type EmbeddingSwitchCurrent,
|
||||
type EmbeddingSwitchJob,
|
||||
type EmbeddingSwitchPlan,
|
||||
type EvolveOverview,
|
||||
type EvolveRunReceipt,
|
||||
type EvolveSchedule,
|
||||
type ExtractionSettings,
|
||||
type ExtractionWindowPreset,
|
||||
type GraphWidthDecision,
|
||||
@@ -17,6 +25,9 @@ import {
|
||||
type MemoryAddInput,
|
||||
type MemoryAddReceipt,
|
||||
type MemoryGraphResponse,
|
||||
type MemoryModeChange,
|
||||
type MemoryModeState,
|
||||
type MemoryPipelineMode,
|
||||
type MemoryProcessingSummary,
|
||||
type ModelCreationOperation,
|
||||
type ModelProbeOperationReceipt,
|
||||
@@ -42,20 +53,35 @@ import {
|
||||
// stays here is the transport adapter: axios wiring, recovery clients, and the
|
||||
// UI-facing error mapping.
|
||||
import {
|
||||
buildEmbeddingSwitchPlanRequest,
|
||||
buildEmbeddingSwitchStartRequest,
|
||||
buildMemoryAddRequest,
|
||||
buildModelProfileCreateRequest,
|
||||
buildModelProfileUpdateRequest,
|
||||
buildSourceImportRequest,
|
||||
buildSourceSyncRequest,
|
||||
classifyModelRemove,
|
||||
EMBEDDING_SPACES_PATH,
|
||||
EMBEDDING_SWITCH_CURRENT_PATH,
|
||||
type EmbeddingSwitchRefusal,
|
||||
type EmbeddingSwitchStartInput,
|
||||
embeddingSwitchCancelPath,
|
||||
hasSourceSyncTarget,
|
||||
memoryAddStatusPath,
|
||||
normalizeManualExtractEvidence,
|
||||
parseEmbeddingSpaceDeleteReceipt,
|
||||
parseEmbeddingSpaces,
|
||||
parseEmbeddingSwitchCurrent,
|
||||
parseEmbeddingSwitchJob,
|
||||
parseEmbeddingSwitchPlan,
|
||||
parseEmbeddingSwitchRefusal,
|
||||
parseEmbeddingSwitchSummary,
|
||||
parseExtractionSettingsReceipt,
|
||||
parseGraphWidthDecisionReceipt,
|
||||
parseManualExtractReceipt,
|
||||
parseMemoryAddReceipt,
|
||||
parseModelProfileReceipt,
|
||||
retiredEmbeddingSpacePath,
|
||||
SourceSyncInputError,
|
||||
} from "@one-memory/operations";
|
||||
import axios from "axios";
|
||||
@@ -588,6 +614,135 @@ export async function probeModelProfile(
|
||||
});
|
||||
}
|
||||
|
||||
function preserveEmbeddingSwitchRefusal(reason: unknown): never {
|
||||
const refusal = embeddingSwitchRefusal(reason);
|
||||
if (refusal !== null && typeof reason === "object" && reason !== null) {
|
||||
throw Object.assign(reason, {
|
||||
embeddingSwitchRefusal: refusal,
|
||||
});
|
||||
}
|
||||
throw reason;
|
||||
}
|
||||
|
||||
/** An older Core answers a route-level 404 because switch v1 is not present. */
|
||||
export function embeddingSwitchUnsupported(reason: unknown): boolean {
|
||||
return (
|
||||
axios.isAxiosError(reason) &&
|
||||
reason.response?.status === 404 &&
|
||||
embeddingSwitchRefusal(reason) === null
|
||||
);
|
||||
}
|
||||
|
||||
/** Recover a Core refusal without losing the replacement plan it may carry. */
|
||||
export function embeddingSwitchRefusal(
|
||||
reason: unknown,
|
||||
): EmbeddingSwitchRefusal | null {
|
||||
if (
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"embeddingSwitchRefusal" in reason
|
||||
) {
|
||||
const preserved = (reason as { embeddingSwitchRefusal?: unknown })
|
||||
.embeddingSwitchRefusal;
|
||||
if (
|
||||
preserved !== null &&
|
||||
typeof preserved === "object" &&
|
||||
"code" in preserved
|
||||
)
|
||||
return preserved as EmbeddingSwitchRefusal;
|
||||
}
|
||||
if (!axios.isAxiosError(reason)) return null;
|
||||
return parseEmbeddingSwitchRefusal(reason.response?.data);
|
||||
}
|
||||
|
||||
export async function planEmbeddingSwitch(
|
||||
profileId: string,
|
||||
expectedRevision?: number,
|
||||
traceId?: string,
|
||||
): Promise<EmbeddingSwitchPlan> {
|
||||
const request = buildEmbeddingSwitchPlanRequest(profileId, expectedRevision);
|
||||
try {
|
||||
const response = await client.post<unknown>(
|
||||
request.path,
|
||||
request.body,
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const plan = parseEmbeddingSwitchPlan(response.data);
|
||||
if (plan === null) throw new Error("embedding_switch_plan_invalid");
|
||||
return plan;
|
||||
} catch (reason) {
|
||||
preserveEmbeddingSwitchRefusal(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startEmbeddingSwitch(
|
||||
input: EmbeddingSwitchStartInput,
|
||||
traceId?: string,
|
||||
): Promise<EmbeddingSwitchJob> {
|
||||
const request = buildEmbeddingSwitchStartRequest(input);
|
||||
try {
|
||||
const response = await client.post<unknown>(
|
||||
request.path,
|
||||
request.body,
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const job = parseEmbeddingSwitchJob(response.data);
|
||||
if (job === null) throw new Error("embedding_switch_job_invalid");
|
||||
return job;
|
||||
} catch (reason) {
|
||||
preserveEmbeddingSwitchRefusal(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentEmbeddingSwitch(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<EmbeddingSwitchCurrent> {
|
||||
const response = await client.get<unknown>(EMBEDDING_SWITCH_CURRENT_PATH, {
|
||||
signal: options.signal,
|
||||
});
|
||||
const current = parseEmbeddingSwitchCurrent(response.data);
|
||||
if (current === null) throw new Error("embedding_switch_current_invalid");
|
||||
return current;
|
||||
}
|
||||
|
||||
export async function cancelEmbeddingSwitch(
|
||||
jobId: string,
|
||||
traceId?: string,
|
||||
): Promise<EmbeddingSwitchJob> {
|
||||
const response = await client.post<unknown>(
|
||||
embeddingSwitchCancelPath(jobId),
|
||||
{},
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const job = parseEmbeddingSwitchJob(response.data);
|
||||
if (job === null) throw new Error("embedding_switch_job_invalid");
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function listEmbeddingSpaces(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<EmbeddingSpaces> {
|
||||
const response = await client.get<unknown>(EMBEDDING_SPACES_PATH, {
|
||||
signal: options.signal,
|
||||
});
|
||||
const spaces = parseEmbeddingSpaces(response.data);
|
||||
if (spaces === null) throw new Error("embedding_spaces_invalid");
|
||||
return spaces;
|
||||
}
|
||||
|
||||
export async function deleteRetiredEmbeddingSpace(
|
||||
spaceId: string,
|
||||
traceId?: string,
|
||||
): Promise<EmbeddingSpaceDeleteReceipt> {
|
||||
const response = await client.delete<unknown>(
|
||||
retiredEmbeddingSpacePath(spaceId),
|
||||
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
|
||||
);
|
||||
const receipt = parseEmbeddingSpaceDeleteReceipt(response.data);
|
||||
if (receipt === null) throw new Error("embedding_space_receipt_invalid");
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export async function activateModelProfile(
|
||||
profileId: string,
|
||||
traceId?: string,
|
||||
@@ -789,30 +944,34 @@ export async function getSdkRaw({
|
||||
source,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawPage> {
|
||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||
params: { source, offset, limit },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function searchSdkRaw(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
options: { source?: string; signal?: AbortSignal } = {},
|
||||
): Promise<SdkRawContextItem[]> {
|
||||
const response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
{
|
||||
query,
|
||||
kinds: ["raw"],
|
||||
...(options.source ? { include_sources: [options.source] } : {}),
|
||||
limit: 20,
|
||||
character_budget: 12_000,
|
||||
},
|
||||
{ signal, timeout: 10_000 },
|
||||
{ signal: options.signal, timeout: 10_000 },
|
||||
);
|
||||
const items = response.data?.items;
|
||||
return Array.isArray(items)
|
||||
@@ -907,6 +1066,56 @@ export async function getMemoryProcessing(): Promise<MemoryProcessingSummary> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getMemoryMode(): Promise<MemoryModeState> {
|
||||
const response = await client.get<MemoryModeState>(
|
||||
"/api/v1/settings/memory-mode",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setMemoryMode(
|
||||
mode: MemoryPipelineMode,
|
||||
): Promise<MemoryModeChange> {
|
||||
const response = await client.put<MemoryModeChange>(
|
||||
"/api/v1/settings/memory-mode",
|
||||
{ mode },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEvolveSchedule(): Promise<EvolveSchedule> {
|
||||
const response = await client.get<EvolveSchedule>(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setEvolveSchedule(
|
||||
update: Partial<EvolveSchedule>,
|
||||
): Promise<EvolveSchedule> {
|
||||
const response = await client.put<EvolveSchedule>(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
update,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEvolveOverview(
|
||||
userId = "local",
|
||||
): Promise<EvolveOverview> {
|
||||
const response = await client.get<EvolveOverview>("/api/v2/evolve", {
|
||||
params: { user_id: userId },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startEvolve(userId = "local"): Promise<EvolveRunReceipt> {
|
||||
const response = await client.post<EvolveRunReceipt>("/api/v2/evolve", {
|
||||
user_id: userId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getDetailedLogging(): Promise<boolean> {
|
||||
const response = await client.get("/api/v1/diagnostics/settings");
|
||||
const value = response.data as Record<string, unknown>;
|
||||
@@ -1153,7 +1362,10 @@ export function isHealthResponse(value: unknown): value is HealthResponse {
|
||||
(candidate.memory_backfill === undefined ||
|
||||
candidate.memory_backfill === "running" ||
|
||||
candidate.memory_backfill === "done" ||
|
||||
candidate.memory_backfill === "failed")
|
||||
candidate.memory_backfill === "failed") &&
|
||||
(candidate.embedding_switch === undefined ||
|
||||
candidate.embedding_switch === null ||
|
||||
parseEmbeddingSwitchSummary(candidate.embedding_switch) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
.r6-settings-dialog-layer.embedding-switch-dialog-layer {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(600px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
color: var(--r6-text);
|
||||
}
|
||||
|
||||
.embedding-switch-dialog > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.embedding-switch-safety p,
|
||||
.embedding-switch-dialog header p {
|
||||
margin: 0;
|
||||
color: var(--r6-muted);
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 22px 0;
|
||||
border-block: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div {
|
||||
padding: 14px 16px 14px 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div + div {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-impact dt,
|
||||
.embedding-switch-details dt {
|
||||
color: var(--meta-reading);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact dd,
|
||||
.embedding-switch-details dd {
|
||||
margin: 3px 0 0;
|
||||
color: var(--r6-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-safety {
|
||||
display: grid;
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 10px 18px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-safety strong {
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-details {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.embedding-switch-details summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
color: var(--r6-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.embedding-switch-details summary:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-alert svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-details[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.embedding-switch-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 24px;
|
||||
margin: 12px 0 0;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--bd);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.embedding-switch-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dg-bd);
|
||||
background: var(--dg-bg);
|
||||
color: var(--dg);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.r6-settings-dialog.embedding-switch-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip,
|
||||
.embedding-switch-outcome {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 9px 18px;
|
||||
border-bottom: 0.5px solid var(--bd);
|
||||
background: var(--panel);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.embedding-switch-strip-copy,
|
||||
.embedding-switch-outcome > div:first-child {
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip strong,
|
||||
.embedding-switch-outcome strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip span,
|
||||
.embedding-switch-outcome span {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-progress {
|
||||
height: 5px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--bd);
|
||||
}
|
||||
|
||||
.embedding-switch-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--ac);
|
||||
transition: width var(--motion-state) var(--motion-enter);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome {
|
||||
justify-content: space-between;
|
||||
background: var(--ok-bg);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome.is-error {
|
||||
background: var(--dg-bg);
|
||||
}
|
||||
|
||||
.embedding-switch-outcome-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedding-switch-cleanup-error {
|
||||
margin: 0;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 0.5px solid var(--dg-bd);
|
||||
background: var(--dg-bg);
|
||||
color: var(--dg);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome-actions > .r6-button:last-child {
|
||||
width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.embedding-switch-dialog-layer {
|
||||
align-items: end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.embedding-switch-dialog {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
padding: 22px 18px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div + div {
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--bd);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.embedding-switch-safety,
|
||||
.embedding-switch-details dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.embedding-switch-strip,
|
||||
.embedding-switch-outcome {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.embedding-switch-progress {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.embedding-switch-progress > span {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-native-reduce-motion="true"] .embedding-switch-progress > span {
|
||||
transition: none;
|
||||
}
|
||||
@@ -561,6 +561,119 @@
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.onboarding-r6-mode {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.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;
|
||||
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;
|
||||
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-card[data-selected="true"] {
|
||||
border-color: #557c93;
|
||||
background: #edf3f6;
|
||||
box-shadow: 0 0 0 2px rgb(85 124 147 / 10%);
|
||||
}
|
||||
.onboarding-r6-mode-card:has(input:focus-visible) {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.onboarding-r6-mode-card input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 4px 0 0;
|
||||
accent-color: #557c93;
|
||||
}
|
||||
.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-error {
|
||||
min-height: 20px;
|
||||
margin: -6px 0 0;
|
||||
color: #944242;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.onboarding-r6-empty {
|
||||
margin: 0;
|
||||
padding: 28px 18px;
|
||||
@@ -698,6 +811,12 @@
|
||||
.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;
|
||||
|
||||
@@ -28,7 +28,9 @@ export type PollKey =
|
||||
| "applications"
|
||||
| "calls"
|
||||
| "model-profiles"
|
||||
| "embedding-switch"
|
||||
| "memory-add-processing"
|
||||
| "sdk-raw-history"
|
||||
| "health";
|
||||
|
||||
export interface PollTask {
|
||||
|
||||
@@ -11,6 +11,41 @@
|
||||
--r6-fast: 140ms;
|
||||
}
|
||||
|
||||
.r6-settings-dialog-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(46 66 82 / 18%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.r6-settings-dialog {
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 28px;
|
||||
border: 1px solid white;
|
||||
border-radius: 16px;
|
||||
background: #f3f7f9;
|
||||
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
|
||||
}
|
||||
|
||||
.r6-settings-dialog h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.r6-settings-dialog p {
|
||||
margin: 0 0 24px;
|
||||
color: #63707b;
|
||||
}
|
||||
|
||||
.r6-settings-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
button.r6-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -318,6 +353,18 @@ button.r6-switch:focus-visible,
|
||||
[data-native-reduce-motion="true"] .r6-button-spinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.r6-settings-dialog-layer {
|
||||
background: rgb(46 66 82 / 28%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-native-reduce-transparency="true"] .r6-settings-dialog-layer {
|
||||
background: rgb(46 66 82 / 28%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
[data-native-reduce-motion="true"]
|
||||
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
|
||||
transition: none;
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
font-size: 13px;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-memory .memory-mode-notice {
|
||||
margin: -8px 0 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f8;
|
||||
color: #405665;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory .memory-pending-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 78%);
|
||||
background: #fff;
|
||||
}
|
||||
.r6-language-setting-title {
|
||||
margin: 0 0 14px;
|
||||
@@ -194,6 +194,105 @@
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.r6-memory-mode-settings {
|
||||
margin: 0 0 32px;
|
||||
padding: 0 0 32px;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-mode-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.r6-settings .r6-memory-mode-settings .r6-mode-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.r6-mode-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-mode-setting-row {
|
||||
max-width: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.r6-settings .r6-mode-option-menu {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button {
|
||||
height: auto;
|
||||
min-height: 68px;
|
||||
padding: 10px 44px 10px 14px;
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button::after {
|
||||
top: 27px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > span,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > span {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > small,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > small {
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 19px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||
[role="option"] {
|
||||
min-height: 62px;
|
||||
padding: 9px 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-settings-note {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f8;
|
||||
color: #405665;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-evolve-settings {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings-dialog ul {
|
||||
margin: 14px 0 20px;
|
||||
padding-left: 20px;
|
||||
color: #4f6373;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-setting-row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -374,28 +473,75 @@
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.r6-settings .r6-model-overview {
|
||||
padding: 26px 0 20px;
|
||||
border-bottom: 0;
|
||||
.r6-settings .r6-model-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.r6-settings .r6-model-caption {
|
||||
.r6-settings .r6-model-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.r6-settings .r6-model-heading p {
|
||||
margin: 2px 0 0;
|
||||
color: #63707b;
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-model-overview {
|
||||
margin-top: 18px;
|
||||
border: 1px solid #d8e2ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .r6-model-lane {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-height: 108px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.r6-settings .r6-model-lane + .r6-model-lane {
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-settings .r6-model-lane-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-settings .r6-model-lane h3 {
|
||||
margin: 0;
|
||||
color: #304757;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 25px;
|
||||
}
|
||||
.r6-settings .r6-model-lane p {
|
||||
margin: 2px 0 9px;
|
||||
color: #63707b;
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-settings .r6-current-model {
|
||||
font:
|
||||
500 22px / 34px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
margin: 0 0 7px;
|
||||
.r6-settings .r6-model-lane strong,
|
||||
.r6-settings .r6-model-lane small {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-settings .r6-model-state {
|
||||
.r6-settings .r6-model-lane strong {
|
||||
color: #304757;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
color: #63707b;
|
||||
margin: 0 0 13px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
}
|
||||
.r6-settings .r6-model-lane small {
|
||||
margin-top: 1px;
|
||||
color: #71808b;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-settings .r6-model-lane > .r6-button {
|
||||
min-width: 72px;
|
||||
}
|
||||
.r6-settings .r6-model-recipient {
|
||||
font-size: 14px;
|
||||
@@ -436,36 +582,6 @@
|
||||
background: rgb(158 178 194 / 22%);
|
||||
color: #0f6e56;
|
||||
}
|
||||
.r6-settings-dialog-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(46 66 82 / 18%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.r6-settings-dialog {
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 28px;
|
||||
border: 1px solid white;
|
||||
border-radius: 16px;
|
||||
background: #f3f7f9;
|
||||
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
|
||||
}
|
||||
.r6-settings-dialog h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.r6-settings-dialog p {
|
||||
margin: 0 0 24px;
|
||||
color: #63707b;
|
||||
}
|
||||
.r6-settings-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.r6-settings .r6-model-maintenance {
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -614,17 +730,61 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 54px;
|
||||
border: 1px solid #c0ccd5;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--r6-control-edge);
|
||||
border-radius: 8px;
|
||||
background: #f0f5f6;
|
||||
color: #262a2e;
|
||||
font-size: 15px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
color: var(--r6-text);
|
||||
font-size: 14px;
|
||||
padding: 10px 13px;
|
||||
}
|
||||
.r6-settings .model-form .om-option-menu-button {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 42px 0 13px;
|
||||
border-color: var(--r6-control-edge);
|
||||
background: #fff;
|
||||
color: var(--r6-text);
|
||||
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"] {
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-settings .model-form input:not([type="checkbox"]):hover,
|
||||
.r6-settings .model-form select:hover,
|
||||
.r6-settings .model-form .om-option-menu-button:hover:not(:disabled) {
|
||||
border-color: #aabcc8;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .model-form input:not([type="checkbox"]):focus-visible,
|
||||
.r6-settings .model-form select:focus-visible,
|
||||
.r6-settings .model-form .om-option-menu-button:focus-visible {
|
||||
outline: 2px solid var(--r6-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-settings .model-advanced {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.r6-settings .model-search-intro {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-settings .model-search-intro strong {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.r6-settings .model-search-intro p {
|
||||
margin: 4px 0 0;
|
||||
color: #63707b;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-settings .model-advanced label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -729,6 +889,14 @@
|
||||
padding: 24px 20px;
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
.r6-settings .r6-model-lane {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.r6-settings .r6-model-lane > .r6-button {
|
||||
justify-self: start;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rawDetail, rawLibraryPage, rawSearchPage } from "./sdk-raw-map";
|
||||
|
||||
const record = {
|
||||
raw_id: "raw-1",
|
||||
content: "[user]: Keep the original words.",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
session_id: "session-1",
|
||||
source: "cursor",
|
||||
memory_at: 1_761_000_000,
|
||||
gmt_created: 1_761_000_100,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: "Keep the original words." }],
|
||||
};
|
||||
|
||||
describe("raw map projection", () => {
|
||||
it("projects a raw page into the shared three-column library shape", () => {
|
||||
const page = rawLibraryPage({
|
||||
items: [record],
|
||||
total: 3,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.items[0]).toMatchObject({
|
||||
memory_id: "raw-1",
|
||||
content: record.content,
|
||||
status: "confirmed",
|
||||
});
|
||||
expect(page.source_ids_by_memory).toEqual({ "raw-1": ["cursor"] });
|
||||
expect(page.source_time_by_memory?.["raw-1"]?.basis).toBe("conversation");
|
||||
expect(page.total).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps search results read-only and page-local", () => {
|
||||
const page = rawSearchPage([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search",
|
||||
content: "Matched original",
|
||||
source: "claude",
|
||||
session_id: "session-2",
|
||||
memory_at: "2026-11-03T09:00:00Z",
|
||||
score: 0.8,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(page).toMatchObject({ total: 1, offset: 0, limit: 1 });
|
||||
expect(page.items[0].memory_id).toBe("raw-search");
|
||||
});
|
||||
|
||||
it("falls back to import time when conversation time is unavailable", () => {
|
||||
const page = rawLibraryPage({
|
||||
items: [{ ...record, memory_at: null }],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.source_time_by_memory?.["raw-1"]).toEqual({
|
||||
at: new Date(record.gmt_created * 1_000).toISOString(),
|
||||
basis: "import",
|
||||
});
|
||||
expect(rawDetail({ ...record, memory_at: null }).sdk_memory_at).toBe(
|
||||
new Date(record.gmt_created * 1_000).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("adapts raw only for the shared selection identity", () => {
|
||||
expect(rawDetail(record)).toMatchObject({
|
||||
memory: { memory_id: "raw-1", content: record.content },
|
||||
source_id: "cursor",
|
||||
evidence: [],
|
||||
history: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryDetail,
|
||||
MemoryLibraryPage,
|
||||
SdkRawContextItem,
|
||||
SdkRawPage,
|
||||
SdkRawRecord,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
|
||||
|
||||
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
|
||||
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
|
||||
|
||||
function isoTime(value: number | string | null | undefined): string {
|
||||
const milliseconds =
|
||||
typeof value === "number"
|
||||
? value * 1_000
|
||||
: typeof value === "string"
|
||||
? Date.parse(value)
|
||||
: Number.NaN;
|
||||
if (
|
||||
Number.isFinite(milliseconds) &&
|
||||
milliseconds >= EARLIEST_PLAUSIBLE &&
|
||||
milliseconds <= LATEST_PLAUSIBLE()
|
||||
)
|
||||
return new Date(milliseconds).toISOString();
|
||||
return "";
|
||||
}
|
||||
|
||||
export function rawRecordTime(record: RawMapRecord): string | null {
|
||||
return (
|
||||
isoTime(record.memory_at) ||
|
||||
("gmt_created" in record ? isoTime(record.gmt_created) : "") ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function sourceOf(record: RawMapRecord): string {
|
||||
return record.source || "default";
|
||||
}
|
||||
|
||||
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
|
||||
const created = rawRecordTime(record) ?? "";
|
||||
return {
|
||||
memory_id: record.raw_id,
|
||||
user_id: "user_id" in record ? record.user_id : "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
content: record.content,
|
||||
scopes: [],
|
||||
evidence_ids: [],
|
||||
// GovernedMemory is only the map's shared projection. Raw remains raw;
|
||||
// this inert value is never shown or written back.
|
||||
origin: "extracted",
|
||||
tags: [],
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
revision: 0,
|
||||
created_at: created,
|
||||
updated_at: created,
|
||||
deleted_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pageFor(
|
||||
records: RawMapRecord[],
|
||||
total: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): MemoryLibraryPage {
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
const sourceTime: Record<
|
||||
string,
|
||||
{ at: string; basis: "conversation" | "import" }
|
||||
> = {};
|
||||
for (const record of records) {
|
||||
sourceIds[record.raw_id] = [sourceOf(record)];
|
||||
const conversationAt = isoTime(record.memory_at);
|
||||
const importedAt =
|
||||
"gmt_created" in record ? isoTime(record.gmt_created) : "";
|
||||
if (conversationAt)
|
||||
sourceTime[record.raw_id] = {
|
||||
at: conversationAt,
|
||||
basis: "conversation",
|
||||
};
|
||||
else if (importedAt)
|
||||
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
|
||||
}
|
||||
return {
|
||||
items: records.map(rawMapMemory),
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
search_window: null,
|
||||
window_id: null,
|
||||
window_reset: false,
|
||||
sdk_memory_refs: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
|
||||
return pageFor(page.items, page.total, page.offset, page.limit);
|
||||
}
|
||||
|
||||
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
|
||||
return pageFor(items, items.length, 0, Math.max(items.length, 1));
|
||||
}
|
||||
|
||||
export function rawDetail(record: RawMapRecord): MemoryDetail {
|
||||
const memory = rawMapMemory(record);
|
||||
return {
|
||||
memory,
|
||||
evidence: [],
|
||||
history: [],
|
||||
index_sync: null,
|
||||
source_id: sourceOf(record),
|
||||
sdk_memory_at: rawRecordTime(record),
|
||||
sdk_created_at:
|
||||
"gmt_created" in record ? isoTime(record.gmt_created) || null : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCurrentEmbeddingSwitch: vi.fn(),
|
||||
planEmbeddingSwitch: vi.fn(),
|
||||
startEmbeddingSwitch: vi.fn(),
|
||||
cancelEmbeddingSwitch: vi.fn(),
|
||||
embeddingSwitchRefusal: vi.fn(
|
||||
(reason: { refusal?: unknown }) => reason?.refusal ?? null,
|
||||
),
|
||||
}));
|
||||
vi.mock("./api", () => api);
|
||||
|
||||
import { pollNow, resetPollScheduler } from "./poll-scheduler";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
|
||||
const NOW = "2026-11-03T09:00:00Z";
|
||||
const summary = (space_id: string | null) => ({
|
||||
space_id,
|
||||
model: "embed",
|
||||
dims: 512,
|
||||
collection_base: space_id ? "memory" : "",
|
||||
vectors: space_id ? 2 : null,
|
||||
});
|
||||
const plan: EmbeddingSwitchPlan = {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token",
|
||||
source: summary("space-1"),
|
||||
target: summary(null),
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [
|
||||
{
|
||||
space_id: "space-1",
|
||||
collection_base: "memory",
|
||||
dims: 512,
|
||||
vectors: 2,
|
||||
unmerged: null,
|
||||
},
|
||||
],
|
||||
total_vectors: 2,
|
||||
users: 1,
|
||||
graph_vectors: null,
|
||||
estimate_s: 1,
|
||||
requests: 1,
|
||||
disk_bytes_needed: 2,
|
||||
disk_bytes_free: 4,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
};
|
||||
const job: EmbeddingSwitchJob = {
|
||||
job_id: "job-1",
|
||||
kind: "switch",
|
||||
state: "preparing",
|
||||
source: summary("space-1"),
|
||||
target: summary("space-2"),
|
||||
stray_space_ids: [],
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "prepare",
|
||||
done: 0,
|
||||
total: 2,
|
||||
percent: 0,
|
||||
embedded: 0,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: null,
|
||||
source_index: 0,
|
||||
sources: 1,
|
||||
rate_per_s: null,
|
||||
eta_s: null,
|
||||
updated_at: NOW,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: null,
|
||||
};
|
||||
const idle: EmbeddingSwitchCurrent = {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: null,
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetPollScheduler();
|
||||
api.embeddingSwitchRefusal.mockImplementation(
|
||||
(reason: { refusal?: unknown }) => reason?.refusal ?? null,
|
||||
);
|
||||
api.getCurrentEmbeddingSwitch.mockResolvedValue(idle);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetPollScheduler();
|
||||
});
|
||||
|
||||
it("loads current state and accepts shared polling updates", async () => {
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
const running = {
|
||||
...idle,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job,
|
||||
} as const;
|
||||
api.getCurrentEmbeddingSwitch.mockResolvedValue(running);
|
||||
await act(async () => {
|
||||
await pollNow("embedding-switch");
|
||||
});
|
||||
expect(result.current.current).toEqual(running);
|
||||
expect(result.current.refreshFailed).toBe(false);
|
||||
});
|
||||
|
||||
it("plans and starts a switch without activating a profile", async () => {
|
||||
api.planEmbeddingSwitch.mockResolvedValue(plan);
|
||||
api.startEmbeddingSwitch.mockResolvedValue(job);
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
await act(async () => {
|
||||
expect(await result.current.planActivation("profile-1", 2)).toEqual(plan);
|
||||
});
|
||||
expect(result.current.plan).toEqual(plan);
|
||||
await act(async () => {
|
||||
expect(await result.current.startPlannedSwitch()).toEqual(job);
|
||||
});
|
||||
expect(api.startEmbeddingSwitch).toHaveBeenCalledWith({
|
||||
profile_id: "profile-1",
|
||||
expected_revision: 2,
|
||||
plan_token: "token",
|
||||
requested_by: "app",
|
||||
});
|
||||
expect(result.current.plan).toBeNull();
|
||||
expect(result.current.current?.job).toEqual(job);
|
||||
});
|
||||
|
||||
it("does not let a pre-start poll overwrite the authoritative start receipt", async () => {
|
||||
api.planEmbeddingSwitch.mockResolvedValue(plan);
|
||||
api.startEmbeddingSwitch.mockResolvedValue(job);
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
await act(async () => {
|
||||
await result.current.planActivation("profile-1", 2);
|
||||
});
|
||||
const stale = deferred<EmbeddingSwitchCurrent>();
|
||||
api.getCurrentEmbeddingSwitch.mockReturnValueOnce(stale.promise);
|
||||
let polling!: Promise<void>;
|
||||
act(() => {
|
||||
polling = pollNow("embedding-switch");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.startPlannedSwitch();
|
||||
});
|
||||
expect(result.current.current?.job).toEqual(job);
|
||||
await act(async () => {
|
||||
stale.resolve(idle);
|
||||
await polling;
|
||||
});
|
||||
expect(result.current.current?.job).toEqual(job);
|
||||
});
|
||||
|
||||
it("does not let a poll started during the mutation overwrite its receipt", async () => {
|
||||
api.planEmbeddingSwitch.mockResolvedValue(plan);
|
||||
const starting = deferred<EmbeddingSwitchJob>();
|
||||
api.startEmbeddingSwitch.mockReturnValue(starting.promise);
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
await act(async () => {
|
||||
await result.current.planActivation("profile-1", 2);
|
||||
});
|
||||
let start!: Promise<EmbeddingSwitchJob | undefined>;
|
||||
act(() => {
|
||||
start = result.current.startPlannedSwitch();
|
||||
});
|
||||
const stale = deferred<EmbeddingSwitchCurrent>();
|
||||
api.getCurrentEmbeddingSwitch.mockReturnValueOnce(stale.promise);
|
||||
let polling!: Promise<void>;
|
||||
act(() => {
|
||||
polling = pollNow("embedding-switch");
|
||||
});
|
||||
await act(async () => {
|
||||
starting.resolve(job);
|
||||
await start;
|
||||
});
|
||||
await act(async () => {
|
||||
stale.resolve(idle);
|
||||
await polling;
|
||||
});
|
||||
expect(result.current.current?.job).toEqual(job);
|
||||
});
|
||||
|
||||
it("falls back cleanly when an older Core has no switch routes", async () => {
|
||||
const missing = {
|
||||
response: { status: 404, data: { detail: "Not Found" } },
|
||||
};
|
||||
api.getCurrentEmbeddingSwitch.mockRejectedValue(missing);
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.supported).toBe(false));
|
||||
expect(result.current.shouldUseLegacyActivation()).toBe(true);
|
||||
const reads = api.getCurrentEmbeddingSwitch.mock.calls.length;
|
||||
await act(async () => {
|
||||
await pollNow("embedding-switch");
|
||||
});
|
||||
expect(api.getCurrentEmbeddingSwitch).toHaveBeenCalledTimes(reads);
|
||||
});
|
||||
|
||||
it("does not treat a structured product 404 as an older Core", async () => {
|
||||
api.getCurrentEmbeddingSwitch.mockRejectedValue({
|
||||
response: {
|
||||
status: 404,
|
||||
data: { detail: "model_profile_not_found" },
|
||||
},
|
||||
});
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.refreshFailed).toBe(true));
|
||||
expect(result.current.supported).not.toBe(false);
|
||||
expect(result.current.shouldUseLegacyActivation()).toBe(false);
|
||||
});
|
||||
|
||||
it("adopts the replacement plan from a stale-plan refusal", async () => {
|
||||
const replacement = { ...plan, plan_token: "new-token", total_vectors: 3 };
|
||||
api.planEmbeddingSwitch.mockRejectedValue({
|
||||
refusal: { code: "embedding_switch_plan_stale", plan: replacement },
|
||||
});
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
await act(async () => {
|
||||
await result.current.planActivation("profile-1", 2);
|
||||
});
|
||||
expect(result.current.plan).toEqual(replacement);
|
||||
expect(result.current.errorCode).toBe("embedding_switch_plan_stale");
|
||||
});
|
||||
|
||||
it("cancels only the current job and merges the receipt", async () => {
|
||||
api.getCurrentEmbeddingSwitch.mockResolvedValue({
|
||||
...idle,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job,
|
||||
});
|
||||
api.cancelEmbeddingSwitch.mockResolvedValue({
|
||||
...job,
|
||||
cancel_requested: true,
|
||||
});
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current?.job).toEqual(job));
|
||||
await act(async () => {
|
||||
await result.current.cancelCurrentSwitch();
|
||||
});
|
||||
expect(api.cancelEmbeddingSwitch).toHaveBeenCalledWith("job-1");
|
||||
expect(result.current.current?.job?.cancel_requested).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose arbitrary transport error messages", async () => {
|
||||
api.planEmbeddingSwitch.mockRejectedValue(
|
||||
new Error("request failed with secret=do-not-display"),
|
||||
);
|
||||
const { result } = renderHook(useEmbeddingSwitch);
|
||||
await waitFor(() => expect(result.current.current).toEqual(idle));
|
||||
await act(async () => {
|
||||
await result.current.planActivation("profile-1", 2);
|
||||
});
|
||||
expect(result.current.errorCode).toBe("embedding_switch_failed");
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
cancelEmbeddingSwitch,
|
||||
embeddingSwitchRefusal,
|
||||
getCurrentEmbeddingSwitch,
|
||||
planEmbeddingSwitch,
|
||||
startEmbeddingSwitch,
|
||||
} from "./api";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
|
||||
export type EmbeddingSwitchBusy = "plan" | "start" | "cancel" | null;
|
||||
|
||||
interface SequencedCurrent {
|
||||
sequence: number;
|
||||
current: EmbeddingSwitchCurrent;
|
||||
}
|
||||
|
||||
let currentReadSequence = 0;
|
||||
|
||||
function unsupportedRoute(reason: unknown): boolean {
|
||||
if (!reason || typeof reason !== "object" || !("response" in reason))
|
||||
return false;
|
||||
const response = (reason as { response?: unknown }).response;
|
||||
if (!response || typeof response !== "object") return false;
|
||||
const candidate = response as {
|
||||
status?: unknown;
|
||||
data?: { detail?: unknown };
|
||||
};
|
||||
return (
|
||||
candidate.status === 404 &&
|
||||
(candidate.data?.detail === undefined ||
|
||||
candidate.data.detail === "Not Found")
|
||||
);
|
||||
}
|
||||
|
||||
async function readCurrentSequenced(): Promise<SequencedCurrent> {
|
||||
const sequence = ++currentReadSequence;
|
||||
return { sequence, current: await getCurrentEmbeddingSwitch() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable switch controller. It owns only transport-backed switch state;
|
||||
* profile activation and product UI remain outside this module.
|
||||
*/
|
||||
export function useEmbeddingSwitch() {
|
||||
const [current, setCurrent] = useState<EmbeddingSwitchCurrent | null>(null);
|
||||
const [plan, setPlan] = useState<EmbeddingSwitchPlan | null>(null);
|
||||
const [busy, setBusy] = useState<EmbeddingSwitchBusy>(null);
|
||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
||||
const [refreshFailed, setRefreshFailed] = useState(false);
|
||||
const [supported, setSupported] = useState<boolean | null>(null);
|
||||
const supportedRef = useRef<boolean | null>(null);
|
||||
const alive = useRef(false);
|
||||
const generation = useRef(0);
|
||||
const acceptedRead = useRef(0);
|
||||
const action = useRef<Promise<unknown> | null>(null);
|
||||
|
||||
const acceptCurrent = useCallback((next: SequencedCurrent): boolean => {
|
||||
if (!alive.current || next.sequence < acceptedRead.current) return false;
|
||||
acceptedRead.current = next.sequence;
|
||||
setCurrent(next.current);
|
||||
setRefreshFailed(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const refresh =
|
||||
useCallback(async (): Promise<EmbeddingSwitchCurrent | null> => {
|
||||
const owner = generation.current;
|
||||
try {
|
||||
const next = await readCurrentSequenced();
|
||||
if (!alive.current || owner !== generation.current) return null;
|
||||
supportedRef.current = true;
|
||||
setSupported(true);
|
||||
return acceptCurrent(next) ? next.current : null;
|
||||
} catch (reason) {
|
||||
if (alive.current && owner === generation.current) {
|
||||
if (unsupportedRoute(reason)) {
|
||||
supportedRef.current = false;
|
||||
setSupported(false);
|
||||
setRefreshFailed(false);
|
||||
} else {
|
||||
setRefreshFailed(true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [acceptCurrent]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void refresh();
|
||||
return () => {
|
||||
alive.current = false;
|
||||
generation.current++;
|
||||
action.current = null;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!alive.current || supported === false) return;
|
||||
const unsubscribe = subscribePoll({
|
||||
key: "embedding-switch",
|
||||
intervalMs: current?.phase === "idle" ? 12_000 : 1_000,
|
||||
fetch: async () => {
|
||||
try {
|
||||
return await readCurrentSequenced();
|
||||
} catch (reason) {
|
||||
if (alive.current) {
|
||||
if (unsupportedRoute(reason)) {
|
||||
supportedRef.current = false;
|
||||
setSupported(false);
|
||||
setRefreshFailed(false);
|
||||
} else {
|
||||
setRefreshFailed(true);
|
||||
}
|
||||
}
|
||||
throw reason;
|
||||
}
|
||||
},
|
||||
onValue: (value) => {
|
||||
const next = value as Partial<SequencedCurrent>;
|
||||
if (typeof next.sequence === "number" && next.current !== undefined)
|
||||
acceptCurrent(next as SequencedCurrent);
|
||||
},
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [acceptCurrent, current?.phase, supported]);
|
||||
|
||||
const run = useCallback(
|
||||
<T>(
|
||||
kind: Exclude<EmbeddingSwitchBusy, null>,
|
||||
operation: () => Promise<T>,
|
||||
commit: (value: T) => void,
|
||||
): Promise<T | undefined> => {
|
||||
if (!alive.current || action.current) return Promise.resolve(undefined);
|
||||
const owner = generation.current;
|
||||
// A current-state read that started before a lifecycle mutation must not
|
||||
// overwrite its authoritative receipt when it resolves afterwards.
|
||||
if (kind === "start" || kind === "cancel")
|
||||
acceptedRead.current = ++currentReadSequence;
|
||||
setBusy(kind);
|
||||
setErrorCode(null);
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const result = await operation();
|
||||
if (!alive.current || owner !== generation.current) return undefined;
|
||||
if (kind === "start" || kind === "cancel")
|
||||
acceptedRead.current = ++currentReadSequence;
|
||||
commit(result);
|
||||
return result;
|
||||
} catch (reason) {
|
||||
if (!alive.current || owner !== generation.current) return undefined;
|
||||
if (unsupportedRoute(reason)) {
|
||||
supportedRef.current = false;
|
||||
setSupported(false);
|
||||
setErrorCode("embedding_switch_unsupported");
|
||||
return undefined;
|
||||
}
|
||||
const refusal = embeddingSwitchRefusal(reason);
|
||||
if (refusal?.plan) setPlan(refusal.plan);
|
||||
const message = reason instanceof Error ? reason.message : "";
|
||||
setErrorCode(
|
||||
refusal?.code ??
|
||||
(/^embedding_(switch|space)_[a-z0-9_]+$/.test(message)
|
||||
? message
|
||||
: "embedding_switch_failed"),
|
||||
);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (alive.current && owner === generation.current) {
|
||||
action.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
action.current = pending;
|
||||
return pending;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const planActivation = useCallback(
|
||||
(profileId: string, expectedRevision?: number) =>
|
||||
run(
|
||||
"plan",
|
||||
() => planEmbeddingSwitch(profileId, expectedRevision),
|
||||
setPlan,
|
||||
),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startPlannedSwitch = useCallback(
|
||||
(selected: EmbeddingSwitchPlan | null = plan) => {
|
||||
if (!selected) {
|
||||
setErrorCode("embedding_switch_plan_required");
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (!selected.needed) {
|
||||
setErrorCode("embedding_switch_not_needed");
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return run(
|
||||
"start",
|
||||
() =>
|
||||
startEmbeddingSwitch({
|
||||
profile_id: selected.target_profile_id,
|
||||
expected_revision: selected.target_revision,
|
||||
plan_token: selected.plan_token,
|
||||
requested_by: "app",
|
||||
}),
|
||||
(job) => {
|
||||
setPlan(null);
|
||||
setCurrent((previous) =>
|
||||
previous
|
||||
? {
|
||||
...previous,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
job,
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
[plan, run],
|
||||
);
|
||||
|
||||
const cancelCurrentSwitch = useCallback(() => {
|
||||
const job = current?.job;
|
||||
if (!job) {
|
||||
setErrorCode("embedding_switch_not_found");
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return run(
|
||||
"cancel",
|
||||
() => cancelEmbeddingSwitch(job.job_id),
|
||||
(updated: EmbeddingSwitchJob) =>
|
||||
setCurrent((previous) =>
|
||||
previous?.job?.job_id === updated.job_id
|
||||
? { ...previous, job: updated }
|
||||
: previous,
|
||||
),
|
||||
);
|
||||
}, [current?.job, run]);
|
||||
|
||||
return {
|
||||
current,
|
||||
plan,
|
||||
busy,
|
||||
errorCode,
|
||||
refreshFailed,
|
||||
supported,
|
||||
refresh,
|
||||
planActivation,
|
||||
startPlannedSwitch,
|
||||
cancelCurrentSwitch,
|
||||
shouldUseLegacyActivation: () => supportedRef.current === false,
|
||||
clearPlan: () => setPlan(null),
|
||||
clearError: () => setErrorCode(null),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.6`(build 37)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 38)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Updated: 2026-11-01.
|
||||
|
||||
Status: Core, CLI and App implemented. The App presents this as a user-facing
|
||||
"memory search" and "index update" flow; protocol names remain unchanged.
|
||||
|
||||
This contract is what the App and the CLI build on to change the embedding
|
||||
model without losing a memory, and to fold in vector data an earlier build left
|
||||
in another embedding space. Core owns every decision; clients plan, confirm,
|
||||
@@ -215,6 +218,7 @@ the startup scan, or null.
|
||||
|---|---|---|
|
||||
| `embedding_switch_required` | 409 (+ `plan`) | activate with another embedding on a non-empty home |
|
||||
| `embedding_switch_running` | 409 | activate / start / embedding edit of the active or target profile while a job is open |
|
||||
| `memory_lifecycle_busy` | 409 | start while evolve or graph backfill is running |
|
||||
| `embedding_switch_plan_stale` | 409 (+ `plan`) | `plan_token` no longer matches (data or live space changed) |
|
||||
| `embedding_switch_not_needed` | 409 (+ `plan`) | start with the same identity, or on an empty home (activate instead) |
|
||||
| `embedding_switch_in_progress` | 409 | memory edit/delete, `POST /api/v1/graph-width` while writes are paused |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Memory mode, evolve and the nightly schedule v1
|
||||
|
||||
Status: Core + CLI implemented (PR B of the SDK-surface batch). The App
|
||||
controls are built separately against this contract.
|
||||
Status: Core + CLI + App implemented. The App uses this contract for first-run
|
||||
mode choice, Settings, the mode-specific memory page and evolve status.
|
||||
|
||||
All routes need the Runtime bearer token. Unknown query parameters and body
|
||||
fields answer `422 {"detail": "request_validation_failed"}`, like every
|
||||
@@ -72,6 +72,8 @@ and they are the owner's:
|
||||
(`build_memory_graph(user_id, limit=10000, clear_first=False)`);
|
||||
- this runs in the background, one user at a time, never beside that
|
||||
user's write;
|
||||
- after a crash or App update, startup resumes users that did not finish
|
||||
and does not repeat users already recorded as succeeded;
|
||||
- progress is reported in `graph_build`.
|
||||
- Vector collection names never depend on the mode. The mode is a separate
|
||||
client parameter, not part of the embedding-space identity.
|
||||
@@ -84,6 +86,7 @@ No parameters. `200` response:
|
||||
{
|
||||
"mode": "pro",
|
||||
"configured_mode": "pro",
|
||||
"explicitly_configured": false,
|
||||
"forced_by_env": false,
|
||||
"modes": ["lite", "pro", "ultra"],
|
||||
"graph_build": {
|
||||
@@ -97,6 +100,9 @@ No parameters. `200` response:
|
||||
|
||||
- `mode` is what SDK clients are built with now; `configured_mode` is the
|
||||
saved setting.
|
||||
- `explicitly_configured` is `true` once the user has chosen a mode, including
|
||||
explicitly choosing the default `pro`. An older or untouched installation
|
||||
with no `memory_mode` key defaults to `pro` and reports `false`.
|
||||
- `graph_build.state` is `idle | running | succeeded | failed`. Each entry in
|
||||
`graph_build.users[<user_id>]` is
|
||||
`{state: pending|running|succeeded|failed, memories_scanned, nodes, error_code}`.
|
||||
@@ -129,6 +135,11 @@ this order:
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "embedding_maintenance_in_progress"}`: an embedding switch,
|
||||
startup scan or startup merge has closed the model-write gate. Nothing
|
||||
changed; retry after embedding maintenance finishes. GET remains available.
|
||||
- `409 {"detail": "memory_lifecycle_busy"}`: evolve or graph backfill is
|
||||
running. A real mode change waits until that lifecycle task finishes.
|
||||
- `409 {"detail": "graph_store_unavailable"}`: `ultra` was asked for, but the
|
||||
graph store package is not installed. Nothing changed.
|
||||
- `422 request_validation_failed`: an unknown mode or an extra field.
|
||||
@@ -164,6 +175,12 @@ on in the background.
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "embedding_maintenance_in_progress"}`: an embedding switch,
|
||||
startup scan or startup merge has closed the model-write gate. No evolve run
|
||||
was started; retry after embedding maintenance finishes. Evolve GET routes
|
||||
remain available.
|
||||
- `409 {"detail": "memory_lifecycle_busy"}`: the graph backfill is still
|
||||
running. Start manual evolve after it finishes.
|
||||
- `409 {"detail": "memory_mode_not_ultra"}`: the effective mode is not ultra.
|
||||
- `422`: a malformed `user_id`, `max_batches` outside its range, or an extra
|
||||
field.
|
||||
@@ -334,6 +351,21 @@ mode. A change rebuilds the SDK handles the same way. Contract:
|
||||
|
||||
## 9. Copy the UI can reuse
|
||||
|
||||
The App's product rules are:
|
||||
|
||||
- A new installation explicitly chooses lite or pro. Choosing lite persists
|
||||
the mode and completes onboarding without a generation model. Choosing pro
|
||||
opens model setup and completes onboarding only after activation succeeds.
|
||||
- Existing installations keep the default pro behaviour; an absent
|
||||
`memory_mode` is never inferred from model availability.
|
||||
- Ultra is enabled only from Settings. Its nightly schedule is on by default
|
||||
at 03:00 local time, and Settings also exposes a manual evolve action.
|
||||
- The memory page shows raw conversations in lite and advanced memories in
|
||||
pro/ultra. Higher-mode memories remain stored while lite is active but are
|
||||
neither shown nor served by lite search.
|
||||
- Mode changes and manual evolve are disabled while embedding maintenance has
|
||||
closed the write gate.
|
||||
|
||||
| Key | zh-CN | en | es |
|
||||
|---|---|---|---|
|
||||
| title | 记忆模式 | Memory mode | Modo de memoria |
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 37,
|
||||
"build_number": 38,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Shared transport contract for Core's embedding-space lifecycle. */
|
||||
|
||||
export type EmbeddingSwitchKind = "switch" | "merge";
|
||||
export type EmbeddingSwitchJobState =
|
||||
| "preparing"
|
||||
| "reembedding"
|
||||
| "verifying"
|
||||
| "committing"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "rolled_back";
|
||||
export type EmbeddingSwitchProgressPhase =
|
||||
| "prepare"
|
||||
| "vectors"
|
||||
| "graph"
|
||||
| "verify"
|
||||
| "commit"
|
||||
| "rollback"
|
||||
| "done";
|
||||
|
||||
export interface EmbeddingSpaceSummary {
|
||||
space_id: string | null;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
collection_base: string;
|
||||
vectors: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchPlannedSource {
|
||||
space_id: string | null;
|
||||
collection_base: string;
|
||||
dims: number | null;
|
||||
vectors: number;
|
||||
unmerged: number | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchPlan {
|
||||
needed: boolean;
|
||||
reason: "embedding_identity_changed" | "same_embedding" | "empty_home";
|
||||
plan_token: string;
|
||||
source: EmbeddingSpaceSummary;
|
||||
target: EmbeddingSpaceSummary;
|
||||
target_profile_id: string;
|
||||
target_revision: number;
|
||||
target_provider_kind: string;
|
||||
target_local: boolean;
|
||||
sources: EmbeddingSwitchPlannedSource[];
|
||||
total_vectors: number;
|
||||
users: number | null;
|
||||
graph_vectors: number | null;
|
||||
estimate_s: number | null;
|
||||
requests: number | null;
|
||||
disk_bytes_needed: number;
|
||||
disk_bytes_free: number | null;
|
||||
retention_days: number;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchProgress {
|
||||
phase: EmbeddingSwitchProgressPhase;
|
||||
done: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
embedded: number;
|
||||
reused: number;
|
||||
kept: number;
|
||||
role: string | null;
|
||||
source_index: number;
|
||||
sources: number;
|
||||
rate_per_s: number | null;
|
||||
eta_s: number | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchJob {
|
||||
job_id: string;
|
||||
kind: EmbeddingSwitchKind;
|
||||
state: EmbeddingSwitchJobState;
|
||||
source: EmbeddingSpaceSummary;
|
||||
target: EmbeddingSpaceSummary;
|
||||
stray_space_ids: string[];
|
||||
target_profile_id: string | null;
|
||||
target_revision: number | null;
|
||||
requested_by: "app" | "cli" | "startup";
|
||||
progress: EmbeddingSwitchProgress;
|
||||
cancel_requested: boolean;
|
||||
error_code: string | null;
|
||||
attempt: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchCurrent {
|
||||
schema_version: 1;
|
||||
phase: "idle" | "scanning" | "running";
|
||||
writes_paused: boolean;
|
||||
scan_state: "pending" | "scanning" | "done" | "failed" | "unavailable";
|
||||
scan_error: string | null;
|
||||
job: EmbeddingSwitchJob | null;
|
||||
last_finished: EmbeddingSwitchJob | null;
|
||||
}
|
||||
|
||||
export type EmbeddingSpaceState =
|
||||
| "live"
|
||||
| "building"
|
||||
| "stray"
|
||||
| "retired"
|
||||
| "dropped";
|
||||
|
||||
export interface EmbeddingSpace {
|
||||
space_id: string;
|
||||
state: EmbeddingSpaceState;
|
||||
model: string | null;
|
||||
dims: number | null;
|
||||
name_dims: number | null;
|
||||
collection_base: string;
|
||||
naming: "legacy" | "v2" | "discovered";
|
||||
graph_dir: string | null;
|
||||
provider_kind: string | null;
|
||||
profile_id: string | null;
|
||||
profile_revision: number | null;
|
||||
vectors: number | null;
|
||||
created_at: string;
|
||||
live_since: string | null;
|
||||
retired_at: string | null;
|
||||
keep_until: string | null;
|
||||
dropped_at: string | null;
|
||||
}
|
||||
|
||||
export interface ScannedEmbeddingSpace {
|
||||
collection_base: string;
|
||||
name_dims: number | null;
|
||||
width: number | null;
|
||||
vectors: number;
|
||||
counts: Record<string, number>;
|
||||
space_id: string | null;
|
||||
state: EmbeddingSpaceState | "unregistered";
|
||||
}
|
||||
|
||||
export interface EmbeddingSpaces {
|
||||
schema_version: 1;
|
||||
live_space_id: string | null;
|
||||
spaces: EmbeddingSpace[];
|
||||
scanned: ScannedEmbeddingSpace[] | null;
|
||||
scan_error: string | null;
|
||||
}
|
||||
|
||||
export interface EmbeddingSpaceDeleteReceipt {
|
||||
schema_version: 1;
|
||||
space: EmbeddingSpace;
|
||||
}
|
||||
|
||||
/** Compact projection included by health and memory-processing responses. */
|
||||
export interface EmbeddingSwitchSummary {
|
||||
job_id?: string;
|
||||
kind: EmbeddingSwitchKind;
|
||||
state: EmbeddingSwitchJobState | "scanning";
|
||||
percent: number;
|
||||
eta_s: number | null;
|
||||
}
|
||||
@@ -52,6 +52,25 @@ export type {
|
||||
ConnectionSetupResult,
|
||||
ConnectionSetupSelection,
|
||||
} from "./connection-setup.js";
|
||||
export type {
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceDeleteReceipt,
|
||||
EmbeddingSpaceState,
|
||||
EmbeddingSpaceSummary,
|
||||
EmbeddingSpaces,
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchJobState,
|
||||
EmbeddingSwitchKind,
|
||||
EmbeddingSwitchPlan,
|
||||
EmbeddingSwitchPlannedSource,
|
||||
EmbeddingSwitchProgress,
|
||||
EmbeddingSwitchProgressPhase,
|
||||
EmbeddingSwitchSummary,
|
||||
ScannedEmbeddingSpace,
|
||||
} from "./embedding-switch.js";
|
||||
|
||||
import type { EmbeddingSwitchSummary } from "./embedding-switch.js";
|
||||
|
||||
export interface HealthResponse {
|
||||
status: "ok";
|
||||
@@ -72,6 +91,7 @@ export interface HealthResponse {
|
||||
*/
|
||||
recovery?: "pending" | "running" | "done" | "failed";
|
||||
recovery_failed_steps?: string[];
|
||||
embedding_switch?: EmbeddingSwitchSummary | null;
|
||||
}
|
||||
|
||||
export type ImportStatus =
|
||||
@@ -902,6 +922,98 @@ export interface MemoryProcessingBlock {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type MemoryPipelineMode = "lite" | "pro" | "ultra";
|
||||
|
||||
export interface GraphBuildUser {
|
||||
state: "pending" | "running" | "succeeded" | "failed";
|
||||
memories_scanned: number | null;
|
||||
nodes: number | null;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
export interface GraphBuildState {
|
||||
state: "idle" | "running" | "succeeded" | "failed";
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
users: Record<string, GraphBuildUser>;
|
||||
}
|
||||
|
||||
export interface EvolveSchedule {
|
||||
enabled: boolean;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface EvolveRunReceipt {
|
||||
run_id: string;
|
||||
user_id: string;
|
||||
agent_id: string;
|
||||
max_batches: number | null;
|
||||
trigger: "manual" | "scheduled";
|
||||
state: "running" | "succeeded" | "failed";
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
rounds: number;
|
||||
tasks_processed: number;
|
||||
batch_failures: number;
|
||||
cycle_status: string | null;
|
||||
should_continue: boolean | null;
|
||||
continuation_reason: string | null;
|
||||
remaining_clusters: number | null;
|
||||
remaining_actionable_clusters: number | null;
|
||||
remaining_fresh_facts: number | null;
|
||||
capped: boolean;
|
||||
elapsed_ms: number;
|
||||
error_code: string | null;
|
||||
failure_kind: string | null;
|
||||
request_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserEvolveState {
|
||||
last_run_at: string | null;
|
||||
last_result: EvolveRunReceipt | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface EvolveOverview {
|
||||
mode: MemoryPipelineMode;
|
||||
schedule: EvolveSchedule;
|
||||
users: Record<string, UserEvolveState>;
|
||||
running: EvolveRunReceipt[];
|
||||
}
|
||||
|
||||
export interface MemoryModeState {
|
||||
mode: MemoryPipelineMode;
|
||||
configured_mode: MemoryPipelineMode;
|
||||
explicitly_configured: boolean;
|
||||
forced_by_env: boolean;
|
||||
modes: MemoryPipelineMode[];
|
||||
graph_build: GraphBuildState;
|
||||
}
|
||||
|
||||
export interface MemoryModeChange extends MemoryModeState {
|
||||
previous_mode: MemoryPipelineMode;
|
||||
effects: Array<
|
||||
| "earlier_raw_not_extracted"
|
||||
| "higher_mode_memories_hidden"
|
||||
| "existing_memories_added_to_graph"
|
||||
| "evolve_stopped"
|
||||
| "lite_search_stops"
|
||||
| "graph_backfill_already_running"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface MemoryPipelineStatus {
|
||||
mode: MemoryPipelineMode;
|
||||
configured_mode: MemoryPipelineMode;
|
||||
forced_by_env: boolean;
|
||||
evolve: EvolveOverview;
|
||||
last_evolve_at: string | null;
|
||||
graph_build: GraphBuildState;
|
||||
features: Record<string, boolean | number>;
|
||||
ui_language?: string | null;
|
||||
sdk_language?: string;
|
||||
}
|
||||
|
||||
export interface MemoryProcessingSummary {
|
||||
pending: number;
|
||||
/** `pending` split by how recent the evidence is: what arrived today, and
|
||||
@@ -963,6 +1075,10 @@ export interface MemoryProcessingSummary {
|
||||
llm_backoff_until?: string | null;
|
||||
/** The provider failure behind the pause, as a stable code. */
|
||||
llm_backoff_error?: string | null;
|
||||
/** The active memory pipeline and its graph/evolve status. */
|
||||
pipeline?: MemoryPipelineStatus | null;
|
||||
/** A running embedding switch or startup merge pauses mode mutations. */
|
||||
embedding_switch?: EmbeddingSwitchSummary | null;
|
||||
metrics?: {
|
||||
raw_evidence: number;
|
||||
complete_turns: number;
|
||||
|
||||
@@ -29,6 +29,18 @@ import {
|
||||
mergeCatalogs,
|
||||
spanishMessages,
|
||||
} from "./gui-i18n-messages.js";
|
||||
import {
|
||||
modeEnglishMessages,
|
||||
modeFrenchMessages,
|
||||
modeGermanMessages,
|
||||
modeIndonesianMessages,
|
||||
modeJapaneseMessages,
|
||||
modeKoreanMessages,
|
||||
modePortugueseMessages,
|
||||
modeRussianMessages,
|
||||
modeSpanishMessages,
|
||||
modeTraditionalChineseMessages,
|
||||
} from "./gui-i18n-mode.js";
|
||||
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]).sort();
|
||||
@@ -231,6 +243,27 @@ describe("English catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mode catalog", () => {
|
||||
it("does not leave non-English locales on English fallback copy", () => {
|
||||
const locales = [
|
||||
modeSpanishMessages,
|
||||
modeTraditionalChineseMessages,
|
||||
modeJapaneseMessages,
|
||||
modeGermanMessages,
|
||||
modePortugueseMessages,
|
||||
modeFrenchMessages,
|
||||
modeKoreanMessages,
|
||||
modeRussianMessages,
|
||||
modeIndonesianMessages,
|
||||
];
|
||||
for (const messages of locales)
|
||||
for (const key of Object.keys(modeEnglishMessages) as Array<
|
||||
keyof typeof modeEnglishMessages
|
||||
>)
|
||||
expect(messages[key], key).not.toBe(modeEnglishMessages[key]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spanish catalog", () => {
|
||||
it("covers every shipped key with matching placeholders", () => {
|
||||
expect(Object.keys(spanishMessages).sort()).toEqual(
|
||||
|
||||
@@ -72,6 +72,7 @@ export const currentChineseMessages = {
|
||||
"sdk.raw.loadFailed": "原始对话暂时无法读取。",
|
||||
"sdk.raw.searchFailed": "原始对话搜索失败,请重试。",
|
||||
"sdk.raw.recent": "最近原始对话",
|
||||
"sdk.raw.empty": "还没有原始对话。连接来源后,对话会显示在这里。",
|
||||
"sdk.raw.found": "找到 {count} 条",
|
||||
"sdk.raw.found.one": "找到 {count} 条",
|
||||
|
||||
@@ -219,6 +220,8 @@ export const currentEnglishMessages: Record<
|
||||
"sdk.raw.searchFailed":
|
||||
"Could not search original conversations. Please retry.",
|
||||
"sdk.raw.recent": "Recent original conversations",
|
||||
"sdk.raw.empty":
|
||||
"No original conversations yet. Connect a source to see conversations here.",
|
||||
"sdk.raw.found": "{count} results",
|
||||
"sdk.raw.found.one": "{count} result",
|
||||
|
||||
@@ -379,6 +382,8 @@ export const currentSpanishMessages: Record<
|
||||
"sdk.raw.searchFailed":
|
||||
"No se pudieron buscar las conversaciones originales. Reinténtalo.",
|
||||
"sdk.raw.recent": "Conversaciones originales recientes",
|
||||
"sdk.raw.empty":
|
||||
"Todavía no hay conversaciones originales. Conecta una fuente para verlas aquí.",
|
||||
"sdk.raw.found": "{count} resultados",
|
||||
"sdk.raw.found.one": "{count} resultado",
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentEnglishMessages } from "./gui-i18n-current.js";
|
||||
import { modeEnglishMessages } from "./gui-i18n-mode.js";
|
||||
|
||||
// English translations integrated from PR #114.
|
||||
const baseEnglishMessages = {
|
||||
@@ -1606,6 +1607,12 @@ const baseEnglishMessages = {
|
||||
"memory:来源与主题": "Sources and topics",
|
||||
"memory:记忆或原始对话": "Memories or original conversations",
|
||||
"memory:原始对话": "Original conversations",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Basic memory is active. New conversations are kept as original records and do not create advanced memories. Existing advanced memories stay saved, but are not shown or used for recall.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Smart memory is active. The model turns new conversations into advanced memories.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Deep memory is active. New conversations create advanced memories that the knowledge graph and evolve continue to consolidate.",
|
||||
"memory:地图组织方式": "Map grouping",
|
||||
"memory:筛选地图记忆": "Filter map memories",
|
||||
"memory:搜索原始对话": "Search original conversations",
|
||||
@@ -2000,4 +2007,5 @@ const baseEnglishMessages = {
|
||||
export const englishMessages = {
|
||||
...baseEnglishMessages,
|
||||
...currentEnglishMessages,
|
||||
...modeEnglishMessages,
|
||||
} as const;
|
||||
@@ -159,6 +159,12 @@ export const memoryChineseMessages = {
|
||||
"memory:来源与主题": "来源与主题",
|
||||
"memory:记忆或原始对话": "记忆或原始对话",
|
||||
"memory:原始对话": "原始对话",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"当前为 lite:只保存和搜索原始对话,不生成高级记忆。已有高级记忆仍会保留,但不显示、不参与召回。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"当前为 pro:新对话会由模型整理为高级记忆。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"当前为 ultra:新对话会生成高级记忆,并由知识图谱继续整合。",
|
||||
"memory:地图组织方式": "地图组织方式",
|
||||
"memory:筛选地图记忆": "筛选地图记忆",
|
||||
"memory:搜索原始对话": "搜索原始对话",
|
||||
@@ -567,6 +573,12 @@ export const memorySpanishMessages: Record<
|
||||
"memory:来源与主题": "Orígenes y temas",
|
||||
"memory:记忆或原始对话": "Memorias o conversaciones originales",
|
||||
"memory:原始对话": "Conversaciones originales",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Estás usando la memoria básica. Las conversaciones nuevas se guardan como registros originales y no generan memorias avanzadas. Las memorias avanzadas anteriores se conservan, pero no se muestran ni se usan para recuperar recuerdos.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Estás usando la memoria inteligente. El modelo convierte las conversaciones nuevas en memorias avanzadas.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Estás usando la memoria profunda. Las conversaciones nuevas generan memorias avanzadas que el grafo de conocimiento y evolve siguen consolidando.",
|
||||
"memory:地图组织方式": "Organización del mapa",
|
||||
"memory:筛选地图记忆": "Filtrar memorias del mapa",
|
||||
"memory:搜索原始对话": "Buscar conversaciones originales",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
migrationChineseMessages,
|
||||
migrationSpanishMessages,
|
||||
} from "./gui-i18n-migration.js";
|
||||
import { modeChineseMessages, modeSpanishMessages } from "./gui-i18n-mode.js";
|
||||
import {
|
||||
onboardingChineseMessages,
|
||||
onboardingSpanishMessages,
|
||||
@@ -124,6 +125,11 @@ export const guiCatalogs: readonly Catalog[] = [
|
||||
chinese: onboardingChineseMessages,
|
||||
spanish: onboardingSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "mode",
|
||||
chinese: modeChineseMessages,
|
||||
spanish: modeSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "applications",
|
||||
chinese: applicationsChineseMessages,
|
||||
@@ -185,6 +191,7 @@ export const chineseMessages = merged.chinese as typeof guideChineseMessages &
|
||||
typeof recoveryChineseMessages &
|
||||
typeof settingsChineseMessages &
|
||||
typeof onboardingChineseMessages &
|
||||
typeof modeChineseMessages &
|
||||
typeof applicationsChineseMessages &
|
||||
typeof memoryChineseMessages &
|
||||
typeof callsChineseMessages &
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
const modeChineseMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"模式未能保存。你的选择已保留,请重试。",
|
||||
选择记忆方式: "选择记忆方式",
|
||||
"最后一步 · 决定如何处理对话": "最后一步 · 选择记忆模式",
|
||||
返回导入: "返回导入",
|
||||
继续: "继续",
|
||||
"正在保存…": "正在保存…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"当前模型已经启用,可以继续完成首次设置。",
|
||||
继续使用当前模型: "继续使用当前模型",
|
||||
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
|
||||
记忆模式: "记忆模式",
|
||||
记忆模式与模型: "记忆模式与模型",
|
||||
推荐: "推荐",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"使用模型提取事实、偏好和计划。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"无需模型。保存并搜索原始对话。",
|
||||
"基础记忆(lite)": "基础记忆(lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"保存并搜索原始对话,不生成高级记忆。",
|
||||
"智能记忆(pro)": "智能记忆(pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
"深度记忆(ultra)": "深度记忆(ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"使用知识图谱,每天自动整合记忆。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite 期间已完成的对话不会补提取;待处理和新对话会生成高级记忆。",
|
||||
"原始对话检索会停止,改为使用高级记忆。": "搜索将改为使用高级记忆。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"自动整合会停止,已有数据保留。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"已有高级记忆将在后台加入知识图谱。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
|
||||
"记忆模式状态暂时无法读取,请重试。": "记忆模式状态暂时无法读取,请重试。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"模式切换未能确认,仍显示上次确认的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"决定新对话的处理方式和记忆页内容。",
|
||||
刷新记忆模式: "刷新记忆模式",
|
||||
当前模式: "当前模式",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
|
||||
每天自动整合: "每天自动整合",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
自动整合时间: "自动整合时间",
|
||||
"使用这台设备的本地时间。": "使用这台设备的本地时间。",
|
||||
立即整合记忆: "立即整合记忆",
|
||||
"正在整合记忆…": "正在整合记忆…",
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切换到 {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "原始对话在所有模式下都会保留。",
|
||||
切换模式: "切换模式",
|
||||
} as const;
|
||||
|
||||
export { modeChineseMessages };
|
||||
export type ModeMessageKey = keyof typeof modeChineseMessages;
|
||||
type ModeMessages = Record<ModeMessageKey, string>;
|
||||
|
||||
export const modeEnglishMessages: ModeMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Could not save the mode. Your selection is retained; please retry.",
|
||||
选择记忆方式: "Choose how memory works",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Final step · Choose how conversations are handled",
|
||||
返回导入: "Back to import",
|
||||
继续: "Continue",
|
||||
"正在保存…": "Saving…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"The current model is already enabled. Continue to finish setup.",
|
||||
继续使用当前模型: "Continue with current model",
|
||||
"你希望 MemHub 如何形成记忆?": "How should MemHub create memories?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Choose one option. You can change it later in Settings.",
|
||||
记忆模式: "Memory mode",
|
||||
记忆模式与模型: "Memory mode and model",
|
||||
推荐: "Recommended",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Use a model to understand conversations and build memories that grow over time.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Save and search original conversations without a model or generated memories.",
|
||||
"基础记忆(lite)": "Basic memory (lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Save and search original conversations without using a generation model or creating advanced memories.",
|
||||
"智能记忆(pro)": "Smart memory (pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Use the current model to extract facts, preferences and plans from new conversations.",
|
||||
"深度记忆(ultra)": "Deep memory (ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Add a knowledge graph to smart memory and consolidate memories daily at 03:00 by default.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Original conversations already processed in lite will not be extracted retroactively. Queued and new conversations will create advanced memories.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Original-conversation search will stop and advanced memories will be used instead.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Existing advanced memories will be kept, but lite will not display or recall them.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Daily consolidation will stop. Existing data will not be deleted.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Existing advanced memories will be added to the knowledge graph in the background. You can keep using MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"The knowledge graph is still being built; it will not be started again.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Memory mode is temporarily unavailable. Please retry.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configure and enable a generation model before using pro or ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"The embedding model is being checked or changed. Wait for it to finish before switching memory modes.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"This installation lacks the knowledge graph component required for ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"The mode change could not be confirmed. The last confirmed mode is still shown.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Could not save automatic consolidation settings. Please retry.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"The embedding model is being checked or changed. Wait for it to finish before consolidating memories.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Could not start memory consolidation. Please retry.",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"The mode controls how new conversations are saved and what appears on the Memory page.",
|
||||
刷新记忆模式: "Refresh memory mode",
|
||||
当前模式: "Current mode",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"This development environment forces ultra. Saved selections will not take effect yet.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"The embedding model is being checked or changed. Modes and manual consolidation are unavailable until it finishes.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Adding existing advanced memories to the knowledge graph…",
|
||||
每天自动整合: "Daily automatic consolidation",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Run evolve daily at {time}. Missed runs start after the device wakes.",
|
||||
自动整合时间: "Automatic consolidation time",
|
||||
"使用这台设备的本地时间。": "Uses this device's local time.",
|
||||
立即整合记忆: "Consolidate memories now",
|
||||
"正在整合记忆…": "Consolidating memories…",
|
||||
"上次整合:{time}": "Last consolidated: {time}",
|
||||
尚未整合: "Not yet consolidated",
|
||||
立即整合: "Consolidate now",
|
||||
"切换到 {mode}?": "Switch to {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Original conversations are kept in every mode.",
|
||||
切换模式: "Switch mode",
|
||||
};
|
||||
|
||||
export const modeSpanishMessages: ModeMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"No se pudo guardar el modo. Tu selección se conserva; reinténtalo.",
|
||||
选择记忆方式: "Elegir cómo funciona la memoria",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Último paso · Decide cómo tratar las conversaciones",
|
||||
返回导入: "Volver a la importación",
|
||||
继续: "Continuar",
|
||||
"正在保存…": "Guardando…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"El modelo actual ya está activo. Continúa para terminar la configuración.",
|
||||
继续使用当前模型: "Continuar con el modelo actual",
|
||||
"你希望 MemHub 如何形成记忆?": "¿Cómo quieres que MemHub cree memorias?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Elige una opción. Podrás cambiarla después en Configuración.",
|
||||
记忆模式: "Modo de memoria",
|
||||
记忆模式与模型: "Modo de memoria y modelo",
|
||||
推荐: "Recomendado",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Usa un modelo para comprender las conversaciones y crear memorias que evolucionan con el tiempo.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Guarda y busca las conversaciones originales sin modelo ni memorias generadas.",
|
||||
"基础记忆(lite)": "Memoria básica (lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Guarda y busca las conversaciones originales sin usar un modelo generativo ni crear memorias avanzadas.",
|
||||
"智能记忆(pro)": "Memoria inteligente (pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Usa el modelo actual para extraer hechos, preferencias y planes de las conversaciones nuevas.",
|
||||
"深度记忆(ultra)": "Memoria profunda (ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Añade un grafo de conocimiento a la memoria inteligente y consolida las memorias cada día a las 03:00.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Las conversaciones ya procesadas en lite no se extraerán de forma retroactiva. Las que estén en cola y las nuevas crearán memorias avanzadas.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Se dejarán de buscar conversaciones originales y se usarán memorias avanzadas.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Las memorias avanzadas existentes se conservarán, pero lite no las mostrará ni las usará en consultas.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"La consolidación diaria se detendrá. Los datos existentes no se eliminarán.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Las memorias avanzadas se añadirán al grafo en segundo plano. Puedes seguir usando MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"El grafo de conocimiento aún se está creando y no se iniciará de nuevo.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"El modo de memoria no está disponible por ahora. Reinténtalo.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configura y activa un modelo generativo antes de usar pro o ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. Espera para cambiar el modo de memoria.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Esta instalación no incluye el componente de grafo necesario para ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"No se pudo confirmar el cambio. Se sigue mostrando el último modo confirmado.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"No se pudo guardar la consolidación automática. Reinténtalo.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. Espera para consolidar memorias.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"No se pudo iniciar la consolidación. Reinténtalo.",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"El modo determina cómo se guardan las conversaciones nuevas y qué aparece en Memorias.",
|
||||
刷新记忆模式: "Actualizar modo de memoria",
|
||||
当前模式: "Modo actual",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Este entorno de desarrollo fuerza ultra. La opción guardada aún no tendrá efecto.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. No puedes cambiar de modo ni consolidar manualmente hasta que termine.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Añadiendo memorias avanzadas al grafo de conocimiento…",
|
||||
每天自动整合: "Consolidación automática diaria",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Ejecuta evolve cada día a las {time}. Si el dispositivo está suspendido, se ejecutará al reactivarse.",
|
||||
自动整合时间: "Hora de consolidación automática",
|
||||
"使用这台设备的本地时间。": "Usa la hora local de este dispositivo.",
|
||||
立即整合记忆: "Consolidar memorias ahora",
|
||||
"正在整合记忆…": "Consolidando memorias…",
|
||||
"上次整合:{time}": "Última consolidación: {time}",
|
||||
尚未整合: "Aún sin consolidar",
|
||||
立即整合: "Consolidar ahora",
|
||||
"切换到 {mode}?": "¿Cambiar a {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Las conversaciones originales se conservan en todos los modos.",
|
||||
切换模式: "Cambiar modo",
|
||||
};
|
||||
|
||||
export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
...modeChineseMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"模式無法儲存。你的選擇已保留,請重試。",
|
||||
选择记忆方式: "選擇記憶方式",
|
||||
"最后一步 · 决定如何处理对话": "最後一步 · 決定如何處理對話",
|
||||
返回导入: "返回匯入",
|
||||
继续: "繼續",
|
||||
"正在保存…": "儲存中…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"目前模型已啟用,可以繼續完成首次設定。",
|
||||
继续使用当前模型: "繼續使用目前模型",
|
||||
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成記憶?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"請選擇一種方式;之後仍可在設定中更改。",
|
||||
记忆模式: "記憶模式",
|
||||
记忆模式与模型: "記憶模式與模型",
|
||||
推荐: "推薦",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"使用模型理解對話,擷取並整理可持續成長的記憶。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"不需模型,只儲存及搜尋原始對話,不產生提煉記憶。",
|
||||
"基础记忆(lite)": "基礎記憶(lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"不呼叫生成模型。儲存並搜尋原始對話,不再產生進階記憶。",
|
||||
"智能记忆(pro)": "智慧記憶(pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"使用目前模型從新對話中擷取事實、偏好和計畫。",
|
||||
"深度记忆(ultra)": "深度記憶(ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"在智慧記憶之上使用知識圖譜,並預設每天 03:00 自動整合記憶。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite 下已處理完成的原始對話不會補擷取;佇列中及之後的新對話會產生進階記憶。",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"原始對話搜尋會停止,改為使用進階記憶。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"過去產生的進階記憶會保留,但在 lite 下不會顯示或參與記憶召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"每日自動整合會停止,已產生的資料不會刪除。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"現有進階記憶會在背景加入知識圖譜,期間仍可繼續使用 MemHub。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知識圖譜仍在補建,不會重複啟動。",
|
||||
"记忆模式状态暂时无法读取,请重试。": "暫時無法讀取記憶模式狀態,請重試。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"使用 pro 或 ultra 前,請先設定並啟用生成模型。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"正在檢查或切換向量模型,完成後才能切換記憶模式。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"目前安裝缺少知識圖譜元件,無法啟用 ultra。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"無法確認模式切換,仍顯示上次確認的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在檢查或切換向量模型,完成後才能整合記憶。",
|
||||
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"模式決定新對話如何儲存,以及記憶頁面顯示的內容。",
|
||||
刷新记忆模式: "重新整理記憶模式",
|
||||
当前模式: "目前模式",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"開發環境目前強制使用 ultra,儲存的選擇暫時不會生效。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"正在檢查或切換向量模型,完成前無法切換模式或手動整合。",
|
||||
"正在把已有高级记忆加入知识图谱…": "正在將現有進階記憶加入知識圖譜…",
|
||||
每天自动整合: "每日自動整合",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"每天 {time} 自動執行 evolve;裝置休眠錯過後會補跑。",
|
||||
自动整合时间: "自動整合時間",
|
||||
"使用这台设备的本地时间。": "使用這台裝置的本地時間。",
|
||||
立即整合记忆: "立即整合記憶",
|
||||
"正在整合记忆…": "正在整合記憶…",
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切換到 {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
||||
切换模式: "切換模式",
|
||||
};
|
||||
|
||||
export const modeJapaneseMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"モードを保存できませんでした。選択内容は保持されています。もう一度お試しください。",
|
||||
选择记忆方式: "記憶方法を選択",
|
||||
"最后一步 · 决定如何处理对话": "最後のステップ · 会話の処理方法を選択",
|
||||
返回导入: "インポートに戻る",
|
||||
继续: "続ける",
|
||||
"正在保存…": "保存中…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"現在のモデルはすでに有効です。このまま初期設定を完了できます。",
|
||||
继续使用当前模型: "現在のモデルで続ける",
|
||||
"你希望 MemHub 如何形成记忆?": "MemHub でどのように記憶を作成しますか?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"いずれかを選択してください。後から設定で変更できます。",
|
||||
记忆模式: "記憶モード",
|
||||
记忆模式与模型: "記憶モードとモデル",
|
||||
推荐: "おすすめ",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"モデルで会話を理解し、継続的に成長する記憶として整理します。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"モデルを使わず、元の会話の保存と検索のみを行います。",
|
||||
"基础记忆(lite)": "基本記憶(lite)",
|
||||
"智能记忆(pro)": "スマート記憶(pro)",
|
||||
"深度记忆(ultra)": "高度な記憶(ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"モードによって、新しい会話の保存方法と記憶ページの表示内容が決まります。",
|
||||
刷新记忆模式: "記憶モードを更新",
|
||||
当前模式: "現在のモード",
|
||||
每天自动整合: "毎日自動で統合",
|
||||
自动整合时间: "自動統合の時刻",
|
||||
"使用这台设备的本地时间。": "このデバイスの現地時刻を使用します。",
|
||||
立即整合记忆: "今すぐ記憶を統合",
|
||||
"正在整合记忆…": "記憶を統合中…",
|
||||
"上次整合:{time}": "前回の統合:{time}",
|
||||
尚未整合: "まだ統合されていません",
|
||||
立即整合: "今すぐ統合",
|
||||
"切换到 {mode}?": "{mode} に切り替えますか?",
|
||||
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
||||
切换模式: "モードを切り替える",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"生成モデルを使わず、元の会話を保存・検索します。高度な記憶は生成しません。",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"現在のモデルで新しい会話から事実、好み、予定を抽出します。",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"スマート記憶に知識グラフを追加し、既定では毎日 03:00 に記憶を自動統合します。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite で処理済みの会話はさかのぼって抽出されません。待機中および今後の会話からは高度な記憶が生成されます。",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"元の会話の検索を停止し、代わりに高度な記憶を使用します。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"既存の高度な記憶は保持されますが、lite では表示も参照もされません。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"毎日の自動統合は停止します。既存のデータは削除されません。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"既存の高度な記憶はバックグラウンドで知識グラフに追加されます。その間も MemHub を使用できます。",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"知識グラフは引き続き構築中です。重複して開始されません。",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"記憶モードの状態を読み込めません。もう一度お試しください。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"pro または ultra を使う前に、生成モデルを設定して有効にしてください。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶モードを変更できます。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"この環境には知識グラフのコンポーネントがないため、ultra を有効にできません。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"自動統合の設定を保存できませんでした。もう一度お試しください。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"記憶の統合を開始できませんでした。もう一度お試しください。",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"この開発環境では ultra が強制されています。保存した選択はまだ反映されません。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"埋め込みモデルを確認または切り替えています。完了するまでモード変更と手動統合はできません。",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"既存の高度な記憶を知識グラフに追加しています…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"毎日 {time} に evolve を実行します。スリープ中に実行できなかった場合は、復帰後に実行します。",
|
||||
};
|
||||
|
||||
export const modeGermanMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Der Modus konnte nicht gespeichert werden. Ihre Auswahl bleibt erhalten. Bitte erneut versuchen.",
|
||||
选择记忆方式: "Erinnerungsmodus wählen",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Letzter Schritt · Verarbeitung der Unterhaltungen wählen",
|
||||
返回导入: "Zurück zum Import",
|
||||
继续: "Weiter",
|
||||
"正在保存…": "Wird gespeichert…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Das aktuelle Modell ist bereits aktiviert. Fahre fort, um die Einrichtung abzuschließen.",
|
||||
继续使用当前模型: "Mit aktuellem Modell fortfahren",
|
||||
"你希望 MemHub 如何形成记忆?": "Wie soll MemHub Erinnerungen erstellen?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Wählen Sie eine Option. Sie können sie später in den Einstellungen ändern.",
|
||||
记忆模式: "Erinnerungsmodus",
|
||||
记忆模式与模型: "Erinnerungsmodus und Modell",
|
||||
推荐: "Empfohlen",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Ein Modell versteht Unterhaltungen und erstellt daraus Erinnerungen, die sich weiterentwickeln.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Originalunterhaltungen ohne Modell speichern und durchsuchen; keine abgeleiteten Erinnerungen.",
|
||||
"基础记忆(lite)": "Basiserinnerung (lite)",
|
||||
"智能记忆(pro)": "Intelligente Erinnerung (pro)",
|
||||
"深度记忆(ultra)": "Tiefe Erinnerung (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Der Modus legt fest, wie neue Unterhaltungen gespeichert werden und was auf der Erinnerungsseite erscheint.",
|
||||
刷新记忆模式: "Erinnerungsmodus aktualisieren",
|
||||
当前模式: "Aktueller Modus",
|
||||
每天自动整合: "Täglich automatisch konsolidieren",
|
||||
自动整合时间: "Zeit der automatischen Konsolidierung",
|
||||
"使用这台设备的本地时间。": "Verwendet die lokale Zeit dieses Geräts.",
|
||||
立即整合记忆: "Erinnerungen jetzt konsolidieren",
|
||||
"正在整合记忆…": "Erinnerungen werden konsolidiert…",
|
||||
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
|
||||
尚未整合: "Noch nicht konsolidiert",
|
||||
立即整合: "Jetzt konsolidieren",
|
||||
"切换到 {mode}?": "Zu {mode} wechseln?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
||||
切换模式: "Modus wechseln",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Originalunterhaltungen ohne generatives Modell speichern und durchsuchen. Es werden keine erweiterten Erinnerungen erstellt.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Mit dem aktuellen Modell Fakten, Vorlieben und Pläne aus neuen Unterhaltungen extrahieren.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Ergänzt die intelligente Erinnerung um einen Wissensgraphen und konsolidiert Erinnerungen standardmäßig täglich um 03:00 Uhr.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Bereits in lite verarbeitete Unterhaltungen werden nicht nachträglich extrahiert. Wartende und neue Unterhaltungen erzeugen erweiterte Erinnerungen.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Die Suche in Originalunterhaltungen wird beendet; stattdessen werden erweiterte Erinnerungen verwendet.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Vorhandene erweiterte Erinnerungen bleiben erhalten, werden in lite aber weder angezeigt noch abgerufen.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Die tägliche Konsolidierung wird beendet. Vorhandene Daten werden nicht gelöscht.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Vorhandene erweiterte Erinnerungen werden im Hintergrund dem Wissensgraphen hinzugefügt. MemHub bleibt nutzbar.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Der Wissensgraph wird noch aufgebaut und nicht erneut gestartet.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Der Erinnerungsmodus ist vorübergehend nicht verfügbar. Bitte erneut versuchen.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Konfigurieren und aktivieren Sie ein generatives Modell, bevor Sie pro oder ultra verwenden.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Danach kann der Erinnerungsmodus geändert werden.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Dieser Installation fehlt die für ultra benötigte Wissensgraph-Komponente.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Der Moduswechsel konnte nicht bestätigt werden. Der zuletzt bestätigte Modus wird weiterhin angezeigt.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Die Einstellungen zur automatischen Konsolidierung konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Danach können Erinnerungen konsolidiert werden.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Die Konsolidierung konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Diese Entwicklungsumgebung erzwingt ultra. Die gespeicherte Auswahl wird noch nicht angewendet.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Bis zum Abschluss sind Moduswechsel und manuelle Konsolidierung nicht verfügbar.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Vorhandene erweiterte Erinnerungen werden dem Wissensgraphen hinzugefügt…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"evolve wird täglich um {time} ausgeführt. Verpasste Läufe starten nach dem Aufwachen des Geräts.",
|
||||
};
|
||||
|
||||
export const modePortugueseMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Não foi possível guardar o modo. A sua escolha foi mantida; tente novamente.",
|
||||
选择记忆方式: "Escolher o modo de memória",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Último passo · Decida como tratar as conversas",
|
||||
返回导入: "Voltar à importação",
|
||||
继续: "Continuar",
|
||||
"正在保存…": "A guardar…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"O modelo atual já está ativo. Continua para concluir a configuração.",
|
||||
继续使用当前模型: "Continuar com o modelo atual",
|
||||
"你希望 MemHub 如何形成记忆?": "Como deve o MemHub criar memórias?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Escolha uma opção. Pode alterá-la mais tarde nas Definições.",
|
||||
记忆模式: "Modo de memória",
|
||||
记忆模式与模型: "Modo de memória e modelo",
|
||||
推荐: "Recomendado",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Use um modelo para compreender conversas e criar memórias que evoluem com o tempo.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Guarde e pesquise conversas originais sem modelo nem memórias geradas.",
|
||||
"基础记忆(lite)": "Memória básica (lite)",
|
||||
"智能记忆(pro)": "Memória inteligente (pro)",
|
||||
"深度记忆(ultra)": "Memória profunda (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"O modo determina como as novas conversas são guardadas e o que aparece na página Memórias.",
|
||||
刷新记忆模式: "Atualizar modo de memória",
|
||||
当前模式: "Modo atual",
|
||||
每天自动整合: "Consolidação automática diária",
|
||||
自动整合时间: "Hora da consolidação automática",
|
||||
"使用这台设备的本地时间。": "Usa a hora local deste dispositivo.",
|
||||
立即整合记忆: "Consolidar memórias agora",
|
||||
"正在整合记忆…": "A consolidar memórias…",
|
||||
"上次整合:{time}": "Última consolidação: {time}",
|
||||
尚未整合: "Ainda não consolidado",
|
||||
立即整合: "Consolidar agora",
|
||||
"切换到 {mode}?": "Mudar para {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"As conversas originais são mantidas em todos os modos.",
|
||||
切换模式: "Mudar de modo",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Guarda e pesquisa conversas originais sem usar um modelo generativo nem criar memórias avançadas.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Usa o modelo atual para extrair factos, preferências e planos de novas conversas.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Acrescenta um grafo de conhecimento à memória inteligente e consolida memórias diariamente às 03:00 por predefinição.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"As conversas já processadas em lite não serão extraídas retroativamente. As que estão em fila e as novas criarão memórias avançadas.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"A pesquisa em conversas originais será interrompida e passará a usar memórias avançadas.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"As memórias avançadas existentes serão mantidas, mas o lite não as mostrará nem usará na recuperação.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"A consolidação diária será interrompida. Os dados existentes não serão eliminados.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"As memórias avançadas existentes serão adicionadas ao grafo em segundo plano. Pode continuar a usar o MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"O grafo de conhecimento ainda está a ser criado e não será iniciado novamente.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"O modo de memória está temporariamente indisponível. Tente novamente.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configure e ative um modelo generativo antes de usar pro ou ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para mudar o modo de memória.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Esta instalação não inclui o componente de grafo necessário para ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Não foi possível confirmar a mudança. Continua a ser mostrado o último modo confirmado.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Não foi possível guardar a consolidação automática. Tente novamente.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para consolidar memórias.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Não foi possível iniciar a consolidação. Tente novamente.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Este ambiente de desenvolvimento força ultra. A opção guardada ainda não terá efeito.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Não pode mudar de modo nem consolidar manualmente até terminar.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"A adicionar memórias avançadas ao grafo de conhecimento…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Executa evolve diariamente às {time}. Se o dispositivo estiver suspenso, será executado ao reativar.",
|
||||
};
|
||||
|
||||
export const modeFrenchMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Impossible d’enregistrer le mode. Votre choix est conservé. Réessayez.",
|
||||
选择记忆方式: "Choisir le mode de mémoire",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Dernière étape · Choisissez le traitement des conversations",
|
||||
返回导入: "Retour à l’importation",
|
||||
继续: "Continuer",
|
||||
"正在保存…": "Enregistrement…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Le modèle actuel est déjà activé. Continuez pour terminer la configuration.",
|
||||
继续使用当前模型: "Continuer avec le modèle actuel",
|
||||
"你希望 MemHub 如何形成记忆?": "Comment MemHub doit-il créer des mémoires ?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Choisissez une option. Vous pourrez la modifier plus tard dans les réglages.",
|
||||
记忆模式: "Mode de mémoire",
|
||||
记忆模式与模型: "Mode de mémoire et modèle",
|
||||
推荐: "Recommandé",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Utilisez un modèle pour comprendre les conversations et créer des mémoires qui évoluent.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Enregistrez et recherchez les conversations originales sans modèle ni mémoire générée.",
|
||||
"基础记忆(lite)": "Mémoire de base (lite)",
|
||||
"智能记忆(pro)": "Mémoire intelligente (pro)",
|
||||
"深度记忆(ultra)": "Mémoire approfondie (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Le mode détermine comment les nouvelles conversations sont enregistrées et ce qui apparaît dans Mémoires.",
|
||||
刷新记忆模式: "Actualiser le mode de mémoire",
|
||||
当前模式: "Mode actuel",
|
||||
每天自动整合: "Consolidation automatique quotidienne",
|
||||
自动整合时间: "Heure de consolidation automatique",
|
||||
"使用这台设备的本地时间。": "Utilise l’heure locale de cet appareil.",
|
||||
立即整合记忆: "Consolider les mémoires maintenant",
|
||||
"正在整合记忆…": "Consolidation des mémoires…",
|
||||
"上次整合:{time}": "Dernière consolidation : {time}",
|
||||
尚未整合: "Pas encore consolidé",
|
||||
立即整合: "Consolider maintenant",
|
||||
"切换到 {mode}?": "Passer à {mode} ?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Les conversations originales sont conservées dans tous les modes.",
|
||||
切换模式: "Changer de mode",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Enregistre et recherche les conversations originales sans modèle génératif ni création de mémoires avancées.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Utilise le modèle actuel pour extraire les faits, préférences et projets des nouvelles conversations.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Ajoute un graphe de connaissances à la mémoire intelligente et consolide les mémoires chaque jour à 03:00 par défaut.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Les conversations déjà traitées en lite ne seront pas extraites rétroactivement. Celles en attente et les nouvelles créeront des mémoires avancées.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"La recherche dans les conversations originales s’arrêtera au profit des mémoires avancées.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Les mémoires avancées existantes seront conservées, mais lite ne les affichera ni ne les utilisera pour les recherches.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"La consolidation quotidienne s’arrêtera. Les données existantes ne seront pas supprimées.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Les mémoires avancées existantes seront ajoutées au graphe en arrière-plan. Vous pouvez continuer à utiliser MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Le graphe de connaissances est toujours en cours de création et ne sera pas relancé.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Le mode de mémoire est temporairement indisponible. Réessayez.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configurez et activez un modèle génératif avant d’utiliser pro ou ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de changer de mode.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Cette installation ne contient pas le composant de graphe requis pour ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Le changement n’a pas pu être confirmé. Le dernier mode confirmé reste affiché.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Impossible d’enregistrer la consolidation automatique. Réessayez.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de consolider les mémoires.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Impossible de lancer la consolidation. Réessayez.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Cet environnement de développement impose ultra. Le choix enregistré ne s’applique pas encore.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Le changement de mode et la consolidation manuelle sont indisponibles jusqu’à la fin.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Ajout des mémoires avancées au graphe de connaissances…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Exécute evolve chaque jour à {time}. Les exécutions manquées démarrent au réveil de l’appareil.",
|
||||
};
|
||||
|
||||
export const modeKoreanMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"모드를 저장하지 못했습니다. 선택은 유지됩니다. 다시 시도하세요.",
|
||||
选择记忆方式: "메모리 방식 선택",
|
||||
"最后一步 · 决定如何处理对话": "마지막 단계 · 대화 처리 방식 선택",
|
||||
返回导入: "가져오기로 돌아가기",
|
||||
继续: "계속",
|
||||
"正在保存…": "저장 중…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"현재 모델이 이미 활성화되어 있습니다. 계속해서 초기 설정을 완료하세요.",
|
||||
继续使用当前模型: "현재 모델로 계속",
|
||||
"你希望 MemHub 如何形成记忆?": "MemHub가 메모리를 어떻게 만들까요?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"한 가지 방식을 선택하세요. 나중에 설정에서 변경할 수 있습니다.",
|
||||
记忆模式: "메모리 모드",
|
||||
记忆模式与模型: "메모리 모드 및 모델",
|
||||
推荐: "추천",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"모델이 대화를 이해하고 시간이 지날수록 발전하는 메모리로 정리합니다.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"모델 없이 원본 대화만 저장하고 검색하며, 정리된 메모리는 만들지 않습니다.",
|
||||
"基础记忆(lite)": "기본 메모리(lite)",
|
||||
"智能记忆(pro)": "스마트 메모리(pro)",
|
||||
"深度记忆(ultra)": "심층 메모리(ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"모드에 따라 새 대화의 저장 방식과 메모리 페이지의 표시 내용이 결정됩니다.",
|
||||
刷新记忆模式: "메모리 모드 새로고침",
|
||||
当前模式: "현재 모드",
|
||||
每天自动整合: "매일 자동 통합",
|
||||
自动整合时间: "자동 통합 시간",
|
||||
"使用这台设备的本地时间。": "이 기기의 현지 시간을 사용합니다.",
|
||||
立即整合记忆: "지금 메모리 통합",
|
||||
"正在整合记忆…": "메모리 통합 중…",
|
||||
"上次整合:{time}": "마지막 통합: {time}",
|
||||
尚未整合: "아직 통합되지 않음",
|
||||
立即整合: "지금 통합",
|
||||
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
|
||||
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
||||
切换模式: "모드 전환",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"생성 모델을 사용하지 않고 원본 대화를 저장하고 검색합니다. 고급 메모리는 만들지 않습니다.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"현재 모델로 새 대화에서 사실, 선호도, 계획을 추출합니다.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"스마트 메모리에 지식 그래프를 추가하고 기본적으로 매일 03:00에 메모리를 자동 통합합니다.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite에서 이미 처리된 대화는 소급 추출되지 않습니다. 대기 중인 대화와 새 대화에서는 고급 메모리가 생성됩니다.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"원본 대화 검색을 중지하고 고급 메모리를 사용합니다.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"기존 고급 메모리는 유지되지만 lite에서는 표시되거나 검색에 사용되지 않습니다.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"매일 자동 통합이 중지됩니다. 기존 데이터는 삭제되지 않습니다.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"기존 고급 메모리가 백그라운드에서 지식 그래프에 추가됩니다. 그동안 MemHub를 계속 사용할 수 있습니다.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"지식 그래프가 아직 구축 중이며 다시 시작되지 않습니다.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"메모리 모드를 일시적으로 읽을 수 없습니다. 다시 시도하세요.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"pro 또는 ultra를 사용하기 전에 생성 모델을 구성하고 활성화하세요.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리 모드를 변경할 수 있습니다.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"현재 설치에 ultra에 필요한 지식 그래프 구성 요소가 없습니다.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"메모리 통합을 시작하지 못했습니다. 다시 시도하세요.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"현재 개발 환경에서는 ultra가 강제됩니다. 저장한 선택은 아직 적용되지 않습니다.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료될 때까지 모드 전환과 수동 통합을 사용할 수 없습니다.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"기존 고급 메모리를 지식 그래프에 추가하는 중…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"매일 {time}에 evolve를 실행합니다. 절전 중 놓친 실행은 기기가 깨어난 후 시작됩니다.",
|
||||
};
|
||||
|
||||
export const modeRussianMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Не удалось сохранить режим. Ваш выбор сохранён. Повторите попытку.",
|
||||
选择记忆方式: "Выберите режим памяти",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Последний шаг · Выберите способ обработки диалогов",
|
||||
返回导入: "Назад к импорту",
|
||||
继续: "Продолжить",
|
||||
"正在保存…": "Сохранение…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Текущая модель уже включена. Продолжите, чтобы завершить настройку.",
|
||||
继续使用当前模型: "Продолжить с текущей моделью",
|
||||
"你希望 MemHub 如何形成记忆?": "Как MemHub должен создавать воспоминания?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Выберите один вариант. Его можно изменить позже в настройках.",
|
||||
记忆模式: "Режим памяти",
|
||||
记忆模式与模型: "Режим памяти и модель",
|
||||
推荐: "Рекомендуется",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Модель анализирует диалоги и создаёт воспоминания, которые развиваются со временем.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Сохраняйте и ищите исходные диалоги без модели и создания воспоминаний.",
|
||||
"基础记忆(lite)": "Базовая память (lite)",
|
||||
"智能记忆(pro)": "Интеллектуальная память (pro)",
|
||||
"深度记忆(ultra)": "Глубокая память (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Режим определяет, как сохраняются новые диалоги и что отображается на странице памяти.",
|
||||
刷新记忆模式: "Обновить режим памяти",
|
||||
当前模式: "Текущий режим",
|
||||
每天自动整合: "Ежедневное автообъединение",
|
||||
自动整合时间: "Время автообъединения",
|
||||
"使用这台设备的本地时间。": "Используется местное время этого устройства.",
|
||||
立即整合记忆: "Объединить воспоминания сейчас",
|
||||
"正在整合记忆…": "Объединение воспоминаний…",
|
||||
"上次整合:{time}": "Последнее объединение: {time}",
|
||||
尚未整合: "Ещё не объединялись",
|
||||
立即整合: "Объединить сейчас",
|
||||
"切换到 {mode}?": "Переключиться на {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Исходные диалоги сохраняются во всех режимах.",
|
||||
切换模式: "Переключить режим",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Сохраняет и ищет исходные диалоги без генеративной модели. Расширенные воспоминания не создаются.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Использует текущую модель для извлечения фактов, предпочтений и планов из новых диалогов.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Добавляет граф знаний к интеллектуальной памяти и по умолчанию объединяет воспоминания ежедневно в 03:00.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Уже обработанные в lite диалоги не будут извлечены задним числом. Диалоги в очереди и новые диалоги создадут расширенные воспоминания.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Поиск в исходных диалогах прекратится; вместо него будут использоваться расширенные воспоминания.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Существующие расширенные воспоминания сохранятся, но lite не будет показывать или использовать их при поиске.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Ежедневное объединение прекратится. Существующие данные не будут удалены.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Существующие расширенные воспоминания будут добавлены в граф знаний в фоновом режиме. MemHub останется доступен.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Граф знаний ещё строится и не будет запущен повторно.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Режим памяти временно недоступен. Повторите попытку.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Настройте и включите генеративную модель перед использованием pro или ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет сменить режим памяти.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"В этой установке нет компонента графа знаний, необходимого для ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Не удалось начать объединение воспоминаний. Повторите попытку.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"В этой среде разработки принудительно используется ultra. Сохранённый выбор пока не применяется.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Модель эмбеддингов проверяется или меняется. До завершения смена режима и ручное объединение недоступны.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Расширенные воспоминания добавляются в граф знаний…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"evolve запускается ежедневно в {time}. Пропущенный запуск начнётся после выхода устройства из сна.",
|
||||
};
|
||||
|
||||
export const modeIndonesianMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Mode tidak dapat disimpan. Pilihan Anda tetap tersimpan; coba lagi.",
|
||||
选择记忆方式: "Pilih cara kerja memori",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Langkah terakhir · Tentukan cara memproses percakapan",
|
||||
返回导入: "Kembali ke impor",
|
||||
继续: "Lanjutkan",
|
||||
"正在保存…": "Menyimpan…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Model saat ini sudah aktif. Lanjutkan untuk menyelesaikan penyiapan.",
|
||||
继续使用当前模型: "Lanjutkan dengan model saat ini",
|
||||
"你希望 MemHub 如何形成记忆?": "Bagaimana MemHub membuat memori?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Pilih satu opsi. Anda dapat mengubahnya nanti di Pengaturan.",
|
||||
记忆模式: "Mode memori",
|
||||
记忆模式与模型: "Mode memori dan model",
|
||||
推荐: "Disarankan",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Gunakan model untuk memahami percakapan dan membuat memori yang terus berkembang.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Simpan dan cari percakapan asli tanpa model atau memori hasil olahan.",
|
||||
"基础记忆(lite)": "Memori dasar (lite)",
|
||||
"智能记忆(pro)": "Memori cerdas (pro)",
|
||||
"深度记忆(ultra)": "Memori mendalam (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Mode menentukan cara percakapan baru disimpan dan apa yang tampil di halaman Memori.",
|
||||
刷新记忆模式: "Muat ulang mode memori",
|
||||
当前模式: "Mode saat ini",
|
||||
每天自动整合: "Konsolidasi otomatis harian",
|
||||
自动整合时间: "Waktu konsolidasi otomatis",
|
||||
"使用这台设备的本地时间。": "Menggunakan waktu lokal perangkat ini.",
|
||||
立即整合记忆: "Konsolidasikan memori sekarang",
|
||||
"正在整合记忆…": "Mengonsolidasikan memori…",
|
||||
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
|
||||
尚未整合: "Belum dikonsolidasikan",
|
||||
立即整合: "Konsolidasikan sekarang",
|
||||
"切换到 {mode}?": "Beralih ke {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
||||
切换模式: "Beralih mode",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Simpan dan cari percakapan asli tanpa model generatif. Memori lanjutan tidak dibuat.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Gunakan model saat ini untuk mengekstrak fakta, preferensi, dan rencana dari percakapan baru.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Tambahkan knowledge graph ke memori cerdas dan konsolidasikan memori setiap hari pukul 03.00 secara default.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Percakapan yang sudah diproses dalam lite tidak akan diekstrak ulang. Percakapan dalam antrean dan percakapan baru akan membuat memori lanjutan.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Pencarian percakapan asli akan dihentikan dan diganti dengan memori lanjutan.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Memori lanjutan yang ada tetap disimpan, tetapi lite tidak akan menampilkan atau menggunakannya dalam pencarian.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Konsolidasi harian akan dihentikan. Data yang ada tidak akan dihapus.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Memori lanjutan yang ada akan ditambahkan ke knowledge graph di latar belakang. MemHub tetap dapat digunakan.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Knowledge graph masih dibuat dan tidak akan dimulai ulang.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Mode memori untuk sementara tidak tersedia. Coba lagi.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Konfigurasikan dan aktifkan model generatif sebelum menggunakan pro atau ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengganti mode memori.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Instalasi ini tidak memiliki komponen knowledge graph yang diperlukan untuk ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Perubahan mode tidak dapat dikonfirmasi. Mode terakhir yang dikonfirmasi tetap ditampilkan.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Pengaturan konsolidasi otomatis tidak dapat disimpan. Coba lagi.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengonsolidasikan memori.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Konsolidasi memori tidak dapat dimulai. Coba lagi.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Lingkungan pengembangan ini memaksa penggunaan ultra. Pilihan yang disimpan belum berlaku.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Model embedding sedang diperiksa atau diganti. Mode dan konsolidasi manual tidak tersedia hingga selesai.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Menambahkan memori lanjutan ke knowledge graph…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Jalankan evolve setiap hari pukul {time}. Proses yang terlewat akan dimulai setelah perangkat aktif kembali.",
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type GuiLocale,
|
||||
isLanguagePreference,
|
||||
isLanguageSnapshot,
|
||||
isMessageKey,
|
||||
type LanguagePreference,
|
||||
type LanguageSnapshot,
|
||||
LOCALE_NAMES,
|
||||
@@ -227,4 +228,22 @@ describe("GUI messages", () => {
|
||||
}),
|
||||
).toBe("Provided to <img src=x onerror=alert(1)>$&{count}");
|
||||
});
|
||||
|
||||
it("translates mode copy interpolated before lookup", () => {
|
||||
const cases = [
|
||||
[
|
||||
"每天 03:00 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
"Run evolve daily at 03:00.",
|
||||
],
|
||||
[
|
||||
"上次整合:11/3/2026, 4:00:00 PM",
|
||||
"Last consolidated: 11/3/2026, 4:00:00 PM",
|
||||
],
|
||||
["切换到 ultra?", "Switch to ultra?"],
|
||||
] as const;
|
||||
for (const [source, expected] of cases) {
|
||||
expect(isMessageKey(source)).toBe(true);
|
||||
expect(translate("en", source as MessageKey)).toContain(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,28 @@ export function resolveGuiLocale(
|
||||
}
|
||||
|
||||
export function isMessageKey(value: string): value is MessageKey {
|
||||
return Object.hasOwn(chineseMessages, value);
|
||||
return (
|
||||
Object.hasOwn(chineseMessages, value) || dynamicMessage(value) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function dynamicMessage(
|
||||
value: string,
|
||||
): { key: MessageKey; params: Record<string, string> } | null {
|
||||
const patterns: readonly [RegExp, MessageKey, string][] = [
|
||||
[
|
||||
/^每天 (.+) 自动运行 evolve,设备休眠错过后会补跑。$/,
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
"time",
|
||||
],
|
||||
[/^上次整合:(.+)$/, "上次整合:{time}", "time"],
|
||||
[/^切换到 (lite|pro|ultra)?$/, "切换到 {mode}?", "mode"],
|
||||
];
|
||||
for (const [pattern, key, param] of patterns) {
|
||||
const match = pattern.exec(value);
|
||||
if (match?.[1]) return { key, params: { [param]: match[1] } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
@@ -171,9 +192,12 @@ export function translate(
|
||||
key: MessageKey,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
return translator.getFixedT(locale)(key, {
|
||||
const dynamic = dynamicMessage(key);
|
||||
const selected = dynamic?.key ?? key;
|
||||
return translator.getFixedT(locale)(selected, {
|
||||
...dynamic?.params,
|
||||
...params,
|
||||
defaultValue: chineseMessages[key],
|
||||
defaultValue: chineseMessages[selected],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeGermanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** German (de, formal Sie) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const germanMessages: Record<MessageKey, string> = {
|
||||
...modeGermanMessages,
|
||||
"connectionFallback.title": "Andere Tools verbinden",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Lokale Kopplung ist nur in der Desktop-App verfügbar.",
|
||||
@@ -80,6 +82,14 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Originalunterhaltungen konnten nicht durchsucht werden. Bitte wiederholen Sie den Vorgang.",
|
||||
"sdk.raw.recent": "Letzte Originalunterhaltungen",
|
||||
"sdk.raw.empty":
|
||||
"Noch keine Originalunterhaltungen. Verbinde eine Quelle, damit sie hier erscheinen.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Der Basisspeicher ist aktiv. Neue Unterhaltungen werden als Originale gespeichert und erzeugen keine erweiterten Erinnerungen. Vorhandene erweiterte Erinnerungen bleiben gespeichert, werden aber nicht angezeigt oder abgerufen.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Der intelligente Speicher ist aktiv. Das Modell verarbeitet neue Unterhaltungen zu erweiterten Erinnerungen.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Der tiefe Speicher ist aktiv. Neue Unterhaltungen erzeugen erweiterte Erinnerungen, die Wissensgraph und evolve weiter zusammenführen.",
|
||||
"sdk.raw.found": "{count} Ergebnisse",
|
||||
"sdk.raw.found.one": "{count} Ergebnis",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeFrenchMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** French (fr, vous) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const frenchMessages: Record<MessageKey, string> = {
|
||||
...modeFrenchMessages,
|
||||
"connectionFallback.title": "Connecter d’autres outils",
|
||||
"connectionFallback.desktopOnly":
|
||||
"L’association locale n’est disponible que dans l’application de bureau.",
|
||||
@@ -81,6 +83,14 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Impossible de rechercher dans les conversations originales. Veuillez réessayer.",
|
||||
"sdk.raw.recent": "Conversations originales récentes",
|
||||
"sdk.raw.empty":
|
||||
"Aucune conversation originale pour le moment. Connectez une source pour les afficher ici.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"La mémoire de base est active. Les nouvelles conversations sont conservées telles quelles et ne créent pas de mémoires avancées. Les mémoires avancées existantes restent enregistrées, mais ne sont ni affichées ni rappelées.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"La mémoire intelligente est active. Le modèle transforme les nouvelles conversations en mémoires avancées.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"La mémoire approfondie est active. Les nouvelles conversations créent des mémoires avancées que le graphe de connaissances et evolve continuent de consolider.",
|
||||
"sdk.raw.found": "{count} résultats",
|
||||
"sdk.raw.found.one": "{count} résultat",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeIndonesianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Indonesian (id, Anda) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const indonesianMessages: Record<MessageKey, string> = {
|
||||
...modeIndonesianMessages,
|
||||
"connectionFallback.title": "Hubungkan alat lain",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Pemasangan lokal hanya tersedia di aplikasi desktop.",
|
||||
@@ -75,6 +77,14 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Tidak dapat mencari percakapan asli. Silakan coba lagi.",
|
||||
"sdk.raw.recent": "Percakapan asli terbaru",
|
||||
"sdk.raw.empty":
|
||||
"Belum ada percakapan asli. Hubungkan sumber agar percakapan tampil di sini.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Memori dasar aktif. Percakapan baru disimpan sebagai catatan asli dan tidak membuat memori lanjutan. Memori lanjutan yang ada tetap tersimpan, tetapi tidak ditampilkan atau digunakan untuk recall.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Memori cerdas aktif. Model mengolah percakapan baru menjadi memori lanjutan.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Memori mendalam aktif. Percakapan baru membuat memori lanjutan yang terus dikonsolidasikan oleh knowledge graph dan evolve.",
|
||||
"sdk.raw.found": "{count} hasil",
|
||||
"sdk.raw.found.one": "{count} hasil",
|
||||
"memoryInspector.emptyHint": "Lihat sumber, isi, dan riwayat perubahannya.",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeJapaneseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Japanese (ja) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const japaneseMessages: Record<MessageKey, string> = {
|
||||
...modeJapaneseMessages,
|
||||
"connectionFallback.title": "ほかのツールを接続",
|
||||
"connectionFallback.desktopOnly":
|
||||
"ローカルのペアリングはデスクトップアプリでのみ利用できます。",
|
||||
@@ -77,6 +79,14 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"元の会話を検索できませんでした。もう一度お試しください。",
|
||||
"sdk.raw.recent": "最近の元の会話",
|
||||
"sdk.raw.empty":
|
||||
"元の会話はまだありません。ソースを接続すると、ここに表示されます。",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"基本メモリを使用中です。新しい会話は原文として保存され、高度なメモリは生成されません。既存の高度なメモリは保持されますが、現在は表示も呼び出しもされません。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"スマートメモリを使用中です。モデルが新しい会話を高度なメモリに整理します。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"ディープメモリを使用中です。新しい会話から高度なメモリを生成し、知識グラフと evolve が統合を続けます。",
|
||||
"sdk.raw.found": "{count} 件見つかりました",
|
||||
"sdk.raw.found.one": "{count} 件見つかりました",
|
||||
"memoryInspector.emptyHint": "ソース、内容、変更履歴を確認できます。",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeKoreanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Korean (ko) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const koreanMessages: Record<MessageKey, string> = {
|
||||
...modeKoreanMessages,
|
||||
"connectionFallback.title": "다른 도구 연결",
|
||||
"connectionFallback.desktopOnly":
|
||||
"로컬 페어링은 데스크톱 앱에서만 사용할 수 있습니다.",
|
||||
@@ -74,6 +76,14 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.loadFailed": "원본 대화를 일시적으로 불러올 수 없습니다.",
|
||||
"sdk.raw.searchFailed": "원본 대화를 검색하지 못했습니다. 다시 시도하세요.",
|
||||
"sdk.raw.recent": "최근 원본 대화",
|
||||
"sdk.raw.empty":
|
||||
"아직 원본 대화가 없습니다. 소스를 연결하면 여기에 표시됩니다.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"기본 메모리를 사용 중입니다. 새 대화는 원본 기록으로 저장되며 고급 메모리를 만들지 않습니다. 기존 고급 메모리는 보존되지만 현재 표시되거나 회상에 사용되지 않습니다.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"스마트 메모리를 사용 중입니다. 모델이 새 대화를 고급 메모리로 정리합니다.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"심층 메모리를 사용 중입니다. 새 대화에서 고급 메모리를 만들고 지식 그래프와 evolve가 계속 통합합니다.",
|
||||
"sdk.raw.found": "결과 {count}개",
|
||||
"sdk.raw.found.one": "결과 {count}개",
|
||||
"memoryInspector.emptyHint": "출처, 내용, 변경 기록을 확인하세요.",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modePortugueseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Portuguese (pt, Brazilian usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const portugueseMessages: Record<MessageKey, string> = {
|
||||
...modePortugueseMessages,
|
||||
"connectionFallback.title": "Conectar outras ferramentas",
|
||||
"connectionFallback.desktopOnly":
|
||||
"O pareamento local só está disponível no app para desktop.",
|
||||
@@ -80,6 +82,14 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Não foi possível pesquisar as conversas originais. Tente novamente.",
|
||||
"sdk.raw.recent": "Conversas originais recentes",
|
||||
"sdk.raw.empty":
|
||||
"Ainda não há conversas originais. Ligue uma origem para as ver aqui.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"A memória básica está ativa. As novas conversas são guardadas como registos originais e não criam memórias avançadas. As memórias avançadas existentes continuam guardadas, mas não são mostradas nem usadas na recuperação.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"A memória inteligente está ativa. O modelo transforma novas conversas em memórias avançadas.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"A memória profunda está ativa. As novas conversas criam memórias avançadas que o grafo de conhecimento e o evolve continuam a consolidar.",
|
||||
"sdk.raw.found": "Resultados: {count}",
|
||||
"sdk.raw.found.one": "{count} resultado",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeRussianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Russian (ru) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const russianMessages: Record<MessageKey, string> = {
|
||||
...modeRussianMessages,
|
||||
"connectionFallback.title": "Подключить другие инструменты",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Локальное сопряжение доступно только в настольном приложении.",
|
||||
@@ -77,6 +79,14 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Не удалось выполнить поиск по исходным диалогам. Повторите попытку.",
|
||||
"sdk.raw.recent": "Недавние исходные диалоги",
|
||||
"sdk.raw.empty":
|
||||
"Исходных диалогов пока нет. Подключите источник, и они появятся здесь.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Включена базовая память. Новые диалоги сохраняются в исходном виде и не создают расширенные воспоминания. Существующие расширенные воспоминания сохраняются, но не показываются и не используются при поиске.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Включена интеллектуальная память. Модель преобразует новые диалоги в расширенные воспоминания.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Включена глубокая память. Новые диалоги создают расширенные воспоминания, которые продолжают объединяться графом знаний и evolve.",
|
||||
"sdk.raw.found": "Результатов: {count}",
|
||||
"sdk.raw.found.one": "{count} результат",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeTraditionalChineseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Traditional Chinese (zh-Hant, Taiwan usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
...modeTraditionalChineseMessages,
|
||||
"connectionFallback.title": "連線其他工具",
|
||||
"connectionFallback.desktopOnly": "本機配對僅在桌面 App 中可用。",
|
||||
"connectionFallback.description":
|
||||
@@ -69,6 +71,13 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.loadFailed": "原始對話暫時無法讀取。",
|
||||
"sdk.raw.searchFailed": "原始對話搜尋失敗,請重試。",
|
||||
"sdk.raw.recent": "最近原始對話",
|
||||
"sdk.raw.empty": "還沒有原始對話。連接來源後,對話會顯示在這裡。",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"目前使用基礎記憶。新對話會保留為原始記錄,不會產生進階記憶;過去的進階記憶仍會保留,但目前不會顯示或參與記憶召回。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"目前使用智慧記憶。模型會把新對話整理為進階記憶。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"目前使用深度記憶。新對話會產生進階記憶,並由知識圖譜與 evolve 持續整合。",
|
||||
"sdk.raw.found": "找到 {count} 則",
|
||||
"sdk.raw.found.one": "找到 {count} 則",
|
||||
"memoryInspector.emptyHint": "檢視它的來源、內容和修改歷史。",
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEmbeddingSwitchPlanRequest,
|
||||
buildEmbeddingSwitchStartRequest,
|
||||
EmbeddingSwitchInputError,
|
||||
embeddingSwitchCancelPath,
|
||||
parseEmbeddingSpaceDeleteReceipt,
|
||||
parseEmbeddingSpaces,
|
||||
parseEmbeddingSwitchCurrent,
|
||||
parseEmbeddingSwitchPlan,
|
||||
parseEmbeddingSwitchRefusal,
|
||||
retiredEmbeddingSpacePath,
|
||||
} from "./embedding-switch-operation";
|
||||
|
||||
const NOW = "2026-11-03T09:00:00Z";
|
||||
|
||||
function summary(spaceId: string | null, model = "embed-a") {
|
||||
return {
|
||||
space_id: spaceId,
|
||||
model,
|
||||
dims: 512,
|
||||
collection_base: spaceId ? "memory" : "",
|
||||
vectors: spaceId ? 12 : null,
|
||||
};
|
||||
}
|
||||
|
||||
function plan() {
|
||||
return {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "plan-token",
|
||||
source: summary("space-1"),
|
||||
target: summary(null, "embed-b"),
|
||||
target_profile_id: "profile-2",
|
||||
target_revision: 4,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [
|
||||
{
|
||||
space_id: "space-1",
|
||||
collection_base: "memory",
|
||||
dims: 512,
|
||||
vectors: 12,
|
||||
unmerged: null,
|
||||
},
|
||||
],
|
||||
total_vectors: 12,
|
||||
users: 2,
|
||||
graph_vectors: null,
|
||||
estimate_s: 3,
|
||||
requests: 1,
|
||||
disk_bytes_needed: 4096,
|
||||
disk_bytes_free: 8192,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function job() {
|
||||
return {
|
||||
job_id: "job-1",
|
||||
kind: "switch",
|
||||
state: "reembedding",
|
||||
source: summary("space-1"),
|
||||
target: summary("space-2", "embed-b"),
|
||||
stray_space_ids: [],
|
||||
target_profile_id: "profile-2",
|
||||
target_revision: 4,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "vectors",
|
||||
done: 6,
|
||||
total: 12,
|
||||
percent: 50,
|
||||
embedded: 6,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: "memory",
|
||||
source_index: 1,
|
||||
sources: 1,
|
||||
rate_per_s: 2,
|
||||
eta_s: 3,
|
||||
updated_at: NOW,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function space() {
|
||||
return {
|
||||
space_id: "space-1",
|
||||
state: "retired",
|
||||
model: "embed-a",
|
||||
dims: 512,
|
||||
name_dims: 512,
|
||||
collection_base: "memory",
|
||||
naming: "legacy",
|
||||
graph_dir: "graph",
|
||||
provider_kind: "local_onnx",
|
||||
profile_id: null,
|
||||
profile_revision: null,
|
||||
vectors: 12,
|
||||
created_at: NOW,
|
||||
live_since: NOW,
|
||||
retired_at: NOW,
|
||||
keep_until: NOW,
|
||||
dropped_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("embedding switch parsing", () => {
|
||||
it("parses the complete plan and preserves it on structured refusals", () => {
|
||||
expect(parseEmbeddingSwitchPlan(plan())).toEqual(plan());
|
||||
expect(
|
||||
parseEmbeddingSwitchRefusal({
|
||||
detail: "embedding_switch_plan_stale",
|
||||
plan: plan(),
|
||||
}),
|
||||
).toEqual({ code: "embedding_switch_plan_stale", plan: plan() });
|
||||
});
|
||||
|
||||
it("rejects malformed plan fields rather than defaulting them", () => {
|
||||
expect(
|
||||
parseEmbeddingSwitchPlan({ ...plan(), target_revision: 0 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseEmbeddingSwitchPlan({ ...plan(), blockers: ["BAD CODE"] }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseEmbeddingSwitchRefusal({
|
||||
detail: "embedding_switch_plan_stale",
|
||||
plan: { ...plan(), sources: "not-an-array" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses current jobs and rejects unknown states or invalid progress", () => {
|
||||
const current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: job(),
|
||||
last_finished: null,
|
||||
};
|
||||
expect(parseEmbeddingSwitchCurrent(current)).toEqual(current);
|
||||
expect(
|
||||
parseEmbeddingSwitchCurrent({
|
||||
...current,
|
||||
job: { ...job(), state: "completed" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseEmbeddingSwitchCurrent({
|
||||
...current,
|
||||
job: { ...job(), progress: { ...job().progress, percent: 101 } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses registry, scan and retired-space deletion receipts", () => {
|
||||
const spaces = {
|
||||
schema_version: 1,
|
||||
live_space_id: null,
|
||||
spaces: [space()],
|
||||
scanned: [
|
||||
{
|
||||
collection_base: "memory",
|
||||
name_dims: 512,
|
||||
width: 512,
|
||||
vectors: 12,
|
||||
counts: { memory: 12 },
|
||||
space_id: "space-1",
|
||||
state: "retired",
|
||||
},
|
||||
],
|
||||
scan_error: null,
|
||||
};
|
||||
expect(parseEmbeddingSpaces(spaces)).toEqual(spaces);
|
||||
expect(
|
||||
parseEmbeddingSpaceDeleteReceipt({
|
||||
schema_version: 1,
|
||||
space: { ...space(), state: "dropped", dropped_at: NOW },
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
parseEmbeddingSpaces({
|
||||
...spaces,
|
||||
scanned: [{ ...spaces.scanned[0], counts: { memory: -1 } }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedding switch requests", () => {
|
||||
it("builds bounded plan and start bodies", () => {
|
||||
expect(buildEmbeddingSwitchPlanRequest("profile/a", 4)).toEqual({
|
||||
path: "/api/v1/embedding-switches/plan",
|
||||
body: { profile_id: "profile/a", expected_revision: 4 },
|
||||
});
|
||||
expect(
|
||||
buildEmbeddingSwitchStartRequest({
|
||||
profile_id: "profile/a",
|
||||
expected_revision: 4,
|
||||
plan_token: "token",
|
||||
requested_by: "app",
|
||||
}),
|
||||
).toEqual({
|
||||
path: "/api/v1/embedding-switches",
|
||||
body: {
|
||||
profile_id: "profile/a",
|
||||
expected_revision: 4,
|
||||
plan_token: "token",
|
||||
requested_by: "app",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes path ids and rejects invalid inputs before transport", () => {
|
||||
expect(embeddingSwitchCancelPath("job/a")).toContain("job%2Fa/cancel");
|
||||
expect(retiredEmbeddingSpacePath("space/a")).toContain("space%2Fa");
|
||||
expect(() => buildEmbeddingSwitchPlanRequest("", 1)).toThrow(
|
||||
EmbeddingSwitchInputError,
|
||||
);
|
||||
expect(() =>
|
||||
buildEmbeddingSwitchStartRequest({
|
||||
profile_id: "profile",
|
||||
expected_revision: 0,
|
||||
plan_token: "token",
|
||||
requested_by: "app",
|
||||
}),
|
||||
).toThrow("expected_revision_invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
import type {
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceDeleteReceipt,
|
||||
EmbeddingSpaceState,
|
||||
EmbeddingSpaceSummary,
|
||||
EmbeddingSpaces,
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchJobState,
|
||||
EmbeddingSwitchPlan,
|
||||
EmbeddingSwitchPlannedSource,
|
||||
EmbeddingSwitchProgress,
|
||||
EmbeddingSwitchSummary,
|
||||
ScannedEmbeddingSpace,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
const CODE = /^[a-z0-9_.:-]{1,128}$/;
|
||||
const PLAN_REASONS = [
|
||||
"embedding_identity_changed",
|
||||
"same_embedding",
|
||||
"empty_home",
|
||||
] as const;
|
||||
const JOB_STATES: readonly EmbeddingSwitchJobState[] = [
|
||||
"preparing",
|
||||
"reembedding",
|
||||
"verifying",
|
||||
"committing",
|
||||
"done",
|
||||
"failed",
|
||||
"rolled_back",
|
||||
];
|
||||
const PROGRESS_PHASES: readonly EmbeddingSwitchProgress["phase"][] = [
|
||||
"prepare",
|
||||
"vectors",
|
||||
"graph",
|
||||
"verify",
|
||||
"commit",
|
||||
"rollback",
|
||||
"done",
|
||||
];
|
||||
const SPACE_STATES: readonly EmbeddingSpaceState[] = [
|
||||
"live",
|
||||
"building",
|
||||
"stray",
|
||||
"retired",
|
||||
"dropped",
|
||||
];
|
||||
|
||||
export class EmbeddingSwitchInputError extends Error {}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown, max = 256): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= max;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, max = 256): value is string | null {
|
||||
return value === null || text(value, max);
|
||||
}
|
||||
|
||||
function count(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function nullableCount(value: unknown): value is number | null {
|
||||
return value === null || count(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function nullableFinite(value: unknown): value is number | null {
|
||||
return value === null || finite(value);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): value is string {
|
||||
return text(value, 128) && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function nullableTimestamp(value: unknown): value is string | null {
|
||||
return value === null || timestamp(value);
|
||||
}
|
||||
|
||||
function parseSpaceSummary(value: unknown): EmbeddingSpaceSummary | null {
|
||||
const item = record(value);
|
||||
if (
|
||||
!item ||
|
||||
!optionalText(item.space_id, 128) ||
|
||||
!optionalText(item.model) ||
|
||||
!nullableCount(item.dims) ||
|
||||
typeof item.collection_base !== "string" ||
|
||||
item.collection_base.length > 2_048 ||
|
||||
!nullableCount(item.vectors)
|
||||
)
|
||||
return null;
|
||||
return item as unknown as EmbeddingSpaceSummary;
|
||||
}
|
||||
|
||||
function parsePlannedSource(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchPlannedSource | null {
|
||||
const item = record(value);
|
||||
if (
|
||||
!item ||
|
||||
!optionalText(item.space_id, 128) ||
|
||||
typeof item.collection_base !== "string" ||
|
||||
item.collection_base.length > 2_048 ||
|
||||
!nullableCount(item.dims) ||
|
||||
!count(item.vectors) ||
|
||||
!nullableCount(item.unmerged)
|
||||
)
|
||||
return null;
|
||||
return item as unknown as EmbeddingSwitchPlannedSource;
|
||||
}
|
||||
|
||||
/** Bounded projection of the CLI validator, extended to the full v1 plan. */
|
||||
export function parseEmbeddingSwitchPlan(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchPlan | null {
|
||||
const plan = record(value);
|
||||
if (!plan) return null;
|
||||
const source = parseSpaceSummary(plan.source);
|
||||
const target = parseSpaceSummary(plan.target);
|
||||
const sources = Array.isArray(plan.sources)
|
||||
? plan.sources.map(parsePlannedSource)
|
||||
: null;
|
||||
if (
|
||||
typeof plan.needed !== "boolean" ||
|
||||
!PLAN_REASONS.includes(plan.reason as (typeof PLAN_REASONS)[number]) ||
|
||||
!text(plan.plan_token, 128) ||
|
||||
!source ||
|
||||
!target ||
|
||||
!text(plan.target_profile_id, 128) ||
|
||||
!count(plan.target_revision) ||
|
||||
plan.target_revision < 1 ||
|
||||
!text(plan.target_provider_kind, 64) ||
|
||||
typeof plan.target_local !== "boolean" ||
|
||||
!sources ||
|
||||
sources.some((item) => item === null) ||
|
||||
!count(plan.total_vectors) ||
|
||||
!nullableCount(plan.users) ||
|
||||
!nullableCount(plan.graph_vectors) ||
|
||||
!nullableCount(plan.estimate_s) ||
|
||||
!nullableCount(plan.requests) ||
|
||||
!count(plan.disk_bytes_needed) ||
|
||||
!nullableCount(plan.disk_bytes_free) ||
|
||||
!count(plan.retention_days) ||
|
||||
!Array.isArray(plan.blockers) ||
|
||||
!plan.blockers.every(
|
||||
(item): item is string => typeof item === "string" && CODE.test(item),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...(plan as unknown as EmbeddingSwitchPlan),
|
||||
source,
|
||||
target,
|
||||
sources: sources as EmbeddingSwitchPlannedSource[],
|
||||
};
|
||||
}
|
||||
|
||||
function parseProgress(value: unknown): EmbeddingSwitchProgress | null {
|
||||
const progress = record(value);
|
||||
if (
|
||||
!progress ||
|
||||
!PROGRESS_PHASES.includes(
|
||||
progress.phase as EmbeddingSwitchProgress["phase"],
|
||||
) ||
|
||||
!count(progress.done) ||
|
||||
!count(progress.total) ||
|
||||
!finite(progress.percent) ||
|
||||
progress.percent < 0 ||
|
||||
progress.percent > 100 ||
|
||||
!count(progress.embedded) ||
|
||||
!count(progress.reused) ||
|
||||
!count(progress.kept) ||
|
||||
!optionalText(progress.role, 128) ||
|
||||
!count(progress.source_index) ||
|
||||
!count(progress.sources) ||
|
||||
!nullableFinite(progress.rate_per_s) ||
|
||||
(typeof progress.rate_per_s === "number" && progress.rate_per_s < 0) ||
|
||||
!nullableCount(progress.eta_s) ||
|
||||
!nullableTimestamp(progress.updated_at)
|
||||
)
|
||||
return null;
|
||||
return progress as unknown as EmbeddingSwitchProgress;
|
||||
}
|
||||
|
||||
export function parseEmbeddingSwitchJob(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchJob | null {
|
||||
const job = record(value);
|
||||
if (!job) return null;
|
||||
const source = parseSpaceSummary(job.source);
|
||||
const target = parseSpaceSummary(job.target);
|
||||
const progress = parseProgress(job.progress);
|
||||
if (
|
||||
!text(job.job_id, 128) ||
|
||||
(job.kind !== "switch" && job.kind !== "merge") ||
|
||||
!JOB_STATES.includes(job.state as EmbeddingSwitchJobState) ||
|
||||
!source ||
|
||||
!target ||
|
||||
!Array.isArray(job.stray_space_ids) ||
|
||||
!job.stray_space_ids.every((item) => text(item, 128)) ||
|
||||
!optionalText(job.target_profile_id, 128) ||
|
||||
!nullableCount(job.target_revision) ||
|
||||
!["app", "cli", "startup"].includes(String(job.requested_by)) ||
|
||||
!progress ||
|
||||
typeof job.cancel_requested !== "boolean" ||
|
||||
!optionalText(job.error_code, 128) ||
|
||||
(typeof job.error_code === "string" && !CODE.test(job.error_code)) ||
|
||||
!count(job.attempt) ||
|
||||
job.attempt < 1 ||
|
||||
!timestamp(job.created_at) ||
|
||||
!timestamp(job.updated_at) ||
|
||||
!nullableTimestamp(job.finished_at)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...(job as unknown as EmbeddingSwitchJob),
|
||||
source,
|
||||
target,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEmbeddingSwitchCurrent(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchCurrent | null {
|
||||
const current = record(value);
|
||||
if (
|
||||
current?.schema_version !== 1 ||
|
||||
!["idle", "scanning", "running"].includes(String(current.phase)) ||
|
||||
typeof current.writes_paused !== "boolean" ||
|
||||
!["pending", "scanning", "done", "failed", "unavailable"].includes(
|
||||
String(current.scan_state),
|
||||
) ||
|
||||
!optionalText(current.scan_error, 128)
|
||||
)
|
||||
return null;
|
||||
const job =
|
||||
current.job === null ? null : parseEmbeddingSwitchJob(current.job);
|
||||
const lastFinished =
|
||||
current.last_finished === null
|
||||
? null
|
||||
: parseEmbeddingSwitchJob(current.last_finished);
|
||||
if (
|
||||
(current.job !== null && job === null) ||
|
||||
(current.last_finished !== null && lastFinished === null)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...(current as unknown as EmbeddingSwitchCurrent),
|
||||
job,
|
||||
last_finished: lastFinished,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEmbeddingSpace(value: unknown): EmbeddingSpace | null {
|
||||
const space = record(value);
|
||||
if (
|
||||
!space ||
|
||||
!text(space.space_id, 128) ||
|
||||
!SPACE_STATES.includes(space.state as EmbeddingSpaceState) ||
|
||||
!optionalText(space.model) ||
|
||||
!nullableCount(space.dims) ||
|
||||
!nullableCount(space.name_dims) ||
|
||||
typeof space.collection_base !== "string" ||
|
||||
space.collection_base.length > 2_048 ||
|
||||
!["legacy", "v2", "discovered"].includes(String(space.naming)) ||
|
||||
!optionalText(space.graph_dir, 2_048) ||
|
||||
!optionalText(space.provider_kind, 64) ||
|
||||
!optionalText(space.profile_id, 128) ||
|
||||
!nullableCount(space.profile_revision) ||
|
||||
!nullableCount(space.vectors) ||
|
||||
!timestamp(space.created_at) ||
|
||||
!nullableTimestamp(space.live_since) ||
|
||||
!nullableTimestamp(space.retired_at) ||
|
||||
!nullableTimestamp(space.keep_until) ||
|
||||
!nullableTimestamp(space.dropped_at)
|
||||
)
|
||||
return null;
|
||||
return space as unknown as EmbeddingSpace;
|
||||
}
|
||||
|
||||
function parseScannedSpace(value: unknown): ScannedEmbeddingSpace | null {
|
||||
const space = record(value);
|
||||
const counts = record(space?.counts);
|
||||
if (
|
||||
!space ||
|
||||
typeof space.collection_base !== "string" ||
|
||||
space.collection_base.length > 2_048 ||
|
||||
!nullableCount(space.name_dims) ||
|
||||
!nullableCount(space.width) ||
|
||||
!count(space.vectors) ||
|
||||
!counts ||
|
||||
!Object.values(counts).every(count) ||
|
||||
!optionalText(space.space_id, 128) ||
|
||||
![...SPACE_STATES, "unregistered"].includes(
|
||||
space.state as EmbeddingSpaceState | "unregistered",
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return space as unknown as ScannedEmbeddingSpace;
|
||||
}
|
||||
|
||||
export function parseEmbeddingSpaces(value: unknown): EmbeddingSpaces | null {
|
||||
const body = record(value);
|
||||
if (
|
||||
body?.schema_version !== 1 ||
|
||||
!optionalText(body.live_space_id, 128) ||
|
||||
!Array.isArray(body.spaces) ||
|
||||
!(body.scanned === null || Array.isArray(body.scanned)) ||
|
||||
!optionalText(body.scan_error, 128)
|
||||
)
|
||||
return null;
|
||||
const spaces = body.spaces.map(parseEmbeddingSpace);
|
||||
const scanned =
|
||||
body.scanned === null ? null : body.scanned.map(parseScannedSpace);
|
||||
if (
|
||||
spaces.some((space) => space === null) ||
|
||||
scanned?.some((space) => space === null)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...(body as unknown as EmbeddingSpaces),
|
||||
spaces: spaces as EmbeddingSpace[],
|
||||
scanned: scanned as ScannedEmbeddingSpace[] | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEmbeddingSpaceDeleteReceipt(
|
||||
value: unknown,
|
||||
): EmbeddingSpaceDeleteReceipt | null {
|
||||
const receipt = record(value);
|
||||
const space = parseEmbeddingSpace(receipt?.space);
|
||||
return receipt?.schema_version === 1 && space
|
||||
? { schema_version: 1, space }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseEmbeddingSwitchSummary(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchSummary | null {
|
||||
const summary = record(value);
|
||||
if (
|
||||
!summary ||
|
||||
(summary.job_id !== undefined && !text(summary.job_id, 128)) ||
|
||||
(summary.kind !== "switch" && summary.kind !== "merge") ||
|
||||
![...JOB_STATES, "scanning"].includes(
|
||||
summary.state as EmbeddingSwitchJobState | "scanning",
|
||||
) ||
|
||||
!finite(summary.percent) ||
|
||||
summary.percent < 0 ||
|
||||
summary.percent > 100 ||
|
||||
!nullableCount(summary.eta_s)
|
||||
)
|
||||
return null;
|
||||
return summary as unknown as EmbeddingSwitchSummary;
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchRefusal {
|
||||
code: string;
|
||||
plan: EmbeddingSwitchPlan | null;
|
||||
}
|
||||
|
||||
export function parseEmbeddingSwitchRefusal(
|
||||
value: unknown,
|
||||
): EmbeddingSwitchRefusal | null {
|
||||
const refusal = record(value);
|
||||
if (
|
||||
!refusal ||
|
||||
typeof refusal.detail !== "string" ||
|
||||
!CODE.test(refusal.detail)
|
||||
)
|
||||
return null;
|
||||
const plan =
|
||||
refusal.plan === undefined ? null : parseEmbeddingSwitchPlan(refusal.plan);
|
||||
if (refusal.plan !== undefined && plan === null) return null;
|
||||
return { code: refusal.detail, plan };
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchPlanRequest {
|
||||
path: "/api/v1/embedding-switches/plan";
|
||||
body: { profile_id: string; expected_revision?: number };
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchStartInput {
|
||||
profile_id: string;
|
||||
expected_revision: number;
|
||||
plan_token: string;
|
||||
requested_by: "app" | "cli";
|
||||
}
|
||||
|
||||
export interface EmbeddingSwitchStartRequest {
|
||||
path: "/api/v1/embedding-switches";
|
||||
body: EmbeddingSwitchStartInput;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, field: string): asserts value is string {
|
||||
if (!text(value, 128))
|
||||
throw new EmbeddingSwitchInputError(`${field}_invalid`);
|
||||
}
|
||||
|
||||
function revision(value: unknown): asserts value is number {
|
||||
if (!count(value) || value < 1)
|
||||
throw new EmbeddingSwitchInputError("expected_revision_invalid");
|
||||
}
|
||||
|
||||
export function buildEmbeddingSwitchPlanRequest(
|
||||
profileId: string,
|
||||
expectedRevision?: number,
|
||||
): EmbeddingSwitchPlanRequest {
|
||||
identifier(profileId, "profile_id");
|
||||
if (expectedRevision !== undefined) revision(expectedRevision);
|
||||
return {
|
||||
path: "/api/v1/embedding-switches/plan",
|
||||
body: {
|
||||
profile_id: profileId,
|
||||
...(expectedRevision === undefined
|
||||
? {}
|
||||
: { expected_revision: expectedRevision }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmbeddingSwitchStartRequest(
|
||||
input: EmbeddingSwitchStartInput,
|
||||
): EmbeddingSwitchStartRequest {
|
||||
identifier(input.profile_id, "profile_id");
|
||||
revision(input.expected_revision);
|
||||
identifier(input.plan_token, "plan_token");
|
||||
if (input.requested_by !== "app" && input.requested_by !== "cli")
|
||||
throw new EmbeddingSwitchInputError("requested_by_invalid");
|
||||
return { path: "/api/v1/embedding-switches", body: { ...input } };
|
||||
}
|
||||
|
||||
export const EMBEDDING_SWITCH_CURRENT_PATH =
|
||||
"/api/v1/embedding-switches/current" as const;
|
||||
export const EMBEDDING_SPACES_PATH = "/api/v1/embedding-spaces" as const;
|
||||
|
||||
export function embeddingSwitchCancelPath(jobId: string): string {
|
||||
identifier(jobId, "job_id");
|
||||
return `/api/v1/embedding-switches/${encodeURIComponent(jobId)}/cancel`;
|
||||
}
|
||||
|
||||
export function retiredEmbeddingSpacePath(spaceId: string): string {
|
||||
identifier(spaceId, "space_id");
|
||||
return `/api/v1/embedding-spaces/${encodeURIComponent(spaceId)}`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* copies drifting apart.
|
||||
*/
|
||||
|
||||
export * from "./embedding-switch-operation.js";
|
||||
export * from "./memory-add-operation.js";
|
||||
export * from "./memory-processing-operation.js";
|
||||
export * from "./model-operation.js";
|
||||
|
||||
Reference in new issue
Block a user