排查发现后台作业把结果写进 step2.runs[activeRun],而顶层 step2 残留前台 seed 的陈旧气泡(无交付、无结论)。_result_sections 旧逻辑「顶层为空才回退 run」因顶层 非空而永不回退,导致分享页 consensus 与 seat_deliveries 为空。改为优先读活动 run (与前端 hydrate 一致),已有分享链接无需重建即生效。 同时把第三步另外两个产物纳入分享: - 方案总结报告(step3.summaryReport.md,Markdown 内联渲染) - 大屏多页(step3.dashboardJson,用应用内 React 大屏 DashboardApp 渲染,新增 /roundtable-share/:code/dashboard 路由,与应用渲染/新窗口打开同源) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
15 KiB
Python
351 lines
15 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 (经典 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 HTML report exists")
|
|
# Step 3「方案总结报告」—— the Markdown report from roundtable-summary (step3.summaryReport.md).
|
|
summary_report_md: str = Field(default="", description="Step 3 Markdown 方案总结报告")
|
|
has_summary_report: bool = Field(default=False, description="Whether a Step 3 Markdown summary report exists")
|
|
# Step 3「大屏多页」(模式二) —— structured dashboard data (step3.dashboardJson); the
|
|
# frontend renders it to self-contained HTML via renderDashboardHtml at view time.
|
|
dashboard_json: str | None = Field(default=None, description="Step 3 大屏多页 structured data (report-json)")
|
|
has_dashboard: bool = Field(default=False, description="Whether a Step 3 大屏多页 dashboard 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 draft hydration (`RoundtablePlanningPage.normalizeStep2Runs`):
|
|
when the draft has ``runs[]`` the rendered content is the **active run**
|
|
(``activeRunId``), NOT the top-level ``step2`` fields — those can be a stale
|
|
foreground seed (e.g. only the 系统消息/开场 2 条气泡) left over after a later
|
|
background job wrote its results into ``runs[]``. Reading the top level there
|
|
yields an empty consensus and no deliveries (the reported bug). Only drafts
|
|
with no ``runs[]`` (legacy/foreground-only) fall back to the top-level fields.
|
|
|
|
The consensus is ``lastLeaderContent`` and each seat delivery is a dialogue
|
|
bubble tagged ``confidence == "子智能体交付"``.
|
|
"""
|
|
step2 = draft.get("step2") if isinstance(draft.get("step2"), dict) else {}
|
|
step2 = step2 or {}
|
|
|
|
runs = [r for r in step2.get("runs", []) if isinstance(r, dict)] if isinstance(step2.get("runs"), list) else []
|
|
if runs:
|
|
active_id = step2.get("activeRunId")
|
|
source = next((r for r in runs if r.get("id") == active_id), None) or runs[0]
|
|
else:
|
|
source = step2
|
|
|
|
consensus = source.get("lastLeaderContent") or ""
|
|
dialogues = source.get("step2RoundtableDialogues")
|
|
if not isinstance(dialogues, list):
|
|
dialogues = []
|
|
|
|
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 {}
|
|
step3 = step3 or {}
|
|
html = step3.get("html") or ""
|
|
# 方案总结报告(Markdown,模式:roundtable-summary)—— 嵌在 step3.summaryReport.md。
|
|
summary_report = step3.get("summaryReport") if isinstance(step3.get("summaryReport"), dict) else {}
|
|
summary_report_md = (summary_report or {}).get("md") or ""
|
|
# 大屏多页(模式二)—— 只存结构化数据 step3.dashboardJson,前端按主题渲染成自包含 HTML。
|
|
dashboard_json = step3.get("dashboardJson") or None
|
|
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.get("summary"),
|
|
generated_at=step3.get("generatedAt"),
|
|
has_report=bool(html),
|
|
summary_report_md=summary_report_md,
|
|
has_summary_report=bool(summary_report_md),
|
|
dashboard_json=dashboard_json,
|
|
has_dashboard=bool(dashboard_json),
|
|
created_at=share.get("created_at"),
|
|
)
|