feat: 对话列表显示总数,SOUL 编辑区高度自适应收敛
- 新增 POST /threads/count:与 /threads/search 同一套过滤(标题搜索、
系统会话排除),仅返回 {total};ThreadMetaStore 增加 count 方法
(SQL 走 COUNT(*),JSON 过滤分批扫描计数)
- 对话页面搜索框下常驻显示「共 N 条对话」,分页条显示 当前页/总页数
(useThreadsCount,仅随筛选条件变化,翻页不重复请求)
- 智能体右侧 SOUL 预览改为 40vh 上限自适应展开(不再全部展开);
详情面板/卡片编辑/创建页的 SOUL 输入框统一为适中高度 + 可拖拽
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f107ebc46d
commit
3d6f35391e
10 files changed
+204
-8
No files matched your search
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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.
|
||||
<p className="text-muted-foreground whitespace-pre-wrap text-sm leading-relaxed">
|
||||
// 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.
|
||||
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{soulPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) {
|
||||
const normalizedSearch = options.search?.trim() ?? "";
|
||||
return useQuery<number>({
|
||||
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();
|
||||
|
||||
@@ -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 && (
|
||||
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
|
||||
共 {totalCount} 条对话
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
<ScrollArea className="size-full py-4">
|
||||
@@ -231,7 +245,9 @@ export default function ChatsPage() {
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{currentPage}
|
||||
{totalPages != null
|
||||
? `${currentPage} / ${totalPages}`
|
||||
: currentPage}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -546,7 +546,7 @@ export default function NewAgentPage() {
|
||||
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
||||
value={soulInput}
|
||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||
className="min-h-[45vh] resize-y overflow-y-auto font-mono text-xs"
|
||||
className="max-h-[50vh] min-h-64 resize-y overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
+44
@@ -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:
|
||||
|
||||
+52
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user