feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选

后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。

前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-07 22:30:23 +08:00
1 parent 05d14bb364
commit 7affaaf7c0
10 files changed
+373 -44

No files matched your search

@@ -49,6 +49,10 @@ class ChainResponse(BaseModel):
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -73,6 +77,8 @@ class ChainUpdateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -149,10 +155,18 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request) -> ChainListResponse:
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
其余值按 ``mine`` 处理。
"""
normalized_scope = scope if scope in ("mine", "public") else "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id)
rows = await store.list_chains(user_id, scope=normalized_scope)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -0,0 +1,27 @@
"""Merge chain_stages and admin_leaderboard_daily_stats migration heads.
Two independent migrations diverged from ``20260607_02``: ``20260607_03``
(roundtable_chains.stages) and ``20260608_01`` (admin_leaderboard_daily_stats +
analytics indexes). This no-op merge revision joins both lineages so
``upgrade head`` succeeds.
Revision ID: 20260608_02
Revises: 20260607_03, 20260608_01
Create Date: 2026-06-08
"""
from __future__ import annotations
revision = "20260608_02"
down_revision = ("20260607_03", "20260608_01")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,58 @@
"""Add is_public column to roundtable_chains.
「发布」business chains: ``is_public=True`` makes a chain visible to **all
accounts** under the "公共" filter (read + select-into-roundtable only; edit /
delete stay owner-only). Defaults to ``0`` (private). ``create_all`` adds the
column for brand-new DBs; this revision patches DBs that already have the table.
Chained after the merge head ``20260608_02`` (chain_stages + leaderboard) so the
revision graph stays single-headed — otherwise ``alembic upgrade head`` aborts
with "multiple heads" and the auto-migrate on startup silently falls back to the
create_all schema (which lacks this column → 500 on list).
Revision ID: 20260608_03
Revises: 20260608_02
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260608_03"
down_revision = "20260608_02"
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 upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "is_public"):
op.add_column(
"roundtable_chains",
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
)
op.create_index(
"ix_roundtable_chains_is_public", "roundtable_chains", ["is_public"]
)
def downgrade() -> None:
if _has_column("roundtable_chains", "is_public"):
with op.batch_alter_table("roundtable_chains") as batch:
batch.drop_index("ix_roundtable_chains_is_public")
op.drop_column("roundtable_chains", "is_public")
@@ -14,7 +14,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -39,6 +39,11 @@ class RoundtableChainRow(Base):
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -44,7 +44,7 @@ class RoundtableChainRepository:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
return {
"id": row.id,
"title": row.title,
@@ -52,27 +52,46 @@ class RoundtableChainRepository:
"seats": _loads(row.seats) or [],
# None when absent → frontend reads it as "linear chain" (no layering).
"stages": _loads(row.stages),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
async def list_chains(
self, user_id: str, *, scope: str = "mine", limit: int = 200
) -> list[dict[str, Any]]:
"""列出链条。
``scope``:
- ``"mine"``(默认):仅当前用户自己的链条;
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
"""
if scope == "public":
cond = RoundtableChainRow.is_public.is_(True)
else:
cond = RoundtableChainRow.user_id == user_id
stmt = (
select(RoundtableChainRow)
.where(RoundtableChainRow.user_id == user_id)
.where(cond)
.order_by(RoundtableChainRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
return [self._to_dict(row, user_id) for row in result.scalars()]
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
if row is None:
return None
return self._to_dict(row)
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
return None
return self._to_dict(row, user_id)
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
@@ -83,6 +102,7 @@ class RoundtableChainRepository:
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
stages=_dumps(data.get("stages")),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
)
@@ -90,7 +110,7 @@ class RoundtableChainRepository:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def update_chain(
self,
@@ -101,6 +121,7 @@ class RoundtableChainRepository:
description=_UNSET,
seats=_UNSET,
stages=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
@@ -114,10 +135,12 @@ class RoundtableChainRepository:
row.seats = _dumps(seats)
if stages is not _UNSET:
row.stages = _dumps(stages)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
async with self._sf() as session: