diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 0b059976..d977872c 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -921,7 +921,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar ? "正在加载 SOUL.md..." : "输入 SOUL.md 内容" } - className="min-h-[40vh] resize-y font-mono text-xs" + className="max-h-[45vh] min-h-48 resize-y font-mono text-xs" />
diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 15d0e31e..39c9d292 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -577,13 +577,13 @@ export function AgentDetailPanel({ setDraft({ ...draft, soul: e.currentTarget.value }) } placeholder="输入 SOUL.md 内容" - className="min-h-[45vh] resize-y font-mono text-xs" + className="max-h-[45vh] min-h-48 resize-y font-mono text-xs" /> ) : ( - // No inner max-height: the SOUL text flows at full length and the - // panel body scrolls as a whole, instead of a small nested scroll - // box on an otherwise empty screen. -

+ // Viewport-relative cap: the SOUL preview expands with the screen + // (taller than the old fixed 240px box) but never swallows the + // whole panel — long content still scrolls inside. +

{soulPreview}

)} diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 121b84e8..caea58c4 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -841,6 +841,47 @@ export function useThreadsPage(options: { }); } +/** + * Total number of conversations matching the same filters as + * `useThreadsPage`, via the lightweight `/threads/count` endpoint — powers + * the "共 N 条 / X 页" display without fetching any rows. Depends only on the + * filters (not the page), so paging doesn't refire it. + */ +export function useThreadsCount(options: { + search?: string; + metadata?: Record; +}) { + const normalizedSearch = options.search?.trim() ?? ""; + return useQuery({ + queryKey: [ + "threads", + "search", + "count", + normalizedSearch, + options.metadata ?? null, + ], + queryFn: async () => { + const response = await apiFetch( + `${getLangGraphBaseURL()}/threads/count`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + metadata: options.metadata ?? {}, + query: normalizedSearch || undefined, + }), + }, + ); + if (!response.ok) { + throw new Error("Failed to count conversations"); + } + const data = (await response.json()) as { total: number }; + return data.total; + }, + refetchOnWindowFocus: false, + }); +} + export function useDeleteThread() { const queryClient = useQueryClient(); const apiClient = getAPIClient(); diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index e0bb9aad..49c6d497 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -22,6 +22,7 @@ import { } from "@/components/workspace/workspace-container"; import { useI18n } from "@/core/i18n/hooks"; import { + useThreadsCount, useThreadsPage, useToggleThreadFavorite, useToggleThreadPin, @@ -90,6 +91,10 @@ export default function ChatsPage() { search: debouncedSearch, metadata: pinnedFilter, }); + const { data: totalCount } = useThreadsCount({ + search: debouncedSearch, + metadata: favoriteFilter, + }); const pinnedThreads = pinnedData?.threads ?? []; const pagedThreads = useMemo( @@ -98,6 +103,10 @@ export default function ChatsPage() { ); const currentPage = page; const hasMore = pageData?.hasMore ?? false; + // Page windows are sliced over the raw (pinned-inclusive) row order, so the + // page count derives from the raw total as well. + const totalPages = + totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null; const renderThread = (thread: AgentThread) => { const pinned = isThreadPinned(thread); @@ -201,6 +210,11 @@ export default function ChatsPage() { value={search} onChange={(e) => setSearch(e.target.value)} /> + {totalCount != null && ( +
+ 共 {totalCount} 条对话 +
+ )}
@@ -231,7 +245,9 @@ export default function ChatsPage() { {t.chats.prevPage} - {currentPage} + {totalPages != null + ? `${currentPage} / ${totalPages}` + : currentPage}
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 37611144..647aa236 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -237,6 +237,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | +| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics — powers the chats page's 总数/总页数 display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | | **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens | diff --git a/offline-backend-20260512/backend/app/gateway/routers/threads.py b/offline-backend-20260512/backend/app/gateway/routers/threads.py index 3024f488..6a637378 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/threads.py +++ b/offline-backend-20260512/backend/app/gateway/routers/threads.py @@ -692,6 +692,32 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th ] +class ThreadCountResponse(BaseModel): + """Response model for the thread count endpoint.""" + + total: int = Field(description="Number of threads matching the filters") + + +@router.post("/count", response_model=ThreadCountResponse) +async def count_threads(body: ThreadSearchRequest, request: Request) -> ThreadCountResponse: + """Count threads matching the same filters as ``/threads/search``. + + ``limit``/``offset`` in the body are ignored. Backs the chats page + pagination total ("共 N 条 / X 页") without fetching any rows. + """ + from app.gateway.deps import get_thread_store + + repo = get_thread_store(request) + explicit_system_filter = bool(body.metadata) and ("thread_type" in body.metadata or "system" in body.metadata) + total = await repo.count( + metadata=body.metadata or None, + status=body.status, + exclude_system=not explicit_system_filter, + query=body.query, + ) + return ThreadCountResponse(total=total) + + @router.patch("/{thread_id}", response_model=ThreadResponse) @require_permission("threads", "write", owner_check=True, require_existing=True) async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py index f2602442..9d8f4560 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py @@ -59,6 +59,22 @@ class ThreadMetaStore(abc.ABC): """ pass + @abc.abstractmethod + async def count( + self, + *, + metadata: dict | None = None, + status: str | None = None, + user_id: str | None | _AutoSentinel = AUTO, + exclude_system: bool = False, + query: str | None = None, + ) -> int: + """Count threads matching the same filters as :meth:`search`. + + Backs pagination UIs that need a total alongside per-page results. + """ + pass + @abc.abstractmethod async def update_display_name(self, thread_id: str, display_name: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None: pass diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py index 16e86ab0..0557beb7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py @@ -126,6 +126,50 @@ class MemoryThreadMetaStore(ThreadMetaStore): scan_offset += len(items) return matched[offset:needed] + async def count( + self, + *, + metadata: dict | None = None, + status: str | None = None, + user_id: str | None | _AutoSentinel = AUTO, + exclude_system: bool = False, + query: str | None = None, + ) -> int: + """Count threads matching the same filters as :meth:`search`.""" + resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.count") + filter_dict: dict[str, Any] = {} + if metadata: + filter_dict.update(metadata) + if status: + filter_dict["status"] = status + if resolved_user_id is not None: + filter_dict["user_id"] = resolved_user_id + + normalized_query = (query or "").strip().lower() + total = 0 + batch_size = 500 + scan_offset = 0 + while True: + items = await self._store.asearch( + THREADS_NS, + filter=filter_dict or None, + limit=batch_size, + offset=scan_offset, + ) + if not items: + break + for item in items: + record = self._item_to_dict(item) + if exclude_system and (record.get("metadata") or {}).get("system"): + continue + if normalized_query and normalized_query not in (record.get("display_name") or "").lower(): + continue + total += 1 + if len(items) < batch_size: + break + scan_offset += len(items) + return total + async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool: item = await self._store.aget(THREADS_NS, thread_id) if item is None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index 4ad82013..b701d7cd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -167,6 +167,58 @@ class ThreadMetaRepository(ThreadMetaStore): scan_offset += len(rows) return matched[offset:needed] + async def count( + self, + *, + metadata: dict | None = None, + status: str | None = None, + user_id: str | None | _AutoSentinel = AUTO, + exclude_system: bool = False, + query: str | None = None, + ) -> int: + """Count threads matching the same filters as :meth:`search`.""" + resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.count") + conditions = [] + if resolved_user_id is not None: + conditions.append(ThreadMetaRow.user_id == resolved_user_id) + if status: + conditions.append(ThreadMetaRow.status == status) + if query and query.strip(): + conditions.append(func.lower(ThreadMetaRow.display_name).contains(query.strip().lower())) + + if not metadata and not exclude_system: + stmt = select(func.count()).select_from(ThreadMetaRow) + for condition in conditions: + stmt = stmt.where(condition) + async with self._sf() as session: + return int((await session.execute(stmt)).scalar_one()) + + # JSON filters can't run in portable SQL — scan just the metadata + # column in batches and count in Python (mirrors search()). + stmt = select(ThreadMetaRow.metadata_json).order_by(ThreadMetaRow.thread_id) + for condition in conditions: + stmt = stmt.where(condition) + total = 0 + batch_size = 1000 + scan_offset = 0 + async with self._sf() as session: + while True: + result = await session.execute(stmt.limit(batch_size).offset(scan_offset)) + rows = result.scalars().all() + if not rows: + break + for metadata_json in rows: + md = metadata_json or {} + if metadata and not all(md.get(k) == v for k, v in metadata.items()): + continue + if exclude_system and md.get("system"): + continue + total += 1 + if len(rows) < batch_size: + break + scan_offset += len(rows) + return total + 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).""" if resolved_user_id is None: