Files
董宏凯andCursor 9e510ce088 feat: 优化会商公开分享页,展示总控结论与各席位交付
公开分享页拆分为落地页与独立报告页,后端补充 consensus_text 与 seat_deliveries 字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 19:00:13 +08:00

331 lines
14 KiB
Python

"""Roundtable-draft sharing API.
A user creates a share code for *one* of their roundtable-planning drafts (a full
会商 session). Two modes:
- ``import`` — redeeming the code copies the draft into the redeemer's account as
an independent copy they fully own (fresh draft id, deduped per recipient).
- ``view`` — a public, read-only page that renders the draft's Step 3 result
report (the self-contained 方案总览 HTML). Anyone with the link can open it
without logging in.
Mirrors ``app/gateway/routers/thread_shares.py`` but the shared resource is a
``roundtable_drafts`` row (read via ``app.state.roundtable_draft_store``) rather
than a conversation thread.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_roundtable_draft_share_store
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-draft-shares", tags=["roundtable-draft-shares"])
# Public, unauthenticated router. Its prefix is whitelisted in
# ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``) — only read-only,
# intentionally world-readable endpoints belong here.
public_router = APIRouter(prefix="/api/public/roundtable-shares", tags=["roundtable-draft-shares-public"])
_UNTITLED = "未命名会商"
_SHARE_MODES = ("import", "view")
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_draft_store(request: Request):
store = getattr(request.app.state, "roundtable_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
return store
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class CreateShareRequest(BaseModel):
draft_id: str = Field(..., description="The roundtable draft to share")
mode: str = Field(
default="import",
description='"import" — recipient copies the draft; "view" — public read-only result page',
)
class ShareResponse(BaseModel):
share_code: str = Field(..., description="Short code that recipients enter to import")
owner_user_id: str = Field(..., description="User who created the share")
draft_id: str = Field(..., description="The shared draft")
revoked: bool = Field(default=False, description="Whether the code has been revoked")
mode: str = Field(default="import", description='"import" or "view"')
created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)")
class SharesListResponse(BaseModel):
shares: list[ShareResponse]
class SharePreviewResponse(BaseModel):
share_code: str
shared_by: str = Field(..., description="Owner user id of the shared draft")
title: str = Field(..., description="Title of the shared draft")
class ImportResultResponse(BaseModel):
imported: int = Field(..., description="1 when the draft was copied, else 0")
skipped: int = Field(..., description="1 when it was already imported, else 0")
draft_id: str | None = Field(default=None, description="The new local draft id (when imported)")
class SeatDeliveryItem(BaseModel):
"""One sub-agent's final delivery from the Step 2 roundtable."""
sender: str = ""
content: str = ""
class PublicDraftShareSnapshotResponse(BaseModel):
"""Read-only roundtable-result snapshot served to anonymous viewers.
Mirrors what the in-app Step 3「最终方案展示」(`HighFidelityReport`) shows, minus
the live per-seat output files (those need authenticated thread access):
总控最终结论 + 各席位最终交付 + the generated 结果绘制 report.
"""
share_code: str
title: str = Field(..., description="Draft title")
mode: str = Field(default="view")
# 总控最终结论 —— the coordinator's final consensus text (Step 2 lastLeaderContent).
consensus_text: str = Field(default="", description="Coordinator's final consensus (markdown)")
# 各席位最终交付 —— each sub-agent's final delivery, filtered from Step 2 dialogues.
seat_deliveries: list[SeatDeliveryItem] = Field(default_factory=list)
# The Step 3「结果绘制」report — a self-contained HTML document. Empty when the
# shared draft never reached / generated a result report.
html: str = Field(default="", description="Self-contained Step 3 result report HTML")
summary: str | None = Field(default=None, description="The report's closing one-liner")
generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)")
has_report: bool = Field(default=False, description="Whether a Step 3 report exists")
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
# ---------------------------------------------------------------------------
# Owner side — create / list / revoke
# ---------------------------------------------------------------------------
@router.post("", response_model=ShareResponse, summary="Create a share code for one roundtable draft")
async def create_share(
body: CreateShareRequest,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> ShareResponse:
me = _current_user_id(request)
mode = body.mode if body.mode in _SHARE_MODES else "import"
# Only the draft's owner may share it.
draft = await _get_draft_store(request).get_draft(body.draft_id, me)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
# Reuse an existing active code (of the same mode) so re-sharing is idempotent.
existing = await share_store.get_active_for_draft(me, body.draft_id, mode=mode)
if existing is not None:
return ShareResponse(**existing)
record = await share_store.create(me, body.draft_id, mode=mode)
return ShareResponse(**record)
@router.get("", response_model=SharesListResponse, summary="List my roundtable-draft share codes")
async def list_shares(
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> SharesListResponse:
records = await share_store.list_for_owner(_current_user_id(request))
return SharesListResponse(shares=[ShareResponse(**r) for r in records])
@router.delete("/{share_code}", summary="Revoke a roundtable-draft share code")
async def revoke_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> dict[str, bool]:
ok = await share_store.revoke(share_code, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Share code not found")
return {"success": True}
# ---------------------------------------------------------------------------
# Recipient side — preview / import
# ---------------------------------------------------------------------------
async def _resolve_active_share(share_code: str, share_store: RoundtableDraftShareStore) -> dict:
share = await share_store.get(share_code)
if share is None:
raise HTTPException(status_code=404, detail="Share code not found")
if share.get("revoked"):
raise HTTPException(status_code=410, detail="This share code has been revoked")
return share
@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared roundtable draft")
async def preview_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> SharePreviewResponse:
share = await _resolve_active_share(share_code, share_store)
owner = share["owner_user_id"]
draft = await _get_draft_store(request).get_draft(share["draft_id"], owner)
if draft is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
return SharePreviewResponse(
share_code=share_code,
shared_by=owner,
title=draft.get("title") or _UNTITLED,
)
def _imported_draft_id(share_code: str, recipient: str) -> str:
"""Deterministic per-(code, recipient) draft id so re-import is idempotent.
``roundtable_drafts.id`` is a global PK, so the id must be unique across
users — hence the recipient is folded into the hash rather than reusing a
code-only id (which would collide when two people import the same code).
"""
digest = hashlib.sha1(f"{share_code}:{recipient}".encode()).hexdigest()
return f"imp_{digest[:40]}"
@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared roundtable draft")
async def import_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> ImportResultResponse:
share = await _resolve_active_share(share_code, share_store)
if share.get("mode") == "view":
raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing")
owner = share["owner_user_id"]
origin_id = share["draft_id"]
recipient = _current_user_id(request)
if owner == recipient:
raise HTTPException(status_code=400, detail="You cannot import your own share code")
draft_store = _get_draft_store(request)
src = await draft_store.get_draft(origin_id, owner)
if src is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
# Dedup: a deterministic id per (code, recipient) makes re-import a no-op.
new_id = _imported_draft_id(share_code, recipient)
if await draft_store.get_draft(new_id, recipient) is not None:
return ImportResultResponse(imported=0, skipped=1, draft_id=new_id)
await draft_store.create_draft(
recipient,
{
"id": new_id,
"title": src.get("title") or _UNTITLED,
"furthest_step": src.get("furthest_step") or 1,
"step1": src.get("step1"),
"step2": src.get("step2"),
"step3": src.get("step3"),
},
)
logger.info("Roundtable share %s imported by %s as draft %s", share_code, recipient, new_id)
return ImportResultResponse(imported=1, skipped=0, draft_id=new_id)
# ---------------------------------------------------------------------------
# Public read-only view — no authentication required
# ---------------------------------------------------------------------------
def _result_sections(draft: dict) -> tuple[str, list[dict[str, str]]]:
"""Derive (总控最终结论, 各席位最终交付) from a draft's Step 2 snapshot.
Mirrors the frontend `Step3Panel`: the consensus is `step2.lastLeaderContent`
and each seat delivery is a Step 2 dialogue bubble tagged
``confidence == "子智能体交付"``. Falls back to the active run's snapshot when
the top-level Step 2 fields are empty (multi-run drafts).
"""
step2 = draft.get("step2") if isinstance(draft.get("step2"), dict) else {}
step2 = step2 or {}
consensus = step2.get("lastLeaderContent") or ""
dialogues = step2.get("step2RoundtableDialogues")
if not isinstance(dialogues, list):
dialogues = []
# Multi-run drafts keep the freshest content on the active run; use it when
# the top-level snapshot carries nothing.
if not consensus and not dialogues and isinstance(step2.get("runs"), list):
runs = [r for r in step2["runs"] if isinstance(r, dict)]
active_id = step2.get("activeRunId")
active = next((r for r in runs if r.get("id") == active_id), None) or (runs[0] if runs else None)
if active:
consensus = active.get("lastLeaderContent") or ""
d = active.get("step2RoundtableDialogues")
dialogues = d if isinstance(d, list) else []
seat_deliveries = [
{"sender": str(d.get("sender") or ""), "content": str(d.get("content") or "")}
for d in dialogues
if isinstance(d, dict) and d.get("confidence") == "子智能体交付"
]
return consensus, seat_deliveries
@public_router.get(
"/{share_code}",
response_model=PublicDraftShareSnapshotResponse,
summary="Public read-only snapshot of a view-shared roundtable draft",
)
async def get_public_draft_share_snapshot(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> PublicDraftShareSnapshotResponse:
"""Serve a frozen, read-only roundtable result. Anyone with the link can read it.
Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode
codes all return 404 so the endpoint never reveals which case applies.
"""
share = await share_store.get(share_code)
if share is None or share.get("revoked") or share.get("mode") != "view":
raise HTTPException(status_code=404, detail="Share not found")
draft = await _get_draft_store(request).get_draft(share["draft_id"], share["owner_user_id"])
if draft is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else None
html = (step3 or {}).get("html") or ""
consensus_text, seat_deliveries = _result_sections(draft)
return PublicDraftShareSnapshotResponse(
share_code=share_code,
title=draft.get("title") or _UNTITLED,
mode="view",
consensus_text=consensus_text,
seat_deliveries=[SeatDeliveryItem(**s) for s in seat_deliveries],
html=html,
summary=(step3 or {}).get("summary"),
generated_at=(step3 or {}).get("generatedAt"),
has_report=bool(html),
created_at=share.get("created_at"),
)