公开分享页拆分为落地页与独立报告页,后端补充 consensus_text 与 seat_deliveries 字段。 Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
14 KiB
Python
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"),
|
|
)
|