From 44c0f6189ba04054f7292c58a4ccef1c590d34e4 Mon Sep 17 00:00:00 2001
From: Administrator 技能加载中...
当前没有可用技能
没有匹配的技能
- {skill?.name_zh?.trim() ? skill.name_zh : "未设置"}
-
+
创建者: {displayUsername(selectedTask.creator_email) || selectedTask.creator_user_id} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 7cb23cf0..526ddeff 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -299,7 +299,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. -**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter. `POST /api/agents/private-square/list` takes the same pagination params. Repository entry point: `AgentStore.list_paginated(...)`. +**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`. **Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL). @@ -380,7 +380,7 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / - **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json - **Injection**: Enabled skills listed in agent system prompt with container paths - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory -- **Chinese name (`name_zh`)**: a per-deployment Chinese display name stored alongside `enabled`/`display` in `extensions_config.json` (`SkillStateConfig.name_zh`); surfaced on `SkillResponse.name_zh`. Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (user sets/clears it), `POST /api/skills/{name}/name-zh/generate` (LLM generates one for that skill — reuses the curator model setting, falls back to the main model), `POST /api/skills/name-zh/generate-missing` (一键补充: bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor) shows `name_zh || name` and offers per-skill "AI 生成" plus a header "一键补充中文名" button. Tests: `tests/test_skill_name_zh.py`. +- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one — reuses the curator model setting, falls back to the main model), `POST /api/skills/name-zh/generate-missing` (一键补充: bulk-generate for every editable skill that lacks one, bounded-concurrency `asyncio.gather`, skips skills the caller can't edit). The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name` and offers per-skill "AI 生成" plus a header "一键补充中文名" button. Tests: `tests/test_skill_name_zh.py`. #### Skill Curator (`packages/harness/deerflow/skills/curator.py`) diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 40de50f3..7848dc8c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -555,6 +555,10 @@ async def list_agents( pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", description="Sort order. Default is newest created first.", ), + owner: str | None = Query( + default=None, + description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.", + ), admin: bool = Query( default=False, description=( @@ -586,6 +590,7 @@ async def list_agents( page_size=page_size, admin=admin, sort=sort, + owner=owner, ) responses = await _build_list_responses(store, tag_store, user_id, rows) return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size) @@ -601,6 +606,8 @@ async def list_agents( if tag_ids: wanted = set(tag_ids) responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] + if owner and owner.strip(): + responses = [r for r in responses if r.user_id == owner.strip()] responses = _sort_agent_responses(responses, sort) return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) except HTTPException: @@ -1083,6 +1090,7 @@ class PrivateSquareListRequest(BaseModel): pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", description="Sort order. Default is newest created first.", ) + owner: str | None = Field(default=None, description="Keep only agents published by this user id (the card's publisher).") class AgentPrivateUpdateRequest(BaseModel): @@ -1128,6 +1136,7 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request) page=body.page, page_size=body.page_size, sort=body.sort, + owner=body.owner, ) responses = await _build_list_responses(store, tag_store, user_id, rows) return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size) diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index d5c78015..1ffa3f72 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -740,7 +740,8 @@ async def _attach_and_filter_tags( resp.tags = assignments.get(resp.name, []) if search: needle = search.strip().lower() - responses = [r for r in responses if needle in r.name.lower()] + # Match the English name OR the Chinese display name so 中文 search works. + responses = [r for r in responses if needle in r.name.lower() or (r.name_zh or "").lower().find(needle) >= 0] if tag_ids: wanted = set(tag_ids) responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] @@ -769,7 +770,7 @@ async def list_skills( config: AppConfig = Depends(get_config), skill_store: SkillStore = Depends(get_skill_store), tag_store: TagStore = Depends(get_tag_store), - search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"), + search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"), tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"), sort: str = Query( default="time_desc", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py index 0ff4ae33..d2fc32cd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -119,18 +119,19 @@ class AgentStore(ABC): raise NotImplementedError @abstractmethod - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, - search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - sort: str = "time_desc", - ) -> tuple[list[dict[str, Any]], int]: + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, + search: str | None, + tag_ids: list[str] | None, + page: int, + page_size: int, + admin: bool = False, + sort: str = "time_desc", + owner: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: """Return one page of agents plus the total matching count. ``scope``: @@ -142,13 +143,15 @@ class AgentStore(ABC): agent), but still honors ``search`` / ``tag_ids``. Caller authorizes. ``search`` matches the raw name OR its desensitized display form. - ``tag_ids`` keeps agents carrying ANY of the tags (OR). Both filters, - the COUNT, and LIMIT/OFFSET all execute in SQL. + ``tag_ids`` keeps agents carrying ANY of the tags (OR). ``owner`` keeps + only agents published by that user (``user_id`` equality) — built-in + agents (no owner) never match. All filters, the COUNT, and LIMIT/OFFSET + all execute in SQL. Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items - for the 1-based ``page``. - """ - raise NotImplementedError + for the 1-based ``page``. + """ + raise NotImplementedError # --- Listing cache (skills / tool_groups / model) --- @@ -199,28 +202,28 @@ class AgentStore(ABC): raise NotImplementedError @abstractmethod - async def delete_favorites_for_agent(self, agent_id: str) -> None: - """Drop every user's favorite of ``agent_id`` (called on agent delete).""" - raise NotImplementedError - - # --- Per-user pins ("置顶") --- - - @abstractmethod - async def add_pin(self, user_id: str, agent_id: str) -> None: - """Pin ``agent_id`` for ``user_id``. Idempotent.""" - raise NotImplementedError - - @abstractmethod - async def remove_pin(self, user_id: str, agent_id: str) -> bool: - """Remove a per-user pin. Returns ``False`` when none existed.""" - raise NotImplementedError - - @abstractmethod - async def list_pin_ids(self, user_id: str) -> set[str]: - """Return the set of agent ids pinned by ``user_id``.""" - raise NotImplementedError - - @abstractmethod - async def delete_pins_for_agent(self, agent_id: str) -> None: - """Drop every user's pin of ``agent_id`` (called on agent delete).""" - raise NotImplementedError + async def delete_favorites_for_agent(self, agent_id: str) -> None: + """Drop every user's favorite of ``agent_id`` (called on agent delete).""" + raise NotImplementedError + + # --- Per-user pins ("置顶") --- + + @abstractmethod + async def add_pin(self, user_id: str, agent_id: str) -> None: + """Pin ``agent_id`` for ``user_id``. Idempotent.""" + raise NotImplementedError + + @abstractmethod + async def remove_pin(self, user_id: str, agent_id: str) -> bool: + """Remove a per-user pin. Returns ``False`` when none existed.""" + raise NotImplementedError + + @abstractmethod + async def list_pin_ids(self, user_id: str) -> set[str]: + """Return the set of agent ids pinned by ``user_id``.""" + raise NotImplementedError + + @abstractmethod + async def delete_pins_for_agent(self, agent_id: str) -> None: + """Drop every user's pin of ``agent_id`` (called on agent delete).""" + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 762e3aa2..484dd7f9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -21,9 +21,9 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]: class MemoryAgentStore(AgentStore): def __init__(self) -> None: - self._agents: dict[str, dict[str, Any]] = {} - self._favorites: set[tuple[str, str]] = set() - self._pins: set[tuple[str, str]] = set() + self._agents: dict[str, dict[str, Any]] = {} + self._favorites: set[tuple[str, str]] = set() + self._pins: set[tuple[str, str]] = set() # Listing cache mirrors (see AgentExtrasRow / AgentSkillRow): self._skills: dict[str, list[str]] = {} self._extras: dict[str, dict[str, Any]] = {} @@ -186,51 +186,54 @@ class MemoryAgentStore(AgentStore): return bool(agent.get("is_private")) return self._is_visible(agent, user_id) - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - sort: str = "time_desc", - ) -> tuple[list[dict[str, Any]], int]: + tag_ids: list[str] | None, + page: int, + page_size: int, + admin: bool = False, + sort: str = "time_desc", + owner: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: # NOTE: the in-memory store has no tag-assignment data, so ``tag_ids`` # is ignored here. Tag-filter behavior is covered by the SQL repo tests. + owner_key = owner.strip() if owner and owner.strip() else None matched = [ a for a in self._agents.values() if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin) and (not search or not search.strip() or name_matches(a.get("name") or "", search)) + and (owner_key is None or a.get("user_id") == owner_key) ] - if sort == "time_asc": - matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or "")) - elif sort == "updated_desc": - matched.sort( - key=lambda a: (a.get("updated_at") or "", a.get("id") or ""), - reverse=True, - ) - elif sort == "updated_asc": - matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or "")) - elif sort == "name_asc": - matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or "")) - elif sort == "name_desc": - matched.sort( - key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""), - reverse=True, - ) - else: - matched.sort( - key=lambda a: (a.get("created_at") or "", a.get("id") or ""), - reverse=True, - ) - pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id} - matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,)) - total = len(matched) - start = max(page - 1, 0) * page_size + if sort == "time_asc": + matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or "")) + elif sort == "updated_desc": + matched.sort( + key=lambda a: (a.get("updated_at") or "", a.get("id") or ""), + reverse=True, + ) + elif sort == "updated_asc": + matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or "")) + elif sort == "name_asc": + matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or "")) + elif sort == "name_desc": + matched.sort( + key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""), + reverse=True, + ) + else: + matched.sort( + key=lambda a: (a.get("created_at") or "", a.get("id") or ""), + reverse=True, + ) + pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id} + matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,)) + total = len(matched) + start = max(page - 1, 0) * page_size page_rows = matched[start : start + page_size] return [_copy(a) for a in page_rows], total @@ -275,23 +278,23 @@ class MemoryAgentStore(AgentStore): async def list_favorite_ids(self, user_id: str) -> set[str]: return {agent_id for (uid, agent_id) in self._favorites if uid == user_id} - async def delete_favorites_for_agent(self, agent_id: str) -> None: - self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id} - - # --- Pins ("置顶") --- - - async def add_pin(self, user_id: str, agent_id: str) -> None: - self._pins.add((user_id, agent_id)) - - async def remove_pin(self, user_id: str, agent_id: str) -> bool: - key = (user_id, agent_id) - if key not in self._pins: - return False - self._pins.remove(key) - return True - - async def list_pin_ids(self, user_id: str) -> set[str]: - return {agent_id for uid, agent_id in self._pins if uid == user_id} - - async def delete_pins_for_agent(self, agent_id: str) -> None: - self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id} + async def delete_favorites_for_agent(self, agent_id: str) -> None: + self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id} + + # --- Pins ("置顶") --- + + async def add_pin(self, user_id: str, agent_id: str) -> None: + self._pins.add((user_id, agent_id)) + + async def remove_pin(self, user_id: str, agent_id: str) -> bool: + key = (user_id, agent_id) + if key not in self._pins: + return False + self._pins.remove(key) + return True + + async def list_pin_ids(self, user_id: str) -> set[str]: + return {agent_id for uid, agent_id in self._pins if uid == user_id} + + async def delete_pins_for_agent(self, agent_id: str) -> None: + self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 965c93ae..aa0f5096 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -6,19 +6,19 @@ from datetime import UTC, datetime from typing import Any from uuid import uuid4 -from sqlalchemy import and_, case, delete, func, or_, select +from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.model import ( - AgentExtrasRow, - AgentFavoriteRow, - AgentPinRow, - AgentRow, - AgentSkillRow, -) + AgentExtrasRow, + AgentFavoriteRow, + AgentPinRow, + AgentRow, + AgentSkillRow, +) from deerflow.persistence.tags.model import TagAssignmentRow @@ -278,18 +278,19 @@ class AgentRepository(AgentStore): ) return AgentRow.id.in_(subq) - async def list_paginated( - self, - *, - user_id: str, - scope: str | None, + async def list_paginated( + self, + *, + user_id: str, + scope: str | None, search: str | None, - tag_ids: list[str] | None, - page: int, - page_size: int, - admin: bool = False, - sort: str = "time_desc", - ) -> tuple[list[dict[str, Any]], int]: + tag_ids: list[str] | None, + page: int, + page_size: int, + admin: bool = False, + sort: str = "time_desc", + owner: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: filters = [] scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin) if scope_filter is not None: @@ -298,30 +299,33 @@ class AgentRepository(AgentStore): filters.append(self._search_clause(search)) if tag_ids: filters.append(self._tag_clause(tag_ids)) + if owner and owner.strip(): + # Filter to one publisher. Built-in agents (user_id IS NULL) never match. + filters.append(AgentRow.user_id == owner.strip()) count_stmt = select(func.count()).select_from(AgentRow) - pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) - pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1) - - if sort == "time_asc": - order_by = (AgentRow.created_at.asc(), AgentRow.id.asc()) - elif sort == "updated_desc": - order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc()) - elif sort == "updated_asc": - order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc()) - elif sort == "name_asc": - order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc()) - elif sort == "name_desc": - order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc()) - else: - order_by = (AgentRow.created_at.desc(), AgentRow.id.asc()) - - page_stmt = ( - select(AgentRow) - .order_by(pin_rank.asc(), *order_by) - .limit(page_size) - .offset(max(page - 1, 0) * page_size) - ) + pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) + pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1) + + if sort == "time_asc": + order_by = (AgentRow.created_at.asc(), AgentRow.id.asc()) + elif sort == "updated_desc": + order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc()) + elif sort == "updated_asc": + order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc()) + elif sort == "name_asc": + order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc()) + elif sort == "name_desc": + order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc()) + else: + order_by = (AgentRow.created_at.desc(), AgentRow.id.asc()) + + page_stmt = ( + select(AgentRow) + .order_by(pin_rank.asc(), *order_by) + .limit(page_size) + .offset(max(page - 1, 0) * page_size) + ) if filters: where = and_(*filters) count_stmt = count_stmt.where(where) @@ -441,49 +445,49 @@ class AgentRepository(AgentStore): ) return {row[0] for row in result.all()} - async def delete_favorites_for_agent(self, agent_id: str) -> None: - async with self._sf() as session: - await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id)) - await session.commit() - - # --- Pins ("置顶") --- - - async def add_pin(self, user_id: str, agent_id: str) -> None: - async with self._sf() as session: - existing = ( - await session.execute( - select(AgentPinRow.id) - .where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id) - .limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return - session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) - try: - await session.commit() - except IntegrityError: - await session.rollback() - - async def remove_pin(self, user_id: str, agent_id: str) -> bool: - async with self._sf() as session: - result = await session.execute( - delete(AgentPinRow).where( - AgentPinRow.user_id == user_id, - AgentPinRow.agent_id == agent_id, - ) - ) - await session.commit() - return (result.rowcount or 0) > 0 - - async def list_pin_ids(self, user_id: str) -> set[str]: - async with self._sf() as session: - result = await session.execute( - select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) - ) - return {row[0] for row in result.all()} - - async def delete_pins_for_agent(self, agent_id: str) -> None: - async with self._sf() as session: - await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id)) - await session.commit() + async def delete_favorites_for_agent(self, agent_id: str) -> None: + async with self._sf() as session: + await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id)) + await session.commit() + + # --- Pins ("置顶") --- + + async def add_pin(self, user_id: str, agent_id: str) -> None: + async with self._sf() as session: + existing = ( + await session.execute( + select(AgentPinRow.id) + .where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return + session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) + try: + await session.commit() + except IntegrityError: + await session.rollback() + + async def remove_pin(self, user_id: str, agent_id: str) -> bool: + async with self._sf() as session: + result = await session.execute( + delete(AgentPinRow).where( + AgentPinRow.user_id == user_id, + AgentPinRow.agent_id == agent_id, + ) + ) + await session.commit() + return (result.rowcount or 0) > 0 + + async def list_pin_ids(self, user_id: str) -> set[str]: + async with self._sf() as session: + result = await session.execute( + select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) + ) + return {row[0] for row in result.all()} + + async def delete_pins_for_agent(self, agent_id: str) -> None: + async with self._sf() as session: + await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id)) + await session.commit()