新增智能体私密广场(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:
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
|
||||
|
||||
+34
-6
@@ -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()]
|
||||
+62
@@ -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")
|
||||
Reference in new issue
Block a user