feat(圆桌): 业务链条显示配置人 + 配置页拆两步并新增精美流程图 - 列表/弹窗卡片显示「配置人」(后端把 owner user_id 解析为 email, ChainResponse 增 owner_id/owner_name,store 输出 owner_id) - 配置编辑器拆为两步:第一步基本信息(描述改多行文本),第二步链条编排 - 第一步有编排内容时,左侧基本信息 + 右侧 ChainFlowDiagram 流程图: 直角连线、点节点开详情抽屉、连线显示阶段目标(依赖)、滚轮缩放、 拖动平移、全屏 + 全屏右下角缩略图 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
167 lines
6.5 KiB
Python
167 lines
6.5 KiB
Python
"""SQLAlchemy-backed roundtable business-chain store.
|
||
|
||
The ``seats`` JSON column is stored as a serialized string in
|
||
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
|
||
— same approach as ``roundtable_drafts`` / ``ai_writing_sessions``.
|
||
|
||
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
|
||
that belongs to another user is treated as not found.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||
|
||
_UNSET = object()
|
||
|
||
|
||
def _loads(value: Any) -> Any:
|
||
if not isinstance(value, str):
|
||
return value
|
||
if not value:
|
||
return None
|
||
try:
|
||
return json.loads(value)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _dumps(value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return json.dumps(value, ensure_ascii=False)
|
||
|
||
|
||
class RoundtableChainRepository:
|
||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||
self._sf = session_factory
|
||
|
||
@staticmethod
|
||
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
|
||
return {
|
||
"id": row.id,
|
||
"title": row.title,
|
||
"description": row.description,
|
||
"seats": _loads(row.seats) or [],
|
||
# None when absent → frontend reads it as "linear chain" (no layering).
|
||
"stages": _loads(row.stages),
|
||
# Per-stage goals aligned to ``stages``; None when absent.
|
||
"stage_goals": _loads(getattr(row, "stage_goals", None)),
|
||
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
|
||
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
|
||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||
"is_public": bool(getattr(row, "is_public", False)),
|
||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
|
||
"owner_id": row.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, *, 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(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, 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:
|
||
return None
|
||
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)
|
||
row = RoundtableChainRow(
|
||
id=data["id"],
|
||
user_id=user_id,
|
||
title=(data.get("title") or "").strip(),
|
||
description=(data.get("description") or None),
|
||
seats=_dumps(data.get("seats") or []),
|
||
stages=_dumps(data.get("stages")),
|
||
stage_goals=_dumps(data.get("stage_goals")),
|
||
coordinator_prompt=(data.get("coordinator_prompt") or None),
|
||
is_public=bool(data.get("is_public") or False),
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
async with self._sf() as session:
|
||
session.add(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return self._to_dict(row, user_id)
|
||
|
||
async def update_chain(
|
||
self,
|
||
chain_id: str,
|
||
user_id: str,
|
||
*,
|
||
title=_UNSET,
|
||
description=_UNSET,
|
||
seats=_UNSET,
|
||
stages=_UNSET,
|
||
stage_goals=_UNSET,
|
||
coordinator_prompt=_UNSET,
|
||
is_public=_UNSET,
|
||
) -> dict[str, Any] | None:
|
||
async with self._sf() as session:
|
||
row = await session.get(RoundtableChainRow, chain_id)
|
||
if row is None or row.user_id != user_id:
|
||
return None
|
||
if title is not _UNSET and title is not None:
|
||
row.title = str(title).strip()
|
||
if description is not _UNSET:
|
||
row.description = description or None
|
||
if seats is not _UNSET:
|
||
row.seats = _dumps(seats)
|
||
if stages is not _UNSET:
|
||
row.stages = _dumps(stages)
|
||
if stage_goals is not _UNSET:
|
||
row.stage_goals = _dumps(stage_goals)
|
||
if coordinator_prompt is not _UNSET:
|
||
row.coordinator_prompt = coordinator_prompt or None
|
||
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, user_id)
|
||
|
||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||
async with self._sf() as session:
|
||
row = await session.get(RoundtableChainRow, chain_id)
|
||
if row is None or row.user_id != user_id:
|
||
return False
|
||
await session.delete(row)
|
||
await session.commit()
|
||
return True
|