暂无结果报告
+这次会商还没有生成第三步「结果绘制」的方案报告。
+加载中…
+ ) : ( + drafts.slice(0, 10).map((draft) => ( +`.
+ return `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
+}
diff --git a/frontend-web/src/core/roundtable-draft-shares/hooks.ts b/frontend-web/src/core/roundtable-draft-shares/hooks.ts
new file mode 100644
index 00000000..98a10f5f
--- /dev/null
+++ b/frontend-web/src/core/roundtable-draft-shares/hooks.ts
@@ -0,0 +1,61 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ createDraftShare,
+ importDraftShare,
+ listDraftShares,
+ previewDraftShare,
+ revokeDraftShare,
+ type DraftShareMode,
+} from "./api";
+
+export function useDraftShares(enabled = true) {
+ return useQuery({
+ queryKey: ["roundtable-draft-shares"],
+ queryFn: listDraftShares,
+ enabled,
+ });
+}
+
+export function useCreateDraftShare() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ draftId,
+ mode = "import",
+ }: {
+ draftId: string;
+ mode?: DraftShareMode;
+ }) => createDraftShare(draftId, mode),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] });
+ },
+ });
+}
+
+export function useRevokeDraftShare() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (shareCode: string) => revokeDraftShare(shareCode),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] });
+ },
+ });
+}
+
+export function usePreviewDraftShare() {
+ return useMutation({
+ mutationFn: (shareCode: string) => previewDraftShare(shareCode),
+ });
+}
+
+export function useImportDraftShare() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (shareCode: string) => importDraftShare(shareCode),
+ onSuccess: () => {
+ // Imported drafts show up in the user's roundtable history sidebar.
+ void queryClient.invalidateQueries({ queryKey: ["roundtable-drafts"] });
+ },
+ });
+}
diff --git a/frontend-web/src/pages/PublicRoundtableSharePage.tsx b/frontend-web/src/pages/PublicRoundtableSharePage.tsx
new file mode 100644
index 00000000..42c1392e
--- /dev/null
+++ b/frontend-web/src/pages/PublicRoundtableSharePage.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useParams } from "react-router-dom";
+
+import { getPublicDraftShareSnapshot } from "@/core/roundtable-draft-shares/api";
+
+/**
+ * Public, read-only roundtable-result viewer.
+ *
+ * Reachable at `#/roundtable-share/` with NO authentication — anyone with
+ * the link can open it. It renders the Step 3「结果绘制」report (a self-contained
+ * HTML document) inside a sandboxed iframe with no `allow-scripts`, mirroring the
+ * scheduled-task HTML-page preview, so a shared report can never execute code in
+ * the app origin.
+ */
+export default function PublicRoundtableSharePage() {
+ const { shareCode } = useParams<{ shareCode: string }>();
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ["public-roundtable-share", shareCode],
+ queryFn: () => getPublicDraftShareSnapshot(shareCode!),
+ enabled: !!shareCode,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+
+ if (isLoading) {
+ return (
+
+
+ 正在加载分享内容…
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+ 分享不可用
+
+ 该分享链接不存在、已被撤销,或不是只读分享。
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {data.title || "未命名会商"}
+
+
+ 只读分享
+
+
+
+ 这是一份多智能体会商的结果报告快照(只读),你可以查看全部内容,但无法继续研讨。
+
+
+
+ {data.has_report ? (
+
+ ) : (
+
+ 暂无结果报告
+ 这次会商还没有生成第三步「结果绘制」的方案报告。
+
+ )}
+
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts
new file mode 100644
index 00000000..ad8fa61c
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts
@@ -0,0 +1,50 @@
+/**
+ * 侧边栏「多智能体会商」记录用的轻量草稿列表 hook。
+ *
+ * 与页面内的 {@link useDraftPersistence} 不同 —— 那个负责完整的「存 / 加载 / 重命名 /
+ * 删除」编排,依赖 Step1/Step2 快照。侧边栏只需要只读地展示最近草稿 + 重命名 / 删除,
+ * 所以这里单独包一层,结构对齐 `useAIWritingSessions`(`{ drafts, loading, refresh,
+ * rename, remove }`)。
+ */
+import { useCallback, useState } from "react";
+
+import {
+ deleteDraft,
+ listDrafts,
+ updateDraft,
+ type DraftMeta,
+} from "@/roundtable-planning/api/drafts";
+
+export function useRoundtableDrafts() {
+ const [drafts, setDrafts] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await listDrafts();
+ setDrafts(data);
+ } catch {
+ // 历史记录非关键,静默忽略
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const rename = useCallback(async (id: string, title: string) => {
+ // 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
+ const updated = await updateDraft(id, { title });
+ if (updated) {
+ setDrafts((prev) =>
+ prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)),
+ );
+ }
+ }, []);
+
+ const remove = useCallback(async (id: string) => {
+ await deleteDraft(id);
+ setDrafts((prev) => prev.filter((d) => d.id !== id));
+ }, []);
+
+ return { drafts, loading, refresh, rename, remove };
+}
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index e2582397..1532299d 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -36,6 +36,7 @@ import {
Clipboard,
Clock,
Cpu,
+ Download,
History,
ListOrdered,
Loader2,
@@ -54,6 +55,7 @@ import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
+import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
@@ -99,6 +101,8 @@ export default function RoundtablePlanningPage() {
const [isChainListOpen, setIsChainListOpen] = useState(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState(null);
+ // 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
+ const [isImportOpen, setIsImportOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -442,6 +446,30 @@ export default function RoundtablePlanningPage() {
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
});
+ // ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
+ // 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
+ // 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
+ // 用 ref 持有最新 handleLoadDraft,让下面的 effect 保持「挂载只跑一次」,避免
+ // handleLoadDraft 身份变化导致重复消费 / 重复加载。
+ const handleLoadDraftRef = useRef(drafts.handleLoadDraft);
+ useEffect(() => {
+ handleLoadDraftRef.current = drafts.handleLoadDraft;
+ }, [drafts.handleLoadDraft]);
+
+ useEffect(() => {
+ const pending = sessionStorage.getItem("roundtable:pending-draft");
+ if (pending) {
+ sessionStorage.removeItem("roundtable:pending-draft");
+ handleLoadDraftRef.current(pending);
+ }
+ const onOpenDraft = (e: Event) => {
+ const id = (e as CustomEvent).detail;
+ if (id) handleLoadDraftRef.current(id);
+ };
+ window.addEventListener("roundtable:open-draft", onOpenDraft);
+ return () => window.removeEventListener("roundtable:open-draft", onOpenDraft);
+ }, []);
+
// ─── 角色面板转场 ────────────────────────────────────────
// Step 1: 仅指挥员 + 任务理解智能体两行(状态跟随 intent init)。
// Step 2: 完整席位(loadedFromDraft 时直接 "在线",否则 "正在上线")。
@@ -660,6 +688,16 @@ export default function RoundtablePlanningPage() {
}}
/>
+ {/* 导入会商弹窗:输入分享码 → 复制副本 → 加载到当前页面继续研讨 */}
+ {
+ void drafts.refresh();
+ if (draftId) drafts.handleLoadDraft(draftId);
+ }}
+ />
+
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
+
+
` → re-import is a no-op). **`view`**: a public, read-only result page — see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
+| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3「结果绘制」report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 24239ece..fe309d65 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -45,6 +45,7 @@ from app.gateway.routers import (
recommend,
recommended_questions,
roundtable_chains,
+ roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
runs,
@@ -925,6 +926,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
app.include_router(thread_shares.public_router)
+ # 圆桌会商草稿分享(import 复制副本 / view 只读结果报告页)
+ app.include_router(roundtable_draft_shares.router)
+ # Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
+ app.include_router(roundtable_draft_shares.public_router)
+
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index 8232cdaa..1a819a0e 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -167,6 +167,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
+ from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
+
+ app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
+
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
@@ -330,6 +334,14 @@ def get_thread_share_store(request: Request):
return val
+def get_roundtable_draft_share_store(request: Request):
+ """Return the roundtable-draft share-link store."""
+ val = getattr(request.app.state, "roundtable_draft_share_store", None)
+ if val is None:
+ raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
+ return val
+
+
def get_embed_session_store(request: Request):
"""Return the iframe embed session → thread mapping store."""
val = getattr(request.app.state, "embed_session_store", None)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py
new file mode 100644
index 00000000..df1c4c9e
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py
@@ -0,0 +1,277 @@
+"""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 PublicDraftShareSnapshotResponse(BaseModel):
+ """Read-only roundtable-result snapshot served to anonymous viewers."""
+
+ share_code: str
+ title: str = Field(..., description="Draft title")
+ mode: str = Field(default="view")
+ # 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
+# ---------------------------------------------------------------------------
+
+
+@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 ""
+ return PublicDraftShareSnapshotResponse(
+ share_code=share_code,
+ title=draft.get("title") or _UNTITLED,
+ mode="view",
+ html=html,
+ summary=(step3 or {}).get("summary"),
+ generated_at=(step3 or {}).get("generatedAt"),
+ has_report=bool(html),
+ created_at=share.get("created_at"),
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py
new file mode 100644
index 00000000..ab5b568e
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py
@@ -0,0 +1,57 @@
+"""Create roundtable_draft_shares table.
+
+Per-draft share links for the roundtable-planning page, mirroring
+``thread_shares`` but pointing at a ``roundtable_drafts`` row. ``import`` codes
+copy the draft into the recipient's account; ``view`` codes back a public
+read-only result-report page. ``create_all`` makes the table for brand-new DBs;
+this revision patches DBs that already exist.
+
+Revision ID: 20260606_01
+Revises: 20260605_02
+Create Date: 2026-06-06
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+
+revision = "20260606_01"
+down_revision = "20260605_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 upgrade() -> None:
+ if _has_table("roundtable_draft_shares"):
+ return
+ op.create_table(
+ "roundtable_draft_shares",
+ sa.Column("share_code", sa.String(length=32), primary_key=True),
+ sa.Column("owner_user_id", sa.String(length=64), nullable=False),
+ sa.Column("draft_id", sa.String(length=64), nullable=False),
+ sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("mode", sa.String(length=16), nullable=False, server_default="import"),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ )
+ op.create_index(
+ "ix_roundtable_draft_shares_owner_user_id",
+ "roundtable_draft_shares",
+ ["owner_user_id"],
+ )
+ op.create_index(
+ "ix_roundtable_draft_shares_draft_id",
+ "roundtable_draft_shares",
+ ["draft_id"],
+ )
+
+
+def downgrade() -> None:
+ if _has_table("roundtable_draft_shares"):
+ op.drop_table("roundtable_draft_shares")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
index 7f54831e..c05c6924 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
@@ -23,6 +23,7 @@ from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
+from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
@@ -54,6 +55,7 @@ __all__ = [
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
+ "RoundtableDraftShareRow",
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py
new file mode 100644
index 00000000..16089569
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py
@@ -0,0 +1,22 @@
+"""Persistence layer for roundtable-draft share links."""
+
+from deerflow.persistence.roundtable_draft_shares.base import (
+ RoundtableDraftShareStore,
+ generate_share_code,
+)
+from deerflow.persistence.roundtable_draft_shares.memory import MemoryRoundtableDraftShareStore
+from deerflow.persistence.roundtable_draft_shares.sql import RoundtableDraftShareRepository
+
+__all__ = [
+ "RoundtableDraftShareStore",
+ "MemoryRoundtableDraftShareStore",
+ "RoundtableDraftShareRepository",
+ "generate_share_code",
+ "make_roundtable_draft_share_store",
+]
+
+
+def make_roundtable_draft_share_store(session_factory):
+ if session_factory is None:
+ return MemoryRoundtableDraftShareStore()
+ return RoundtableDraftShareRepository(session_factory)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py
new file mode 100644
index 00000000..4830a813
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py
@@ -0,0 +1,56 @@
+"""Abstract persistence API for roundtable-draft share links.
+
+Mirrors ``thread_shares`` but shares one *roundtable-planning draft* (a full
+会商 session: Step 1 intent + Step 2 roundtable + optional Step 3 report)
+instead of a conversation thread. Redeeming an ``import`` code copies the draft
+into the recipient's account; a ``view`` code is a public read-only page that
+renders the draft's Step 3 result report.
+
+The share-code alphabet / generator is reused from ``thread_shares.base`` so
+codes are interchangeable in shape (unambiguous, human-readable).
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+# Reuse the same unambiguous-alphabet code generator as conversation shares.
+from deerflow.persistence.thread_shares.base import generate_share_code
+
+__all__ = ["RoundtableDraftShareStore", "generate_share_code"]
+
+
+class RoundtableDraftShareStore(ABC):
+ @abstractmethod
+ async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
+ """Create a new share link for one draft and return its record.
+
+ ``mode`` is ``"import"`` (recipient copies the draft) or ``"view"``
+ (a public read-only result-report page).
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ async def get(self, share_code: str) -> dict[str, Any] | None:
+ """Look up a share by code regardless of owner (None if missing)."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
+ """Return the owner's non-revoked share of ``mode`` for ``draft_id``, if any.
+
+ Used so re-sharing the same draft in the same mode reuses its existing
+ code instead of minting a new one each time.
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
+ """List all share links created by ``owner_user_id`` (newest first)."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def revoke(self, share_code: str, owner_user_id: str) -> bool:
+ """Revoke a share link. Returns False if missing or not owned."""
+ raise NotImplementedError
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py
new file mode 100644
index 00000000..a89942a4
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py
@@ -0,0 +1,59 @@
+"""In-memory ``RoundtableDraftShareStore`` for tests / memory backend."""
+
+from __future__ import annotations
+
+import copy
+from datetime import UTC, datetime
+from typing import Any
+
+from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
+
+
+class MemoryRoundtableDraftShareStore(RoundtableDraftShareStore):
+ def __init__(self) -> None:
+ self._shares: dict[str, dict[str, Any]] = {}
+
+ async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
+ code = generate_share_code()
+ while code in self._shares:
+ code = generate_share_code()
+ record = {
+ "share_code": code,
+ "owner_user_id": owner_user_id,
+ "draft_id": draft_id,
+ "revoked": False,
+ "mode": mode,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+ self._shares[code] = record
+ return copy.deepcopy(record)
+
+ async def get(self, share_code: str) -> dict[str, Any] | None:
+ record = self._shares.get(share_code)
+ return copy.deepcopy(record) if record is not None else None
+
+ async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
+ matches = [
+ r
+ for r in self._shares.values()
+ if r["owner_user_id"] == owner_user_id
+ and r["draft_id"] == draft_id
+ and r.get("mode", "import") == mode
+ and not r["revoked"]
+ ]
+ if not matches:
+ return None
+ matches.sort(key=lambda r: r["created_at"], reverse=True)
+ return copy.deepcopy(matches[0])
+
+ async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
+ rows = [r for r in self._shares.values() if r["owner_user_id"] == owner_user_id]
+ rows.sort(key=lambda r: r["created_at"], reverse=True)
+ return [copy.deepcopy(r) for r in rows]
+
+ async def revoke(self, share_code: str, owner_user_id: str) -> bool:
+ record = self._shares.get(share_code)
+ if record is None or record["owner_user_id"] != owner_user_id:
+ return False
+ record["revoked"] = True
+ return True
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py
new file mode 100644
index 00000000..35bf0ed3
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py
@@ -0,0 +1,38 @@
+"""ORM model for roundtable-draft share links.
+
+A share link is a short, revocable code created by a user for *one* of their
+roundtable-planning drafts. Redeeming it copies that draft into the redeemer's
+account (``import``) or exposes its Step 3 result report on a public read-only
+page (``view``). The table only stores the link — the draft snapshot is read
+lazily at import / view time, so a revoked link instantly stops working.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import Boolean, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from deerflow.persistence.base import Base
+from deerflow.persistence.types import BeijingDateTime
+
+
+class RoundtableDraftShareRow(Base):
+ __tablename__ = "roundtable_draft_shares"
+
+ # Short, URL-safe, unguessable code embedded in the share link.
+ share_code: Mapped[str] = mapped_column(String(32), primary_key=True)
+ # The user who created the share.
+ owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ # The single roundtable draft this link shares.
+ draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ # Soft-disable: a revoked link can no longer be previewed, imported, or viewed.
+ revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ # Share mode:
+ # "import" — recipient (a logged-in user) copies the draft into their own
+ # account and can keep working on the copy.
+ # "view" — a public, read-only page anyone can open without logging in;
+ # it renders the draft's Step 3 result report (HTML).
+ mode: Mapped[str] = mapped_column(String(16), nullable=False, default="import", server_default="import")
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py
new file mode 100644
index 00000000..8834ed0d
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py
@@ -0,0 +1,84 @@
+"""SQLAlchemy-backed roundtable-draft share-link repository."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
+from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
+
+
+def _row_to_dict(row: RoundtableDraftShareRow) -> dict[str, Any]:
+ data = row.to_dict()
+ created = data.get("created_at")
+ if isinstance(created, datetime):
+ data["created_at"] = created.isoformat()
+ return data
+
+
+class RoundtableDraftShareRepository(RoundtableDraftShareStore):
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
+ # Retry on the (astronomically unlikely) code collision.
+ async with self._sf() as session:
+ for _ in range(8):
+ code = generate_share_code()
+ if await session.get(RoundtableDraftShareRow, code) is not None:
+ continue
+ row = RoundtableDraftShareRow(
+ share_code=code,
+ owner_user_id=owner_user_id,
+ draft_id=draft_id,
+ revoked=False,
+ mode=mode,
+ )
+ session.add(row)
+ await session.commit()
+ await session.refresh(row)
+ return _row_to_dict(row)
+ raise RuntimeError("Failed to allocate a unique share code")
+
+ async def get(self, share_code: str) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(RoundtableDraftShareRow, share_code)
+ return _row_to_dict(row) if row is not None else None
+
+ async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
+ stmt = (
+ select(RoundtableDraftShareRow)
+ .where(
+ RoundtableDraftShareRow.owner_user_id == owner_user_id,
+ RoundtableDraftShareRow.draft_id == draft_id,
+ RoundtableDraftShareRow.mode == mode,
+ RoundtableDraftShareRow.revoked.is_(False),
+ )
+ .order_by(RoundtableDraftShareRow.created_at.desc())
+ )
+ async with self._sf() as session:
+ row = (await session.execute(stmt)).scalars().first()
+ return _row_to_dict(row) if row is not None else None
+
+ async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
+ stmt = (
+ select(RoundtableDraftShareRow)
+ .where(RoundtableDraftShareRow.owner_user_id == owner_user_id)
+ .order_by(RoundtableDraftShareRow.created_at.desc())
+ )
+ async with self._sf() as session:
+ result = await session.execute(stmt)
+ return [_row_to_dict(row) for row in result.scalars()]
+
+ async def revoke(self, share_code: str, owner_user_id: str) -> bool:
+ async with self._sf() as session:
+ row = await session.get(RoundtableDraftShareRow, share_code)
+ if row is None or row.owner_user_id != owner_user_id:
+ return False
+ row.revoked = True
+ await session.commit()
+ return True