新增智能体私密广场(is_private + 管理员密码门控)

- agents 表新增 is_private 列与迁移 20260526_01;列表 filter 拆分为公开
  广场用的 _listing_filter(剔除私密)与单条访问用的 _visible_filter(保留可达)
- system_settings 增 PrivateSquareSettings(enabled + password_hash),
  对应 GET/PUT /api/system-settings/private-square 由管理员管理
- /api/agents 接口:列表/创建/更新新增 is_private 字段;新增
  POST /api/agents/private-square/list(密码校验后返回私密智能体)与
  PUT /api/agents/{id}/private(管理员跨用户搬动智能体)
- 前端「智能体」页签新增「私密广场」入口(密码校验 + sessionStorage 缓存 +
  锁定按钮),卡片增加紫色私密徽章与盾牌切换按钮(owner 自助 / 管理员跨用户),
  「广场」与首页推荐候选均剔除私密智能体
- 新增管理员配置页 /settings/private-square 与侧边栏入口

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:28:41 +08:00
1 parent 0256d527f0
commit 3734b6a05f
20 files changed
+879 -20

No files matched your search

@@ -12,10 +12,12 @@ import yaml
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_tag_store
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
@@ -32,6 +34,7 @@ class AgentResponse(BaseModel):
description: str = Field(default="", description="Agent description")
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
builtin: bool = Field(default=False, description="True when user_id is null")
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -68,6 +71,7 @@ class AgentCreateRequest(BaseModel):
description: str = Field(default="", description="Agent description")
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
published: bool = Field(default=False, description="Whether the new agent should be public")
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
@@ -80,6 +84,7 @@ class AgentUpdateRequest(BaseModel):
description: str | None = Field(default=None, description="Updated description")
soul: str | None = Field(default=None, description="Updated SOUL.md content")
published: bool | None = Field(default=None, description="Updated publication state")
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
@@ -261,6 +266,7 @@ def _record_to_response(
description=record.get("description") or "",
user_id=record.get("user_id"),
published=bool(record.get("published", False)),
is_private=bool(record.get("is_private", False)),
builtin=record.get("user_id") is None,
created_at=record.get("created_at"),
updated_at=record.get("updated_at"),
@@ -446,6 +452,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
"description": request.description or "",
"user_id": _current_user_id(http_request),
"published": request.published,
"is_private": request.is_private,
}
)
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
@@ -486,6 +493,8 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
update_data["description"] = request.description or ""
if "published" in fields_set:
update_data["published"] = bool(request.published)
if "is_private" in fields_set:
update_data["is_private"] = bool(request.is_private)
next_name = update_data.get("name", current["name"])
next_description = update_data.get("description", current.get("description") or "")
@@ -662,6 +671,102 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
class AgentPrivateUpdateRequest(BaseModel):
"""Admin-only request body for moving an agent into / out of the private square."""
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
@router.post(
"/agents/private-square/list",
response_model=AgentsListResponse,
summary="List Private-Square Agents",
description=(
"Verify the admin-configured private-square password and return every "
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
"403 when the supplied password does not match, and the full agent list "
"otherwise — regardless of ownership, since the password is the gate."
),
)
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
_require_agents_api_enabled()
settings = load_system_settings().private_square
if not settings.password_hash:
raise HTTPException(status_code=404, detail="Private square password is not configured")
if not body.password or not await verify_password_async(body.password, settings.password_hash):
raise HTTPException(status_code=403, detail="Incorrect private square password")
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
records = await store.list_private()
responses = [_record_to_response(r, include_soul=True) for r in records]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.id, [])
return AgentsListResponse(agents=responses)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to list private agents: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
@router.put(
"/agents/{agent_id}/private",
response_model=AgentResponse,
summary="Move Agent Between Squares (admin)",
description=(
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
"password-gated private square. Owners can already toggle the flag on "
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
"endpoint exists so admins can re-categorize other users' agents too."
),
)
async def set_agent_private(
agent_id: str,
body: AgentPrivateUpdateRequest,
request: Request,
) -> AgentResponse:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
update = {"is_private": bool(body.is_private)}
if record.get("user_id") is None:
updated = await store.update_builtin(agent_id, update)
else:
updated = await store.update(agent_id, str(record["user_id"]), update)
if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
return _record_to_response(updated, include_soul=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
class AgentFeaturedReorderRequest(BaseModel):
"""Request body for admin to bulk-reorder the homepage featured list."""
@@ -7,6 +7,7 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.system_settings import (
PromptPrefixSettings,
@@ -31,6 +32,22 @@ class PromptPrefixUpdateRequest(BaseModel):
prompt_prefix: str | None = None
class PrivateSquareStatusResponse(BaseModel):
"""Status of the private-square feature, readable by any logged-in user.
The password hash itself is **never** returned — the response only signals
whether the feature is enabled and whether a password has been set.
"""
enabled: bool = Field(..., description="Whether the private square is exposed to users")
password_set: bool = Field(..., description="Whether a password is currently configured")
class PrivateSquareUpdateRequest(BaseModel):
enabled: bool | None = Field(default=None, description="Toggle the private-square feature")
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
@@ -61,5 +78,49 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
updated_prefix = current.prompt_prefix.model_copy(update=fields)
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix}))
return _to_response(saved.prompt_prefix)
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
async def get_private_square_status() -> PrivateSquareStatusResponse:
"""Return whether the private square is enabled and a password is set."""
settings = load_system_settings().private_square
return PrivateSquareStatusResponse(
enabled=settings.enabled,
password_set=bool(settings.password_hash),
)
@router.put("/private-square", response_model=PrivateSquareStatusResponse)
async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse:
"""Update the private-square configuration. Admin only.
Setting ``password`` to a non-empty string rehashes and stores it.
Passing an empty string clears the password (effectively locking the
private square so no one can read it).
"""
await _require_admin(request)
current = load_system_settings()
updated = current.private_square
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "enabled" in fields and fields["enabled"] is not None:
update["enabled"] = bool(fields["enabled"])
if "password" in fields and fields["password"] is not None:
plain = str(fields["password"])
if plain == "":
update["password_hash"] = ""
else:
update["password_hash"] = await hash_password_async(plain)
if update:
updated = updated.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"private_square": updated}))
else:
saved = current
return PrivateSquareStatusResponse(
enabled=saved.private_square.enabled,
password_set=bool(saved.private_square.password_hash),
)
@@ -37,10 +37,25 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场).
The password gates read access to all agents flagged ``is_private=True``.
The hash is stored opaquely — the router layer is responsible for hashing
and verifying so the harness package keeps zero dependencies on
``app.gateway.auth``.
"""
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
model_config = ConfigDict(extra="ignore")
@@ -81,3 +81,12 @@ class AgentStore(ABC):
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
raise NotImplementedError
@abstractmethod
async def list_private(self) -> list[dict[str, Any]]:
"""Return every agent flagged ``is_private=True``.
Caller is responsible for authorization — typically the caller has
already verified the admin-configured private-square password.
"""
raise NotImplementedError
@@ -21,6 +21,12 @@ class AgentRow(Base):
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# When True, the agent is moved into the password-gated "private square"
# (私密广场) and is hidden from the regular public square / built-in list.
# The flag is independent of ``published``: a private agent is still
# visible to its owner in the "我的" tab, and visible to anyone in the
# private-square listing once the password has been verified.
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
# featured rank (lower sorts first). Only ``published=True`` agents are
# eligible; the router enforces that invariant on writes. BIGINT so the
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
@@ -28,10 +28,31 @@ class AgentRepository(AgentStore):
@staticmethod
def _visible_filter(user_id: str):
"""Visibility for single-record fetches (``get_visible``).
Lenient: a caller who already knows the agent id can fetch it even
when it's flagged ``is_private``. The private-square password is the
*discovery* gate (listings); individual reads are not re-gated, so a
bookmarked / linked private agent stays openable.
"""
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
@staticmethod
def _listing_filter(user_id: str):
"""Visibility for *listings* — hides private-square agents from
non-owners so they only surface inside the password-gated endpoint.
Owners keep seeing their own private agents in '我的'.
"""
return or_(
AgentRow.user_id == user_id,
and_(
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
),
)
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.created_at.asc())
stmt = select(AgentRow).where(self._listing_filter(user_id)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
@@ -61,6 +82,7 @@ class AgentRepository(AgentStore):
description=data.get("description") or "",
user_id=data.get("user_id"),
published=bool(data.get("published", False)),
is_private=bool(data.get("is_private", False)),
created_at=now,
updated_at=now,
)
@@ -99,7 +121,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id != user_id:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -112,7 +134,7 @@ class AgentRepository(AgentStore):
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id is not None:
return None
for key in ("name", "description", "published"):
for key in ("name", "description", "published", "is_private"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
@@ -157,7 +179,7 @@ class AgentRepository(AgentStore):
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(AgentRow)
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
.where(AgentRow.featured_order.is_not(None), self._listing_filter(user_id))
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
)
async with self._sf() as session:
@@ -168,9 +190,15 @@ class AgentRepository(AgentStore):
if not agent_ids:
return []
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._listing_filter(user_id))
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
return [_row_to_dict(r) for r in rows]
async def list_private(self) -> list[dict[str, Any]]:
stmt = select(AgentRow).where(AgentRow.is_private.is_(True)).order_by(AgentRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
@@ -0,0 +1,62 @@
"""Add ``agents.is_private`` for the private-square (私密广场) feature.
A nullable-default bool column flagging agents that should live in the
password-gated private square instead of the regular public square.
Revision ID: 20260526_01
Revises: 20260522_01
Create Date: 2026-05-26
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260526_01"
down_revision = "20260522_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
if _has_table("agents") and not _has_column("agents", "is_private"):
op.add_column(
"agents",
sa.Column(
"is_private",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if _has_table("agents") and not _has_index("agents", "ix_agents_is_private"):
op.create_index("ix_agents_is_private", "agents", ["is_private"])
def downgrade() -> None:
if _has_index("agents", "ix_agents_is_private"):
op.drop_index("ix_agents_is_private", table_name="agents")
if _has_column("agents", "is_private"):
op.drop_column("agents", "is_private")