mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
feat(search): find conversations and jump to matching messages (#8032)
* feat(search): find conversations and jump to matching messages * test(extensions): align example registry with search contribution * test(search): scope bookmark search to its extension * fix(search): address review correctness and scaling gaps
This commit is contained in:
parent
599105e0fb
commit
dba950443d
@ -49,6 +49,7 @@ from app.gateway.routers import (
|
||||
projects,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
search,
|
||||
skill_mutations,
|
||||
skills,
|
||||
subagent_batches,
|
||||
@ -1379,6 +1380,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
||||
app.include_router(uploads.router)
|
||||
|
||||
# Thread cleanup API is mounted at /api/threads/{thread_id}
|
||||
app.include_router(search.router)
|
||||
app.include_router(threads.router)
|
||||
|
||||
# Per-user activity feed (own prefix, so /api/threads/{thread_id} never captures it)
|
||||
|
||||
@ -50,3 +50,6 @@ make test-blocking-io # after adding any file, config
|
||||
skill name, file name) is validated when it is created, including Windows
|
||||
reserved device names. Read and delete routes stay permissive so that data
|
||||
stored by an older release can still be exported and removed.
|
||||
- **Search results use the latest owner-visible message.** A historical jump
|
||||
resolves only the requested thread. Use the shared history seed path for
|
||||
legacy checkpoints so it keeps its existing write reservation.
|
||||
|
||||
160
backend/app/gateway/routers/search.py
Normal file
160
backend/app/gateway/routers/search.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Workspace search and direct navigation to an owner-visible message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_run_event_store, get_run_manager, get_thread_store
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
from deerflow.utils.thread_id import ThreadId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["search"])
|
||||
|
||||
|
||||
class WorkspaceSearchRequest(BaseModel):
|
||||
query: str = Field(default="", max_length=256)
|
||||
types: list[Literal["thread", "message"]] = Field(default_factory=lambda: ["thread", "message"], min_length=1, max_length=2)
|
||||
archived: bool | None = Field(default=None, strict=True)
|
||||
project_id: str | None = Field(default=None, max_length=64)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
cursor: str | None = Field(default=None, max_length=4096)
|
||||
|
||||
|
||||
def snippet(text: str, query: str) -> list[dict]:
|
||||
"""Plain text only. Map case-fold expansions back to source characters."""
|
||||
normalized = text.casefold()
|
||||
position = normalized.find(query.casefold()) if query else -1
|
||||
if position < 0:
|
||||
return [{"text": text[:200] + ("…" if len(text) > 200 else "")}]
|
||||
offsets = []
|
||||
for index, character in enumerate(text):
|
||||
offsets.extend([index] * len(character.casefold()))
|
||||
if len(offsets) >= position + len(query.casefold()):
|
||||
break
|
||||
start, end = offsets[position], offsets[position + len(query.casefold()) - 1] + 1
|
||||
left, right = max(0, start - 65), min(len(text), end + 110)
|
||||
return [{"text": ("…" if left else "") + text[left:start]}, {"text": text[start:end], "matched": True}, {"text": text[end:right] + ("…" if right < len(text) else "")}]
|
||||
|
||||
|
||||
def _corpus(request: Request) -> GatewaySearchCorpus:
|
||||
return GatewaySearchCorpus(get_thread_store(request), get_run_event_store(request), get_run_manager(request), str(request.state.auth.require_user().id), request=request)
|
||||
|
||||
|
||||
def _encode_cursor(scope: str, phase: str, position) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps([scope, phase, position]).encode()).decode()
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
@require_permission("threads", "read")
|
||||
async def search_workspace(body: WorkspaceSearchRequest, request: Request):
|
||||
corpus = _corpus(request)
|
||||
query = body.query.strip()
|
||||
scope = hashlib.sha256(json.dumps([corpus.user_id, query, sorted(set(body.types)), body.archived, body.project_id, "project_id" in body.model_fields_set]).encode()).hexdigest()
|
||||
phase, position = "thread", None
|
||||
if body.cursor:
|
||||
try:
|
||||
saved_scope, phase, position = json.loads(base64.urlsafe_b64decode(body.cursor))
|
||||
if saved_scope != scope or phase not in {"thread", "message"}:
|
||||
raise ValueError
|
||||
if phase == "thread" and (not isinstance(position, list) or len(position) != 3 or not isinstance(position[0], int) or not all(isinstance(v, str) for v in position[1:])):
|
||||
raise ValueError
|
||||
if phase == "message" and position is not None and not isinstance(position, str):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(400, "Invalid search cursor") from exc
|
||||
all_threads = await corpus.threads()
|
||||
threads = tuple(t for t in all_threads if (body.archived is None or t.archived == body.archived) and ("project_id" not in body.model_fields_set or t.project_id == body.project_id))
|
||||
lookup = {t.thread_id: t for t in threads}
|
||||
providers = getattr(getattr(request.app.state, "extensions", None), "workspace_search_providers", ())
|
||||
messages_available = bool(providers) and request.state.auth.has_permission("runs", "read")
|
||||
response = {"items": [], "cursor": None, "indexing": False, "messages_available": messages_available}
|
||||
|
||||
def thread_hit(thread):
|
||||
return {
|
||||
"type": "thread",
|
||||
"thread_id": thread.thread_id,
|
||||
"title": thread.title,
|
||||
"agent_name": thread.agent_name,
|
||||
"archived": thread.archived,
|
||||
"project_id": thread.project_id,
|
||||
"updated_at": thread.updated_at,
|
||||
"snippet": snippet(thread.title, query),
|
||||
}
|
||||
|
||||
if phase == "thread" and "thread" in body.types:
|
||||
|
||||
def key(thread):
|
||||
title = thread.title.casefold()
|
||||
return (2 if query and title == query.casefold() else 1, thread.updated_at, thread.thread_id)
|
||||
|
||||
matches = sorted((t for t in threads if not query or query.casefold() in t.title.casefold()), key=key, reverse=True)
|
||||
if position is not None:
|
||||
matches = [t for t in matches if key(t) < tuple(position)]
|
||||
selected = matches[: body.limit]
|
||||
response["items"].extend(thread_hit(t) for t in selected)
|
||||
if len(matches) > body.limit:
|
||||
response["cursor"] = _encode_cursor(scope, "thread", key(selected[-1]))
|
||||
return response
|
||||
if not query or "message" not in body.types or not messages_available:
|
||||
return response
|
||||
if len(response["items"]) == body.limit:
|
||||
response["cursor"] = _encode_cursor(scope, "message", None)
|
||||
return response
|
||||
provider = providers[0][1]
|
||||
provider_cursor = position if phase == "message" else None
|
||||
# Invalidated candidates must not consume a visible result slot. Keep a
|
||||
# bounded amount of work per HTTP page and expose continuation when needed.
|
||||
for _ in range(5):
|
||||
try:
|
||||
page = await provider.search(corpus, query=query, threads=threads, limit=body.limit - len(response["items"]), cursor=provider_cursor)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, "Invalid search cursor") from exc
|
||||
except Exception:
|
||||
logger.exception("Workspace search provider failed")
|
||||
raise HTTPException(503, "Message search is temporarily unavailable") from None
|
||||
response["indexing"] = page.indexing
|
||||
for candidate in page.items:
|
||||
thread = lookup.get(candidate.thread_id)
|
||||
if thread is None or candidate.incarnation != thread.incarnation or not await corpus.owns(thread):
|
||||
continue
|
||||
# Re-read authoritative visible text before returning ANY snippet.
|
||||
current = await corpus.latest_message(thread, candidate.message_id)
|
||||
if current is None or current.seq != candidate.seq or query.casefold() not in current.text.casefold():
|
||||
continue
|
||||
hit = thread_hit(thread)
|
||||
hit.update(type="message", message_id=current.message_id, seq=current.seq, role=current.role, updated_at=current.created_at, snippet=snippet(current.text, query))
|
||||
response["items"].append(hit)
|
||||
if page.indexing:
|
||||
break
|
||||
if page.cursor == provider_cursor and page.cursor is not None:
|
||||
raise HTTPException(503, "Search pagination did not advance")
|
||||
provider_cursor = page.cursor
|
||||
if provider_cursor is None or len(response["items"]) >= body.limit:
|
||||
break
|
||||
if provider_cursor:
|
||||
response["cursor"] = _encode_cursor(scope, "message", provider_cursor)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}/messages/around")
|
||||
@require_permission("runs", "read", owner_check=True, require_existing=True)
|
||||
async def message_context(thread_id: ThreadId, request: Request, message_id: str = Query(min_length=1, max_length=512), seq: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=50)):
|
||||
corpus = _corpus(request)
|
||||
window = await corpus.around(thread_id, message_id, seq=seq, limit=limit)
|
||||
if window is None:
|
||||
raise HTTPException(404, "Message is unavailable")
|
||||
thread = await corpus.thread(thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(404, "Message is unavailable")
|
||||
messages = [asdict(message) for row in window.pop("data") if (message := corpus.project(thread, row)) is not None]
|
||||
return {**window, "messages": messages}
|
||||
@ -530,6 +530,8 @@ class ThreadCreateRequest(BaseModel):
|
||||
class ThreadSearchRequest(BaseModel):
|
||||
"""Request body for searching threads."""
|
||||
|
||||
query: str | None = Field(default=None, max_length=256, description="Literal case-insensitive title substring")
|
||||
|
||||
archived: bool | None = Field(default=None, strict=True, description="Archive filter; omitted includes all, false includes legacy unarchived threads")
|
||||
metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)")
|
||||
project_id: str | None = Field(default=None, description="Filter by project; explicit null = unassigned threads; omit key for all")
|
||||
@ -1367,6 +1369,7 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
||||
try:
|
||||
rows = await repo.search(
|
||||
metadata=body.metadata or None,
|
||||
**({"query": body.query} if body.query is not None else {}),
|
||||
status=body.status,
|
||||
**({"archived": body.archived} if body.archived is not None else {}),
|
||||
project_id=project_filter,
|
||||
|
||||
172
backend/app/gateway/search_corpus.py
Normal file
172
backend/app/gateway/search_corpus.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""Permission-scoped transcript projection shared by search and message navigation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
from deerflow_extension_api.search import SearchMessage, SearchMessagePage, SearchThread
|
||||
|
||||
from app.gateway.conversation_access import _visible_text
|
||||
from app.gateway.conversation_reader import default_history_hidden_run_ids, read_visible_message_page
|
||||
from deerflow.runtime.events.message_identity import message_identity
|
||||
from deerflow.utils.time import coerce_iso
|
||||
|
||||
|
||||
def visible_search_text(row: dict, *, thread_id: str):
|
||||
"""Use the transcript reader and the chat UI's display-only exclusions."""
|
||||
content = row.get("content")
|
||||
if isinstance(content, dict) and content.get("name") in {"summary", "loop_warning", "todo_reminder", "todo_completion_reminder"}:
|
||||
return None
|
||||
visible = _visible_text(row, thread_id=thread_id)
|
||||
if visible and visible[0] == "user":
|
||||
text = re.sub(r"<(current_uploads|uploaded_files|slash_skill_activation)>[\s\S]*?</\1>", "", visible[1]).strip()
|
||||
return ("user", text) if text else None
|
||||
return visible
|
||||
|
||||
|
||||
class GatewaySearchCorpus:
|
||||
def __init__(self, thread_store, event_store, run_manager, user_id: str, *, request=None):
|
||||
self.thread_store = thread_store
|
||||
self.event_store = event_store
|
||||
self.run_manager = run_manager
|
||||
self.user_id = user_id
|
||||
self.request = request
|
||||
|
||||
async def threads(self) -> tuple[SearchThread, ...]:
|
||||
result = []
|
||||
offset = 0
|
||||
while True:
|
||||
rows = await self.thread_store.search(user_id=self.user_id, limit=200, offset=offset)
|
||||
for row in rows:
|
||||
metadata = row.get("metadata") or {}
|
||||
if metadata.get("deerflow_sidecar") is True:
|
||||
continue
|
||||
result.append(self.project_thread(row))
|
||||
offset += len(rows)
|
||||
if len(rows) < 200:
|
||||
return tuple(result)
|
||||
|
||||
@staticmethod
|
||||
def project_thread(row) -> SearchThread:
|
||||
from app.gateway.services import custom_agent_name
|
||||
|
||||
try:
|
||||
agent_name = custom_agent_name(row.get("assistant_id"))
|
||||
except (TypeError, ValueError):
|
||||
agent_name = None
|
||||
metadata = row.get("metadata") or {}
|
||||
return SearchThread(
|
||||
thread_id=row["thread_id"],
|
||||
incarnation=str(row.get("incarnation") or row.get("created_at") or ""),
|
||||
title=row.get("display_name") or "",
|
||||
updated_at=coerce_iso(row.get("updated_at", "")),
|
||||
agent_name=agent_name,
|
||||
project_id=metadata.get("deerflow_project_id"),
|
||||
archived=metadata.get("deerflow_archived") is True,
|
||||
)
|
||||
|
||||
async def thread(self, thread_id: str) -> SearchThread | None:
|
||||
row = await self.thread_store.get(thread_id, user_id=self.user_id)
|
||||
if row is None or (row.get("metadata") or {}).get("deerflow_sidecar") is True:
|
||||
return None
|
||||
return self.project_thread(row)
|
||||
|
||||
async def seed_legacy_history(self, thread: SearchThread) -> None:
|
||||
if self.request is None or await self.event_store.list_messages(thread.thread_id, limit=1, user_id=None):
|
||||
return
|
||||
row = await self.thread_store.get(thread.thread_id, user_id=self.user_id)
|
||||
if row is None or self.project_thread(row).incarnation != thread.incarnation:
|
||||
return
|
||||
from app.gateway.services import ensure_checkpoint_history_seeded
|
||||
|
||||
await ensure_checkpoint_history_seeded(self.request, thread_id=thread.thread_id, assistant_id=row.get("assistant_id"))
|
||||
|
||||
async def owns(self, thread: SearchThread) -> bool:
|
||||
row = await self.thread_store.get(thread.thread_id, user_id=self.user_id)
|
||||
return bool(row and str(row.get("incarnation") or row.get("created_at") or "") == thread.incarnation and (row.get("metadata") or {}).get("deerflow_sidecar") is not True)
|
||||
|
||||
async def revision(self, thread: SearchThread) -> str | None:
|
||||
if not await self.owns(thread):
|
||||
return None
|
||||
await self.seed_legacy_history(thread)
|
||||
latest = await self.event_store.list_messages(thread.thread_id, limit=1, user_id=self.user_id)
|
||||
hidden = await default_history_hidden_run_ids(self.run_manager, thread.thread_id, user_id=self.user_id)
|
||||
revision = [thread.incarnation, thread.updated_at, latest[-1]["seq"] if latest else 0, sorted(hidden)]
|
||||
return hashlib.sha256(json.dumps(revision).encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def project(thread: SearchThread, row: dict) -> SearchMessage | None:
|
||||
visible = visible_search_text(row, thread_id=thread.thread_id)
|
||||
content = row.get("content")
|
||||
identity = message_identity(content) if isinstance(content, dict) else None
|
||||
if not visible or not identity:
|
||||
return None
|
||||
return SearchMessage(thread.thread_id, thread.incarnation, identity.removeprefix("message:"), row["run_id"], row["seq"], visible[0], visible[1], coerce_iso(row.get("created_at", "")))
|
||||
|
||||
async def messages(self, thread: SearchThread, *, before_seq: int | None) -> SearchMessagePage:
|
||||
if not await self.owns(thread):
|
||||
return SearchMessagePage()
|
||||
rows, more = await self._page(thread.thread_id, limit=200, before_seq=before_seq)
|
||||
items = tuple(message for row in rows if (message := self.project(thread, row)) is not None)
|
||||
return SearchMessagePage(items, rows[0]["seq"] if more else None)
|
||||
|
||||
async def _page(self, thread_id: str, *, limit: int, before_seq=None, after_seq=None):
|
||||
return await read_visible_message_page(
|
||||
event_store=self.event_store,
|
||||
run_manager=self.run_manager,
|
||||
thread_id=thread_id,
|
||||
user_id=self.user_id,
|
||||
limit=limit,
|
||||
before_seq=before_seq,
|
||||
after_seq=after_seq,
|
||||
message_filter=lambda row: visible_search_text(row, thread_id=thread_id) is not None and message_identity(row.get("content", {})) is not None,
|
||||
)
|
||||
|
||||
async def around(self, thread_id: str, message_id: str, *, limit: int = 20, seq: int | None = None) -> dict | None:
|
||||
row = await self.thread_store.get(thread_id, user_id=self.user_id)
|
||||
if row is None or (row.get("metadata") or {}).get("deerflow_sidecar") is True:
|
||||
return None
|
||||
await self.seed_legacy_history(self.project_thread(row))
|
||||
# Walk backward from the head so later versions win even when they
|
||||
# live on another storage page. Sequence hints never select old text.
|
||||
rows = await self.latest_rows(thread_id)
|
||||
index = next((i for i, item in enumerate(rows) if message_identity(item["content"]) == f"message:{message_id}"), None)
|
||||
if index is None or (seq is not None and rows[index]["seq"] != seq):
|
||||
return None
|
||||
target = rows[index]
|
||||
seq = target["seq"]
|
||||
left, right = max(0, index - limit), min(len(rows), index + limit + 1)
|
||||
older, newer = rows[left:index], rows[index + 1 : right]
|
||||
has_before, has_after = left > 0, right < len(rows)
|
||||
# Recheck ownership/incarnation after the asynchronous history reads.
|
||||
current = await self.thread_store.get(thread_id, user_id=self.user_id)
|
||||
if current is None or current.get("incarnation") != row.get("incarnation"):
|
||||
return None
|
||||
return {"data": older + [target] + newer, "target_seq": seq, "has_before": has_before, "has_after": has_after}
|
||||
|
||||
async def latest_rows(self, thread_id: str) -> list[dict]:
|
||||
latest = {}
|
||||
before = None
|
||||
while True:
|
||||
rows, more = await self._page(thread_id, limit=200, before_seq=before)
|
||||
for row in reversed(rows):
|
||||
latest.setdefault(message_identity(row["content"]), row)
|
||||
if not more:
|
||||
break
|
||||
before = rows[0]["seq"]
|
||||
return sorted(latest.values(), key=lambda row: row["seq"])
|
||||
|
||||
async def latest_message(self, thread: SearchThread, message_id: str) -> SearchMessage | None:
|
||||
if not await self.owns(thread):
|
||||
return None
|
||||
before = None
|
||||
while True:
|
||||
rows, more = await self._page(thread.thread_id, limit=200, before_seq=before)
|
||||
for row in reversed(rows):
|
||||
if message_identity(row["content"]) == f"message:{message_id}":
|
||||
return self.project(thread, row)
|
||||
if not more:
|
||||
return None
|
||||
before = rows[0]["seq"]
|
||||
@ -2283,6 +2283,38 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs/stream \
|
||||
> section for details. Scheduled-task launches use
|
||||
> `scheduler.recursion_limit` from `config.yaml` instead of a client body.
|
||||
|
||||
## Workspace search
|
||||
|
||||
`POST /api/threads/search` additionally accepts an optional `query` (up to 256
|
||||
characters). It matches a trimmed, case-insensitive literal substring of the
|
||||
conversation title before pagination, preserving the existing array response.
|
||||
|
||||
`POST /api/search` requires `threads:read` and returns grouped title/message
|
||||
matches for the current user. Message results additionally require `runs:read`
|
||||
and an installed `workspace_search` extension provider.
|
||||
|
||||
```json
|
||||
{"query":"报告","types":["thread","message"],"archived":null,"limit":20,"cursor":null}
|
||||
```
|
||||
|
||||
`types` defaults to both kinds; `limit` is 1–100. `archived` is a nullable boolean;
|
||||
`project_id` omitted includes every project, explicit null selects unassigned
|
||||
threads. Blank queries return recent titles. The response is
|
||||
`{items, cursor, indexing, messages_available}`. Each item carries `type`,
|
||||
`thread_id`, `title`, `agent_name`, `archived`, `project_id`, `updated_at` and
|
||||
`snippet` (plain-text `{text, matched?}` segments). Message hits also carry the
|
||||
canonical `message_id`, thread-global `seq` and `role`. Preserve the query and
|
||||
filters with each opaque cursor; invalid cursors return 400. When `indexing` is
|
||||
true, retry the current page until historical backfill completes. Provider errors
|
||||
return 503; without a provider, title search stays available.
|
||||
|
||||
`GET /api/threads/{thread_id}/messages/around?message_id=...&seq=...&limit=20`
|
||||
requires `runs:read` and thread ownership. `seq` is an optional exact version hint;
|
||||
`limit` (1–50) bounds each side of the target. The response contains `messages`,
|
||||
`target_seq`, `has_before` and `has_after`. Hidden/superseded targets and stale
|
||||
version hints return 404. Earlier/later navigation uses the boundary message as
|
||||
the next anchor. The UI keeps these windows separate from live history.
|
||||
|
||||
## Chat archive and restore
|
||||
|
||||
`POST /api/threads/search` accepts `archived: true` for archived chats or
|
||||
|
||||
@ -102,7 +102,7 @@ from deerflow_extension_api.state import ExtensionData
|
||||
|
||||
#: Contract version. Before 1.0, minors may break and patches are additive.
|
||||
#: From 1.0 on, bump the major for breaking changes.
|
||||
API_VERSION = "0.2.11"
|
||||
API_VERSION = "0.2.12"
|
||||
|
||||
__all__ = [
|
||||
"AGENT_RUNS_CONTEXT_KEY",
|
||||
|
||||
@ -210,6 +210,10 @@ class ExtensionRegistry(Protocol):
|
||||
"""
|
||||
return False
|
||||
|
||||
def workspace_search(self, provider: Any) -> bool:
|
||||
"""Register one WorkspaceSearchProvider; False on hosts without search."""
|
||||
return False
|
||||
|
||||
def middlewares(self, contributor: MiddlewareContributor) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
"""Owner-bound workspace search contributions. No framework or storage dependency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchThread:
|
||||
thread_id: str
|
||||
incarnation: str
|
||||
title: str
|
||||
updated_at: str
|
||||
agent_name: str | None = None
|
||||
project_id: str | None = None
|
||||
archived: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchMessage:
|
||||
thread_id: str
|
||||
incarnation: str
|
||||
message_id: str
|
||||
run_id: str
|
||||
seq: int
|
||||
role: str
|
||||
text: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchMessagePage:
|
||||
items: tuple[SearchMessage, ...] = ()
|
||||
before_seq: int | None = None
|
||||
|
||||
|
||||
class SearchCorpus(Protocol):
|
||||
"""Host-scoped visible conversation reader; IDs never grant access."""
|
||||
|
||||
user_id: str
|
||||
|
||||
async def threads(self) -> tuple[SearchThread, ...]: ...
|
||||
|
||||
async def revision(self, thread: SearchThread) -> str | None: ...
|
||||
|
||||
async def messages(self, thread: SearchThread, *, before_seq: int | None) -> SearchMessagePage: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchPage:
|
||||
items: tuple[SearchMessage, ...] = ()
|
||||
cursor: str | None = None
|
||||
indexing: bool = False
|
||||
|
||||
|
||||
class WorkspaceSearchProvider(Protocol):
|
||||
"""Return literal, case-insensitive matches. Host rechecks each candidate.
|
||||
|
||||
Cursor is provider-owned, opaque and scoped to the query. Services own index
|
||||
resources and stop background work before host storage closes.
|
||||
"""
|
||||
|
||||
async def search(self, corpus: SearchCorpus, *, query: str, threads: tuple[SearchThread, ...], limit: int, cursor: str | None = None) -> SearchPage: ...
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "deerflow-extension-api"
|
||||
version = "0.2.11"
|
||||
version = "0.2.12"
|
||||
description = "Public contracts for DeerFlow extensions"
|
||||
requires-python = ">=3.12"
|
||||
# Keep the contract package import-light and independent from the host. Public
|
||||
|
||||
@ -86,6 +86,8 @@ hook directory, so they never run a developer's or CI's Git hooks.
|
||||
look identical.
|
||||
- **Shutdown stops accepting detached observations before the memory flush**, so an
|
||||
accepted observation is never registered after shutdown began.
|
||||
- **Search indexes are rebuildable.** The Gateway rechecks each indexed hit
|
||||
against the current owner-visible message before returning text.
|
||||
|
||||
## Full-stack plugins and host access
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ class LoadedExtensions:
|
||||
routers: tuple[tuple[str, Any], ...] = ()
|
||||
plugins: tuple[tuple[str, PluginContribution], ...] = ()
|
||||
knowledge_providers: tuple[tuple[str, tuple[str, Any]], ...] = ()
|
||||
workspace_search_providers: tuple[tuple[str, Any], ...] = ()
|
||||
documentation: tuple[tuple[str, dict], ...] = ()
|
||||
|
||||
# Precomputed attributes, not methods: hook sites read one attribute to
|
||||
@ -87,6 +88,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
self._routers: list[_Entry] = []
|
||||
self._plugins: list[_Entry] = []
|
||||
self._knowledge_providers: list[_Entry] = []
|
||||
self._workspace_search_providers: list[_Entry] = []
|
||||
self._current_source: str | None = None
|
||||
self._model_access: ModelInvocationScope | None = None
|
||||
|
||||
@ -149,6 +151,14 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
self._plugins.append((self._source(), contribution))
|
||||
return True
|
||||
|
||||
def workspace_search(self, provider: Any) -> bool:
|
||||
if self._workspace_search_providers:
|
||||
raise ValueError("Only one workspace search provider may be installed")
|
||||
if not inspect.iscoroutinefunction(getattr(provider, "search", None)):
|
||||
raise ValueError("Workspace search requires an async search method")
|
||||
self._workspace_search_providers.append((self._source(), provider))
|
||||
return True
|
||||
|
||||
def middlewares(self, contributor: MiddlewareContributor) -> None:
|
||||
self._middlewares.append((self._source(), contributor))
|
||||
|
||||
@ -207,6 +217,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
self._routers,
|
||||
self._plugins,
|
||||
self._knowledge_providers,
|
||||
self._workspace_search_providers,
|
||||
):
|
||||
bucket[:] = [entry for entry in bucket if entry[0] != source]
|
||||
|
||||
@ -222,6 +233,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
len(self._routers),
|
||||
len(self._plugins),
|
||||
len(self._knowledge_providers),
|
||||
len(self._workspace_search_providers),
|
||||
)
|
||||
|
||||
def rollback_to(self, mark: tuple[int, ...]) -> None:
|
||||
@ -242,6 +254,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
self._routers,
|
||||
self._plugins,
|
||||
self._knowledge_providers,
|
||||
self._workspace_search_providers,
|
||||
),
|
||||
mark,
|
||||
strict=True,
|
||||
@ -260,6 +273,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
|
||||
routers=tuple(self._routers),
|
||||
plugins=tuple(self._plugins),
|
||||
knowledge_providers=tuple(self._knowledge_providers),
|
||||
workspace_search_providers=tuple(self._workspace_search_providers),
|
||||
has_middleware_contributors=bool(self._middlewares),
|
||||
has_task_lifecycle=bool(self._task_lifecycle),
|
||||
has_system_model_observers=bool(self._system_model_observers),
|
||||
|
||||
@ -97,6 +97,7 @@ class ThreadMetaStore(abc.ABC):
|
||||
self,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
query: str | None = None,
|
||||
status: str | None = None,
|
||||
archived: bool | None = None,
|
||||
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
|
||||
|
||||
@ -106,6 +106,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
self,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
query: str | None = None,
|
||||
status: str | None = None,
|
||||
archived: bool | None = None,
|
||||
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
|
||||
@ -150,6 +151,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
records = [record for record in records if isinstance(record.get("metadata"), dict) and all(json_value_matches(record["metadata"], key, value) for key, value in metadata.items())]
|
||||
if archived is not None:
|
||||
records = [record for record in records if ((record.get("metadata") or {}).get(THREAD_ARCHIVED_METADATA_KEY) is True) == archived]
|
||||
if query and query.strip():
|
||||
needle = query.strip().casefold()
|
||||
records = [record for record in records if needle in (record.get("display_name") or "").casefold()]
|
||||
records.sort(key=self._sort_key, reverse=True)
|
||||
return records[offset : offset + limit]
|
||||
|
||||
|
||||
@ -215,6 +215,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
self,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
query: str | None = None,
|
||||
status: str | None = None,
|
||||
archived: bool | None = None,
|
||||
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
|
||||
@ -268,10 +269,31 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
else:
|
||||
stmt = stmt.where(ThreadMetaRow.project_id == project_id)
|
||||
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
needle = query.strip().casefold() if query else ""
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
if not needle:
|
||||
result = await session.execute(stmt.limit(limit).offset(offset))
|
||||
return [self._row_to_dict(r) for r in result.scalars()]
|
||||
# Casefold is dialect-independent (including ß -> ss). Scan bounded
|
||||
# metadata batches and stop as soon as the requested page is full.
|
||||
matches = []
|
||||
scanned, skipped = 0, 0
|
||||
while len(matches) < limit:
|
||||
result = await session.execute(stmt.limit(200).offset(scanned))
|
||||
batch = list(result.scalars())
|
||||
for row in batch:
|
||||
if needle not in (row.display_name or "").casefold():
|
||||
continue
|
||||
if skipped < offset:
|
||||
skipped += 1
|
||||
else:
|
||||
matches.append(self._row_to_dict(row))
|
||||
if len(matches) == limit:
|
||||
break
|
||||
if len(batch) < 200:
|
||||
break
|
||||
scanned += len(batch)
|
||||
return matches
|
||||
|
||||
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
||||
"""Return True if the row exists and is owned (or filter bypassed)."""
|
||||
|
||||
@ -13,7 +13,7 @@ dependencies = [
|
||||
# the contract version it implements, extensions declare ranges. A range
|
||||
# here would let pip resolve a newer contract package than this harness
|
||||
# implements, making newer extensions look supported at runtime.
|
||||
"deerflow-extension-api==0.2.11",
|
||||
"deerflow-extension-api==0.2.12",
|
||||
"dotenv>=0.9.9",
|
||||
"exa-py>=1.0.0",
|
||||
"httpx>=0.28.0",
|
||||
|
||||
@ -29,6 +29,7 @@ def main() -> int:
|
||||
parser.add_argument("--port", type=int, default=8011)
|
||||
parser.add_argument("--fixture", default=str(_BACKEND / "tests" / "fixtures" / "replay" / "write_read_file.ultra.json"))
|
||||
parser.add_argument("--cors", default="http://localhost:3000")
|
||||
parser.add_argument("--workspace-search", action="store_true", help="Enable the packaged workspace search provider for full-stack search tests")
|
||||
args = parser.parse_args()
|
||||
|
||||
from _replay_fixture import REPLAY_MODEL_BLOCK, build_config_yaml, prepare_hermetic_extras
|
||||
@ -36,6 +37,10 @@ def main() -> int:
|
||||
home = Path(tempfile.mkdtemp(prefix="replay-gw-"))
|
||||
cfg = home / "config.yaml"
|
||||
cfg.write_text(build_config_yaml(model_block=REPLAY_MODEL_BLOCK, home=home), encoding="utf-8")
|
||||
if args.workspace_search:
|
||||
sys.path.insert(0, str(_BACKEND.parent / "examples/deerflow-extension-workspace-search"))
|
||||
with cfg.open("a", encoding="utf-8") as stream:
|
||||
stream.write("\nplugins:\n - name: workspace-search\n use: deerflow_workspace_search:install\n table_prefix: extension_workspace_search_\n required: true\n")
|
||||
|
||||
# Override (not setdefault): the replay gateway must be hermetic, so an outer
|
||||
# DEER_FLOW_HOME can't leak in and shift prompt-affecting paths/skills.
|
||||
|
||||
@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
|
||||
"""Every additive contract slice bumps both gates together."""
|
||||
from importlib.metadata import version
|
||||
|
||||
assert API_VERSION == "0.2.11"
|
||||
assert API_VERSION == "0.2.12"
|
||||
assert API_VERSION == version("deerflow-extension-api")
|
||||
|
||||
|
||||
|
||||
@ -77,6 +77,7 @@ def _collect_thread_id_routes():
|
||||
project_documents,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
search,
|
||||
skills,
|
||||
subagent_batches,
|
||||
suggestions,
|
||||
@ -93,6 +94,7 @@ def _collect_thread_id_routes():
|
||||
project_documents,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
search,
|
||||
skills,
|
||||
subagent_batches,
|
||||
suggestions,
|
||||
|
||||
343
backend/tests/test_workspace_search.py
Normal file
343
backend/tests/test_workspace_search.py
Normal file
@ -0,0 +1,343 @@
|
||||
"""Search must find the visible transcript, with the same owner and identity rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||
from deerflow.runtime.runs.manager import EditReplayVisibility
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "sqlite"])
|
||||
async def threads(request, tmp_path):
|
||||
if request.param == "memory":
|
||||
yield MemoryThreadMetaStore(InMemoryStore())
|
||||
return
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'threads.db'}")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(ThreadMetaRow.metadata.create_all)
|
||||
yield ThreadMetaRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_title_query_is_literal_unicode_and_precedes_pagination(threads):
|
||||
for thread_id, title in [("a", "旧调研报告"), ("b", "新调研报告"), ("c", "无关标题"), ("d", "Straße_100%")]:
|
||||
await threads.create(thread_id, display_name=title, user_id="alice")
|
||||
await threads.create("foreign", display_name="调研报告", user_id="bob")
|
||||
assert [r["thread_id"] for r in await threads.search(query="报告", user_id="alice", limit=1, offset=1)] == ["a"]
|
||||
assert [r["thread_id"] for r in await threads.search(query="STRASSE_100%", user_id="alice")] == ["d"]
|
||||
assert [r["thread_id"] for r in await threads.search(query="%", user_id="alice")] == ["d"]
|
||||
assert len(await threads.search(query=" ", user_id="alice")) == 4
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_corpus_hides_internal_and_superseded_text_and_normalizes_ids(threads):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
|
||||
events = MemoryRunEventStore()
|
||||
manager = AsyncMock()
|
||||
manager.list_successful_regenerate_sources.return_value = {"old"}
|
||||
manager.list_edit_replay_visibility.return_value = EditReplayVisibility()
|
||||
await threads.create("t", user_id="alice", display_name="报告")
|
||||
for run, mid, kind, extra, caller, body in [
|
||||
("r", "u__user", "human", {"original_user_content": "原始报告"}, "lead_agent", "注入的提示"),
|
||||
("r", "a", "ai", {}, "lead_agent", "回答报告"),
|
||||
("r", "hidden", "ai", {"hide_from_ui": True}, "lead_agent", "隐藏报告"),
|
||||
("old", "old", "ai", {}, "lead_agent", "旧版本报告"),
|
||||
("r", "sub", "ai", {}, "subagent:research", "子任务报告"),
|
||||
("r", "tool", "tool", {}, "lead_agent", "工具日志报告"),
|
||||
]:
|
||||
await events.put(thread_id="t", run_id=run, event_type="message", category="message", content={"id": mid, "type": kind, "content": body, "additional_kwargs": extra}, metadata={"caller": caller})
|
||||
corpus = GatewaySearchCorpus(threads, events, manager, "alice")
|
||||
thread = (await corpus.threads())[0]
|
||||
page = await corpus.messages(thread, before_seq=None)
|
||||
assert [(m.message_id, m.text) for m in page.items] == [("u", "原始报告"), ("a", "回答报告")]
|
||||
assert await GatewaySearchCorpus(threads, events, manager, "bob").threads() == ()
|
||||
assert (await GatewaySearchCorpus(threads, events, manager, "bob").messages(thread, before_seq=None)).items == ()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_message_window_finds_unloaded_history_and_rejects_hidden_target(threads):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
|
||||
events = MemoryRunEventStore()
|
||||
manager = AsyncMock()
|
||||
manager.list_successful_regenerate_sources.return_value = set()
|
||||
manager.list_edit_replay_visibility.return_value = EditReplayVisibility()
|
||||
await threads.create("t", user_id="alice")
|
||||
for i in range(140):
|
||||
await events.put(thread_id="t", run_id="r", event_type="message", category="message", content={"id": f"m-{i}", "type": "human" if i % 2 == 0 else "ai", "content": f"报告 {i}"})
|
||||
corpus = GatewaySearchCorpus(threads, events, manager, "alice")
|
||||
window = await corpus.around("t", "m-4", limit=3)
|
||||
assert window["target_seq"] == 5
|
||||
assert [r["seq"] for r in window["data"]] == list(range(2, 9))
|
||||
assert window["has_before"] and window["has_after"]
|
||||
manager.list_successful_regenerate_sources.return_value = {"r"}
|
||||
assert await corpus.around("t", "m-4", limit=3) is None
|
||||
assert await GatewaySearchCorpus(threads, events, manager, "bob").around("t", "m-4", limit=3) is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def index(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow_extension_api import ExtensionRuntimeDeps
|
||||
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-workspace-search"))
|
||||
from deerflow_workspace_search.index import WorkspaceSearchIndex
|
||||
|
||||
instance = WorkspaceSearchIndex(path=str(tmp_path / "search.db"), wait_seconds=10)
|
||||
await instance.start(ExtensionRuntimeDeps())
|
||||
yield instance
|
||||
await instance.stop()
|
||||
|
||||
|
||||
def manager():
|
||||
result = AsyncMock()
|
||||
result.list_successful_regenerate_sources.return_value = set()
|
||||
result.list_edit_replay_visibility.return_value = EditReplayVisibility()
|
||||
return result
|
||||
|
||||
|
||||
async def put(events, thread_id, message_id, body, *, run_id="r", extra=None):
|
||||
return await events.put(thread_id=thread_id, run_id=run_id, event_type="llm.ai.response", category="message", content={"id": message_id, "type": "ai", "content": body, "additional_kwargs": extra or {}})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("event_backend", ["memory", "jsonl", "sqlite"])
|
||||
async def test_index_backfills_short_chinese_code_unicode_and_literal_punctuation(index, tmp_path, event_backend):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.runtime.events.store.db import DbRunEventStore
|
||||
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
owner = str(get_effective_user_id())
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
await threads.create("t", user_id=owner)
|
||||
engine = None
|
||||
if event_backend == "sqlite":
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'events.db'}")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(RunEventRow.metadata.create_all)
|
||||
events = DbRunEventStore(async_sessionmaker(engine, expire_on_commit=False))
|
||||
else:
|
||||
events = MemoryRunEventStore() if event_backend == "memory" else JsonlRunEventStore(tmp_path / "events")
|
||||
try:
|
||||
for i in range(230):
|
||||
await put(events, "t", f"m-{i}", f"普通历史 {i}")
|
||||
await put(events, "t", "match", "旧版调研报告")
|
||||
await put(events, "t", "match", "调研报告 Straße parse_config_v2 100%_done ")
|
||||
await put(events, "t", "hidden", "机密报告", extra={"hide_from_ui": True})
|
||||
corpus = GatewaySearchCorpus(threads, events, manager(), owner)
|
||||
scope = await corpus.threads()
|
||||
for query in ["报告", "调研报告", "STRASSE", "parse_config_v2", "%_", "100%_done"]:
|
||||
page = await index.search(corpus, query=query, threads=scope, limit=10)
|
||||
assert not page.indexing
|
||||
assert [m.message_id for m in page.items] == ["match"]
|
||||
assert "旧版" not in page.items[0].text
|
||||
for query in ["机密", '" OR *', "100X_done"]:
|
||||
assert not (await index.search(corpus, query=query, threads=scope, limit=10)).items
|
||||
finally:
|
||||
if engine:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_index_refreshes_regeneration_deletion_recreation_and_owner_scope(index):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
events = MemoryRunEventStore()
|
||||
runs = manager()
|
||||
await threads.create("t", user_id="alice")
|
||||
await threads.create("other", user_id="bob")
|
||||
await put(events, "t", "first", "报告第一版", run_id="old")
|
||||
await put(events, "other", "foreign", "报告秘密")
|
||||
corpus = GatewaySearchCorpus(threads, events, runs, "alice")
|
||||
|
||||
async def search(query="报告", limit=10, cursor=None):
|
||||
return await index.search(corpus, query=query, threads=await corpus.threads(), limit=limit, cursor=cursor)
|
||||
|
||||
assert [m.message_id for m in (await search()).items] == ["first"]
|
||||
await put(events, "t", "second", "报告第二版", run_id="new")
|
||||
runs.list_successful_regenerate_sources.return_value = {"old"}
|
||||
assert [m.message_id for m in (await search()).items] == ["second"]
|
||||
await threads.delete("t", user_id="alice")
|
||||
assert not (await search()).items
|
||||
await events.delete_by_thread("t", user_id="alice")
|
||||
await threads.create("t", user_id="alice")
|
||||
assert not (await search()).items
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_index_cursor_pages_every_match_and_rejects_another_query(index):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
await threads.create("t", user_id="alice")
|
||||
events = MemoryRunEventStore()
|
||||
for i in range(7):
|
||||
await put(events, "t", f"m-{i}", "报告")
|
||||
corpus = GatewaySearchCorpus(threads, events, manager(), "alice")
|
||||
scope = await corpus.threads()
|
||||
first = await index.search(corpus, query="报告", threads=scope, limit=2)
|
||||
assert first.cursor
|
||||
with pytest.raises(ValueError):
|
||||
await index.search(corpus, query="other", threads=scope, limit=2, cursor=first.cursor)
|
||||
ids = [m.message_id for m in first.items]
|
||||
page = first
|
||||
while page.cursor:
|
||||
page = await index.search(corpus, query="报告", threads=scope, limit=2, cursor=page.cursor)
|
||||
ids.extend(m.message_id for m in page.items)
|
||||
assert ids == [f"m-{i}" for i in reversed(range(7))]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_http_search_and_jump_are_owner_bound_and_keep_title_contract(index):
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.routers import search
|
||||
from app.gateway.routers import threads as thread_routes
|
||||
|
||||
user = User(id=UUID("00000000-0000-0000-0000-000000000123"), email="search@example.com", password_hash="unused")
|
||||
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
||||
app.include_router(search.router)
|
||||
app.include_router(thread_routes.router)
|
||||
app.state.thread_store = MemoryThreadMetaStore(InMemoryStore())
|
||||
app.state.run_event_store = MemoryRunEventStore()
|
||||
app.state.run_manager = manager()
|
||||
app.state.extensions = SimpleNamespace(workspace_search_providers=(("test", index),))
|
||||
await app.state.thread_store.create("t", user_id=str(user.id), display_name="调研报告", assistant_id="researcher")
|
||||
await app.state.thread_store.create("other", user_id="bob", display_name="调研报告秘密")
|
||||
await put(app.state.run_event_store, "t", "old-message", "包含报告正文")
|
||||
await put(app.state.run_event_store, "other", "secret", "报告秘密")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
title = await client.post("/api/threads/search", json={"query": "报告"})
|
||||
assert title.status_code == 200
|
||||
assert [r["thread_id"] for r in title.json()] == ["t"]
|
||||
response = await client.post("/api/search", json={"query": "报告"})
|
||||
assert response.status_code == 200, response.text
|
||||
hits = response.json()["items"]
|
||||
assert [h["type"] for h in hits] == ["thread", "message"]
|
||||
assert all(h["agent_name"] == "researcher" for h in hits)
|
||||
assert hits[1]["snippet"][1] == {"text": "报告", "matched": True}
|
||||
jump = await client.get("/api/threads/t/messages/around", params={"message_id": "old-message", "seq": 1})
|
||||
assert jump.status_code == 200, jump.text
|
||||
assert jump.json()["messages"][0]["text"] == "包含报告正文"
|
||||
assert (await client.get("/api/threads/other/messages/around", params={"message_id": "secret"})).status_code in {403, 404}
|
||||
assert (await client.post("/api/search", json={"query": "报告", "cursor": "invalid"})).status_code == 400
|
||||
await app.state.thread_store.delete("t", user_id=str(user.id))
|
||||
assert not (await client.post("/api/search", json={"query": "报告"})).json()["items"]
|
||||
|
||||
|
||||
def test_provider_registration_rolls_back_and_rejects_duplicates():
|
||||
from deerflow.extensions.registry import ExtensionRegistry
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
provider = AsyncMock()
|
||||
before = registry.mark()
|
||||
with registry.attributed_to("search"):
|
||||
assert registry.workspace_search(provider)
|
||||
with pytest.raises(ValueError):
|
||||
registry.workspace_search(provider)
|
||||
assert registry.build().workspace_search_providers == (("search", provider),)
|
||||
registry.rollback_to(before)
|
||||
assert registry.build().workspace_search_providers == ()
|
||||
|
||||
|
||||
def test_snippet_preserves_unicode_expansions_and_never_returns_markup():
|
||||
from app.gateway.routers.search import snippet
|
||||
|
||||
parts = snippet("前缀 Straße <script>alert(1)</script>", "STRASSE")
|
||||
assert parts[1] == {"text": "Straße", "matched": True}
|
||||
assert "".join(p["text"] for p in parts) == "前缀 Straße <script>alert(1)</script>"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_projection_matches_chat_control_message_exclusions(index):
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
events = MemoryRunEventStore()
|
||||
await threads.create("t", user_id="alice")
|
||||
for name in ("summary", "loop_warning", "todo_reminder", "todo_completion_reminder"):
|
||||
await events.put(thread_id="t", run_id="r", event_type="llm.ai.response", category="message", content={"type": "ai", "id": name, "name": name, "content": "hidden marker"})
|
||||
await events.put(thread_id="t", run_id="r", event_type="llm.human.input", category="message", content={"type": "human", "id": "activation", "content": "<slash_skill_activation>hidden marker</slash_skill_activation>"})
|
||||
await events.put(thread_id="t", run_id="r", event_type="llm.human.input", category="message", content={"type": "human", "id": "upload", "content": "user text<current_uploads>hidden marker</current_uploads>"})
|
||||
corpus = GatewaySearchCorpus(threads, events, manager(), "alice")
|
||||
scope = await corpus.threads()
|
||||
assert not (await index.search(corpus, query="hidden marker", threads=scope, limit=20)).items
|
||||
assert (await index.search(corpus, query="user text", threads=scope, limit=20)).items[0].text == "user text"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_slow_backfill_is_reported_and_reused(index, monkeypatch):
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
completed = asyncio.Event()
|
||||
refresh = AsyncMock(side_effect=lambda _: completed.wait())
|
||||
|
||||
async def wait_for_release(_):
|
||||
await completed.wait()
|
||||
return True
|
||||
|
||||
refresh.side_effect = wait_for_release
|
||||
monkeypatch.setattr(index, "_refresh", refresh)
|
||||
index.wait_seconds = 0.001
|
||||
corpus = SimpleNamespace(user_id="alice")
|
||||
pending = await index.search(corpus, query="报告", threads=(), limit=10)
|
||||
assert pending.indexing and not pending.items
|
||||
completed.set()
|
||||
index.wait_seconds = 10
|
||||
ready = await index.search(corpus, query="报告", threads=(), limit=10)
|
||||
assert not ready.indexing
|
||||
assert refresh.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_route_filters_and_permission_boundary(monkeypatch, index):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import _router_auth_helpers as auth_helpers
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.routers import search
|
||||
|
||||
user = User(email="filters@example.com")
|
||||
app = auth_helpers.make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
||||
app.include_router(search.router)
|
||||
app.state.thread_store = MemoryThreadMetaStore(InMemoryStore())
|
||||
app.state.run_event_store = MemoryRunEventStore()
|
||||
app.state.run_manager = manager()
|
||||
app.state.extensions = SimpleNamespace(workspace_search_providers=(("test", index),))
|
||||
for tid, meta in (("active", {}), ("archived", {"deerflow_archived": True}), ("project", {"deerflow_project_id": "p"}), ("sidecar", {"deerflow_sidecar": True})):
|
||||
await app.state.thread_store.create(tid, user_id=str(user.id), display_name="报告", metadata=meta)
|
||||
await put(app.state.run_event_store, tid, "m", "报告正文")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/api/search", json={"query": "报告", "archived": False, "project_id": None})
|
||||
assert response.status_code == 200, response.text
|
||||
assert [h["thread_id"] for h in response.json()["items"]] == ["active", "active"]
|
||||
monkeypatch.setattr(auth_helpers, "_STUB_PERMISSIONS", ["threads:read"])
|
||||
response = await client.post("/api/search", json={"query": "报告", "project_id": "p"})
|
||||
assert response.status_code == 200
|
||||
assert not response.json()["messages_available"]
|
||||
assert [h["type"] for h in response.json()["items"]] == ["thread"]
|
||||
assert (await client.get("/api/threads/project/messages/around?message_id=m")).status_code == 403
|
||||
monkeypatch.setattr(auth_helpers, "_STUB_PERMISSIONS", [])
|
||||
assert (await client.post("/api/search", json={"query": "报告"})).status_code == 403
|
||||
123
backend/tests/test_workspace_search_review.py
Normal file
123
backend/tests/test_workspace_search_review.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Review regressions use real message stores and the SQL index."""
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
from sqlalchemy import event
|
||||
from test_workspace_search import index, manager, put # noqa: F401
|
||||
|
||||
from app.gateway.search_corpus import GatewaySearchCorpus
|
||||
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_latest_versions_cross_pages_and_single_thread_lookup(monkeypatch):
|
||||
from app.gateway.routers import search
|
||||
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
events = MemoryRunEventStore()
|
||||
await threads.create("t", user_id="alice")
|
||||
await put(events, "t", "same", "obsolete needle")
|
||||
for number in range(220):
|
||||
await put(events, "t", f"middle-{number}", "context")
|
||||
await put(events, "t", "same", "current answer")
|
||||
corpus = GatewaySearchCorpus(threads, events, manager(), "alice")
|
||||
thread = await corpus.thread("t")
|
||||
assert (await corpus.latest_message(thread, "same")).text == "current answer"
|
||||
assert await corpus.around("t", "same", seq=1) is None
|
||||
window = await corpus.around("t", "same", seq=222, limit=50)
|
||||
assert len({row["content"]["id"] for row in window["data"]}) == len(window["data"])
|
||||
assert "obsolete needle" not in str(window)
|
||||
threads.search = AsyncMock(side_effect=AssertionError("jump must not enumerate other threads"))
|
||||
monkeypatch.setattr(search, "_corpus", lambda request: corpus)
|
||||
result = await inspect.unwrap(search.message_context)("t", SimpleNamespace(), message_id="same", seq=222, limit=5)
|
||||
assert result["target_seq"] == 222
|
||||
threads.search.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_provider_bounds_membership_parameters(index): # noqa: F811
|
||||
from deerflow_extension_api.search import SearchMessage, SearchThread
|
||||
|
||||
threads = tuple(SearchThread(f"t-{i}", "v1", "Title", "2026-01-01") for i in range(1200))
|
||||
await index._replace("alice", threads[-1], "revision", [SearchMessage(threads[-1].thread_id, "v1", "message", "run", 1, "assistant", "needle", "2026-01-01")])
|
||||
index._refresh = AsyncMock(return_value=True)
|
||||
counts = []
|
||||
|
||||
def record(connection, cursor, statement, parameters, context, executemany):
|
||||
if "extension_workspace_search_documents" in statement:
|
||||
counts.append(len(parameters))
|
||||
|
||||
event.listen(index._engine.sync_engine, "before_cursor_execute", record)
|
||||
try:
|
||||
result = await index.search(SimpleNamespace(user_id="alice"), query="needle", threads=threads, limit=20)
|
||||
assert [item.thread_id for item in result.items] == [threads[-1].thread_id]
|
||||
assert max(counts) < 300
|
||||
finally:
|
||||
event.remove(index._engine.sync_engine, "before_cursor_execute", record)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fts_startup_skips_clean_rebuild_and_repairs_fallback_writes(index): # noqa: F811
|
||||
from deerflow_extension_api import ExtensionRuntimeDeps
|
||||
from deerflow_extension_api.search import SearchMessage, SearchThread
|
||||
|
||||
if not index._fts:
|
||||
pytest.skip("SQLite has no trigram FTS")
|
||||
thread = SearchThread("t", "v1", "Title", "2026-01-01")
|
||||
|
||||
def message(body):
|
||||
return SearchMessage("t", "v1", "message", "run", 1, "assistant", body, "2026-01-01")
|
||||
|
||||
await index._replace("alice", thread, "one", [message("old needle")])
|
||||
await index.stop()
|
||||
original = index.__class__(path=index.path)
|
||||
await original.start(ExtensionRuntimeDeps())
|
||||
original._owns_engine = False
|
||||
sf = original._sf
|
||||
statements = []
|
||||
event.listen(original._engine.sync_engine, "before_cursor_execute", lambda c, cu, sql, p, ctx, many: statements.append(sql))
|
||||
try:
|
||||
await original.start(ExtensionRuntimeDeps(session_factory=sf))
|
||||
assert not any(sql.strip() == "DELETE FROM extension_workspace_search_fts" for sql in statements)
|
||||
original._fts = False
|
||||
await original._replace("alice", thread, "two", [message("fresh needle")])
|
||||
statements.clear()
|
||||
await original.start(ExtensionRuntimeDeps(session_factory=sf))
|
||||
assert any(sql.strip() == "DELETE FROM extension_workspace_search_fts" for sql in statements)
|
||||
original._refresh = AsyncMock(return_value=True)
|
||||
result = await original.search(SimpleNamespace(user_id="alice"), query="fresh", threads=(thread,), limit=20)
|
||||
assert [item.text for item in result.items] == ["fresh needle"]
|
||||
finally:
|
||||
await original.stop()
|
||||
await original._engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_legacy_checkpoint_is_seeded_before_revision_completion(monkeypatch):
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
|
||||
from app.gateway import services
|
||||
|
||||
saver = InMemorySaver()
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_edge(START, END)
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "legacy"}}
|
||||
await graph.ainvoke({"messages": [HumanMessage(content="legacy needle", id="old")]}, config)
|
||||
threads = MemoryThreadMetaStore(InMemoryStore())
|
||||
await threads.create("legacy", user_id="alice")
|
||||
events = MemoryRunEventStore()
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(run_event_store=events, checkpointer=saver)))
|
||||
monkeypatch.setattr(services, "abuild_checkpoint_state_accessor", AsyncMock(return_value=(SimpleNamespace(aget=graph.aget_state), config)))
|
||||
corpus = GatewaySearchCorpus(threads, events, manager(), "alice", request=request)
|
||||
thread = await corpus.thread("legacy")
|
||||
assert await corpus.revision(thread)
|
||||
assert [message.text for message in (await corpus.messages(thread, before_seq=None)).items] == ["legacy needle"]
|
||||
assert (await corpus.around("legacy", "old"))["target_seq"] > 0
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@ -967,7 +967,7 @@ extensions = []
|
||||
|
||||
[[package]]
|
||||
name = "deerflow-extension-api"
|
||||
version = "0.2.11"
|
||||
version = "0.2.12"
|
||||
source = { editable = "packages/extension-api" }
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -3724,6 +3724,15 @@ authorization:
|
||||
# See examples/deerflow-extension-example for a package that exercises all five.
|
||||
#
|
||||
# plugins:
|
||||
# - name: workspace-search
|
||||
# package: deerflow-extension-workspace-search
|
||||
# use: deerflow_workspace_search:install
|
||||
# table_prefix: extension_workspace_search_
|
||||
# enabled: true
|
||||
# # Install examples/deerflow-extension-workspace-search first; restart Gateway.
|
||||
# # SQLite/Postgres reuse the application database. Memory deployments use:
|
||||
# config:
|
||||
# path: .deer-flow/workspace-search.db
|
||||
# - name: example # PEP 621 entry-point name
|
||||
# package: deerflow-extension-example # Python distribution managed by uv
|
||||
# use: deerflow_extension_example:install
|
||||
|
||||
@ -30,6 +30,17 @@ Dismissing the drawer returns keyboard focus to the control that opened it.
|
||||
The Agents gallery header wraps to the available width so its import and create
|
||||
actions remain visible beside the compact sidebar on narrow screens.
|
||||
|
||||
## Search conversations and messages
|
||||
|
||||
Use **Search** in the sidebar or **⌘K / Ctrl+K** to find conversation titles and
|
||||
visible user/assistant message text, including code. Filter by conversations or
|
||||
messages and include archived chats. Results show matching excerpts; selecting a
|
||||
message opens its historical context, with earlier/later navigation and a **Return
|
||||
to latest messages** button. Type `>` for new chat, settings and keyboard commands.
|
||||
Title search is built in. Enable message search with the packaged
|
||||
[workspace search extension](../../examples/deerflow-extension-workspace-search/README.md).
|
||||
Its first search backfills existing history and shows progress while indexing.
|
||||
|
||||
## Chat Archive
|
||||
|
||||
Deleting a chat from the sidebar requires confirmation showing its title. Deletion removes the conversation and its files and cannot be undone.
|
||||
|
||||
@ -29,6 +29,7 @@ class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.plugins: list[Any] = []
|
||||
self.knowledge_providers: list[tuple[str, Any]] = []
|
||||
self.workspace_search_providers: list[Any] = []
|
||||
self.middleware_contributors: list[Any] = []
|
||||
self.task_lifecycle_contributors: list[Any] = []
|
||||
self.system_model_observers: list[Any] = []
|
||||
@ -45,6 +46,10 @@ class FakeRegistry:
|
||||
self.knowledge_providers.append((name, provider))
|
||||
return True
|
||||
|
||||
def workspace_search(self, provider: Any) -> bool:
|
||||
self.workspace_search_providers.append(provider)
|
||||
return True
|
||||
|
||||
def middlewares(self, contributor: Any) -> None:
|
||||
self.middleware_contributors.append(contributor)
|
||||
|
||||
@ -92,6 +97,7 @@ def test_install_registers_the_five_demonstrated_contribution_kinds() -> None:
|
||||
assert registry.agent_assembly_observers == []
|
||||
assert registry.context_compaction_observers == []
|
||||
assert registry.knowledge_providers == []
|
||||
assert registry.workspace_search_providers == []
|
||||
assert registry.plugins == []
|
||||
assert [route.path for route in registry.contributed_routers[0].routes] == ["/api/extension-example/stats"]
|
||||
assert install.__deerflow_api__ == "0.2.0"
|
||||
|
||||
67
examples/deerflow-extension-workspace-search/README.md
Normal file
67
examples/deerflow-extension-workspace-search/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Workspace message search
|
||||
|
||||
Adds message-body search to the workspace Search / ⌘K / Ctrl+K panel. The host
|
||||
provides title search, permission checks, highlighted excerpts and deep links;
|
||||
this package maintains the rebuildable message index. Requires a host with the
|
||||
extension-api 0.2.12 workspace search contribution.
|
||||
|
||||
From the repository root, install through the existing extension manager:
|
||||
|
||||
```sh
|
||||
make extension-install SOURCE="$(pwd)/examples/deerflow-extension-workspace-search"
|
||||
```
|
||||
|
||||
Add `table_prefix: extension_workspace_search_` to the resulting `plugins:` entry
|
||||
in `config.yaml`, then restart Gateway. A manually managed installation uses:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- name: workspace-search
|
||||
package: deerflow-extension-workspace-search
|
||||
use: deerflow_workspace_search:install
|
||||
enabled: true
|
||||
table_prefix: extension_workspace_search_
|
||||
config:
|
||||
path: .deer-flow/workspace-search.db
|
||||
```
|
||||
|
||||
The application database is reused when configured. `path` is only the fallback
|
||||
SQLite index location for an in-memory application database. Index tables contain
|
||||
message text and follow the same storage access controls as conversation data.
|
||||
|
||||
Search uses case-insensitive literal substrings, including Chinese, filenames
|
||||
inside messages and code identifiers. SQLite uses FTS5 trigram acceleration when
|
||||
available; one/two-character queries use a literal scan. PostgreSQL uses escaped
|
||||
literal matching with a GIN trigram index when `pg_trgm` is already installed.
|
||||
The extension never installs database extensions. Artifact names outside message
|
||||
text, tool output, hidden controls, internal reasoning and subagent logs are excluded.
|
||||
|
||||
The first nonempty message search starts an owner-scoped historical backfill.
|
||||
Requests wait at most one second and report indexing progress until ready. Later
|
||||
searches compare thread revisions and rebuild changed threads; deletion, editing
|
||||
and regeneration are reconciled before matching. The host rereads every candidate
|
||||
from visible history before showing its excerpt. The index is not an authorization
|
||||
source. Both default and custom-agent conversations preserve their own routes.
|
||||
|
||||
The current refresh reads revision metadata for all of the searching user's
|
||||
threads. Very large histories may spend longer in the indexing state; no external
|
||||
search service or background worker is required. Searches have cursor pagination
|
||||
ordered by title match, thread update time and message sequence.
|
||||
|
||||
Validation: `backend/tests/test_workspace_search.py` covers memory, JSONL and
|
||||
SQLite histories, Unicode/symbol queries, ownership, filters, index refresh and
|
||||
historical windows. `frontend/tests/e2e-real-backend/workspace-search.spec.ts`
|
||||
drives the real Gateway and frontend with synthetic history. Run the replay
|
||||
Gateway with `--workspace-search` to enable this provider in that isolated stack.
|
||||
|
||||
Review follow-up behavior: message jumps use a single owned thread lookup, and
|
||||
latest message versions win across storage pages. Legacy checkpoint-only
|
||||
threads are seeded through the host history-migration reservation before
|
||||
indexing. SQL membership checks use batches of 250 thread IDs. Clean SQLite
|
||||
FTS projections survive restart; literal-fallback writes mark the projection
|
||||
dirty and the next FTS-capable startup repairs it.
|
||||
|
||||
Title matching keeps Python Unicode casefold semantics across SQL dialects,
|
||||
including German sharp-s. It scans metadata in batches of 200 and stops once
|
||||
the requested result page is filled; a no-match query can still scan the
|
||||
owner's title metadata.
|
||||
@ -0,0 +1,10 @@
|
||||
"""Install a persistent workspace search index behind the host search contract."""
|
||||
|
||||
from .index import WorkspaceSearchIndex
|
||||
|
||||
|
||||
def install(registry, config):
|
||||
index = WorkspaceSearchIndex(path=str(config.get("path", ".deer-flow/workspace-search.db")))
|
||||
if not registry.workspace_search(index):
|
||||
raise RuntimeError("This host does not support workspace search providers")
|
||||
registry.service(index)
|
||||
@ -0,0 +1,227 @@
|
||||
"""Rebuildable SQL projection of the host's visible transcript.
|
||||
|
||||
No checkpoint, filesystem conversation scan or streaming-token hook is needed.
|
||||
Refresh jobs read through an explicitly owner-bound host capability. A request
|
||||
waits briefly for its refresh, returning indexing=True while historical data is
|
||||
backfilled. Partial refreshes are never reported as a complete empty result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow_extension_api.search import SearchCorpus, SearchMessage, SearchPage, SearchThread
|
||||
from sqlalchemy import Column, Integer, MetaData, String, Table, Text, and_, delete, insert, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
metadata = MetaData()
|
||||
documents = Table(
|
||||
"extension_workspace_search_documents",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("owner", String(64), nullable=False, index=True),
|
||||
Column("thread_id", String(64), nullable=False, index=True),
|
||||
Column("message_id", Text, nullable=False),
|
||||
Column("seq", Integer, nullable=False),
|
||||
Column("updated_at", String(64), nullable=False),
|
||||
Column("normalized", Text, nullable=False),
|
||||
Column("payload", Text, nullable=False),
|
||||
)
|
||||
revisions = Table(
|
||||
"extension_workspace_search_revisions",
|
||||
metadata,
|
||||
Column("owner", String(64), primary_key=True),
|
||||
Column("thread_id", String(64), primary_key=True),
|
||||
Column("revision", String(64), nullable=False),
|
||||
)
|
||||
fts_state = Table(
|
||||
"extension_workspace_search_fts_state",
|
||||
metadata,
|
||||
Column("key", String(32), primary_key=True),
|
||||
Column("value", String(16), nullable=False),
|
||||
)
|
||||
_FTS = "extension_workspace_search_fts"
|
||||
|
||||
|
||||
class WorkspaceSearchIndex:
|
||||
def __init__(self, *, path: str = ".deer-flow/workspace-search.db", wait_seconds: float = 1.0):
|
||||
self.path = path
|
||||
self.wait_seconds = wait_seconds
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
self._engine = None
|
||||
self._owns_engine = False
|
||||
self._sf = None
|
||||
self._fts = False
|
||||
|
||||
async def start(self, deps):
|
||||
self._sf = deps.session_factory
|
||||
if self._sf is None:
|
||||
await asyncio.to_thread(Path(self.path).absolute().parent.mkdir, parents=True, exist_ok=True)
|
||||
self._engine = create_async_engine(f"sqlite+aiosqlite:///{self.path}")
|
||||
self._sf = async_sessionmaker(self._engine, expire_on_commit=False)
|
||||
self._owns_engine = True
|
||||
else:
|
||||
self._engine = self._sf.kw["bind"]
|
||||
async with self._engine.begin() as connection:
|
||||
await connection.run_sync(metadata.create_all)
|
||||
if self._engine.dialect.name == "sqlite":
|
||||
# Older SQLite distributions may lack FTS5/trigram; literal matching
|
||||
# remains correct, including one/two-character Chinese queries.
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
try:
|
||||
async with self._engine.begin() as connection:
|
||||
existed = await connection.scalar(text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"), {"name": _FTS})
|
||||
state = await connection.scalar(select(fts_state.c.value).where(fts_state.c.key == "projection"))
|
||||
await connection.execute(text(f"CREATE VIRTUAL TABLE IF NOT EXISTS {_FTS} USING fts5(docid UNINDEXED, normalized, tokenize='trigram')"))
|
||||
if not existed or state != "clean":
|
||||
await connection.execute(text(f"DELETE FROM {_FTS}"))
|
||||
await connection.execute(text(f"INSERT INTO {_FTS}(docid, normalized) SELECT id, normalized FROM {documents.name}"))
|
||||
await connection.execute(text(f"INSERT INTO {fts_state.name} (key, value) VALUES ('projection', 'clean') ON CONFLICT(key) DO UPDATE SET value='clean'"))
|
||||
self._fts = True
|
||||
except OperationalError as exc:
|
||||
if "no such module" not in str(exc) and "no such tokenizer" not in str(exc):
|
||||
raise
|
||||
elif self._engine.dialect.name == "postgresql":
|
||||
async with self._engine.begin() as connection:
|
||||
available = await connection.scalar(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')"))
|
||||
if available:
|
||||
await connection.execute(text(f"CREATE INDEX IF NOT EXISTS ix_workspace_search_trgm ON {documents.name} USING gin (normalized gin_trgm_ops)"))
|
||||
|
||||
async def stop(self):
|
||||
tasks = list(self._tasks.values())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
if self._owns_engine and self._engine is not None:
|
||||
await self._engine.dispose()
|
||||
self._sf = None
|
||||
|
||||
async def _replace(self, owner, thread, revision, messages):
|
||||
async with self._sf() as session, session.begin():
|
||||
if self._engine.dialect.name == "sqlite" and not self._fts:
|
||||
await session.execute(text(f"INSERT INTO {fts_state.name} (key, value) VALUES ('projection', 'dirty') ON CONFLICT(key) DO UPDATE SET value='dirty'"))
|
||||
where = and_(documents.c.owner == owner, documents.c.thread_id == thread.thread_id)
|
||||
if self._fts:
|
||||
await session.execute(text(f"DELETE FROM {_FTS} WHERE docid IN (SELECT id FROM {documents.name} WHERE owner=:owner AND thread_id=:thread_id)"), {"owner": owner, "thread_id": thread.thread_id})
|
||||
await session.execute(delete(documents).where(where))
|
||||
if messages:
|
||||
await session.execute(
|
||||
insert(documents),
|
||||
[dict(owner=owner, thread_id=thread.thread_id, message_id=m.message_id, seq=m.seq, updated_at=thread.updated_at, normalized=m.text.casefold(), payload=json.dumps(asdict(m), ensure_ascii=False)) for m in messages],
|
||||
)
|
||||
if self._fts:
|
||||
await session.execute(text(f"INSERT INTO {_FTS}(docid, normalized) SELECT id, normalized FROM {documents.name} WHERE owner=:owner AND thread_id=:thread_id"), {"owner": owner, "thread_id": thread.thread_id})
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
upsert = pg_insert if self._engine.dialect.name == "postgresql" else sqlite_insert
|
||||
await session.execute(upsert(revisions).values(owner=owner, thread_id=thread.thread_id, revision=revision).on_conflict_do_update(index_elements=["owner", "thread_id"], set_={"revision": revision}))
|
||||
|
||||
async def _refresh(self, corpus: SearchCorpus):
|
||||
threads = await corpus.threads()
|
||||
async with self._sf() as session:
|
||||
known = dict((await session.execute(select(revisions.c.thread_id, revisions.c.revision).where(revisions.c.owner == corpus.user_id))).all())
|
||||
for thread in threads:
|
||||
revision = await corpus.revision(thread)
|
||||
if revision is None or known.get(thread.thread_id) == revision:
|
||||
continue
|
||||
messages: dict[str, SearchMessage] = {}
|
||||
before = None
|
||||
while True:
|
||||
page = await corpus.messages(thread, before_seq=before)
|
||||
# Backward pages: keep the latest visible version of each ID.
|
||||
for message in reversed(page.items):
|
||||
messages.setdefault(message.message_id, message)
|
||||
if page.before_seq is None:
|
||||
break
|
||||
if before is not None and page.before_seq >= before:
|
||||
raise RuntimeError("Search backfill cursor did not advance")
|
||||
before = page.before_seq
|
||||
if await corpus.revision(thread) != revision:
|
||||
# A concurrent run changed this thread; retry on the next request.
|
||||
return False
|
||||
await self._replace(corpus.user_id, thread, revision, messages.values())
|
||||
live = {thread.thread_id for thread in threads}
|
||||
for removed in set(known) - live:
|
||||
async with self._sf() as session, session.begin():
|
||||
if self._engine.dialect.name == "sqlite" and not self._fts:
|
||||
await session.execute(text(f"INSERT INTO {fts_state.name} (key, value) VALUES ('projection', 'dirty') ON CONFLICT(key) DO UPDATE SET value='dirty'"))
|
||||
if self._fts:
|
||||
await session.execute(text(f"DELETE FROM {_FTS} WHERE docid IN (SELECT id FROM {documents.name} WHERE owner=:owner AND thread_id=:thread_id)"), {"owner": corpus.user_id, "thread_id": removed})
|
||||
await session.execute(delete(documents).where(documents.c.owner == corpus.user_id, documents.c.thread_id == removed))
|
||||
await session.execute(delete(revisions).where(revisions.c.owner == corpus.user_id, revisions.c.thread_id == removed))
|
||||
return True
|
||||
|
||||
async def search(self, corpus: SearchCorpus, *, query: str, threads: tuple[SearchThread, ...], limit: int, cursor: str | None = None) -> SearchPage:
|
||||
if self._sf is None:
|
||||
raise RuntimeError("Workspace search index is unavailable")
|
||||
task = self._tasks.get(corpus.user_id)
|
||||
if task is None:
|
||||
# Retain completed backfills for the next poll, but bound abandoned
|
||||
# users' task handles and consume background exceptions.
|
||||
for owner, previous in list(self._tasks.items()):
|
||||
if len(self._tasks) < 64:
|
||||
break
|
||||
if previous.done():
|
||||
del self._tasks[owner]
|
||||
task = asyncio.create_task(self._refresh(corpus))
|
||||
task.add_done_callback(lambda completed: None if completed.cancelled() else completed.exception())
|
||||
self._tasks[corpus.user_id] = task
|
||||
try:
|
||||
complete = await asyncio.wait_for(asyncio.shield(task), timeout=self.wait_seconds)
|
||||
except TimeoutError:
|
||||
return SearchPage(indexing=True)
|
||||
finally:
|
||||
if task.done() and self._tasks.get(corpus.user_id) is task:
|
||||
del self._tasks[corpus.user_id]
|
||||
if not complete:
|
||||
return SearchPage(indexing=True)
|
||||
if not threads:
|
||||
return SearchPage()
|
||||
normalized = query.strip().casefold()
|
||||
# The host binds cursors to the filter set. Live thread membership may
|
||||
# change between pages without invalidating an otherwise valid cursor.
|
||||
scope = hashlib.sha256(json.dumps([corpus.user_id, normalized]).encode()).hexdigest()
|
||||
statement = select(documents).where(documents.c.owner == corpus.user_id, documents.c.normalized.contains(normalized, autoescape=True))
|
||||
if self._fts and len(normalized) >= 3:
|
||||
statement = statement.where(documents.c.id.in_(text(f"SELECT docid FROM {_FTS} WHERE {_FTS} MATCH :match"))).params(match='"' + normalized.replace('"', '""') + '"')
|
||||
if cursor:
|
||||
try:
|
||||
saved_scope, updated, thread_id, seq, message_id = json.loads(base64.urlsafe_b64decode(cursor))
|
||||
if saved_scope != scope or not isinstance(seq, int):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("Invalid search cursor") from exc
|
||||
statement = statement.where(
|
||||
or_(
|
||||
documents.c.updated_at < updated,
|
||||
and_(documents.c.updated_at == updated, documents.c.thread_id < thread_id),
|
||||
and_(documents.c.updated_at == updated, documents.c.thread_id == thread_id, documents.c.seq < seq),
|
||||
and_(documents.c.updated_at == updated, documents.c.thread_id == thread_id, documents.c.seq == seq, documents.c.message_id < message_id),
|
||||
)
|
||||
)
|
||||
statement = statement.order_by(documents.c.updated_at.desc(), documents.c.thread_id.desc(), documents.c.seq.desc(), documents.c.message_id.desc()).limit(limit + 1)
|
||||
async with self._sf() as session:
|
||||
rows = []
|
||||
ids = [thread.thread_id for thread in threads]
|
||||
# Keep SQL parameter counts below SQLite's portable limit. Each
|
||||
# batch contributes only its top page; merge by the same total order.
|
||||
for offset in range(0, len(ids), 250):
|
||||
batch = statement.where(documents.c.thread_id.in_(ids[offset : offset + 250]))
|
||||
rows.extend((await session.execute(batch)).mappings().all())
|
||||
rows.sort(key=lambda row: (row["updated_at"], row["thread_id"], row["seq"], row["message_id"]), reverse=True)
|
||||
rows = rows[: limit + 1]
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
next_cursor = None
|
||||
if more:
|
||||
last = rows[-1]
|
||||
next_cursor = base64.urlsafe_b64encode(json.dumps([scope, last["updated_at"], last["thread_id"], last["seq"], last["message_id"]]).encode()).decode()
|
||||
return SearchPage(tuple(SearchMessage(**json.loads(row["payload"])) for row in rows), next_cursor)
|
||||
16
examples/deerflow-extension-workspace-search/pyproject.toml
Normal file
16
examples/deerflow-extension-workspace-search/pyproject.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "deerflow-extension-workspace-search"
|
||||
version = "0.1.0"
|
||||
description = "Visible conversation full-text search for DeerFlow"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["deerflow-extension-api>=0.2.12", "sqlalchemy>=2", "aiosqlite>=0.20"]
|
||||
|
||||
[project.entry-points."deerflow.extensions"]
|
||||
workspace-search = "deerflow_workspace_search:install"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["deerflow_workspace_search"]
|
||||
@ -35,7 +35,7 @@ export default defineConfig({
|
||||
|
||||
webServer: [
|
||||
{
|
||||
command: `uv run python scripts/run_replay_gateway.py --port ${gatewayPort} --cors ${frontendUrl}`,
|
||||
command: `uv run python scripts/run_replay_gateway.py --port ${gatewayPort} --cors ${frontendUrl} --workspace-search`,
|
||||
cwd: "../backend",
|
||||
url: `${gatewayUrl}/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
||||
@ -23,12 +23,10 @@ import {
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import { KnowledgeScopeSelector } from "@/components/workspace/knowledge-scope-selector";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { MESSAGE_LIST_DEFAULT_PADDING_BOTTOM } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
|
||||
import { SearchableMessageList as MessageList } from "@/components/workspace/search-message-window";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
@ -371,6 +369,17 @@ export default function AgentChatPage() {
|
||||
}
|
||||
const executionOptions = {
|
||||
...scopedOptions,
|
||||
onSent: () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("message");
|
||||
url.searchParams.delete("message_seq");
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${url.pathname}${url.search}${url.hash}`,
|
||||
);
|
||||
scopedOptions?.onSent?.();
|
||||
},
|
||||
additionalKwargs: {
|
||||
...scopedOptions?.additionalKwargs,
|
||||
...modelInput.additionalKwargs,
|
||||
|
||||
@ -78,3 +78,8 @@ pnpm catalog:sync # after a change to the capability catalog
|
||||
a large file.
|
||||
- **The parser is told the record separator explicitly.** Auto-detection is
|
||||
corrupted by a newline inside an incomplete quoted field of a truncated sample.
|
||||
|
||||
## Conversation search
|
||||
|
||||
Keep historical message windows separate from live stream history. After
|
||||
the composer accepts a new turn, return to the live conversation.
|
||||
|
||||
@ -18,12 +18,10 @@ import {
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import { KnowledgeScopeSelector } from "@/components/workspace/knowledge-scope-selector";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { MESSAGE_LIST_DEFAULT_PADDING_BOTTOM } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
|
||||
import { SearchableMessageList as MessageList } from "@/components/workspace/search-message-window";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
@ -449,6 +447,17 @@ export default function ChatPage() {
|
||||
}
|
||||
const executionOptions = {
|
||||
...scopedOptions,
|
||||
onSent: () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("message");
|
||||
url.searchParams.delete("message_seq");
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${url.pathname}${url.search}${url.hash}`,
|
||||
);
|
||||
scopedOptions?.onSent?.();
|
||||
},
|
||||
additionalKwargs: {
|
||||
...scopedOptions?.additionalKwargs,
|
||||
...modelInput.additionalKwargs,
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Loader2Icon,
|
||||
SearchIcon,
|
||||
MessagesSquareIcon,
|
||||
KeyboardIcon,
|
||||
MessageSquarePlusIcon,
|
||||
SettingsIcon,
|
||||
@ -8,9 +12,9 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
@ -25,6 +29,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
OPEN_SEARCH_EVENT,
|
||||
pathOfSearchHit,
|
||||
type SearchFilters,
|
||||
} from "@/core/search/api";
|
||||
import { useWorkspaceSearch } from "@/core/search/hooks";
|
||||
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
|
||||
|
||||
import { useSettingsDialog } from "./settings";
|
||||
@ -34,6 +44,24 @@ export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { openSettings } = useSettingsDialog();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [filters, setFilters] = useState<SearchFilters>({
|
||||
type: "all",
|
||||
includeArchived: true,
|
||||
});
|
||||
const commandMode = input.trimStart().startsWith(">");
|
||||
const search = useWorkspaceSearch(
|
||||
input,
|
||||
filters,
|
||||
open && !commandMode && !composing,
|
||||
);
|
||||
const busy = search.waiting || search.isPending || search.isFetching;
|
||||
useEffect(() => {
|
||||
const show = () => setOpen(true);
|
||||
window.addEventListener(OPEN_SEARCH_EVENT, show);
|
||||
return () => window.removeEventListener(OPEN_SEARCH_EVENT, show);
|
||||
}, []);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
@ -72,32 +100,300 @@ export function CommandPalette() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>
|
||||
{metaKey}
|
||||
{shiftKey}N
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 sm:max-w-2xl"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t.workspaceSearch.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t.workspaceSearch.description}
|
||||
</DialogDescription>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(composing ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.nativeEvent.keyCode === 229)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
placeholder={t.workspaceSearch.placeholder}
|
||||
aria-label={t.workspaceSearch.title}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
/>
|
||||
{!commandMode && (
|
||||
<div className="flex flex-wrap items-center gap-1 border-b px-3 py-2">
|
||||
{(["all", "thread", "message"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filters.type === type ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={filters.type === type}
|
||||
onClick={() =>
|
||||
setFilters((current) => ({ ...current, type }))
|
||||
}
|
||||
>
|
||||
{type === "all"
|
||||
? t.workspaceSearch.all
|
||||
: type === "thread"
|
||||
? t.workspaceSearch.threads
|
||||
: t.workspaceSearch.messages}
|
||||
</Button>
|
||||
))}
|
||||
<label className="text-muted-foreground ml-auto flex items-center gap-2 px-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.includeArchived}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
includeArchived: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{t.workspaceSearch.includeArchived}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<CommandList
|
||||
className="max-h-[min(60vh,480px)]"
|
||||
aria-busy={!commandMode && busy}
|
||||
>
|
||||
{!commandMode && (
|
||||
<>
|
||||
{!search.supported && (
|
||||
<p className="text-muted-foreground p-4 text-sm">
|
||||
{t.workspaceSearch.unsupported}
|
||||
</p>
|
||||
)}
|
||||
{search.supported && search.isError && (
|
||||
<div role="alert" className="p-4 text-sm">
|
||||
<p>{t.workspaceSearch.error}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void search.refetch()}
|
||||
>
|
||||
{t.workspaceSearch.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{search.supported &&
|
||||
!search.isError &&
|
||||
busy &&
|
||||
search.items.length === 0 && (
|
||||
<div
|
||||
role="status"
|
||||
className="text-muted-foreground flex items-center gap-2 p-4 text-sm"
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
{t.workspaceSearch.searching}
|
||||
</div>
|
||||
)}
|
||||
{search.data?.pages.some((page) => page.indexing) && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-muted-foreground px-4 py-2 text-sm"
|
||||
>
|
||||
{t.workspaceSearch.indexing}
|
||||
</p>
|
||||
)}
|
||||
{input.trim() &&
|
||||
search.data?.pages[0]?.messages_available === false && (
|
||||
<p className="text-muted-foreground px-4 py-2 text-sm">
|
||||
{t.workspaceSearch.unavailable}
|
||||
</p>
|
||||
)}
|
||||
{(["thread", "message"] as const).map((type) => {
|
||||
const hits = search.items.filter(
|
||||
(hit) => hit.type === type,
|
||||
);
|
||||
if (!hits.length) return null;
|
||||
return (
|
||||
<CommandGroup
|
||||
key={type}
|
||||
heading={
|
||||
type === "message"
|
||||
? t.workspaceSearch.messages
|
||||
: input.trim()
|
||||
? t.workspaceSearch.threads
|
||||
: t.workspaceSearch.recent
|
||||
}
|
||||
>
|
||||
{hits.map((hit) => (
|
||||
<CommandItem
|
||||
key={`${hit.type}:${hit.thread_id}:${hit.message_id ?? ""}:${hit.seq ?? ""}`}
|
||||
value={`${hit.type}:${hit.thread_id}:${hit.message_id ?? ""}:${hit.seq ?? ""}`}
|
||||
className="items-start gap-3 px-3 py-3"
|
||||
onSelect={() => {
|
||||
router.push(pathOfSearchHit(hit));
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{type === "thread" ? (
|
||||
<MessagesSquareIcon className="mt-0.5 size-4" />
|
||||
) : (
|
||||
<SearchIcon className="mt-0.5 size-4" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{hit.title.trim()
|
||||
? hit.title
|
||||
: t.workspaceSearch.untitled}
|
||||
</span>
|
||||
{hit.archived && (
|
||||
<span className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
|
||||
<ArchiveIcon className="size-3" />
|
||||
{t.workspaceSearch.archived}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{type === "message" && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{hit.snippet.map((segment, index) =>
|
||||
segment.matched ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="bg-foreground/10 text-foreground rounded-sm font-medium"
|
||||
>
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1 flex gap-2 text-xs">
|
||||
{type === "message" && (
|
||||
<span>
|
||||
{hit.role === "user"
|
||||
? t.workspaceSearch.user
|
||||
: t.workspaceSearch.assistant}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{hit.updated_at
|
||||
? new Date(
|
||||
hit.updated_at,
|
||||
).toLocaleDateString()
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
{!busy &&
|
||||
!search.isError &&
|
||||
search.supported &&
|
||||
search.items.length === 0 &&
|
||||
!search.data?.pages.some((page) => page.indexing) && (
|
||||
<p className="text-muted-foreground p-6 text-center text-sm">
|
||||
{t.workspaceSearch.empty}
|
||||
</p>
|
||||
)}
|
||||
{search.hasNextPage && (
|
||||
<CommandItem
|
||||
value="load-more"
|
||||
disabled={search.isFetchingNextPage}
|
||||
onSelect={() => void search.fetchNextPage()}
|
||||
>
|
||||
{t.workspaceSearch.more}
|
||||
</CommandItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{commandMode &&
|
||||
![
|
||||
t.sidebar.newChat,
|
||||
t.common.settings,
|
||||
t.shortcuts.keyboardShortcuts,
|
||||
].some((label) =>
|
||||
label
|
||||
.toLocaleLowerCase()
|
||||
.includes(
|
||||
input.trimStart().slice(1).trim().toLocaleLowerCase(),
|
||||
),
|
||||
) && (
|
||||
<div
|
||||
role="status"
|
||||
className="text-muted-foreground py-8 text-center text-sm"
|
||||
>
|
||||
{t.workspaceSearch.empty}
|
||||
</div>
|
||||
)}
|
||||
{(commandMode || !input.trim()) && (
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
{[
|
||||
{
|
||||
label: t.sidebar.newChat,
|
||||
icon: MessageSquarePlusIcon,
|
||||
action: handleNewChat,
|
||||
shortcut: `${metaKey}${shiftKey}N`,
|
||||
},
|
||||
{
|
||||
label: t.common.settings,
|
||||
icon: SettingsIcon,
|
||||
action: handleOpenSettings,
|
||||
shortcut: `${metaKey},`,
|
||||
},
|
||||
{
|
||||
label: t.shortcuts.keyboardShortcuts,
|
||||
icon: KeyboardIcon,
|
||||
action: handleShowShortcuts,
|
||||
shortcut: `${metaKey}/`,
|
||||
},
|
||||
]
|
||||
.filter(
|
||||
(action) =>
|
||||
!commandMode ||
|
||||
action.label
|
||||
.toLocaleLowerCase()
|
||||
.includes(
|
||||
input
|
||||
.trimStart()
|
||||
.slice(1)
|
||||
.trim()
|
||||
.toLocaleLowerCase(),
|
||||
),
|
||||
)
|
||||
.map((action) => (
|
||||
<CommandItem
|
||||
key={action.label}
|
||||
value={action.label}
|
||||
onSelect={action.action}
|
||||
className="px-3 py-3"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
{action.label}
|
||||
<CommandShortcut>{action.shortcut}</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
<div className="text-muted-foreground border-t px-4 py-2 text-xs">
|
||||
{t.workspaceSearch.hint}
|
||||
</div>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
202
frontend/src/components/workspace/search-message-window.tsx
Normal file
202
frontend/src/components/workspace/search-message-window.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowDownIcon, ArrowUpIcon, Loader2Icon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState, type ComponentProps } from "react";
|
||||
import type { ImgHTMLAttributes } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveMessageImageURL } from "@/core/artifacts/utils";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { fetchMessageWindow } from "@/core/search/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MarkdownContent } from "./messages/markdown-content";
|
||||
import { createMarkdownLinkComponent } from "./messages/markdown-link";
|
||||
import { MessageList } from "./messages/message-list";
|
||||
|
||||
export function SearchableMessageList(
|
||||
props: ComponentProps<typeof MessageList>,
|
||||
) {
|
||||
const params = useSearchParams();
|
||||
const messageId = params.get("message");
|
||||
const seq = Number(params.get("message_seq"));
|
||||
if (!messageId) return <MessageList {...props} />;
|
||||
return (
|
||||
<SearchMessageWindow
|
||||
key={`${props.threadId}:${messageId}:${seq}`}
|
||||
threadId={props.threadId}
|
||||
messageId={messageId}
|
||||
seq={Number.isSafeInteger(seq) && seq > 0 ? seq : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchMessageWindow({
|
||||
threadId,
|
||||
messageId,
|
||||
seq,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
seq?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
const target = useRef<HTMLElement | null>(null);
|
||||
const viewport = useRef<HTMLDivElement | null>(null);
|
||||
const [anchor, setAnchor] = useState({ messageId, seq });
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"search-message-window",
|
||||
user?.id,
|
||||
threadId,
|
||||
anchor.messageId,
|
||||
anchor.seq,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchMessageWindow(threadId, anchor.messageId, anchor.seq, signal),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (query.data && target.current && viewport.current) {
|
||||
viewport.current.scrollTop =
|
||||
target.current.offsetTop -
|
||||
(viewport.current.clientHeight - target.current.offsetHeight) / 2;
|
||||
}
|
||||
}, [query.data]);
|
||||
const returnToLatest = () => {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
next.delete("message");
|
||||
next.delete("message_seq");
|
||||
const queryString = next.toString();
|
||||
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, {
|
||||
scroll: false,
|
||||
});
|
||||
};
|
||||
const markdownComponents = {
|
||||
a: createMarkdownLinkComponent(threadId),
|
||||
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
{...props}
|
||||
src={
|
||||
typeof src === "string"
|
||||
? resolveMessageImageURL(src, threadId, [])
|
||||
: src
|
||||
}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
),
|
||||
};
|
||||
const data = query.data;
|
||||
const first = data?.messages[0];
|
||||
const last = data?.messages.at(-1);
|
||||
return (
|
||||
<section
|
||||
className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden pt-12"
|
||||
aria-label={t.workspaceSearch.context}
|
||||
data-testid="search-message-window"
|
||||
>
|
||||
<div className="bg-background flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2 text-sm">
|
||||
<span>{t.workspaceSearch.context}</span>
|
||||
<Button variant="ghost" size="sm" onClick={returnToLatest}>
|
||||
{t.workspaceSearch.returnToLatest}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={viewport}
|
||||
className="relative min-h-0 flex-1 overflow-y-auto px-4 py-6"
|
||||
>
|
||||
{query.isPending && (
|
||||
<div role="status" className="flex justify-center gap-2 py-8 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
{t.workspaceSearch.loadingContext}
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
<div role="alert" className="mx-auto max-w-xl py-8 text-sm">
|
||||
<p>{t.workspaceSearch.messageUnavailable}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
{t.workspaceSearch.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
{data.has_before && first && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setAnchor({ messageId: first.message_id, seq: first.seq })
|
||||
}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
{t.workspaceSearch.earlier}
|
||||
</Button>
|
||||
)}
|
||||
{data.messages.map((message) => (
|
||||
<article
|
||||
key={`${message.message_id}:${message.seq}`}
|
||||
ref={message.seq === data.target_seq ? target : undefined}
|
||||
data-message-id={message.message_id}
|
||||
data-search-target={
|
||||
message.seq === data.target_seq || undefined
|
||||
}
|
||||
className={cn(
|
||||
"rounded-lg p-4 text-sm",
|
||||
message.role === "user" && "bg-muted",
|
||||
message.seq === data.target_seq && "ring-ring ring-2",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-3 text-sm">
|
||||
<span>
|
||||
{message.role === "user"
|
||||
? t.workspaceSearch.user
|
||||
: t.workspaceSearch.assistant}
|
||||
</span>
|
||||
{message.seq === data.target_seq && (
|
||||
<span>{t.workspaceSearch.matchedMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
{message.role === "assistant" ? (
|
||||
<MarkdownContent
|
||||
content={message.text}
|
||||
isLoading={false}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
) : (
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{data.has_after && last && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setAnchor({ messageId: last.message_id, seq: last.seq })
|
||||
}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
{t.workspaceSearch.later}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SearchIcon,
|
||||
BotIcon,
|
||||
CalendarClock,
|
||||
MessagesSquare,
|
||||
@ -9,12 +10,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -25,6 +28,7 @@ import { useAgentsApiEnabled } from "@/core/agents";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { OPEN_SEARCH_EVENT } from "@/core/search/api";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
@ -32,6 +36,11 @@ export function WorkspaceNavChatList() {
|
||||
const pathname = usePathname();
|
||||
const { enabled: agentsEnabled } = useAgentsApiEnabled();
|
||||
const { user } = useAuth();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const [shortcut, setShortcut] = useState("⌘K");
|
||||
useEffect(() => {
|
||||
setShortcut(navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K");
|
||||
}, []);
|
||||
const usageEnabled =
|
||||
!!user &&
|
||||
hasPermission(user, PERMISSIONS.RUNS_READ) &&
|
||||
@ -39,6 +48,23 @@ export function WorkspaceNavChatList() {
|
||||
return (
|
||||
<SidebarGroup className="pt-1">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
window.dispatchEvent(new Event(OPEN_SEARCH_EVENT));
|
||||
}}
|
||||
aria-label={t.workspaceSearch.open}
|
||||
tooltip={t.workspaceSearch.title}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<SearchIcon />
|
||||
<span>{t.workspaceSearch.open}</span>
|
||||
<kbd aria-hidden className="ml-auto text-xs">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
|
||||
<Link className="text-muted-foreground" href="/workspace/chats">
|
||||
|
||||
@ -11,6 +11,11 @@ export const extensionCatalog = [
|
||||
namespace: "office-preview",
|
||||
icon: "file-text",
|
||||
},
|
||||
{
|
||||
key: "workspaceSearch",
|
||||
package: "deerflow-extension-workspace-search",
|
||||
namespace: null,
|
||||
},
|
||||
{
|
||||
key: "agentTeams",
|
||||
icon: "users",
|
||||
|
||||
@ -124,6 +124,11 @@ export const enUS: Translations = {
|
||||
},
|
||||
catalog: {
|
||||
officePreview: bundledCopy("deerflow-extension-office-preview", "en-US"),
|
||||
workspaceSearch: {
|
||||
title: "Conversation search",
|
||||
description:
|
||||
"Search historical message text and jump to the matching conversation turn.",
|
||||
},
|
||||
agentTeams: bundledCopy("deerflow-extension-agent-teams", "en-US"),
|
||||
bookmarks: bundledCopy("deerflow-extension-bookmarks", "en-US"),
|
||||
context: bundledCopy("deerflow-extension-jev-context", "en-US"),
|
||||
@ -2055,6 +2060,39 @@ export const enUS: Translations = {
|
||||
},
|
||||
|
||||
// Shortcuts
|
||||
workspaceSearch: {
|
||||
title: "Search conversations",
|
||||
description: "Find conversations and messages, or run a command.",
|
||||
placeholder: "Search titles and messages…",
|
||||
open: "Search",
|
||||
all: "All",
|
||||
threads: "Conversations",
|
||||
messages: "Messages",
|
||||
includeArchived: "Include archived",
|
||||
archived: "Archived",
|
||||
recent: "Recent conversations",
|
||||
searching: "Searching…",
|
||||
indexing: "Preparing message search… Results will appear here shortly.",
|
||||
unavailable:
|
||||
"Message search is unavailable on this installation. You can search conversation titles.",
|
||||
unsupported: "Search is unavailable in this view.",
|
||||
error: "Search could not finish. Try again.",
|
||||
empty: "No matches. Try another keyword or include archived conversations.",
|
||||
more: "Show more results",
|
||||
retry: "Try again",
|
||||
hint: "↑ ↓ to navigate · Enter to open · > for commands",
|
||||
user: "You",
|
||||
assistant: "Assistant",
|
||||
context: "Message context",
|
||||
returnToLatest: "Return to latest messages",
|
||||
loadingContext: "Loading message context…",
|
||||
messageUnavailable:
|
||||
"This message is no longer available. Return to the conversation to view its current history.",
|
||||
matchedMessage: "Located message",
|
||||
earlier: "Earlier messages",
|
||||
later: "Later messages",
|
||||
untitled: "Untitled conversation",
|
||||
},
|
||||
shortcuts: {
|
||||
searchActions: "Search actions...",
|
||||
noResults: "No results found.",
|
||||
|
||||
@ -99,6 +99,7 @@ export interface Translations {
|
||||
};
|
||||
catalog: Record<
|
||||
| "officePreview"
|
||||
| "workspaceSearch"
|
||||
| "workflows"
|
||||
| "reportSections"
|
||||
| "agentTeams"
|
||||
@ -1749,6 +1750,37 @@ export interface Translations {
|
||||
};
|
||||
|
||||
// Shortcuts
|
||||
workspaceSearch: {
|
||||
title: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
open: string;
|
||||
all: string;
|
||||
threads: string;
|
||||
messages: string;
|
||||
includeArchived: string;
|
||||
archived: string;
|
||||
recent: string;
|
||||
searching: string;
|
||||
indexing: string;
|
||||
unavailable: string;
|
||||
unsupported: string;
|
||||
error: string;
|
||||
empty: string;
|
||||
more: string;
|
||||
retry: string;
|
||||
hint: string;
|
||||
user: string;
|
||||
assistant: string;
|
||||
context: string;
|
||||
returnToLatest: string;
|
||||
loadingContext: string;
|
||||
messageUnavailable: string;
|
||||
matchedMessage: string;
|
||||
earlier: string;
|
||||
later: string;
|
||||
untitled: string;
|
||||
};
|
||||
shortcuts: {
|
||||
searchActions: string;
|
||||
noResults: string;
|
||||
|
||||
@ -117,6 +117,10 @@ export const zhCN: Translations = {
|
||||
},
|
||||
catalog: {
|
||||
officePreview: bundledCopy("deerflow-extension-office-preview", "zh-CN"),
|
||||
workspaceSearch: {
|
||||
title: "会话全文搜索",
|
||||
description: "检索历史消息正文,并跳转到命中的对话轮次。",
|
||||
},
|
||||
agentTeams: bundledCopy("deerflow-extension-agent-teams", "zh-CN"),
|
||||
bookmarks: bundledCopy("deerflow-extension-bookmarks", "zh-CN"),
|
||||
context: bundledCopy("deerflow-extension-jev-context", "zh-CN"),
|
||||
@ -1940,6 +1944,37 @@ export const zhCN: Translations = {
|
||||
},
|
||||
|
||||
// Shortcuts
|
||||
workspaceSearch: {
|
||||
title: "搜索对话",
|
||||
description: "查找历史对话和消息,或执行快捷操作。",
|
||||
placeholder: "搜索标题和消息内容…",
|
||||
open: "搜索",
|
||||
all: "全部",
|
||||
threads: "对话",
|
||||
messages: "消息",
|
||||
includeArchived: "包含已归档",
|
||||
archived: "已归档",
|
||||
recent: "最近对话",
|
||||
searching: "正在搜索…",
|
||||
indexing: "正在准备历史消息搜索,结果将自动显示。",
|
||||
unavailable: "当前部署尚未启用消息搜索,可以搜索对话标题。",
|
||||
unsupported: "当前视图暂不支持搜索。",
|
||||
error: "搜索未能完成,请重试。",
|
||||
empty: "没有找到匹配内容,试试其他关键词或包含已归档对话。",
|
||||
more: "显示更多结果",
|
||||
retry: "重试",
|
||||
hint: "↑ ↓ 选择 · Enter 打开 · 输入 > 查找操作",
|
||||
user: "你",
|
||||
assistant: "助手",
|
||||
context: "消息上下文",
|
||||
returnToLatest: "返回最新消息",
|
||||
loadingContext: "正在加载消息上下文…",
|
||||
messageUnavailable: "这条消息已不可用,可返回对话查看当前历史。",
|
||||
matchedMessage: "已定位消息",
|
||||
earlier: "更早的消息",
|
||||
later: "后续消息",
|
||||
untitled: "未命名对话",
|
||||
},
|
||||
shortcuts: {
|
||||
searchActions: "搜索操作...",
|
||||
noResults: "未找到结果。",
|
||||
|
||||
91
frontend/src/core/search/api.ts
Normal file
91
frontend/src/core/search/api.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { pathOfThread } from "@/core/threads/utils";
|
||||
|
||||
export const OPEN_SEARCH_EVENT = "deerflow:open-search";
|
||||
|
||||
export type SearchSegment = { text: string; matched?: boolean };
|
||||
export type WorkspaceSearchHit = {
|
||||
type: "thread" | "message";
|
||||
thread_id: string;
|
||||
title: string;
|
||||
agent_name: string | null;
|
||||
archived: boolean;
|
||||
updated_at: string;
|
||||
project_id: string | null;
|
||||
message_id?: string;
|
||||
seq?: number;
|
||||
role?: "user" | "assistant";
|
||||
snippet: SearchSegment[];
|
||||
};
|
||||
export type WorkspaceSearchPage = {
|
||||
items: WorkspaceSearchHit[];
|
||||
cursor: string | null;
|
||||
indexing: boolean;
|
||||
messages_available: boolean;
|
||||
};
|
||||
export type SearchFilters = {
|
||||
type: "all" | "thread" | "message";
|
||||
includeArchived: boolean;
|
||||
};
|
||||
|
||||
export async function searchWorkspace(
|
||||
query: string,
|
||||
filters: SearchFilters,
|
||||
cursor: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<WorkspaceSearchPage> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
types: filters.type === "all" ? ["thread", "message"] : [filters.type],
|
||||
archived: filters.includeArchived ? null : false,
|
||||
limit: 20,
|
||||
cursor,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
||||
return (await response.json()) as WorkspaceSearchPage;
|
||||
}
|
||||
|
||||
export function pathOfSearchHit(hit: WorkspaceSearchHit): string {
|
||||
const path = pathOfThread(hit.thread_id, {
|
||||
agent_name: hit.agent_name ?? undefined,
|
||||
});
|
||||
if (hit.type === "thread" || !hit.message_id) return path;
|
||||
const params = new URLSearchParams({ message: hit.message_id });
|
||||
if (hit.seq !== undefined) params.set("message_seq", String(hit.seq));
|
||||
return `${path}?${params}`;
|
||||
}
|
||||
|
||||
export type MessageWindow = {
|
||||
messages: {
|
||||
message_id: string;
|
||||
seq: number;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
target_seq: number;
|
||||
has_before: boolean;
|
||||
has_after: boolean;
|
||||
};
|
||||
|
||||
export async function fetchMessageWindow(
|
||||
threadId: string,
|
||||
messageId: string,
|
||||
seq: number | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<MessageWindow> {
|
||||
const params = new URLSearchParams({ message_id: messageId });
|
||||
if (seq !== undefined) params.set("seq", String(seq));
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/messages/around?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Message unavailable (${response.status})`);
|
||||
return (await response.json()) as MessageWindow;
|
||||
}
|
||||
65
frontend/src/core/search/hooks.ts
Normal file
65
frontend/src/core/search/hooks.ts
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission } from "@/core/auth/permissions";
|
||||
import { isStaticWebsiteOnly } from "@/core/static-mode";
|
||||
|
||||
import {
|
||||
searchWorkspace,
|
||||
type SearchFilters,
|
||||
type WorkspaceSearchPage,
|
||||
} from "./api";
|
||||
|
||||
export function useWorkspaceSearch(
|
||||
input: string,
|
||||
filters: SearchFilters,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const [settled, setSettled] = useState(input);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(input), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [input]);
|
||||
const query = input.trim();
|
||||
const ready = settled.trim() === query;
|
||||
const supported =
|
||||
!isStaticWebsiteOnly() && hasPermission(user, "threads:read");
|
||||
const scope = JSON.stringify([
|
||||
user?.id,
|
||||
supported,
|
||||
user?.permissions,
|
||||
filters,
|
||||
]);
|
||||
const result = useInfiniteQuery<
|
||||
WorkspaceSearchPage,
|
||||
Error,
|
||||
InfiniteData<WorkspaceSearchPage>,
|
||||
string[],
|
||||
string | null
|
||||
>({
|
||||
queryKey: ["workspace-search", scope, query],
|
||||
enabled: enabled && ready && supported,
|
||||
placeholderData: (previous, previousQuery) =>
|
||||
previousQuery?.queryKey[1] === scope ? previous : undefined,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
searchWorkspace(query, filters, pageParam, signal),
|
||||
getNextPageParam: (page) => page.cursor ?? undefined,
|
||||
staleTime: 0,
|
||||
gcTime: 60_000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: (state) =>
|
||||
state.state.data?.pages.some((page) => page.indexing) ? 600 : false,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
supported,
|
||||
waiting: !ready,
|
||||
items: result.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
};
|
||||
}
|
||||
81
frontend/tests/e2e-real-backend/workspace-search.spec.ts
Normal file
81
frontend/tests/e2e-real-backend/workspace-search.spec.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("search titles and old message bodies, navigate both directions and return to live history", async ({
|
||||
page,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
const threadId = `search-${Date.now()}`;
|
||||
const title = "新能源行业调研 · 2026 秋季";
|
||||
const needle = "储能成本测算";
|
||||
const created = await request.post("/api/threads", {
|
||||
data: { thread_id: threadId, metadata: { title } },
|
||||
});
|
||||
expect(created.status(), await created.text()).toBe(200);
|
||||
const renamed = await request.post(`/api/threads/${threadId}/state`, {
|
||||
data: { values: { title } },
|
||||
});
|
||||
expect(renamed.ok(), await renamed.text()).toBeTruthy();
|
||||
const messages = Array.from({ length: 140 }, (_, i) => ({
|
||||
id: `search-message-${i}`,
|
||||
role: i % 2 ? "ai" : "human",
|
||||
content:
|
||||
i === 5
|
||||
? `## ${needle}\n\n报告比较了不同储能路线的单位成本与回收周期。\n\n\`\`\`python\ndef payback_years(capex, annual_savings):\n return capex / annual_savings\n\`\`\`\n\n建议采用分阶段投资方案,并持续跟踪电价变化。`
|
||||
: `${i % 2 ? "分析结果" : "后续问题"} ${i}:比较不同技术路线与市场数据。`,
|
||||
}));
|
||||
const seeded = await request.post("/api/test-only/seed-runs", {
|
||||
data: {
|
||||
thread_id: threadId,
|
||||
runs: [
|
||||
{
|
||||
run_id: `${threadId}-run`,
|
||||
created_at: "2026-10-25T08:00:00Z",
|
||||
messages,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(seeded.ok(), await seeded.text()).toBeTruthy();
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page
|
||||
.getByRole("button", { name: /^Search/ })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
const input = dialog.getByPlaceholder("Search titles and messages…");
|
||||
await input.fill("新能源");
|
||||
await expect(
|
||||
dialog.getByRole("option").filter({ hasText: title }).first(),
|
||||
).toBeVisible();
|
||||
await input.fill(needle);
|
||||
const result = dialog.getByRole("option").filter({ hasText: needle }).first();
|
||||
await expect(result).toBeVisible({ timeout: 30_000 });
|
||||
await expect(result.locator("mark")).toHaveText(needle);
|
||||
await page.screenshot({ path: testInfo.outputPath("search-panel.png") });
|
||||
await result.click();
|
||||
await expect(page).toHaveURL(/message=search-message-5&message_seq=6/);
|
||||
const window = page.getByTestId("search-message-window");
|
||||
const target = window.locator('[data-search-target="true"]');
|
||||
await expect(target).toContainText(needle);
|
||||
await expect(target).toBeInViewport();
|
||||
await expect(window.locator("article")).toHaveCount(26);
|
||||
await page.screenshot({ path: testInfo.outputPath("message-jump.png") });
|
||||
await window.getByRole("button", { name: "Later messages" }).click();
|
||||
await expect(target).toHaveAttribute("data-message-id", "search-message-25");
|
||||
await window.getByRole("button", { name: "Earlier messages" }).click();
|
||||
await expect(target).toHaveAttribute("data-message-id", "search-message-5");
|
||||
await page.reload();
|
||||
await expect(window.locator('[data-search-target="true"]')).toContainText(
|
||||
needle,
|
||||
);
|
||||
await window
|
||||
.getByRole("button", { name: "Return to latest messages" })
|
||||
.click();
|
||||
await expect(page).not.toHaveURL(/message=/);
|
||||
await expect(window).toBeHidden();
|
||||
await expect(
|
||||
page.getByText("分析结果 139:比较不同技术路线与市场数据。", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
@ -274,11 +274,12 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search bookmarks", exact: true })
|
||||
.fill("ORCHID");
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
const bookmarkSearch = page
|
||||
.getByRole("region", { name: "Bookmarks", exact: true })
|
||||
.getByRole("button", { name: "Search", exact: true });
|
||||
await bookmarkSearch.click();
|
||||
await expect(page.getByText(/1 saved/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Search", exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(bookmarkSearch).toBeEnabled();
|
||||
await page
|
||||
.getByRole("button", { name: "Open conversation", exact: true })
|
||||
.click();
|
||||
|
||||
191
frontend/tests/e2e/workspace-search-review.spec.ts
Normal file
191
frontend/tests/e2e/workspace-search-review.spec.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
for (const agentName of [undefined, "researcher"]) {
|
||||
test(`new submissions leave historical search: ${agentName ?? "default"}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [{ name: "researcher" }],
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Search chat",
|
||||
agent_name: agentName,
|
||||
messages: [{ id: "old", type: "ai", content: "older answer" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const pathname = agentName
|
||||
? `/workspace/agents/${agentName}/chats/${MOCK_THREAD_ID}`
|
||||
: `/workspace/chats/${MOCK_THREAD_ID}`;
|
||||
await page.route("**/api/search", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
type: "message",
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Historical match",
|
||||
agent_name: agentName,
|
||||
message_id: "old",
|
||||
seq: 1,
|
||||
role: "assistant",
|
||||
snippet: [{ text: "older answer" }],
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/messages/around?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
message_id: "old",
|
||||
seq: 1,
|
||||
role: "assistant",
|
||||
text: "older answer",
|
||||
},
|
||||
],
|
||||
target_seq: 1,
|
||||
has_before: false,
|
||||
has_after: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(pathname);
|
||||
const composer = page.getByRole("textbox", {
|
||||
name: "How can I assist you today?",
|
||||
});
|
||||
await expect(composer).toBeEditable();
|
||||
const first = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === "POST" && r.url().includes("/runs/stream"),
|
||||
);
|
||||
await composer.fill("first turn");
|
||||
await composer.press("Enter");
|
||||
await (await first).finished();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Submit", exact: true }),
|
||||
).toBeEnabled();
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
await dialog.getByPlaceholder("Search titles and messages…").fill("older");
|
||||
await dialog
|
||||
.getByRole("option")
|
||||
.filter({ hasText: "Historical match" })
|
||||
.click();
|
||||
await expect(page.getByTestId("search-message-window")).toBeVisible();
|
||||
const second = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === "POST" && r.url().includes("/runs/stream"),
|
||||
);
|
||||
await composer.fill("second turn");
|
||||
await composer.press("Enter");
|
||||
await (await second).finished();
|
||||
await expect(page).not.toHaveURL(/message=old/);
|
||||
await expect(page.getByTestId("search-message-window")).toHaveCount(0);
|
||||
await expect(page.getByText("second turn", { exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("return to latest preserves unrelated parameters without URLSearchParams.size", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [{ thread_id: MOCK_THREAD_ID, title: "Audit" }],
|
||||
});
|
||||
await page.addInitScript(() =>
|
||||
Object.defineProperty(URLSearchParams.prototype, "size", {
|
||||
configurable: true,
|
||||
get: () => undefined,
|
||||
}),
|
||||
);
|
||||
await page.route("**/messages/around?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
message_id: "old",
|
||||
seq: 1,
|
||||
role: "assistant",
|
||||
text: "Old text",
|
||||
},
|
||||
],
|
||||
target_seq: 1,
|
||||
has_before: false,
|
||||
has_after: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
`/workspace/chats/${MOCK_THREAD_ID}?message=old&message_seq=1&custom=keep`,
|
||||
);
|
||||
await expect(page.getByTestId("search-message-window")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Return to latest messages" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/workspace/chats/${MOCK_THREAD_ID}\\?custom=keep$`),
|
||||
);
|
||||
});
|
||||
|
||||
test("historical markdown resolves thread artifact links and images", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [{ thread_id: MOCK_THREAD_ID, title: "Artifacts" }],
|
||||
});
|
||||
await page.route("**/messages/around?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
message_id: "old",
|
||||
seq: 1,
|
||||
role: "assistant",
|
||||
text: "[Report](/mnt/user-data/outputs/report.md)\n\n",
|
||||
},
|
||||
],
|
||||
target_seq: 1,
|
||||
has_before: false,
|
||||
has_after: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
`/workspace/chats/${MOCK_THREAD_ID}?message=old&message_seq=1`,
|
||||
);
|
||||
const window = page.getByTestId("search-message-window");
|
||||
await expect(
|
||||
window.getByRole("link", { name: "Report", exact: true }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
/\/api\/threads\/[^/]+\/artifacts\/mnt\/user-data\/outputs\/report.md$/,
|
||||
);
|
||||
await expect(window.getByRole("img", { name: "Chart" })).toHaveAttribute(
|
||||
"src",
|
||||
/\/api\/threads\/[^/]+\/artifacts\/mnt\/user-data\/outputs\/chart.png$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("command mode reports a query without matching commands", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
await dialog
|
||||
.getByPlaceholder("Search titles and messages…")
|
||||
.fill(">no-such-command");
|
||||
await expect(dialog.getByRole("status")).toBeVisible();
|
||||
await expect(dialog.getByRole("option")).toHaveCount(0);
|
||||
});
|
||||
227
frontend/tests/e2e/workspace-search.spec.ts
Normal file
227
frontend/tests/e2e/workspace-search.spec.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
mockLangGraphAPI(page, {
|
||||
agents: [{ name: "researcher" }],
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Search fixture",
|
||||
agent_name: "researcher",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("keyboard commands and Escape work without a search provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/search", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto("/workspace/chats/new");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "How can I assist you today?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Search/ }).first(),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
const input = dialog.getByPlaceholder("Search titles and messages…");
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill(">settings");
|
||||
await expect(dialog.getByRole("option", { name: /Settings/ })).toBeVisible();
|
||||
await input.press("Enter");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
});
|
||||
|
||||
test("filters, stale requests, safe snippets and custom agent links", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: {
|
||||
query: string;
|
||||
types: string[];
|
||||
archived: boolean | null;
|
||||
}[] = [];
|
||||
await page.route("**/api/search", async (route) => {
|
||||
const body = route.request().postDataJSON() as (typeof requests)[number];
|
||||
requests.push(body);
|
||||
if (body.query === "old")
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: body.query
|
||||
? [
|
||||
{
|
||||
type: "message",
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: `${body.query} result`,
|
||||
agent_name: "researcher",
|
||||
archived: true,
|
||||
updated_at: "2026-10-25T08:00:00Z",
|
||||
project_id: null,
|
||||
message_id: "original/id",
|
||||
seq: 3,
|
||||
role: "assistant",
|
||||
snippet: [
|
||||
{ text: `<script>${body.query}</script>`, matched: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/workspace/chats/new");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "How can I assist you today?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page
|
||||
.getByRole("button", { name: /^Search/ })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
const input = dialog.getByPlaceholder("Search titles and messages…");
|
||||
await input.fill("old");
|
||||
await expect.poll(() => requests.some((r) => r.query === "old")).toBeTruthy();
|
||||
await input.fill("latest");
|
||||
await expect(dialog.getByText("latest result")).toBeVisible();
|
||||
await expect(dialog.locator("mark")).toHaveText("<script>latest</script>");
|
||||
await dialog.getByRole("button", { name: "Messages", exact: true }).click();
|
||||
await dialog.getByRole("checkbox", { name: "Include archived" }).uncheck();
|
||||
await expect
|
||||
.poll(() => requests.at(-1))
|
||||
.toMatchObject({ query: "latest", types: ["message"], archived: false });
|
||||
await expect(dialog.getByText("old result")).toBeHidden();
|
||||
await dialog.getByRole("option").filter({ hasText: "latest result" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/workspace/agents/researcher/chats/${MOCK_THREAD_ID}\\?message=original%2Fid&message_seq=3`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("empty and error states retry without losing the query", async ({
|
||||
page,
|
||||
}) => {
|
||||
let fail = true;
|
||||
await page.route("**/api/search", (route) =>
|
||||
fail
|
||||
? route.fulfill({ status: 503, json: {} })
|
||||
: route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto("/workspace/chats/new");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "How can I assist you today?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page
|
||||
.getByRole("button", { name: /^Search/ })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
const input = dialog.getByPlaceholder("Search titles and messages…");
|
||||
await input.fill("missing report");
|
||||
await expect(
|
||||
dialog.getByText("Search could not finish. Try again."),
|
||||
).toBeVisible();
|
||||
fail = false;
|
||||
await dialog.getByRole("button", { name: "Try again" }).click();
|
||||
await expect(dialog.getByText(/No matches/)).toBeVisible();
|
||||
await expect(input).toHaveValue("missing report");
|
||||
});
|
||||
|
||||
test("Chinese composition does not dispatch partial searches or select a command", async ({
|
||||
page,
|
||||
}) => {
|
||||
const queries: string[] = [];
|
||||
await page.route("**/api/search", (route) => {
|
||||
queries.push((route.request().postDataJSON() as { query: string }).query);
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/workspace/chats/new");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "How can I assist you today?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page
|
||||
.getByRole("button", { name: /^Search/ })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
const input = dialog.getByPlaceholder("Search titles and messages…");
|
||||
await input.dispatchEvent("compositionstart");
|
||||
await input.fill("baogao");
|
||||
await input.dispatchEvent("keydown", {
|
||||
key: "Enter",
|
||||
keyCode: 229,
|
||||
isComposing: true,
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.waitForTimeout(350);
|
||||
expect(queries).not.toContain("baogao");
|
||||
await input.fill("报告");
|
||||
await input.dispatchEvent("compositionend");
|
||||
await expect.poll(() => queries).toContain("报告");
|
||||
});
|
||||
|
||||
test("search fits a narrow viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.route("**/api/search", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
items: [],
|
||||
cursor: null,
|
||||
indexing: false,
|
||||
messages_available: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto("/workspace/chats/new");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "How can I assist you today?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Toggle Sidebar" }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /^Search/ })
|
||||
.first()
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Search conversations" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const bounds = await dialog.boundingBox();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
|
||||
await expect(
|
||||
dialog.getByRole("checkbox", { name: "Include archived" }),
|
||||
).toBeInViewport();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
58
frontend/tests/unit/core/search/hooks.dom.test.tsx
Normal file
58
frontend/tests/unit/core/search/hooks.dom.test.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useWorkspaceSearch } from "@/core/search/hooks";
|
||||
|
||||
let viewer = { id: "alice", permissions: ["threads:read"] };
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||
useAuth: () => ({ user: viewer }),
|
||||
}));
|
||||
const search = rs.fn();
|
||||
rs.mock("@/core/search/api", () => ({
|
||||
searchWorkspace: (...args: unknown[]) => search(...args),
|
||||
}));
|
||||
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.clearAllMocks();
|
||||
viewer = { id: "alice", permissions: ["threads:read"] };
|
||||
});
|
||||
|
||||
test("debounce retains results only within the same owner, permission and filter scope", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
search.mockResolvedValueOnce({
|
||||
items: [{ type: "thread", thread_id: "old" }],
|
||||
cursor: null,
|
||||
});
|
||||
search.mockImplementation(() => new Promise(() => undefined));
|
||||
const { result, rerender } = renderHook(
|
||||
({ query, archived }) =>
|
||||
useWorkspaceSearch(
|
||||
query,
|
||||
{ type: "thread", includeArchived: archived },
|
||||
true,
|
||||
),
|
||||
{ wrapper, initialProps: { query: "old", archived: false } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
rerender({ query: "new", archived: false });
|
||||
expect(result.current.waiting).toBe(true);
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
rerender({ query: "new", archived: true });
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
viewer = { id: "bob", permissions: ["threads:read"] };
|
||||
rerender({ query: "old", archived: false });
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
viewer = { id: "alice", permissions: [] };
|
||||
rerender({ query: "old", archived: false });
|
||||
expect(result.current.supported).toBe(false);
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
client.clear();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user