mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
feat(settings): persist account preferences across browsers (#5397)
* feat(settings): persist account preferences across browsers * docs(settings): scope preference guidance to user persistence * fix(settings): preserve SSR and fence custom-agent defaults * test: include user persistence in scoped guidance inventory * fix(settings): sync explicit edits and preserve local tab updates
This commit is contained in:
parent
bddcd68aa6
commit
7513f16e0e
@ -524,6 +524,8 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
|
||||
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
|
||||
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`; durable background-task calls honor the same setting for HTTP/SSE servers as well.
|
||||
MCP tool names are prefixed with `<server_name>_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers.
|
||||
Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local.
|
||||
|
||||
Settings > Tools adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
|
||||
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.
|
||||
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.
|
||||
|
||||
@ -42,6 +42,7 @@ from app.gateway.routers import (
|
||||
thread_runs,
|
||||
threads,
|
||||
uploads,
|
||||
user_preferences,
|
||||
)
|
||||
from app.gateway.trace_middleware import TraceMiddleware
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
@ -855,6 +856,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
||||
|
||||
# Auth API is mounted at /api/v1/auth
|
||||
app.include_router(auth.router)
|
||||
app.include_router(user_preferences.router)
|
||||
|
||||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||
app.include_router(feedback.router)
|
||||
|
||||
60
backend/app/gateway/routers/user_preferences.py
Normal file
60
backend/app/gateway/routers/user_preferences.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Session-authenticated, owner-scoped UI preferences."""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, StringConstraints, ValidationError
|
||||
|
||||
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
|
||||
from app.gateway.deps import get_current_user_from_request
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.user.preferences import UserPreferencesRepository
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth/preferences", tags=["auth"])
|
||||
|
||||
|
||||
class Preferences(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
notification_enabled: bool | None = None
|
||||
model_name: Annotated[str, StringConstraints(max_length=200)] | None = None
|
||||
mode: Literal["flash", "thinking", "pro", "ultra"] | None = None
|
||||
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
|
||||
|
||||
|
||||
async def _owner(request: Request, expected_user: str) -> str:
|
||||
user = await get_current_user_from_request(request)
|
||||
if getattr(request.state, "auth_source", None) != AUTH_SOURCE_SESSION:
|
||||
raise HTTPException(403, "Preferences require an authenticated browser session")
|
||||
owner = str(user.id)
|
||||
if owner != expected_user:
|
||||
raise HTTPException(409, "The signed-in account changed; reload this page")
|
||||
return owner
|
||||
|
||||
|
||||
def _repository() -> UserPreferencesRepository:
|
||||
sessions = get_session_factory()
|
||||
if sessions is None:
|
||||
raise HTTPException(503, "Preference persistence is unavailable")
|
||||
return UserPreferencesRepository(sessions)
|
||||
|
||||
|
||||
@router.get("", response_model=Preferences)
|
||||
async def get_preferences(request: Request, x_expected_user_id: str = Header(...)) -> Preferences:
|
||||
owner = await _owner(request, x_expected_user_id)
|
||||
stored = await _repository().get(owner)
|
||||
# An invalid optional cosmetic setting must not hide the rest of the page.
|
||||
valid = {}
|
||||
for key, value in stored.items():
|
||||
try:
|
||||
Preferences.model_validate({key: value})
|
||||
except ValidationError:
|
||||
continue
|
||||
valid[key] = value
|
||||
return Preferences(**valid)
|
||||
|
||||
|
||||
@router.patch("", status_code=204, response_class=Response)
|
||||
async def patch_preferences(body: Preferences, request: Request, x_expected_user_id: str = Header(...)) -> Response:
|
||||
owner = await _owner(request, x_expected_user_id)
|
||||
await _repository().patch(owner, body.model_dump(exclude_unset=True))
|
||||
return Response(status_code=204)
|
||||
@ -34,6 +34,32 @@ PATs require a configured database backend (SQLite/PostgreSQL) — on the
|
||||
memory-only backend, Bearer credentials are rejected and PAT management routes
|
||||
return `503`.
|
||||
|
||||
### Account Preferences
|
||||
|
||||
`GET /api/v1/auth/preferences` returns the signed-in browser user's four
|
||||
preferences. `PATCH` updates only explicitly supplied fields and returns `204`.
|
||||
Both require `X-Expected-User-Id` matching the session user; PATCH also requires
|
||||
the normal `X-CSRF-Token` header. The expected ID is a stale-tab guard, not an
|
||||
authorization credential. PAT, internal, and auth-disabled callers receive
|
||||
`403`; a different session user receives `409`.
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_enabled": false,
|
||||
"model_name": "my-model",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "high"
|
||||
}
|
||||
```
|
||||
|
||||
All four fields accept `null` to restore the default. `mode` accepts `flash`,
|
||||
`thinking`, `pro`, or `ultra`; `reasoning_effort` accepts `minimal`, `low`,
|
||||
`medium`, or `high`; model names are at most 200 characters. Unknown fields and
|
||||
invalid values return `422`. Missing preferences read as `null`. Separate-field
|
||||
patches preserve each other's changes, and same-field writes are last-commit-wins.
|
||||
Storage requires SQLite or PostgreSQL (`503` when unavailable). Browser
|
||||
notification permission remains device-local and is not changed by this API.
|
||||
|
||||
### Personal Access Tokens
|
||||
|
||||
Base URL: `/api/v1/auth`
|
||||
|
||||
@ -22,7 +22,9 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a
|
||||
**Rolling forward compatibility**: the local chain is
|
||||
`0018_oauth_identity_pg_partial` → `0019_projects` →
|
||||
`0020_threads_meta_project_id` → `0021_batch_acceptance` →
|
||||
`0019_thread_incarnations` → `0022_scheduled_occurrence_seq` (current head).
|
||||
`0019_thread_incarnations` → `0022_scheduled_occurrence_seq` →
|
||||
`0023_user_preferences` (current head). The preference revision adds a separate
|
||||
owner/key table with a cascading users foreign key and does not alter users.
|
||||
The incarnation revision deliberately retains the exact id audited by the
|
||||
rollback-floor binary; Alembic orders revisions by `down_revision`, not by the
|
||||
numeric prefix.
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
"""Persist browser-safe user preferences independently by key."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0023_user_preferences"
|
||||
down_revision = "0022_scheduled_occurrence_seq"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Legacy bootstrap and create_all-based recovery may already have the table.
|
||||
if sa.inspect(op.get_bind()).has_table("user_preferences"):
|
||||
return
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("key", sa.String(40), primary_key=True),
|
||||
sa.Column("value", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("user_preferences"):
|
||||
op.drop_table("user_preferences")
|
||||
12
backend/packages/harness/deerflow/persistence/user/AGENTS.md
Normal file
12
backend/packages/harness/deerflow/persistence/user/AGENTS.md
Normal file
@ -0,0 +1,12 @@
|
||||
# User persistence
|
||||
|
||||
`user_preferences` stores independent `(user_id, key)` rows in the shared SQL
|
||||
database. PATCH upserts only supplied keys in one transaction; `null` resets a
|
||||
field, disjoint edits commute, and same-field writes are last-commit-wins.
|
||||
|
||||
The Gateway's `GET/PATCH /api/v1/auth/preferences` allows only notification
|
||||
enablement, the default model, conversation mode, and reasoning effort. It
|
||||
requires a browser session plus `X-Expected-User-Id` matching that session (a
|
||||
stale-tab guard, never an authorization source); PAT, internal, and auth-disabled
|
||||
callers are rejected. Never persist arbitrary agent context or credentials
|
||||
through this API. See `backend/docs/API.md` for the HTTP contract.
|
||||
@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, String, text
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@ -30,6 +30,15 @@ from deerflow.persistence.base import Base
|
||||
OAUTH_IDENTITY_INDEX_NAME = "idx_users_oauth_identity"
|
||||
|
||||
|
||||
class UserPreferenceRow(Base):
|
||||
"""Independent keys allow concurrent clients to patch disjoint preferences."""
|
||||
|
||||
__tablename__ = "user_preferences"
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
value: Mapped[object] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class UserRow(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
"""Durable per-user preferences; updates touch only explicitly supplied keys."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
from deerflow.persistence.user.model import UserPreferenceRow
|
||||
|
||||
|
||||
class UserPreferencesRepository:
|
||||
def __init__(self, sessions):
|
||||
self.sessions = sessions
|
||||
|
||||
async def get(self, user_id: str) -> dict:
|
||||
async with self.sessions() as session:
|
||||
rows = (await session.execute(select(UserPreferenceRow).where(UserPreferenceRow.user_id == user_id))).scalars()
|
||||
return {row.key: row.value for row in rows}
|
||||
|
||||
async def patch(self, user_id: str, values: dict) -> None:
|
||||
async with self.sessions() as session, session.begin():
|
||||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
# Consistent key order also avoids opposite-order row-lock cycles.
|
||||
for key, value in sorted(values.items()):
|
||||
statement = insert(UserPreferenceRow).values(user_id=user_id, key=key, value=value)
|
||||
await session.execute(statement.on_conflict_do_update(index_elements=["user_id", "key"], set_={"value": statement.excluded.value}))
|
||||
@ -25,6 +25,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
||||
"backend/packages/harness/deerflow/mcp/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/models/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/persistence/migrations/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/persistence/user/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/reflection/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/skills/AGENTS.md",
|
||||
"backend/packages/harness/deerflow/subagents/AGENTS.md",
|
||||
|
||||
@ -131,6 +131,20 @@ def test_me_lists_all_route_permissions_when_authorization_disabled(client):
|
||||
assert res.json()["permissions"] == _ALL_PERMISSIONS
|
||||
|
||||
|
||||
def test_account_preferences_use_registered_auth_and_csrf_middleware(client):
|
||||
path = "/api/v1/auth/preferences"
|
||||
assert client.get(path, headers={"X-Expected-User-Id": "anonymous"}).status_code == 401
|
||||
user = _initialize_admin(client).json()
|
||||
headers = {"X-Expected-User-Id": user["id"]}
|
||||
assert client.get(path, headers=headers).status_code == 200
|
||||
assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 403
|
||||
headers["X-CSRF-Token"] = client.cookies.get("csrf_token")
|
||||
assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 204
|
||||
assert client.get(path, headers=headers).json()["mode"] == "pro"
|
||||
headers["X-Expected-User-Id"] = "another-user"
|
||||
assert client.patch(path, headers=headers, json={"mode": "ultra"}).status_code == 409
|
||||
|
||||
|
||||
def test_me_reuses_middleware_resolved_permissions(client, monkeypatch):
|
||||
"""One provider decision per registered permission — /me reads the
|
||||
AuthContext AuthMiddleware already stamped instead of re-resolving."""
|
||||
|
||||
@ -12,9 +12,10 @@ import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.bootstrap import _get_alembic_config, _get_head_revision
|
||||
from deerflow.persistence.bootstrap import _MIGRATIONS_DIR, _get_alembic_config
|
||||
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
||||
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
|
||||
|
||||
@ -83,8 +84,10 @@ async def _schema(engine):
|
||||
)
|
||||
|
||||
|
||||
async def test_occurrence_revision_is_single_head():
|
||||
assert _get_head_revision() == REVISION
|
||||
async def test_occurrence_revision_is_in_single_head_chain():
|
||||
script = ScriptDirectory(str(_MIGRATIONS_DIR))
|
||||
assert len(script.get_heads()) == 1
|
||||
assert REVISION in {revision.revision for revision in script.walk_revisions()}
|
||||
|
||||
|
||||
async def test_upgrade_preserves_legacy_rows_and_allocates_from_one(migration_database):
|
||||
|
||||
150
backend/tests/test_user_preferences.py
Normal file
150
backend/tests/test_user_preferences.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Owner isolation and disjoint concurrent preference updates."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.user.preferences import UserPreferencesRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def preference_repo(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/preferences.db")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as session:
|
||||
session.add_all([UserRow(id="alice", email="alice@example.com"), UserRow(id="bob", email="bob@example.com")])
|
||||
await session.commit()
|
||||
yield UserPreferencesRepository(sessions)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_preferences_survive_repository_recreation_and_remain_owner_scoped(preference_repo):
|
||||
repo = preference_repo
|
||||
assert await repo.get("alice") == {}
|
||||
await repo.patch("alice", {"model_name": "model-a", "notification_enabled": False})
|
||||
assert await UserPreferencesRepository(repo.sessions).get("alice") == {"model_name": "model-a", "notification_enabled": False}
|
||||
assert await repo.get("bob") == {}
|
||||
await repo.patch("alice", {"model_name": None})
|
||||
assert await repo.get("alice") == {"model_name": None, "notification_enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disjoint_updates_do_not_erase_each_other(preference_repo):
|
||||
await asyncio.gather(
|
||||
preference_repo.patch("alice", {"model_name": "new-model"}),
|
||||
preference_repo.patch("alice", {"notification_enabled": False}),
|
||||
)
|
||||
assert await preference_repo.get("alice") == {"model_name": "new-model", "notification_enabled": False}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api(preference_repo, monkeypatch):
|
||||
from app.gateway.routers import user_preferences
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(user_preferences.router)
|
||||
identity = SimpleNamespace(id="alice", source="session")
|
||||
|
||||
@app.middleware("http")
|
||||
async def authenticated_session(request, call_next):
|
||||
if identity.id:
|
||||
request.state.user = SimpleNamespace(id=identity.id)
|
||||
request.state.auth_source = identity.source
|
||||
return await call_next(request)
|
||||
|
||||
monkeypatch.setattr(user_preferences, "_repository", lambda: preference_repo)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
client.headers["X-Expected-User-Id"] = "alice"
|
||||
yield client, identity
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_preferences_api_partial_patch_reset_and_owner_isolation(api):
|
||||
client, identity = api
|
||||
path = "/api/v1/auth/preferences"
|
||||
assert (await client.patch(path, json={"notification_enabled": False, "mode": "pro"})).status_code == 204
|
||||
assert (await client.patch(path, json={"model_name": "model-a"})).status_code == 204
|
||||
assert (await client.get(path)).json() == {"notification_enabled": False, "model_name": "model-a", "mode": "pro", "reasoning_effort": None}
|
||||
assert (await client.patch(path, json={"mode": None})).status_code == 204
|
||||
identity.id = "bob"
|
||||
# A stale tab with Alice's expected identity must not write using Bob's cookie.
|
||||
assert (await client.patch(path, json={"mode": "ultra"})).status_code == 409
|
||||
assert (await client.get(path)).status_code == 409
|
||||
client.headers["X-Expected-User-Id"] = "bob"
|
||||
assert (await client.get(path)).json() == {"notification_enabled": None, "model_name": None, "mode": None, "reasoning_effort": None}
|
||||
identity.id = "alice"
|
||||
client.headers["X-Expected-User-Id"] = "alice"
|
||||
assert (await client.get(path)).json()["notification_enabled"] is False
|
||||
assert (await client.get(path)).json()["mode"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("body", [{"notification_enabled": "false"}, {"mode": "invalid"}, {"reasoning_effort": "max"}, {"model_name": "a" * 201}, {"context": {"github_token": "not-a-real-token"}}, {"user_id": "bob"}])
|
||||
async def test_preferences_api_rejects_invalid_and_unrelated_fields(api, body):
|
||||
client, _ = api
|
||||
assert (await client.patch("/api/v1/auth/preferences", json=body)).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("source", ["pat", "internal", "auth_disabled"])
|
||||
async def test_preferences_requires_browser_session(api, source):
|
||||
client, identity = api
|
||||
identity.source = source
|
||||
assert (await client.get("/api/v1/auth/preferences")).status_code == 403
|
||||
assert (await client.patch("/api/v1/auth/preferences", json={})).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_preferences_requires_authentication_and_expected_identity(api):
|
||||
client, identity = api
|
||||
del client.headers["X-Expected-User-Id"]
|
||||
assert (await client.get("/api/v1/auth/preferences")).status_code == 422
|
||||
client.headers["X-Expected-User-Id"] = "alice"
|
||||
identity.id = None
|
||||
assert (await client.get("/api/v1/auth/preferences")).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_preferences_read_discards_only_malformed_fields(api, preference_repo):
|
||||
client, _ = api
|
||||
await preference_repo.patch("alice", {"notification_enabled": False, "mode": "invalid", "unknown": "ignored"})
|
||||
assert (await client.get("/api/v1/auth/preferences")).json() == {"notification_enabled": False, "model_name": None, "mode": None, "reasoning_effort": None}
|
||||
|
||||
|
||||
def test_preferences_migration_preserves_existing_users_and_downgrades(tmp_path):
|
||||
import importlib
|
||||
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
revision = importlib.import_module("deerflow.persistence.migrations.versions.0023_user_preferences")
|
||||
engine = create_engine(f"sqlite:///{tmp_path}/migration.db")
|
||||
with engine.begin() as connection:
|
||||
UserRow.__table__.create(connection)
|
||||
connection.execute(UserRow.__table__.insert().values(id="alice", email="alice@example.com"))
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
revision.upgrade()
|
||||
assert "user_preferences" in inspect(connection).get_table_names()
|
||||
connection.execute(text("INSERT INTO user_preferences (user_id, key, value) VALUES ('alice', 'mode', '\"pro\"')"))
|
||||
revision.upgrade()
|
||||
assert connection.execute(text("SELECT value FROM user_preferences WHERE user_id = 'alice'")).scalar() == '"pro"'
|
||||
revision.downgrade()
|
||||
revision.downgrade()
|
||||
assert "user_preferences" not in inspect(connection).get_table_names()
|
||||
assert connection.execute(text("SELECT email FROM users WHERE id = 'alice'")).scalar() == "alice@example.com"
|
||||
engine.dispose()
|
||||
File diff suppressed because one or more lines are too long
@ -50,6 +50,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import { resolveThreadContext } from "@/core/settings/store";
|
||||
import {
|
||||
useThreadMetadata,
|
||||
useThreadStream,
|
||||
@ -457,9 +458,11 @@ export default function AgentChatPage() {
|
||||
isUploading ||
|
||||
(!isNewThread && isHistoryLoading)
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onContextChange={(context, options) => {
|
||||
if (options?.automatic)
|
||||
resolveThreadContext(threadId, context);
|
||||
else setSettings("context", context);
|
||||
}}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@ -9,6 +9,7 @@ import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-ba
|
||||
import { SettingsDialogHost } from "@/components/workspace/settings";
|
||||
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
|
||||
|
||||
function parseSidebarOpenCookie(
|
||||
value: string | undefined,
|
||||
@ -32,18 +33,20 @@ export async function WorkspaceContent({
|
||||
|
||||
return (
|
||||
<QueryClientProvider>
|
||||
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">
|
||||
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<SettingsDialogHost />
|
||||
<WorkspaceSettingsDeepLink />
|
||||
<Toaster position="top-center" />
|
||||
<UserPreferencesBoundary>
|
||||
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">
|
||||
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<SettingsDialogHost />
|
||||
<WorkspaceSettingsDeepLink />
|
||||
<Toaster position="top-center" />
|
||||
</UserPreferencesBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,6 +51,7 @@ import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useProject } from "@/core/projects";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import { resolveThreadContext } from "@/core/settings/store";
|
||||
import { createThread } from "@/core/threads/api";
|
||||
import {
|
||||
useBranchThread,
|
||||
@ -546,9 +547,11 @@ export default function ChatPage() {
|
||||
isUploading ||
|
||||
(!isNewThread && isHistoryLoading)
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onContextChange={(context, options) => {
|
||||
if (options?.automatic)
|
||||
resolveThreadContext(threadId, context);
|
||||
else setSettings("context", context);
|
||||
}}
|
||||
onGoalChange={setLocalGoal}
|
||||
onPrepareThread={ensureProjectThread}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@ -332,13 +332,18 @@ export function InputBox({
|
||||
defaultModelName?: string | null;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
},
|
||||
// Explicit selections contain only the fields changed by that action,
|
||||
// never the whole thread-resolved context (which may override the account).
|
||||
context: Partial<
|
||||
Omit<
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
}
|
||||
>,
|
||||
options?: { automatic: boolean },
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onGoalChange?: (goal: GoalState | null) => void;
|
||||
@ -593,11 +598,14 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
});
|
||||
onContextChange?.(
|
||||
{
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
},
|
||||
{ automatic: true },
|
||||
);
|
||||
}, [context, models, defaultModelName, onContextChange]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
@ -850,11 +858,13 @@ export function InputBox({
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const mode = getResolvedMode(
|
||||
context.mode,
|
||||
model.supports_thinking ?? false,
|
||||
);
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
...(mode !== context.mode ? { mode } : {}),
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
@ -867,7 +877,6 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
mode: getResolvedMode(mode, supportThinking),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
@ -879,7 +888,7 @@ export function InputBox({
|
||||
: "minimal",
|
||||
});
|
||||
},
|
||||
[disabled, onContextChange, context, polishingInput, supportThinking],
|
||||
[disabled, onContextChange, polishingInput, supportThinking],
|
||||
);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
@ -888,11 +897,10 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
reasoning_effort: effort,
|
||||
});
|
||||
},
|
||||
[disabled, onContextChange, context, polishingInput],
|
||||
[disabled, onContextChange, polishingInput],
|
||||
);
|
||||
|
||||
const handleGoalCommand = useCallback(
|
||||
@ -1138,14 +1146,17 @@ export function InputBox({
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
});
|
||||
onContextChange?.(
|
||||
{
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
},
|
||||
{ automatic: true },
|
||||
);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
Promise.resolve(submit()).then(resolve).catch(reject);
|
||||
|
||||
86
frontend/src/core/settings/preferences-sync.ts
Normal file
86
frontend/src/core/settings/preferences-sync.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const fields = {
|
||||
notification_enabled: z.boolean().nullable(),
|
||||
model_name: z.string().max(200).nullable(),
|
||||
mode: z.enum(["flash", "thinking", "pro", "ultra"]).nullable(),
|
||||
reasoning_effort: z.enum(["minimal", "low", "medium", "high"]).nullable(),
|
||||
};
|
||||
const schema = z.object(fields).partial();
|
||||
export type Preferences = z.infer<typeof schema>;
|
||||
|
||||
/** Keep valid individual preferences; never copy arbitrary runtime context. */
|
||||
export function parsePreferences(value: unknown): Preferences {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
const valid: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
const parsed = field.safeParse((value as Record<string, unknown>)[key]);
|
||||
if (parsed.success) valid[key] = parsed.data;
|
||||
}
|
||||
return schema.parse(valid);
|
||||
}
|
||||
|
||||
interface SyncIO {
|
||||
read: () => Promise<Preferences>;
|
||||
patch: (value: Preferences) => Promise<void>;
|
||||
apply: (value: Preferences) => void;
|
||||
savePending: (value: Preferences) => void;
|
||||
saveConfirmed?: (value: Preferences) => void;
|
||||
}
|
||||
|
||||
/** One instance belongs to one account and one tab, including its outbox. */
|
||||
export class PreferencesSync {
|
||||
private stopped = false;
|
||||
private running?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly io: SyncIO,
|
||||
private confirmed: Preferences,
|
||||
private pending: Preferences,
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private publish() {
|
||||
this.io.apply({ ...this.confirmed, ...this.pending });
|
||||
}
|
||||
|
||||
edit(value: Preferences) {
|
||||
if (this.stopped) return;
|
||||
this.pending = { ...this.pending, ...value };
|
||||
this.io.savePending(this.pending);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve();
|
||||
this.running ??= this.run().finally(() => {
|
||||
this.running = undefined;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async run() {
|
||||
const remote = await this.io.read();
|
||||
if (this.stopped) return;
|
||||
this.confirmed = remote;
|
||||
this.io.saveConfirmed?.(this.confirmed);
|
||||
this.publish();
|
||||
while (!this.stopped && Object.keys(this.pending).length) {
|
||||
const sent = { ...this.pending };
|
||||
await this.io.patch(sent);
|
||||
if (this.stopped) return;
|
||||
this.confirmed = { ...this.confirmed, ...sent };
|
||||
this.io.saveConfirmed?.(this.confirmed);
|
||||
for (const key of Object.keys(sent) as (keyof Preferences)[]) {
|
||||
if (this.pending[key] === sent[key]) delete this.pending[key];
|
||||
}
|
||||
this.io.savePending(this.pending);
|
||||
this.publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
saveThreadModelName,
|
||||
type LocalSettings,
|
||||
} from "./local";
|
||||
import type { Preferences } from "./preferences-sync";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@ -22,6 +23,42 @@ const threadModelNames = new Map<string, string | undefined>();
|
||||
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
|
||||
let baseSettingsLoaded = false;
|
||||
let storageListenerRegistered = false;
|
||||
let preferenceEdit:
|
||||
| ((before: LocalSettings, after: LocalSettings) => void)
|
||||
| undefined;
|
||||
|
||||
/** Activate only after the workspace has identified the account. */
|
||||
export function activatePreferences(
|
||||
initial: Preferences,
|
||||
edit: (before: LocalSettings, after: LocalSettings) => void,
|
||||
) {
|
||||
ensureBaseSettingsLoaded();
|
||||
preferenceEdit = edit;
|
||||
const apply = (value: Preferences) => {
|
||||
if (preferenceEdit !== edit) return;
|
||||
baseSettings = {
|
||||
...baseSettings,
|
||||
notification: { enabled: value.notification_enabled ?? true },
|
||||
context: {
|
||||
...baseSettings.context,
|
||||
model_name: value.model_name ?? undefined,
|
||||
mode: value.mode ?? undefined,
|
||||
reasoning_effort: value.reasoning_effort ?? undefined,
|
||||
},
|
||||
};
|
||||
emitChange();
|
||||
};
|
||||
apply(initial);
|
||||
return {
|
||||
apply,
|
||||
stop: () => {
|
||||
if (preferenceEdit === edit) {
|
||||
apply({});
|
||||
preferenceEdit = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
for (const listener of listeners) {
|
||||
@ -29,6 +66,25 @@ function emitChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function persistLocalOnlySettings() {
|
||||
if (!preferenceEdit) {
|
||||
saveLocalSettings(baseSettings);
|
||||
return;
|
||||
}
|
||||
// Account preferences never leak back into the legacy shared-origin key.
|
||||
const legacy = getLocalSettings();
|
||||
saveLocalSettings({
|
||||
...baseSettings,
|
||||
notification: legacy.notification,
|
||||
context: {
|
||||
...baseSettings.context,
|
||||
model_name: legacy.context.model_name,
|
||||
mode: legacy.context.mode,
|
||||
reasoning_effort: legacy.context.reasoning_effort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ensureBaseSettingsLoaded() {
|
||||
if (baseSettingsLoaded || typeof window === "undefined") {
|
||||
return;
|
||||
@ -73,6 +129,23 @@ function mergeSettingsSection<K extends keyof LocalSettings>(
|
||||
};
|
||||
}
|
||||
|
||||
function readSharedSettings(): LocalSettings {
|
||||
const local = getLocalSettings();
|
||||
if (!preferenceEdit) return local;
|
||||
// Device-local fields still follow other tabs, including key removal and
|
||||
// storage.clear(). The legacy key must never replace account preferences.
|
||||
return {
|
||||
...local,
|
||||
notification: baseSettings.notification,
|
||||
context: {
|
||||
...local.context,
|
||||
model_name: baseSettings.context.model_name,
|
||||
mode: baseSettings.context.mode,
|
||||
reasoning_effort: baseSettings.context.reasoning_effort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.storageArea && event.storageArea !== localStorage) {
|
||||
return;
|
||||
@ -81,14 +154,14 @@ function handleStorage(event: StorageEvent) {
|
||||
ensureBaseSettingsLoaded();
|
||||
|
||||
if (event.key === null) {
|
||||
baseSettings = getLocalSettings();
|
||||
baseSettings = readSharedSettings();
|
||||
threadModelNames.clear();
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === LOCAL_SETTINGS_KEY) {
|
||||
baseSettings = getLocalSettings();
|
||||
baseSettings = readSharedSettings();
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
@ -131,8 +204,10 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
|
||||
ensureBaseSettingsLoaded();
|
||||
ensureStorageListenerRegistered();
|
||||
|
||||
const previous = baseSettings;
|
||||
baseSettings = mergeSettingsSection(baseSettings, key, value);
|
||||
saveLocalSettings(baseSettings);
|
||||
persistLocalOnlySettings();
|
||||
preferenceEdit?.(previous, baseSettings);
|
||||
emitChange();
|
||||
};
|
||||
|
||||
@ -144,9 +219,11 @@ export function updateThreadSettings<K extends keyof LocalSettings>(
|
||||
ensureBaseSettingsLoaded();
|
||||
ensureStorageListenerRegistered();
|
||||
|
||||
const previous = baseSettings;
|
||||
const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
|
||||
baseSettings = nextBaseSettings;
|
||||
saveLocalSettings(baseSettings);
|
||||
persistLocalOnlySettings();
|
||||
preferenceEdit?.(previous, baseSettings);
|
||||
|
||||
if (
|
||||
key === "context" &&
|
||||
@ -160,3 +237,22 @@ export function updateThreadSettings<K extends keyof LocalSettings>(
|
||||
|
||||
emitChange();
|
||||
}
|
||||
|
||||
/** Model availability/default resolution is not an explicit account edit. */
|
||||
export function resolveThreadContext(
|
||||
threadId: string,
|
||||
context: Partial<LocalSettings["context"]>,
|
||||
) {
|
||||
if (!preferenceEdit) {
|
||||
updateThreadSettings(threadId, "context", context);
|
||||
return;
|
||||
}
|
||||
baseSettings = mergeSettingsSection(baseSettings, "context", context);
|
||||
// Preserve explicit thread overrides, but do not create one from a temporary
|
||||
// fallback: it would mask the account model when a slow GET finally arrives.
|
||||
if (getThreadModelSnapshot(threadId) && "model_name" in context) {
|
||||
threadModelNames.set(threadId, context.model_name);
|
||||
saveThreadModelName(threadId, context.model_name);
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
|
||||
29
frontend/src/core/settings/user-preferences-boundary.tsx
Normal file
29
frontend/src/core/settings/user-preferences-boundary.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
import { startUserPreferences } from "./user-preferences";
|
||||
|
||||
const useClientLayoutEffect =
|
||||
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export function UserPreferencesBoundary({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const owner =
|
||||
!isStaticWebsiteOnly() && user?.id !== "default" ? user?.id : undefined;
|
||||
// SSR and initial hydration render the same workspace with the settings
|
||||
// store's safe server snapshot. Initialize the browser cache before paint.
|
||||
const [activeOwner, setActiveOwner] = useState(owner);
|
||||
useClientLayoutEffect(() => {
|
||||
const stop = owner ? startUserPreferences(owner) : undefined;
|
||||
setActiveOwner(owner);
|
||||
return stop;
|
||||
}, [owner]);
|
||||
// Only gate a subsequent account switch, keeping the old owner's settings
|
||||
// out of the new owner's consumers until the new cache is activated.
|
||||
if (owner !== activeOwner) return null;
|
||||
return children;
|
||||
}
|
||||
143
frontend/src/core/settings/user-preferences.ts
Normal file
143
frontend/src/core/settings/user-preferences.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { fetch as apiFetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { safeLocalStorage, type LocalSettings } from "./local";
|
||||
import {
|
||||
parsePreferences,
|
||||
PreferencesSync,
|
||||
type Preferences,
|
||||
} from "./preferences-sync";
|
||||
import { activatePreferences } from "./store";
|
||||
|
||||
const PREFIX = "deerflow.preferences.";
|
||||
|
||||
function readJSON(read: () => string | null): Preferences {
|
||||
try {
|
||||
return parsePreferences(JSON.parse(read() ?? "{}"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function preferencesFromSettings(settings: LocalSettings): Preferences {
|
||||
return {
|
||||
notification_enabled: settings.notification.enabled,
|
||||
model_name: settings.context.model_name ?? null,
|
||||
mode: settings.context.mode ?? null,
|
||||
reasoning_effort: settings.context.reasoning_effort ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Network lifecycle is attached to the authenticated workspace, never render. */
|
||||
export function startUserPreferences(userId: string) {
|
||||
const key = `${PREFIX}${userId}`;
|
||||
// sessionStorage is tab-local; a sibling tab cannot overwrite our outbox.
|
||||
const outbox = `${key}.pending`;
|
||||
const abort = new AbortController();
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryMs = 1000;
|
||||
let flushing = false;
|
||||
const request = async (method: "GET" | "PATCH", value?: Preferences) => {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/v1/auth/preferences`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Expected-User-Id": userId,
|
||||
},
|
||||
body: value ? JSON.stringify(value) : undefined,
|
||||
signal: AbortSignal.any([abort.signal, AbortSignal.timeout(15000)]),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (response.status === 409 || response.status === 403) {
|
||||
// A cookie can switch accounts in another tab before useAuth updates.
|
||||
// Keep this account's outbox, but never retry it under the new identity.
|
||||
stop();
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(`Preferences request failed: ${response.status}`);
|
||||
return response;
|
||||
};
|
||||
let apply: (value: Preferences) => void = () => undefined;
|
||||
const cached = readJSON(() => safeLocalStorage.getItem(key));
|
||||
const pending = readJSON(() => window.sessionStorage.getItem(outbox));
|
||||
const sync = new PreferencesSync(
|
||||
{
|
||||
read: async () => parsePreferences(await (await request("GET")).json()),
|
||||
patch: async (value) => {
|
||||
await request("PATCH", value);
|
||||
},
|
||||
saveConfirmed: (value) => {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (safeLocalStorage.getItem(key) !== serialized)
|
||||
safeLocalStorage.setItem(key, serialized);
|
||||
},
|
||||
apply: (value) => apply(value),
|
||||
savePending: (value) => {
|
||||
try {
|
||||
window.sessionStorage.setItem(outbox, JSON.stringify(value));
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
cached,
|
||||
pending,
|
||||
);
|
||||
|
||||
const wake = () => {
|
||||
if (stopped || flushing) return;
|
||||
clearTimeout(timer);
|
||||
flushing = true;
|
||||
void sync
|
||||
.flush()
|
||||
.then(() => {
|
||||
retryMs = 1000;
|
||||
})
|
||||
.catch(() => {
|
||||
if (stopped) return;
|
||||
timer = setTimeout(wake, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30000);
|
||||
})
|
||||
.finally(() => {
|
||||
flushing = false;
|
||||
});
|
||||
};
|
||||
const detach = activatePreferences(
|
||||
{ ...cached, ...pending },
|
||||
(before, after) => {
|
||||
const oldValue = preferencesFromSettings(before);
|
||||
const newValue = preferencesFromSettings(after);
|
||||
const changes = Object.fromEntries(
|
||||
Object.entries(newValue).filter(
|
||||
([field, value]) => oldValue[field as keyof Preferences] !== value,
|
||||
),
|
||||
);
|
||||
if (Object.keys(changes).length) {
|
||||
sync.edit(parsePreferences(changes));
|
||||
wake();
|
||||
}
|
||||
},
|
||||
);
|
||||
apply = detach.apply;
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === key || event.key === null) wake();
|
||||
};
|
||||
function stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
abort.abort();
|
||||
sync.stop();
|
||||
detach.stop();
|
||||
window.removeEventListener("focus", wake);
|
||||
window.removeEventListener("online", wake);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener("focus", wake);
|
||||
window.addEventListener("online", wake);
|
||||
window.addEventListener("storage", onStorage);
|
||||
wake();
|
||||
return stop;
|
||||
}
|
||||
294
frontend/tests/e2e/settings-model-sync.spec.ts
Normal file
294
frontend/tests/e2e/settings-model-sync.spec.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
for (const agent of [false, true]) {
|
||||
for (const field of ["effort", "mode"] as const) {
|
||||
test(`${agent ? "agent" : "normal"} thread ${field} selection preserves the account model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [{ name: "researcher", description: "Research agent" }],
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
agent_name: agent ? "researcher" : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ threadId }) => {
|
||||
localStorage.setItem(
|
||||
`deerflow.thread-model.${threadId}`,
|
||||
"thread-model",
|
||||
);
|
||||
},
|
||||
{ threadId: MOCK_THREAD_ID },
|
||||
);
|
||||
await page.route("**/api/models", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
models: [
|
||||
{
|
||||
name: "account-model",
|
||||
display_name: "Account Model",
|
||||
supports_thinking: true,
|
||||
supports_reasoning_effort: true,
|
||||
},
|
||||
{
|
||||
name: "thread-model",
|
||||
display_name: "Thread Model",
|
||||
supports_thinking: true,
|
||||
supports_reasoning_effort: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: "00000000-0000-0000-0000-000000000028",
|
||||
email: "thread@example.com",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const patches: unknown[] = [];
|
||||
let reads = 0;
|
||||
let server = {
|
||||
model_name: "account-model",
|
||||
mode: "pro",
|
||||
reasoning_effort: "medium",
|
||||
notification_enabled: true,
|
||||
};
|
||||
await page.route("**/api/v1/auth/preferences", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const patch = route.request().postDataJSON() as Partial<
|
||||
typeof server
|
||||
>;
|
||||
patches.push(patch);
|
||||
server = { ...server, ...patch };
|
||||
await route.fulfill({ status: 204 });
|
||||
} else {
|
||||
reads++;
|
||||
await route.fulfill({ json: server });
|
||||
}
|
||||
});
|
||||
await page.goto(
|
||||
`/workspace/${agent ? "agents/researcher/chats" : "chats"}/${MOCK_THREAD_ID}`,
|
||||
);
|
||||
await page
|
||||
.locator("[data-sidebar='sidebar']")
|
||||
.getByRole("button", { name: /Settings and more/ })
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() =>
|
||||
document.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await expect.poll(() => reads).toBe(1);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Thread Model", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: "Reasoning Effort: Medium",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
if (field === "effort") {
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "Reasoning Effort: Medium",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page.getByRole("menuitem").filter({ hasText: /^High/ }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Pro", exact: true }).click();
|
||||
await page
|
||||
.getByRole("menuitem")
|
||||
.filter({ hasText: /^Ultra/ })
|
||||
.click();
|
||||
}
|
||||
await expect
|
||||
.poll(() => patches)
|
||||
.toEqual([
|
||||
field === "effort"
|
||||
? { reasoning_effort: "high" }
|
||||
: { mode: "ultra", reasoning_effort: "high" },
|
||||
]);
|
||||
expect(server.model_name).toBe("account-model");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Thread Model", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("custom agent automatic default does not become an account preference", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [{ name: "researcher", description: "Research agent" }],
|
||||
});
|
||||
const patches: unknown[] = [];
|
||||
let reads = 0;
|
||||
let allowModels!: () => void;
|
||||
const modelsReady = new Promise<void>((resolve) => {
|
||||
allowModels = resolve;
|
||||
});
|
||||
await page.route("**/api/agents/researcher", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
name: "researcher",
|
||||
description: "Research agent",
|
||||
model: "agent-model",
|
||||
system_prompt: "Research",
|
||||
tools: [],
|
||||
skills: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/models", async (route) => {
|
||||
// Do not let the auth-disabled fixture select a model before the account
|
||||
// is activated. The real agent page must then choose its configured model.
|
||||
await modelsReady;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
models: [
|
||||
{
|
||||
name: "first-model",
|
||||
display_name: "First Model",
|
||||
supports_thinking: true,
|
||||
},
|
||||
{
|
||||
name: "agent-model",
|
||||
display_name: "Agent Model",
|
||||
supports_thinking: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: "00000000-0000-0000-0000-000000000027",
|
||||
email: "agent@example.com",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/preferences", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
patches.push(route.request().postDataJSON());
|
||||
await route.fulfill({ status: 204 });
|
||||
} else {
|
||||
reads++;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
model_name: null,
|
||||
mode: null,
|
||||
reasoning_effort: null,
|
||||
notification_enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.goto("/workspace/agents/researcher/chats/new");
|
||||
await page
|
||||
.locator("[data-sidebar='sidebar']")
|
||||
.getByRole("button", { name: /Settings and more/ })
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() =>
|
||||
document.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await expect.poll(() => reads).toBe(1);
|
||||
allowModels();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Agent Model", exact: true }),
|
||||
).toBeVisible();
|
||||
// Flush the local outbox cycle through a completed server read, so this also
|
||||
// catches a PATCH that would otherwise arrive just after the UI assertion.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect.poll(() => reads).toBe(2);
|
||||
expect(patches).toEqual([]);
|
||||
// Explicit model selections on this same page must still be synchronized.
|
||||
await page.getByRole("button", { name: "Agent Model", exact: true }).click();
|
||||
await page.getByRole("option").filter({ hasText: "First Model" }).click();
|
||||
await expect.poll(() => patches).toEqual([{ model_name: "first-model" }]);
|
||||
});
|
||||
|
||||
test("automatic model fallback cannot overwrite a slowly loaded account preference", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
const patches: unknown[] = [];
|
||||
let requested = false;
|
||||
let release!: () => void;
|
||||
const delayed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/api/models", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
models: [
|
||||
{ name: "model-a", display_name: "Model A", supports_thinking: true },
|
||||
{ name: "model-b", display_name: "Model B", supports_thinking: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: "00000000-0000-0000-0000-000000000026",
|
||||
email: "model@example.com",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/preferences", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
patches.push(route.request().postDataJSON());
|
||||
await route.fulfill({ status: 204 });
|
||||
return;
|
||||
}
|
||||
requested = true;
|
||||
await delayed;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
model_name: "model-b",
|
||||
mode: "pro",
|
||||
reasoning_effort: "high",
|
||||
notification_enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/workspace/chats/new");
|
||||
// Wait for actual hydration before refreshing the auth-disabled fixture's
|
||||
// AuthProvider into a session account with a deliberately slow preference GET.
|
||||
await page
|
||||
.locator("[data-sidebar='sidebar']")
|
||||
.getByRole("button", { name: /Settings and more/ })
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() =>
|
||||
document.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await expect.poll(() => requested).toBe(true);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Model A", exact: true }),
|
||||
).toBeVisible();
|
||||
release();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Model B", exact: true }),
|
||||
).toBeVisible();
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
86
frontend/tests/e2e/settings-sync.spec.ts
Normal file
86
frontend/tests/e2e/settings-sync.spec.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test("account notification setting survives clearing browser storage", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
const owner = "00000000-0000-0000-0000-000000000025";
|
||||
let enabled: boolean | null = null;
|
||||
const patches: unknown[] = [];
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: owner,
|
||||
email: "preferences@example.com",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/preferences", async (route) => {
|
||||
expect(route.request().headers()["x-expected-user-id"]).toBe(owner);
|
||||
if (route.request().method() === "PATCH") {
|
||||
const patch = route.request().postDataJSON() as {
|
||||
notification_enabled: boolean;
|
||||
};
|
||||
patches.push(patch);
|
||||
enabled = patch.notification_enabled;
|
||||
await route.fulfill({ status: 204 });
|
||||
} else {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
notification_enabled: enabled,
|
||||
model_name: null,
|
||||
mode: null,
|
||||
reasoning_effort: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(Notification, "permission", {
|
||||
configurable: true,
|
||||
get: () => "granted",
|
||||
});
|
||||
});
|
||||
|
||||
async function openSettings(page: Page) {
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page
|
||||
.locator("[data-sidebar='sidebar']")
|
||||
.getByRole("button", { name: /Settings and more/ })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Settings", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await dialog
|
||||
.getByRole("button", { name: "Notification", exact: true })
|
||||
.click();
|
||||
// Opening the dialog also waits for hydration. The mock web server starts
|
||||
// auth-disabled; refresh the real AuthProvider to this session fixture.
|
||||
const hydrated = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/v1/auth/preferences"),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
document.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await hydrated;
|
||||
// Account resolution remounts the dialog at its default section.
|
||||
await dialog
|
||||
.getByRole("button", { name: "Notification", exact: true })
|
||||
.click();
|
||||
return dialog.getByRole("switch", { name: "Notification", exact: true });
|
||||
}
|
||||
|
||||
const toggle = await openSettings(page);
|
||||
await expect(toggle).toBeChecked();
|
||||
await toggle.click();
|
||||
await expect.poll(() => enabled).toBe(false);
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await expect(await openSettings(page)).not.toBeChecked();
|
||||
expect(patches).toEqual([{ notification_enabled: false }]);
|
||||
});
|
||||
97
frontend/tests/unit/core/settings/preferences-sync.test.ts
Normal file
97
frontend/tests/unit/core/settings/preferences-sync.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
PreferencesSync,
|
||||
type Preferences,
|
||||
} from "@/core/settings/preferences-sync";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function setup(
|
||||
read: () => Promise<Preferences>,
|
||||
patch: (value: Preferences) => Promise<void>,
|
||||
) {
|
||||
let visible: Preferences = {};
|
||||
let pending: Preferences = {};
|
||||
const sync = new PreferencesSync(
|
||||
{
|
||||
read,
|
||||
patch,
|
||||
apply: (value) => {
|
||||
visible = value;
|
||||
},
|
||||
savePending: (value) => {
|
||||
pending = value;
|
||||
},
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
return { sync, visible: () => visible, pending: () => pending };
|
||||
}
|
||||
|
||||
describe("account preference synchronization", () => {
|
||||
it("retries a failed bootstrap and preserves edits made while loading", async () => {
|
||||
let attempts = 0;
|
||||
const writes: Preferences[] = [];
|
||||
const state = setup(
|
||||
async () => {
|
||||
if (++attempts === 1) throw new Error("offline");
|
||||
return { mode: "pro", notification_enabled: true };
|
||||
},
|
||||
async (value) => {
|
||||
writes.push(value);
|
||||
},
|
||||
);
|
||||
await expect(state.sync.flush()).rejects.toThrow("offline");
|
||||
state.sync.edit({ notification_enabled: false });
|
||||
await state.sync.flush();
|
||||
expect(writes).toEqual([{ notification_enabled: false }]);
|
||||
expect(state.visible()).toEqual({
|
||||
mode: "pro",
|
||||
notification_enabled: false,
|
||||
});
|
||||
expect(state.pending()).toEqual({});
|
||||
});
|
||||
|
||||
it("does not let an older acknowledgement erase a newer edit", async () => {
|
||||
const ack = deferred<void>();
|
||||
const writes: Preferences[] = [];
|
||||
const state = setup(
|
||||
async () => ({}),
|
||||
async (value) => {
|
||||
writes.push(value);
|
||||
if (writes.length === 1) await ack.promise;
|
||||
},
|
||||
);
|
||||
await state.sync.flush();
|
||||
state.sync.edit({ mode: "pro" });
|
||||
const flushing = state.sync.flush();
|
||||
await Promise.resolve();
|
||||
state.sync.edit({ mode: "flash" });
|
||||
ack.resolve();
|
||||
await flushing;
|
||||
expect(writes).toEqual([{ mode: "pro" }, { mode: "flash" }]);
|
||||
expect(state.visible().mode).toBe("flash");
|
||||
expect(state.pending()).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores a late read after stopping an account", async () => {
|
||||
const read = deferred<Preferences>();
|
||||
const state = setup(
|
||||
() => read.promise,
|
||||
async () => undefined,
|
||||
);
|
||||
const loading = state.sync.flush();
|
||||
state.sync.stop();
|
||||
read.resolve({ model_name: "alice-only" });
|
||||
await loading;
|
||||
expect(state.visible()).toEqual({});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import { expect, rs, test } from "@rstest/core";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings/hooks";
|
||||
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
|
||||
|
||||
const mocks = rs.hoisted(() => ({ start: rs.fn() }));
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||
useAuth: () => ({ user: { id: "alice" } }),
|
||||
}));
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
|
||||
rs.mock("@/core/settings/user-preferences", () => ({
|
||||
startUserPreferences: mocks.start,
|
||||
}));
|
||||
|
||||
function Workspace() {
|
||||
const [settings] = useLocalSettings();
|
||||
return (
|
||||
<main>
|
||||
<nav>Workspace sidebar</nav>
|
||||
<output>{settings.context.model_name ?? "default"}</output>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
test("signed-in workspace content remains in server HTML without starting browser sync", () => {
|
||||
const html = renderToString(
|
||||
<UserPreferencesBoundary>
|
||||
<Workspace />
|
||||
</UserPreferencesBoundary>,
|
||||
);
|
||||
expect(html).toContain("<nav>Workspace sidebar</nav>");
|
||||
expect(html).toContain("<output>default</output>");
|
||||
expect(mocks.start).not.toHaveBeenCalled();
|
||||
});
|
||||
313
frontend/tests/unit/core/settings/user-preferences.dom.test.tsx
Normal file
313
frontend/tests/unit/core/settings/user-preferences.dom.test.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { hydrateRoot, type Root } from "react-dom/client";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCAL_SETTINGS,
|
||||
getLocalSettings,
|
||||
LOCAL_SETTINGS_KEY,
|
||||
} from "@/core/settings/local";
|
||||
import {
|
||||
getBaseSettingsSnapshot,
|
||||
getThreadModelSnapshot,
|
||||
resolveThreadContext,
|
||||
subscribe,
|
||||
updateLocalSettings,
|
||||
} from "@/core/settings/store";
|
||||
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
|
||||
|
||||
const mocks = rs.hoisted(() => ({
|
||||
user: { id: "alice" },
|
||||
fetch: rs.fn(),
|
||||
}));
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||
useAuth: () => ({ user: mocks.user }),
|
||||
}));
|
||||
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
|
||||
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
|
||||
|
||||
function Consumer() {
|
||||
const settings = useSyncExternalStore(
|
||||
subscribe,
|
||||
getBaseSettingsSnapshot,
|
||||
() => DEFAULT_LOCAL_SETTINGS,
|
||||
);
|
||||
return (
|
||||
<output>{`${settings.notification.enabled}:${settings.context.model_name ?? "default"}:${settings.context.mode ?? "default"}`}</output>
|
||||
);
|
||||
}
|
||||
function Workspace() {
|
||||
return (
|
||||
<UserPreferencesBoundary>
|
||||
<Consumer />
|
||||
</UserPreferencesBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
mocks.user = { id: "alice" };
|
||||
mocks.fetch.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("authenticated workspace preferences", () => {
|
||||
it.each(["write", "remove", "clear"] as const)(
|
||||
"merges local-only settings on a cross-tab %s without changing account preferences",
|
||||
async (operation) => {
|
||||
const account = {
|
||||
notification_enabled: false,
|
||||
model_name: "account-model",
|
||||
mode: "pro",
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
mocks.fetch.mockImplementation(async () => Response.json(account));
|
||||
render(<Workspace />);
|
||||
await screen.findByText("false:account-model:pro");
|
||||
act(() => {
|
||||
updateLocalSettings("projectsDisplayMode", "grouped");
|
||||
updateLocalSettings("tokenUsage", {
|
||||
headerTotal: false,
|
||||
inlineMode: "off",
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
if (operation === "write") {
|
||||
localStorage.setItem(
|
||||
LOCAL_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
projectsDisplayMode: "flat",
|
||||
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
|
||||
notification: { enabled: true },
|
||||
context: {
|
||||
model_name: "legacy-model",
|
||||
mode: "flash",
|
||||
reasoning_effort: "low",
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (operation === "remove") {
|
||||
localStorage.removeItem(LOCAL_SETTINGS_KEY);
|
||||
} else {
|
||||
localStorage.clear();
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key: operation === "clear" ? null : LOCAL_SETTINGS_KEY,
|
||||
storageArea: localStorage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(getBaseSettingsSnapshot()).toMatchObject({
|
||||
projectsDisplayMode: "flat",
|
||||
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
|
||||
notification: { enabled: false },
|
||||
context: {
|
||||
model_name: "account-model",
|
||||
mode: "pro",
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
});
|
||||
// A later unrelated write must not restore the stale display setting.
|
||||
act(() => updateLocalSettings("tokenUsage", { headerTotal: false }));
|
||||
expect(getLocalSettings().projectsDisplayMode).toBe("flat");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
mocks.fetch.mock.calls.every(([, init]) => init.method === "GET"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sessionStorage.getItem("deerflow.preferences.alice.pending"),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
it("hydrates server HTML from the correct account cache without mismatches", async () => {
|
||||
localStorage.setItem(
|
||||
LOCAL_SETTINGS_KEY,
|
||||
JSON.stringify({ context: { model_name: "legacy-other-account" } }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"deerflow.preferences.alice",
|
||||
JSON.stringify({
|
||||
notification_enabled: false,
|
||||
model_name: "alice-cached",
|
||||
}),
|
||||
);
|
||||
mocks.fetch.mockImplementation(
|
||||
() => new Promise<Response>(() => undefined),
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = renderToString(<Workspace />);
|
||||
expect(container.textContent).toBe("true:default:default");
|
||||
document.body.appendChild(container);
|
||||
const errors: unknown[] = [];
|
||||
let root!: Root;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = hydrateRoot(container, <Workspace />, {
|
||||
onRecoverableError: (error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(container.textContent).toBe("false:alice-cached:default");
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
it("does not upload automatic model fallback or mask a later server model", async () => {
|
||||
let finish!: (response: Response) => void;
|
||||
mocks.fetch.mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(<Workspace />);
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
|
||||
act(() =>
|
||||
resolveThreadContext("slow-hydration", {
|
||||
model_name: "fallback",
|
||||
mode: "pro",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
sessionStorage.getItem("deerflow.preferences.alice.pending"),
|
||||
).toBeNull();
|
||||
expect(getThreadModelSnapshot("slow-hydration")).toBeUndefined();
|
||||
finish(Response.json({ model_name: "server-choice", mode: "ultra" }));
|
||||
await screen.findByText("true:server-choice:ultra");
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("restores server preferences after clearing browser storage and sends only edited fields", async () => {
|
||||
let server = {
|
||||
notification_enabled: false,
|
||||
model_name: "saved-model",
|
||||
mode: "pro",
|
||||
};
|
||||
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
expect(new Headers(init.headers).get("X-Expected-User-Id")).toBe("alice");
|
||||
if (init.method === "PATCH") {
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
notification_enabled: true,
|
||||
});
|
||||
server = { ...server, ...JSON.parse(init.body as string) };
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return Response.json(server);
|
||||
});
|
||||
const view = render(<Workspace />);
|
||||
await screen.findByText("false:saved-model:pro");
|
||||
act(() => updateLocalSettings("notification", { enabled: true }));
|
||||
await waitFor(() => expect(server.notification_enabled).toBe(true));
|
||||
await waitFor(() =>
|
||||
expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe(
|
||||
"{}",
|
||||
),
|
||||
);
|
||||
view.unmount();
|
||||
localStorage.clear();
|
||||
render(<Workspace />);
|
||||
await screen.findByText("true:saved-model:pro");
|
||||
});
|
||||
|
||||
it("does not import legacy preferences or expose the previous account on a switch", async () => {
|
||||
localStorage.setItem(
|
||||
LOCAL_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
notification: { enabled: false },
|
||||
context: { model_name: "legacy-secret" },
|
||||
}),
|
||||
);
|
||||
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
expect(init.method).toBe("GET");
|
||||
const owner = new Headers(init.headers).get("X-Expected-User-Id");
|
||||
return Response.json(
|
||||
owner === "alice" ? { model_name: "alice-model" } : {},
|
||||
);
|
||||
});
|
||||
const view = render(<Workspace />);
|
||||
await screen.findByText("true:alice-model:default");
|
||||
mocks.user = { id: "bob" };
|
||||
view.rerender(<Workspace />);
|
||||
expect(screen.queryByText("true:alice-model:default")).toBeNull();
|
||||
await screen.findByText("true:default:default");
|
||||
expect(localStorage.getItem(LOCAL_SETTINGS_KEY)).toContain("legacy-secret");
|
||||
});
|
||||
|
||||
it("retries failed initialization on reconnect and retains pending changes across reload", async () => {
|
||||
mocks.fetch.mockRejectedValue(new Error("offline"));
|
||||
const view = render(<Workspace />);
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
|
||||
act(() => updateLocalSettings("context", { mode: "ultra" }));
|
||||
expect(
|
||||
JSON.parse(sessionStorage.getItem("deerflow.preferences.alice.pending")!),
|
||||
).toEqual({ mode: "ultra" });
|
||||
view.unmount();
|
||||
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PATCH") {
|
||||
expect(JSON.parse(init.body as string)).toEqual({ mode: "ultra" });
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return Response.json({ model_name: "remote" });
|
||||
});
|
||||
render(<Workspace />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
await screen.findByText("true:remote:ultra");
|
||||
await waitFor(() =>
|
||||
expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe(
|
||||
"{}",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not write an unchanged cache back in response to a storage event", async () => {
|
||||
mocks.fetch.mockResolvedValue(Response.json({ mode: "pro" }));
|
||||
// Each HTTP request needs its own readable Response body.
|
||||
mocks.fetch.mockImplementation(async () => Response.json({ mode: "pro" }));
|
||||
render(<Workspace />);
|
||||
await screen.findByText("true:default:pro");
|
||||
const writes = rs.spyOn(Storage.prototype, "setItem");
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "deerflow.preferences.alice" }),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(2));
|
||||
expect(writes).not.toHaveBeenCalled();
|
||||
writes.mockRestore();
|
||||
});
|
||||
|
||||
it("stops requests when another tab changes the authenticated account", async () => {
|
||||
sessionStorage.setItem(
|
||||
"deerflow.preferences.alice.pending",
|
||||
JSON.stringify({ mode: "ultra" }),
|
||||
);
|
||||
mocks.fetch.mockImplementation(
|
||||
async () => new Response(null, { status: 409 }),
|
||||
);
|
||||
render(<Workspace />);
|
||||
await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
sessionStorage.getItem("deerflow.preferences.alice.pending"),
|
||||
).toContain("ultra");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user