mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
feat(frontend): pin recent chats (#4442)
* feat(frontend): pin recent chats * fix(threads): address pin-chat review feedback - Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new update_metadata(touch=False) path so unpinning no longer jumps a chat to the top of the updated_at-sorted recent list. - Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching the Gateway's actual response (no values/context). - Namespace the pinned metadata key as deerflow_pinned for consistency with deerflow_sidecar / deerflow_branch. - Cover touch/touch=False behavior in repo + router tests; document the e2e mock's updated_at preservation now mirrors production. * style(frontend): format thread utils test * fix(threads): make pinned ordering server-side * test(frontend): keep infinite-scroll fixture order stable * test(frontend): stabilize lark reconnect e2e * docs: clarify thread pin metadata contract
This commit is contained in:
parent
04b7e693f0
commit
68c0ffdac8
@ -45,6 +45,7 @@ from app.gateway.utils import sanitize_log_param
|
|||||||
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
|
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.config.summarization_config import ContextSize
|
from deerflow.config.summarization_config import ContextSize
|
||||||
|
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY
|
||||||
from deerflow.runtime import serialize_channel_values_for_api
|
from deerflow.runtime import serialize_channel_values_for_api
|
||||||
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
|
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
|
||||||
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
|
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
|
||||||
@ -116,6 +117,11 @@ def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pin_metadata_patch(metadata: dict[str, Any]) -> bool:
|
||||||
|
"""Return True for the narrow pin/unpin PATCH shape."""
|
||||||
|
return set(metadata) == {THREAD_PINNED_METADATA_KEY} and isinstance(metadata.get(THREAD_PINNED_METADATA_KEY), bool)
|
||||||
|
|
||||||
|
|
||||||
def _message_id(message: Any) -> str | None:
|
def _message_id(message: Any) -> str | None:
|
||||||
if isinstance(message, dict):
|
if isinstance(message, dict):
|
||||||
raw = message.get("id")
|
raw = message.get("id")
|
||||||
@ -973,13 +979,17 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques
|
|||||||
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||||
|
|
||||||
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
|
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
|
||||||
|
# Pin/unpin is not conversation activity, so it must not bump ``updated_at``.
|
||||||
|
# Other metadata PATCH callers keep the public endpoint's existing recency
|
||||||
|
# contract unless they get their own explicit no-touch API surface.
|
||||||
|
touch = not _is_pin_metadata_patch(body.metadata)
|
||||||
try:
|
try:
|
||||||
await thread_store.update_metadata(thread_id, body.metadata)
|
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
|
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
|
||||||
raise HTTPException(status_code=500, detail="Failed to update thread")
|
raise HTTPException(status_code=500, detail="Failed to update thread")
|
||||||
|
|
||||||
# Re-read to get the merged metadata + refreshed updated_at
|
# Re-read to get the merged metadata and the store's timestamp decision.
|
||||||
record = await thread_store.get(thread_id) or record
|
record = await thread_store.get(thread_id) or record
|
||||||
return ThreadResponse(
|
return ThreadResponse(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||||
@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"InvalidMetadataFilterError",
|
"InvalidMetadataFilterError",
|
||||||
"MemoryThreadMetaStore",
|
"MemoryThreadMetaStore",
|
||||||
|
"THREAD_PINNED_METADATA_KEY",
|
||||||
"ThreadMetaRepository",
|
"ThreadMetaRepository",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"ThreadMetaStore",
|
"ThreadMetaStore",
|
||||||
|
|||||||
@ -19,6 +19,11 @@ from typing import Any
|
|||||||
|
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||||
|
|
||||||
|
# Cross-component metadata key. Keep in sync with
|
||||||
|
# ``frontend/src/core/threads/utils.ts`` and
|
||||||
|
# ``frontend/tests/e2e/utils/mock-api.ts``.
|
||||||
|
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
|
||||||
|
|
||||||
|
|
||||||
class InvalidMetadataFilterError(ValueError):
|
class InvalidMetadataFilterError(ValueError):
|
||||||
"""Raised when all client-supplied metadata filter keys are rejected."""
|
"""Raised when all client-supplied metadata filter keys are rejected."""
|
||||||
@ -51,6 +56,12 @@ class ThreadMetaStore(abc.ABC):
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Search threads.
|
||||||
|
|
||||||
|
Results are ordered with pinned threads first
|
||||||
|
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
|
||||||
|
``thread_id`` descending within each group.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@ -62,12 +73,17 @@ class ThreadMetaStore(abc.ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||||
"""Merge ``metadata`` into the thread's metadata field.
|
"""Merge ``metadata`` into the thread's metadata field.
|
||||||
|
|
||||||
Existing keys are overwritten by the new values; keys absent from
|
Existing keys are overwritten by the new values; keys absent from
|
||||||
``metadata`` are preserved. No-op if the thread does not exist
|
``metadata`` are preserved. No-op if the thread does not exist
|
||||||
or the owner check fails.
|
or the owner check fails.
|
||||||
|
|
||||||
|
When ``touch`` is ``True`` (default) the row's ``updated_at`` is
|
||||||
|
refreshed so the change bumps recency ordering. Pass ``touch=False``
|
||||||
|
for metadata that is not conversation activity (e.g. pin/unpin) so the
|
||||||
|
thread keeps its place in ``updated_at``-sorted lists.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,12 @@ from typing import Any
|
|||||||
|
|
||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, ThreadMetaStore
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
from deerflow.utils.time import coerce_iso, now_iso
|
||||||
|
|
||||||
THREADS_NS: tuple[str, ...] = ("threads",)
|
THREADS_NS: tuple[str, ...] = ("threads",)
|
||||||
|
SEARCH_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class MemoryThreadMetaStore(ThreadMetaStore):
|
class MemoryThreadMetaStore(ThreadMetaStore):
|
||||||
@ -75,6 +76,12 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Search threads by materializing matches, then sorting in Python.
|
||||||
|
|
||||||
|
The memory backend loads all matching rows in chunks before slicing so
|
||||||
|
it can mirror SQL's pinned-first ordering. Use the SQL store for
|
||||||
|
scalable paginated I/O.
|
||||||
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
||||||
filter_dict: dict[str, Any] = {}
|
filter_dict: dict[str, Any] = {}
|
||||||
if metadata:
|
if metadata:
|
||||||
@ -84,13 +91,25 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
if resolved_user_id is not None:
|
if resolved_user_id is not None:
|
||||||
filter_dict["user_id"] = resolved_user_id
|
filter_dict["user_id"] = resolved_user_id
|
||||||
|
|
||||||
items = await self._store.asearch(
|
items = []
|
||||||
THREADS_NS,
|
search_offset = 0
|
||||||
filter=filter_dict or None,
|
while True:
|
||||||
limit=limit,
|
page = await self._store.asearch(
|
||||||
offset=offset,
|
THREADS_NS,
|
||||||
)
|
filter=filter_dict or None,
|
||||||
return [self._item_to_dict(item) for item in items]
|
limit=SEARCH_PAGE_SIZE,
|
||||||
|
offset=search_offset,
|
||||||
|
)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
items.extend(page)
|
||||||
|
if len(page) < SEARCH_PAGE_SIZE:
|
||||||
|
break
|
||||||
|
search_offset += len(page)
|
||||||
|
|
||||||
|
records = [self._item_to_dict(item) for item in items]
|
||||||
|
records.sort(key=self._sort_key, reverse=True)
|
||||||
|
return records[offset : offset + limit]
|
||||||
|
|
||||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||||
item = await self._store.aget(THREADS_NS, thread_id)
|
item = await self._store.aget(THREADS_NS, thread_id)
|
||||||
@ -117,14 +136,15 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
record["updated_at"] = now_iso()
|
record["updated_at"] = now_iso()
|
||||||
await self._store.aput(THREADS_NS, thread_id, record)
|
await self._store.aput(THREADS_NS, thread_id, record)
|
||||||
|
|
||||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
|
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
|
||||||
if record is None:
|
if record is None:
|
||||||
return
|
return
|
||||||
merged = dict(record.get("metadata") or {})
|
merged = dict(record.get("metadata") or {})
|
||||||
merged.update(metadata)
|
merged.update(metadata)
|
||||||
record["metadata"] = merged
|
record["metadata"] = merged
|
||||||
record["updated_at"] = now_iso()
|
if touch:
|
||||||
|
record["updated_at"] = now_iso()
|
||||||
await self._store.aput(THREADS_NS, thread_id, record)
|
await self._store.aput(THREADS_NS, thread_id, record)
|
||||||
|
|
||||||
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||||
@ -157,3 +177,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
"created_at": coerce_iso(val.get("created_at", "")),
|
"created_at": coerce_iso(val.get("created_at", "")),
|
||||||
"updated_at": coerce_iso(val.get("updated_at", "")),
|
"updated_at": coerce_iso(val.get("updated_at", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sort_key(record: dict[str, Any]) -> tuple[bool, str, str]:
|
||||||
|
metadata = record.get("metadata")
|
||||||
|
pinned = isinstance(metadata, dict) and metadata.get(THREAD_PINNED_METADATA_KEY) is True
|
||||||
|
return (pinned, str(record.get("updated_at") or ""), str(record.get("thread_id") or ""))
|
||||||
|
|||||||
@ -6,11 +6,12 @@ import logging
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import case, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from deerflow.persistence.json_compat import json_match
|
from deerflow.persistence.json_compat import json_match
|
||||||
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
from deerflow.utils.time import coerce_iso
|
from deerflow.utils.time import coerce_iso
|
||||||
@ -123,7 +124,15 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
||||||
"""
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
||||||
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
|
pinned_order = case(
|
||||||
|
(json_match(ThreadMetaRow.metadata_json, THREAD_PINNED_METADATA_KEY, True), 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
stmt = select(ThreadMetaRow).order_by(
|
||||||
|
pinned_order.desc(),
|
||||||
|
ThreadMetaRow.updated_at.desc(),
|
||||||
|
ThreadMetaRow.thread_id.desc(),
|
||||||
|
)
|
||||||
if resolved_user_id is not None:
|
if resolved_user_id is not None:
|
||||||
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
||||||
if status:
|
if status:
|
||||||
@ -190,6 +199,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
thread_id: str,
|
thread_id: str,
|
||||||
metadata: dict,
|
metadata: dict,
|
||||||
*,
|
*,
|
||||||
|
touch: bool = True,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Merge ``metadata`` into ``metadata_json``.
|
"""Merge ``metadata`` into ``metadata_json``.
|
||||||
@ -197,6 +207,9 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
Read-modify-write inside a single session/transaction so concurrent
|
Read-modify-write inside a single session/transaction so concurrent
|
||||||
callers see consistent state. No-op if the row does not exist or
|
callers see consistent state. No-op if the row does not exist or
|
||||||
the user_id check fails.
|
the user_id check fails.
|
||||||
|
|
||||||
|
``touch`` refreshes ``updated_at`` (default); pass ``touch=False`` to
|
||||||
|
preserve recency ordering for metadata-only changes such as pin/unpin.
|
||||||
"""
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
|
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@ -208,7 +221,14 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
merged = dict(row.metadata_json or {})
|
merged = dict(row.metadata_json or {})
|
||||||
merged.update(metadata)
|
merged.update(metadata)
|
||||||
row.metadata_json = merged
|
row.metadata_json = merged
|
||||||
row.updated_at = datetime.now(UTC)
|
if touch:
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
else:
|
||||||
|
# ``updated_at`` has an ``onupdate`` hook that fires on any row
|
||||||
|
# UPDATE unless the column has an explicit SET value. Mark the
|
||||||
|
# current value dirty so SQLAlchemy emits it in SET, skips the
|
||||||
|
# hook, and preserves recency ordering.
|
||||||
|
flag_modified(row, "updated_at")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
async def update_owner(
|
async def update_owner(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import logging
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta import InvalidMetadataFilterError, ThreadMetaRepository
|
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -137,6 +137,38 @@ class TestThreadMetaRepository:
|
|||||||
async def test_update_metadata_nonexistent_is_noop(self, repo):
|
async def test_update_metadata_nonexistent_is_noop(self, repo):
|
||||||
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise
|
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_metadata_touches_updated_at_by_default(self, repo):
|
||||||
|
await repo.create("t1", metadata={"a": 1})
|
||||||
|
original = (await repo.get("t1"))["updated_at"]
|
||||||
|
|
||||||
|
await repo.update_metadata("t1", {"b": 2})
|
||||||
|
|
||||||
|
record = await repo.get("t1")
|
||||||
|
assert record["metadata"] == {"a": 1, "b": 2}
|
||||||
|
assert record["updated_at"] >= original
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_metadata_touch_false_preserves_updated_at(self, repo):
|
||||||
|
await repo.create("t1", metadata={"a": 1})
|
||||||
|
original = (await repo.get("t1"))["updated_at"]
|
||||||
|
|
||||||
|
# Pin/unpin style patch must not bump recency ordering.
|
||||||
|
await repo.update_metadata("t1", {THREAD_PINNED_METADATA_KEY: True}, touch=False)
|
||||||
|
|
||||||
|
record = await repo.get("t1")
|
||||||
|
assert record["metadata"] == {"a": 1, THREAD_PINNED_METADATA_KEY: True}
|
||||||
|
assert record["updated_at"] == original
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_orders_pinned_threads_before_newer_unpinned_threads(self, repo):
|
||||||
|
await repo.create("older-pinned", metadata={THREAD_PINNED_METADATA_KEY: True})
|
||||||
|
await repo.create("newer-unpinned")
|
||||||
|
|
||||||
|
results = await repo.search(limit=1)
|
||||||
|
|
||||||
|
assert [record["thread_id"] for record in results] == ["older-pinned"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_owner_with_bypass_moves_row(self, repo):
|
async def test_update_owner_with_bypass_moves_row(self, repo):
|
||||||
await repo.create("t1", user_id="default", metadata={"source": "channel"})
|
await repo.create("t1", user_id="default", metadata={"source": "channel"})
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from langgraph.types import Overwrite
|
|||||||
from app.gateway import services as gateway_services
|
from app.gateway import services as gateway_services
|
||||||
from app.gateway.routers import thread_runs, threads
|
from app.gateway.routers import thread_runs, threads
|
||||||
from deerflow.config.paths import Paths
|
from deerflow.config.paths import Paths
|
||||||
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
|
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError
|
||||||
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
|
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
|
||||||
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
||||||
|
|
||||||
@ -957,7 +957,12 @@ def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status:
|
|||||||
assert response.json()["status"] == stored_status
|
assert response.json()["status"] == stored_status
|
||||||
|
|
||||||
|
|
||||||
def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
|
def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
|
||||||
|
"""A pin/unpin PATCH must not bump ``updated_at``.
|
||||||
|
|
||||||
|
Pinning or unpinning a chat does not represent conversation activity.
|
||||||
|
Timestamps are still surfaced as ISO via ``coerce_iso``.
|
||||||
|
"""
|
||||||
app, store, _checkpointer = _build_thread_app()
|
app, store, _checkpointer = _build_thread_app()
|
||||||
thread_id = "patch-target"
|
thread_id = "patch-target"
|
||||||
|
|
||||||
@ -982,16 +987,53 @@ def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
|
|||||||
asyncio.run(_seed())
|
asyncio.run(_seed())
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
|
response = client.patch(
|
||||||
|
f"/api/threads/{thread_id}",
|
||||||
|
json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
||||||
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
||||||
# Patch issues a fresh ``updated_at`` via ``MemoryThreadMetaStore.update_metadata``,
|
# ``touch=False`` preserves the original ``updated_at``; both timestamps
|
||||||
# so it must be > the migrated legacy ``created_at`` (both ISO strings
|
# derive from the same legacy value, so they coerce to the same ISO string.
|
||||||
# sort lexicographically by time when the format is consistent).
|
assert body["updated_at"] == body["created_at"]
|
||||||
assert body["updated_at"] > body["created_at"]
|
assert body["metadata"] == {"k": "v0", THREAD_PINNED_METADATA_KEY: True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
|
||||||
|
"""The public metadata PATCH endpoint still bumps recency by default."""
|
||||||
|
app, store, _checkpointer = _build_thread_app()
|
||||||
|
thread_id = "patch-target"
|
||||||
|
|
||||||
|
legacy_created = "946684800.000000"
|
||||||
|
legacy_updated = "946684800.000000"
|
||||||
|
|
||||||
|
async def _seed() -> None:
|
||||||
|
await store.aput(
|
||||||
|
THREADS_NS,
|
||||||
|
thread_id,
|
||||||
|
{
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"status": "idle",
|
||||||
|
"created_at": legacy_created,
|
||||||
|
"updated_at": legacy_updated,
|
||||||
|
"metadata": {"k": "v0"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(_seed())
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
||||||
|
assert body["updated_at"] != body["created_at"]
|
||||||
assert body["metadata"] == {"k": "v1"}
|
assert body["metadata"] == {"k": "v1"}
|
||||||
|
|
||||||
|
|
||||||
@ -1044,6 +1086,44 @@ def test_search_threads_normalizes_legacy_unix_seconds_to_iso() -> None:
|
|||||||
assert _ISO_TIMESTAMP_RE.match(item["updated_at"]), item
|
assert _ISO_TIMESTAMP_RE.match(item["updated_at"]), item
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_threads_returns_pinned_threads_before_newer_unpinned_threads() -> None:
|
||||||
|
app, store, _checkpointer = _build_thread_app()
|
||||||
|
|
||||||
|
async def _seed() -> None:
|
||||||
|
await store.aput(
|
||||||
|
THREADS_NS,
|
||||||
|
"newer-unpinned",
|
||||||
|
{
|
||||||
|
"thread_id": "newer-unpinned",
|
||||||
|
"status": "idle",
|
||||||
|
"created_at": "2026-07-01T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-07-20T00:00:00+00:00",
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await store.aput(
|
||||||
|
THREADS_NS,
|
||||||
|
"older-pinned",
|
||||||
|
{
|
||||||
|
"thread_id": "older-pinned",
|
||||||
|
"status": "idle",
|
||||||
|
"created_at": "2026-06-01T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-06-01T00:00:00+00:00",
|
||||||
|
"metadata": {THREAD_PINNED_METADATA_KEY: True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(_seed())
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post("/api/threads/search", json={"limit": 1})
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert [item["thread_id"] for item in response.json()] == ["older-pinned"]
|
||||||
|
|
||||||
|
|
||||||
def test_memory_thread_meta_store_writes_iso_on_create() -> None:
|
def test_memory_thread_meta_store_writes_iso_on_create() -> None:
|
||||||
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created
|
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created
|
||||||
threads serialize correctly without depending on the router's
|
threads serialize correctly without depending on the router's
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
Share2,
|
Share2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -53,12 +55,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
useDeleteThread,
|
useDeleteThread,
|
||||||
useInfiniteThreads,
|
useInfiniteThreads,
|
||||||
|
usePinThread,
|
||||||
useRenameThread,
|
useRenameThread,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import {
|
import {
|
||||||
channelSourceOfThread,
|
channelSourceOfThread,
|
||||||
|
isThreadPinned,
|
||||||
pathOfThread,
|
pathOfThread,
|
||||||
|
sortPinnedThreads,
|
||||||
titleOfThread,
|
titleOfThread,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@ -91,6 +96,7 @@ export function RecentChatList() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [infiniteThreads]);
|
}, [infiniteThreads]);
|
||||||
|
const displayedThreads = useMemo(() => sortPinnedThreads(threads), [threads]);
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -112,6 +118,7 @@ export function RecentChatList() {
|
|||||||
|
|
||||||
const { mutate: deleteThread } = useDeleteThread();
|
const { mutate: deleteThread } = useDeleteThread();
|
||||||
const { mutate: renameThread } = useRenameThread();
|
const { mutate: renameThread } = useRenameThread();
|
||||||
|
const { mutate: updatePinnedThread } = usePinThread();
|
||||||
|
|
||||||
// Rename dialog state
|
// Rename dialog state
|
||||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||||
@ -187,6 +194,25 @@ export function RecentChatList() {
|
|||||||
}
|
}
|
||||||
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
|
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback(
|
||||||
|
(thread: AgentThread) => {
|
||||||
|
updatePinnedThread(
|
||||||
|
{
|
||||||
|
threadId: thread.thread_id,
|
||||||
|
pinned: !isThreadPinned(thread),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : t.chats.pinChatFailed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[t.chats.pinChatFailed, updatePinnedThread],
|
||||||
|
);
|
||||||
|
|
||||||
const handleShare = useCallback(
|
const handleShare = useCallback(
|
||||||
async (thread: AgentThread) => {
|
async (thread: AgentThread) => {
|
||||||
// Always use Vercel URL for sharing so others can access
|
// Always use Vercel URL for sharing so others can access
|
||||||
@ -251,9 +277,10 @@ export function RecentChatList() {
|
|||||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
{threads.map((thread) => {
|
{displayedThreads.map((thread) => {
|
||||||
const isActive = pathOfThread(thread) === pathname;
|
const isActive = pathOfThread(thread) === pathname;
|
||||||
const channelSource = channelSourceOfThread(thread);
|
const channelSource = channelSourceOfThread(thread);
|
||||||
|
const pinned = isThreadPinned(thread);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@ -265,6 +292,12 @@ export function RecentChatList() {
|
|||||||
href={pathOfThread(thread)}
|
href={pathOfThread(thread)}
|
||||||
>
|
>
|
||||||
<ThreadChannelIcon source={channelSource} />
|
<ThreadChannelIcon source={channelSource} />
|
||||||
|
{pinned && (
|
||||||
|
<Pin
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-muted-foreground size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="min-w-0 truncate">
|
<span className="min-w-0 truncate">
|
||||||
{titleOfThread(thread)}
|
{titleOfThread(thread)}
|
||||||
</span>
|
</span>
|
||||||
@ -296,6 +329,18 @@ export function RecentChatList() {
|
|||||||
side={"right"}
|
side={"right"}
|
||||||
align={"start"}
|
align={"start"}
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleTogglePin(thread)}
|
||||||
|
>
|
||||||
|
{pinned ? (
|
||||||
|
<PinOff className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Pin className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{pinned ? t.chats.unpinChat : t.chats.pinChat}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleRenameClick(
|
handleRenameClick(
|
||||||
|
|||||||
@ -514,6 +514,9 @@ export const enUS: Translations = {
|
|||||||
loadMoreToSearch: "Load more to search older conversations",
|
loadMoreToSearch: "Load more to search older conversations",
|
||||||
loadingMore: "Loading more...",
|
loadingMore: "Loading more...",
|
||||||
loadOlderChats: "Load older chats",
|
loadOlderChats: "Load older chats",
|
||||||
|
pinChat: "Pin chat",
|
||||||
|
unpinChat: "Unpin chat",
|
||||||
|
pinChatFailed: "Failed to update pinned chat",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sidecar
|
// Sidecar
|
||||||
|
|||||||
@ -413,6 +413,9 @@ export interface Translations {
|
|||||||
loadMoreToSearch: string;
|
loadMoreToSearch: string;
|
||||||
loadingMore: string;
|
loadingMore: string;
|
||||||
loadOlderChats: string;
|
loadOlderChats: string;
|
||||||
|
pinChat: string;
|
||||||
|
unpinChat: string;
|
||||||
|
pinChatFailed: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sidecar
|
// Sidecar
|
||||||
|
|||||||
@ -491,6 +491,9 @@ export const zhCN: Translations = {
|
|||||||
loadMoreToSearch: "加载更多以搜索更早的对话",
|
loadMoreToSearch: "加载更多以搜索更早的对话",
|
||||||
loadingMore: "正在加载...",
|
loadingMore: "正在加载...",
|
||||||
loadOlderChats: "加载更早的对话",
|
loadOlderChats: "加载更早的对话",
|
||||||
|
pinChat: "置顶对话",
|
||||||
|
unpinChat: "取消置顶",
|
||||||
|
pinChatFailed: "更新对话置顶状态失败",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sidecar
|
// Sidecar
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
|
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type { ThreadTokenUsageResponse } from "./types";
|
import type { AgentThread, ThreadTokenUsageResponse } from "./types";
|
||||||
|
|
||||||
export type ThreadCompactResponse = {
|
export type ThreadCompactResponse = {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
@ -34,6 +34,19 @@ export type BranchThreadFromTurnInput = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThreadMetadataPatch = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of thread fields the Gateway ``PATCH /api/threads/{id}`` handler
|
||||||
|
* returns with meaningful values. The endpoint's ``ThreadResponse`` model also
|
||||||
|
* serializes default ``values`` and ``interrupts``, but PATCH leaves those empty;
|
||||||
|
* callers that need state should read it via a full thread fetch instead.
|
||||||
|
*/
|
||||||
|
export type ThreadMetadataPatchResponse = Pick<
|
||||||
|
AgentThread,
|
||||||
|
"thread_id" | "status" | "created_at" | "updated_at" | "metadata"
|
||||||
|
>;
|
||||||
|
|
||||||
async function readThreadAPIError(
|
async function readThreadAPIError(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallback: string,
|
fallback: string,
|
||||||
@ -97,6 +110,30 @@ export async function branchThreadFromTurn(
|
|||||||
return (await response.json()) as ThreadBranchResponse;
|
return (await response.json()) as ThreadBranchResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchThreadMetadata(
|
||||||
|
threadId: string,
|
||||||
|
metadata: ThreadMetadataPatch,
|
||||||
|
): Promise<ThreadMetadataPatchResponse> {
|
||||||
|
const response = await fetchWithAuth(
|
||||||
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ metadata }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readThreadAPIError(response, "Failed to update conversation."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as ThreadMetadataPatchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export async function compactThreadContext(
|
export async function compactThreadContext(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
options: CompactThreadContextOptions = {},
|
options: CompactThreadContextOptions = {},
|
||||||
|
|||||||
@ -29,7 +29,12 @@ import { messageToStep } from "../tasks/steps";
|
|||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||||
|
|
||||||
import { branchThreadFromTurn, fetchThreadTokenUsage } from "./api";
|
import {
|
||||||
|
branchThreadFromTurn,
|
||||||
|
fetchThreadTokenUsage,
|
||||||
|
patchThreadMetadata,
|
||||||
|
type ThreadMetadataPatch,
|
||||||
|
} from "./api";
|
||||||
import {
|
import {
|
||||||
buildThreadsSearchQueryOptions,
|
buildThreadsSearchQueryOptions,
|
||||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||||
@ -43,6 +48,7 @@ import type {
|
|||||||
RunMessage,
|
RunMessage,
|
||||||
ThreadTokenUsageResponse,
|
ThreadTokenUsageResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { THREAD_PINNED_METADATA_KEY } from "./utils";
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
@ -2137,6 +2143,62 @@ export function filterInfiniteThreadsCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeThreadMetadata(
|
||||||
|
thread: AgentThread,
|
||||||
|
metadata: ThreadMetadataPatch,
|
||||||
|
): AgentThread {
|
||||||
|
return {
|
||||||
|
...thread,
|
||||||
|
metadata: {
|
||||||
|
...(thread.metadata ?? {}),
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThreadMetadataInCaches(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
threadId: string,
|
||||||
|
metadata: ThreadMetadataPatch,
|
||||||
|
) {
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: ["threads", "search"],
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
(oldData: Array<AgentThread> | undefined) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
return oldData.map((thread) =>
|
||||||
|
thread.thread_id === threadId
|
||||||
|
? mergeThreadMetadata(thread, metadata)
|
||||||
|
: thread,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||||
|
mapInfiniteThreadsCache(oldData, (thread) =>
|
||||||
|
thread.thread_id === threadId
|
||||||
|
? mergeThreadMetadata(thread, metadata)
|
||||||
|
: thread,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
queryClient.setQueriesData(
|
||||||
|
{
|
||||||
|
queryKey: ["thread", "metadata", threadId],
|
||||||
|
exact: false,
|
||||||
|
},
|
||||||
|
(oldData: AgentThread | null | undefined) =>
|
||||||
|
oldData ? mergeThreadMetadata(oldData, metadata) : oldData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useInfiniteThreads(
|
export function useInfiniteThreads(
|
||||||
params: InfiniteThreadsParams = {
|
params: InfiniteThreadsParams = {
|
||||||
sortBy: "updated_at",
|
sortBy: "updated_at",
|
||||||
@ -2263,6 +2325,34 @@ export function useBranchThread() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePinThread() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
threadId,
|
||||||
|
pinned,
|
||||||
|
}: {
|
||||||
|
threadId: string;
|
||||||
|
pinned: boolean;
|
||||||
|
}) =>
|
||||||
|
patchThreadMetadata(threadId, {
|
||||||
|
[THREAD_PINNED_METADATA_KEY]: pinned,
|
||||||
|
}),
|
||||||
|
onSuccess(response, { threadId, pinned }) {
|
||||||
|
setThreadMetadataInCaches(queryClient, threadId, {
|
||||||
|
...(response.metadata ?? {}),
|
||||||
|
[THREAD_PINNED_METADATA_KEY]: pinned,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled() {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRunDetail(threadId: string, runId: string) {
|
export function useRunDetail(threadId: string, runId: string) {
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
return useQuery<Run>({
|
return useQuery<Run>({
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
|
|
||||||
import type { AgentThread, AgentThreadContext } from "./types";
|
import type { AgentThread, AgentThreadContext } from "./types";
|
||||||
|
|
||||||
|
// Namespaced to match other internal metadata keys (``deerflow_sidecar``,
|
||||||
|
// ``deerflow_branch``) so it cannot collide with a future feature or a
|
||||||
|
// client-supplied key. Keep in sync with the backend thread_meta constant and
|
||||||
|
// the E2E mock-api constant.
|
||||||
|
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
|
||||||
|
|
||||||
export type ChannelThreadSource = {
|
export type ChannelThreadSource = {
|
||||||
type: "im_channel";
|
type: "im_channel";
|
||||||
provider: string;
|
provider: string;
|
||||||
@ -60,6 +66,24 @@ export function titleOfThread(thread: AgentThread) {
|
|||||||
return thread.values?.title ?? "Untitled";
|
return thread.values?.title ?? "Untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isThreadPinned(thread: Pick<AgentThread, "metadata">) {
|
||||||
|
return thread.metadata?.[THREAD_PINNED_METADATA_KEY] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortPinnedThreads<T extends Pick<AgentThread, "metadata">>(
|
||||||
|
threads: readonly T[],
|
||||||
|
) {
|
||||||
|
return threads
|
||||||
|
.map((thread, index) => ({ thread, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const pinnedDiff =
|
||||||
|
Number(isThreadPinned(right.thread)) -
|
||||||
|
Number(isThreadPinned(left.thread));
|
||||||
|
return pinnedDiff || left.index - right.index;
|
||||||
|
})
|
||||||
|
.map(({ thread }) => thread);
|
||||||
|
}
|
||||||
|
|
||||||
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
||||||
dingtalk: "DingTalk",
|
dingtalk: "DingTalk",
|
||||||
discord: "Discord",
|
discord: "Discord",
|
||||||
|
|||||||
@ -49,10 +49,20 @@ test.describe("Integrations settings", () => {
|
|||||||
mockLangGraphAPI(page);
|
mockLangGraphAPI(page);
|
||||||
let authStartRequest: unknown;
|
let authStartRequest: unknown;
|
||||||
const authCompleteRequests: unknown[] = [];
|
const authCompleteRequests: unknown[] = [];
|
||||||
|
let authCompleteCount = 0;
|
||||||
await page.route(
|
await page.route(
|
||||||
"**/api/integrations/lark/auth/complete",
|
"**/api/integrations/lark/auth/complete",
|
||||||
async (route) => {
|
async (route) => {
|
||||||
authCompleteRequests.push(route.request().postDataJSON());
|
authCompleteRequests.push(route.request().postDataJSON());
|
||||||
|
authCompleteCount += 1;
|
||||||
|
if (authCompleteCount > 1) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 504,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "Authorization still pending." }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await route.fallback();
|
await route.fallback();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,14 +11,16 @@ const TOTAL_THREADS = 120;
|
|||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
|
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
|
||||||
// Pad index so titles sort deterministically as strings. The thread-search
|
// Pad index so titles sort deterministically as strings. Keep updated_at
|
||||||
// mock returns threads in the order provided, so paging boundaries are
|
// monotonically descending to match the backend's updated_at-desc search
|
||||||
// stable across runs.
|
// order, so paging boundaries are stable across runs.
|
||||||
const index = String(i + 1).padStart(3, "0");
|
const index = String(i + 1).padStart(3, "0");
|
||||||
return {
|
return {
|
||||||
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
|
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
|
||||||
title: `Conversation ${index}`,
|
title: `Conversation ${index}`,
|
||||||
updated_at: `2025-06-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z`,
|
updated_at: new Date(
|
||||||
|
Date.UTC(2025, 5, 30, 12, 0, 0) - i * 60_000,
|
||||||
|
).toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
92
frontend/tests/e2e/thread-list-pin.spec.ts
Normal file
92
frontend/tests/e2e/thread-list-pin.spec.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockLangGraphAPI, THREAD_PINNED_METADATA_KEY } from "./utils/mock-api";
|
||||||
|
|
||||||
|
const NEWEST_THREAD_ID = "00000000-0000-0000-0000-000000000901";
|
||||||
|
const OLDER_THREAD_ID = "00000000-0000-0000-0000-000000000902";
|
||||||
|
|
||||||
|
async function recentChatTitles(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('a[data-sidebar="menu-button"][href^="/workspace/chats/"]')
|
||||||
|
.evaluateAll((links) =>
|
||||||
|
links
|
||||||
|
.map((link) => link.textContent?.replace(/\s+/g, " ").trim() ?? "")
|
||||||
|
.filter((text) => text && text !== "New chat"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("sidebar recent chats can be pinned and unpinned", async ({ page }) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: NEWEST_THREAD_ID,
|
||||||
|
title: "Newest chat",
|
||||||
|
updated_at: "2026-07-04T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thread_id: OLDER_THREAD_ID,
|
||||||
|
title: "Older chat",
|
||||||
|
updated_at: "2026-07-03T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
await expect(page.getByText("Newest chat")).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => recentChatTitles(page))
|
||||||
|
.toEqual(["Newest chat", "Older chat"]);
|
||||||
|
|
||||||
|
const olderItem = page
|
||||||
|
.locator(
|
||||||
|
`a[data-sidebar="menu-button"][href="/workspace/chats/${OLDER_THREAD_ID}"]`,
|
||||||
|
)
|
||||||
|
.locator("xpath=..");
|
||||||
|
await olderItem.hover();
|
||||||
|
await olderItem.getByRole("button", { name: "More" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Pin chat" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => recentChatTitles(page))
|
||||||
|
.toEqual(["Older chat", "Newest chat"]);
|
||||||
|
|
||||||
|
await olderItem.hover();
|
||||||
|
await olderItem.getByRole("button", { name: "More" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Unpin chat" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => recentChatTitles(page))
|
||||||
|
.toEqual(["Newest chat", "Older chat"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server-side search keeps old pinned chats in the first page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
...Array.from({ length: 51 }, (_, index) => ({
|
||||||
|
thread_id: `00000000-0000-0000-0000-000000001${String(index).padStart(3, "0")}`,
|
||||||
|
title: `Recent chat ${index + 1}`,
|
||||||
|
updated_at: new Date(
|
||||||
|
Date.UTC(2026, 6, 25, 10, 0, 0) - index * 60_000,
|
||||||
|
).toISOString(),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
thread_id: OLDER_THREAD_ID,
|
||||||
|
title: "Old pinned chat",
|
||||||
|
updated_at: "2026-01-01T10:00:00Z",
|
||||||
|
metadata: { [THREAD_PINNED_METADATA_KEY]: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
await expect(page.getByText("Old pinned chat")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await recentChatTitles(page)).slice(0, 2))
|
||||||
|
.toEqual(["Old pinned chat", "Recent chat 1"]);
|
||||||
|
});
|
||||||
@ -16,6 +16,9 @@ export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
|||||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
||||||
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
|
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
|
||||||
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||||
|
// Keep in sync with frontend runtime thread utils and the backend thread_meta
|
||||||
|
// constant; the mock must mirror the same metadata contract for pin ordering.
|
||||||
|
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
|
||||||
|
|
||||||
const MOCK_AUTH_USER = {
|
const MOCK_AUTH_USER = {
|
||||||
id: "default",
|
id: "default",
|
||||||
@ -318,6 +321,44 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
|
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const threadUpdatedAt = (thread: MockThread) =>
|
||||||
|
Date.parse(thread.updated_at ?? "2025-01-01T00:00:00Z") || 0;
|
||||||
|
|
||||||
|
const sortThreadSearchResults = (items: readonly MockThread[]) =>
|
||||||
|
[...items].sort((left, right) => {
|
||||||
|
const pinnedDiff =
|
||||||
|
Number(right.metadata?.[THREAD_PINNED_METADATA_KEY] === true) -
|
||||||
|
Number(left.metadata?.[THREAD_PINNED_METADATA_KEY] === true);
|
||||||
|
return (
|
||||||
|
pinnedDiff ||
|
||||||
|
threadUpdatedAt(right) - threadUpdatedAt(left) ||
|
||||||
|
right.thread_id.localeCompare(left.thread_id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchThreadMetadata = (
|
||||||
|
threadId: string,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
let updated: MockThread | undefined;
|
||||||
|
threads = threads.map((thread) => {
|
||||||
|
if (thread.thread_id !== threadId) {
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
// Preserve ``updated_at`` for pin/unpin metadata changes; the search mock
|
||||||
|
// below mirrors the Gateway's server-side pinned-first ordering.
|
||||||
|
updated = {
|
||||||
|
...thread,
|
||||||
|
metadata: {
|
||||||
|
...(thread.metadata ?? {}),
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
// Auth — keep workspace tests independent from a real gateway session.
|
// Auth — keep workspace tests independent from a real gateway session.
|
||||||
void page.route("**/api/v1/auth/me", (route) => {
|
void page.route("**/api/v1/auth/me", (route) => {
|
||||||
if (route.request().method() === "GET") {
|
if (route.request().method() === "GET") {
|
||||||
@ -611,7 +652,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
|
|
||||||
// Thread search — sidebar thread list & chats list page
|
// Thread search — sidebar thread list & chats list page
|
||||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||||
let body = threads.map(threadSearchResult);
|
let body = sortThreadSearchResults(threads).map(threadSearchResult);
|
||||||
|
|
||||||
let limit: number | undefined;
|
let limit: number | undefined;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@ -698,10 +739,23 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (route.request().method() === "PATCH") {
|
if (route.request().method() === "PATCH") {
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const updated = body.metadata
|
||||||
|
? patchThreadMetadata(threadId, body.metadata)
|
||||||
|
: matchingThread;
|
||||||
|
if (!updated) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "Thread not found" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
body: JSON.stringify(threadSearchResult(updated)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
@ -744,6 +798,32 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||||
|
if (route.request().method() === "PATCH") {
|
||||||
|
const threadId = decodeURIComponent(
|
||||||
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||||
|
);
|
||||||
|
const body = route.request().postDataJSON() as {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const matchingThread = threads.find(
|
||||||
|
(thread) => thread.thread_id === threadId,
|
||||||
|
);
|
||||||
|
const updated = body.metadata
|
||||||
|
? patchThreadMetadata(threadId, body.metadata)
|
||||||
|
: matchingThread;
|
||||||
|
if (!updated) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(threadSearchResult(updated)),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
const threadId = decodeURIComponent(
|
const threadId = decodeURIComponent(
|
||||||
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||||
|
|||||||
@ -1,12 +1,27 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { expect, test } from "@rstest/core";
|
import { expect, test } from "@rstest/core";
|
||||||
|
|
||||||
|
import type { AgentThread } from "@/core/threads/types";
|
||||||
import {
|
import {
|
||||||
channelSourceOfThread,
|
channelSourceOfThread,
|
||||||
|
isThreadPinned,
|
||||||
pathOfThread,
|
pathOfThread,
|
||||||
|
sortPinnedThreads,
|
||||||
textOfMessage,
|
textOfMessage,
|
||||||
|
THREAD_PINNED_METADATA_KEY,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
|
|
||||||
|
function makeThread(
|
||||||
|
threadId: string,
|
||||||
|
metadata: Record<string, unknown> = {},
|
||||||
|
): AgentThread {
|
||||||
|
return {
|
||||||
|
thread_id: threadId,
|
||||||
|
metadata,
|
||||||
|
values: { title: threadId },
|
||||||
|
} as unknown as AgentThread;
|
||||||
|
}
|
||||||
|
|
||||||
test("uses standard chat route when thread has no agent context", () => {
|
test("uses standard chat route when thread has no agent context", () => {
|
||||||
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
||||||
expect(
|
expect(
|
||||||
@ -62,6 +77,44 @@ test("prefers context.agent_name over metadata.agent_name", () => {
|
|||||||
).toBe("/workspace/agents/from-context/chats/thread-789");
|
).toBe("/workspace/agents/from-context/chats/thread-789");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads pinned thread metadata strictly from the pinned metadata key", () => {
|
||||||
|
expect(
|
||||||
|
isThreadPinned(
|
||||||
|
makeThread("pinned", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isThreadPinned(
|
||||||
|
makeThread("false", { [THREAD_PINNED_METADATA_KEY]: false }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isThreadPinned(
|
||||||
|
makeThread("truthy", { [THREAD_PINNED_METADATA_KEY]: "true" }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isThreadPinned(makeThread("legacy-bare-key", { pinned: true }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isThreadPinned(makeThread("missing"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sortPinnedThreads keeps pinned threads first without reordering groups", () => {
|
||||||
|
const threads = [
|
||||||
|
makeThread("recent-1"),
|
||||||
|
makeThread("pinned-1", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||||
|
makeThread("recent-2"),
|
||||||
|
makeThread("pinned-2", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sortPinnedThreads(threads).map((thread) => thread.thread_id)).toEqual([
|
||||||
|
"pinned-1",
|
||||||
|
"pinned-2",
|
||||||
|
"recent-1",
|
||||||
|
"recent-2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("reads IM channel source metadata", () => {
|
test("reads IM channel source metadata", () => {
|
||||||
expect(
|
expect(
|
||||||
channelSourceOfThread({
|
channelSourceOfThread({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user