From 0fd314632ac095f97f835951304561da82aae297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 5 Jun 2026 11:19:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E4=BC=9A=E5=95=86=E8=8D=89=E7=A8=BF=E5=88=86=E4=BA=AB?= =?UTF-8?q?=EF=BC=88import=20=E5=A4=8D=E5=88=B6=20/=20view=20=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=BB=93=E6=9E=9C=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持会商草稿生成分享码、导入副本与公开只读结果页,并在侧边栏展示最近会商记录。 Co-authored-by: Cursor --- frontend-web/src/App.tsx | 6 + .../workspace/recent-roundtable-list.tsx | 266 +++++++++++++++ .../roundtable-draft-share-dialogs.tsx | 322 ++++++++++++++++++ .../workspace/workspace-sidebar.tsx | 2 + .../src/core/roundtable-draft-shares/api.ts | 131 +++++++ .../src/core/roundtable-draft-shares/hooks.ts | 61 ++++ .../src/pages/PublicRoundtableSharePage.tsx | 83 +++++ .../hooks/useRoundtableDrafts.ts | 50 +++ .../pages/RoundtablePlanningPage.tsx | 48 +++ offline-backend-20260512/backend/CLAUDE.md | 2 + .../backend/app/gateway/app.py | 6 + .../backend/app/gateway/deps.py | 12 + .../routers/roundtable_draft_shares.py | 277 +++++++++++++++ .../20260606_01_roundtable_draft_shares.py | 57 ++++ .../deerflow/persistence/models/__init__.py | 2 + .../roundtable_draft_shares/__init__.py | 22 ++ .../roundtable_draft_shares/base.py | 56 +++ .../roundtable_draft_shares/memory.py | 59 ++++ .../roundtable_draft_shares/model.py | 38 +++ .../roundtable_draft_shares/sql.py | 84 +++++ 20 files changed, 1584 insertions(+) create mode 100644 frontend-web/src/components/workspace/recent-roundtable-list.tsx create mode 100644 frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx create mode 100644 frontend-web/src/core/roundtable-draft-shares/api.ts create mode 100644 frontend-web/src/core/roundtable-draft-shares/hooks.ts create mode 100644 frontend-web/src/pages/PublicRoundtableSharePage.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 4503b615..b231ad97 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -17,6 +17,7 @@ import LoginPage from "./pages/LoginPage"; import PageRoutes from "./pages/PageRoutes"; import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage"; import PublicSharePage from "./pages/PublicSharePage"; +import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -63,6 +64,11 @@ export function App() { } /> {/* Public read-only conversation viewer — no authentication. */} } /> + {/* Public read-only roundtable result viewer — no authentication. */} + } + /> {/* Public read-only scheduled-task runs viewer — no authentication. */} , +) { + if (isOnPage) { + // 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。 + window.dispatchEvent( + new CustomEvent("roundtable:open-draft", { detail: draft.id }), + ); + } else { + // 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。 + sessionStorage.setItem("roundtable:pending-draft", draft.id); + router.push(ROUNDTABLE_PATH); + } +} + +/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */ +function StatusBadge({ draft }: { draft: DraftMeta }) { + if (draft.status === "running") { + return ( + + + 进行中 + + ); + } + if (draft.status === "awaiting_input") { + return 待澄清; + } + if (draft.status === "error") { + return 出错; + } + return ( + + 第 {draft.furthestStep} 步 + + ); +} + +export function RecentRoundtableList() { + const router = useRouter(); + const pathname = usePathname(); + const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts(); + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true", + ); + + // 重命名对话框状态。 + const [renameId, setRenameId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + // 分享对话框 —— 正在分享的草稿 id。 + const [shareId, setShareId] = useState(null); + + const toggleCollapsed = () => { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next)); + }; + + useEffect(() => { + void refresh(); + }, [refresh]); + + const handleRenameSubmit = useCallback(async () => { + const id = renameId; + const title = renameValue.trim(); + if (!id || !title) { + setRenameId(null); + return; + } + try { + await rename(id, title); + toast.success("✏️ 已重命名"); + } catch { + toast.error("重命名失败,请重试"); + } finally { + setRenameId(null); + setRenameValue(""); + } + }, [renameId, renameValue, rename]); + + const handleDelete = useCallback( + async (draft: DraftMeta) => { + if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; + try { + await remove(draft.id); + toast.success("🗑 已删除会商记录"); + } catch { + toast.error("删除失败,请重试"); + } + }, + [remove], + ); + + if (!loading && drafts.length === 0) return null; + + const isOnPage = pathname === ROUNDTABLE_PATH; + + return ( + <> + + + 多智能体会商 + + + + +
+ {loading && drafts.length === 0 ? ( +

加载中…

+ ) : ( + drafts.slice(0, 10).map((draft) => ( + + openDraft(draft, isOnPage, router)} + > +
+ + {draft.title || "未命名草稿"} + +
+ + · + + {formatRelative(draft.updatedAt)} + +
+
+
+ + + + + 更多 + + + + { + setRenameId(draft.id); + setRenameValue(draft.title || ""); + }} + > + + 重命名 + + setShareId(draft.id)}> + + 分享 + + + void handleDelete(draft)}> + + 删除 + + + +
+ )) + )} +
+
+
+
+ + {/* 重命名对话框 */} + !o && setRenameId(null)}> + + + 重命名会商 + +
+ setRenameValue(e.target.value)} + placeholder="输入新名称" + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + void handleRenameSubmit(); + } + }} + /> +
+ + + + +
+
+ + {/* 分享对话框 */} + { + if (!o) setShareId(null); + }} + /> + + ); +} diff --git a/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx b/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx new file mode 100644 index 00000000..00b4235e --- /dev/null +++ b/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { + CheckIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + Loader2, + Trash2Icon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { DraftShare, DraftShareMode, DraftSharePreview } from "@/core/roundtable-draft-shares/api"; +import { buildPublicDraftShareUrl, extractShareCode } from "@/core/roundtable-draft-shares/api"; +import { + useCreateDraftShare, + useImportDraftShare, + usePreviewDraftShare, + useRevokeDraftShare, +} from "@/core/roundtable-draft-shares/hooks"; +import { cn } from "@/lib/utils"; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Dialog that produces a share link for a single roundtable draft. Two modes: + * + * - `view` — mints a public, read-only link that anyone can open without logging + * in; it renders the draft's Step 3 result report (方案总览). + * - `import` — mints a short code; a recipient enters it in the import dialog to + * copy the whole 会商 draft into their own account. + */ +export function ShareRoundtableDraftDialog({ + draftId, + open, + onOpenChange, +}: { + draftId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const createShare = useCreateDraftShare(); + const revokeShare = useRevokeDraftShare(); + const [mode, setMode] = useState("view"); + const [share, setShare] = useState(null); + const [copied, setCopied] = useState(false); + + // Reset to the default mode (read-only link) every time the dialog opens. + useEffect(() => { + if (open) setMode("view"); + }, [open, draftId]); + + // Mint / reuse the link whenever the dialog opens or the mode changes. + useEffect(() => { + if (!open || !draftId) return; + setShare(null); + setCopied(false); + createShare + .mutateAsync({ draftId, mode }) + .then(setShare) + .catch((err) => toast.error(errorMessage(err))); + // createShare is stable enough; re-running only on open/draftId/mode is intended. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, draftId, mode]); + + const isView = mode === "view"; + const publicUrl = isView && share ? buildPublicDraftShareUrl(share.share_code) : ""; + + async function handleCopy() { + if (!share) return; + const text = isView ? publicUrl : share.share_code; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success(isView ? "链接已复制" : "分享码已复制"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("复制到剪贴板失败"); + } + } + + async function handleRevoke() { + if (!share || !window.confirm("撤销后此分享立即失效,确定吗?")) return; + try { + await revokeShare.mutateAsync(share.share_code); + toast.success("已撤销分享"); + onOpenChange(false); + } catch (err) { + toast.error(errorMessage(err)); + } + } + + return ( + + + + 分享会商 + + {isView + ? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。" + : "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"} + + + + {/* Mode selector */} +
+ {( + [ + { value: "view" as const, label: "只读链接" }, + { value: "import" as const, label: "导入副本" }, + ] + ).map((option) => ( + + ))} +
+ + {!share ? ( +
+ + 正在生成分享… +
+ ) : ( +
+
+

+ {isView ? "只读链接" : "分享码"} +

+
+ {isView ? ( + e.currentTarget.select()} + className="flex-1 font-mono text-xs" + /> + ) : ( + + {share.share_code} + + )} + +
+
+ {isView ? ( +
+

+ 拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。 +

+ +
+ ) : ( +

+ 把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。 +

+ )} + +
+ )} +
+
+ ); +} + +/** Dialog where a user pastes a share code to import someone's roundtable draft. */ +export function ImportRoundtableDraftDialog({ + open, + onOpenChange, + onImported, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called with the new local draft id after a successful import. */ + onImported?: (draftId: string | null) => void; +}) { + const [input, setInput] = useState(""); + const [preview, setPreview] = useState(null); + const previewShare = usePreviewDraftShare(); + const importShare = useImportDraftShare(); + + // Reset state whenever the dialog closes. + useEffect(() => { + if (!open) { + setInput(""); + setPreview(null); + } + }, [open]); + + async function handlePreview() { + const code = extractShareCode(input); + if (!code) { + toast.error("请输入分享码"); + return; + } + try { + setPreview(await previewShare.mutateAsync(code)); + } catch (err) { + setPreview(null); + toast.error(errorMessage(err)); + } + } + + async function handleImport() { + const code = preview?.share_code ?? extractShareCode(input); + if (!code) { + toast.error("请输入分享码"); + return; + } + try { + const result = await importShare.mutateAsync(code); + if (result.imported > 0) { + toast.success("导入成功,已加入「多智能体会商」历史"); + } else { + toast.info("这份会商之前已导入过"); + } + onImported?.(result.draft_id); + onOpenChange(false); + } catch (err) { + toast.error(errorMessage(err)); + } + } + + return ( + + + + 导入会商 + + 输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。 + + + +
+ { + setInput(e.target.value); + setPreview(null); + }} + placeholder="粘贴分享码或链接" + className="font-mono" + /> + +
+ + {preview && ( +
+

{preview.title || "未命名会商"}

+

+ 来自用户 {preview.shared_by} +

+ +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index eeacf4f7..dc1e097b 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { cn } from "@/lib/utils"; import { RecentChatList } from "./recent-chat-list"; +import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentWritingList } from "./recent-writing-list"; import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; @@ -46,6 +47,7 @@ export function WorkspaceSidebar({ {isSidebarOpen && } + {isSidebarOpen && } {isSidebarOpen && } {!compactChrome && ( diff --git a/frontend-web/src/core/roundtable-draft-shares/api.ts b/frontend-web/src/core/roundtable-draft-shares/api.ts new file mode 100644 index 00000000..47a6f9d2 --- /dev/null +++ b/frontend-web/src/core/roundtable-draft-shares/api.ts @@ -0,0 +1,131 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; +// Share codes share the same alphabet/shape as conversation shares, so reuse the +// extractor instead of duplicating the regex. +export { extractShareCode } from "@/core/thread-shares/api"; + +/** + * Roundtable-draft share modes: + * - `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). + */ +export type DraftShareMode = "import" | "view"; + +export interface DraftShare { + share_code: string; + owner_user_id: string; + draft_id: string; + revoked: boolean; + mode: DraftShareMode; + created_at: string | null; +} + +/** Read-only roundtable-result snapshot returned by the public view-share endpoint. */ +export interface PublicDraftShareSnapshot { + share_code: string; + title: string; + mode: string; + /** Self-contained Step 3 result report HTML (empty when no report exists). */ + html: string; + summary: string | null; + generated_at: string | null; + has_report: boolean; + created_at: string | null; +} + +export interface DraftSharePreview { + share_code: string; + shared_by: string; + title: string; +} + +export interface DraftImportResult { + imported: number; + skipped: number; + draft_id: string | null; +} + +const baseUrl = () => `${getBackendBaseURL()}/api/roundtable-draft-shares`; + +async function parseError(response: Response, fallback: string): Promise { + const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + const detail = data.detail; + const message = + typeof detail === "string" && detail + ? detail + : `${fallback} (HTTP ${response.status})`; + return new Error(message); +} + +/** Create (or reuse) a share code for one roundtable draft in the given mode. */ +export async function createDraftShare( + draftId: string, + mode: DraftShareMode = "import", +): Promise { + const response = await apiFetch(baseUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft_id: draftId, mode }), + }); + if (!response.ok) throw await parseError(response, "创建分享失败"); + return response.json() as Promise; +} + +/** List the current user's own roundtable-draft share links. */ +export async function listDraftShares(): Promise { + const response = await apiFetch(baseUrl()); + if (!response.ok) throw await parseError(response, "加载分享列表失败"); + const json = (await response.json()) as { shares: DraftShare[] }; + return json.shares; +} + +/** Revoke a share link so it can no longer be previewed, imported, or viewed. */ +export async function revokeDraftShare(shareCode: string): Promise { + const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, { + method: "DELETE", + }); + if (!response.ok) throw await parseError(response, "撤销分享失败"); +} + +/** Preview a share link before importing it. */ +export async function previewDraftShare(shareCode: string): Promise { + const response = await apiFetch( + `${baseUrl()}/${encodeURIComponent(shareCode)}/preview`, + ); + if (!response.ok) throw await parseError(response, "无法读取该分享"); + return response.json() as Promise; +} + +/** Import the roundtable draft behind a share link into the current account. */ +export async function importDraftShare(shareCode: string): Promise { + const response = await apiFetch( + `${baseUrl()}/${encodeURIComponent(shareCode)}/import`, + { method: "POST" }, + ); + if (!response.ok) throw await parseError(response, "导入失败"); + return response.json() as Promise; +} + +/** + * Fetch the public read-only snapshot of a `view`-mode roundtable share. + * + * Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is + * intentionally world-readable so anyone with the link can open it. + */ +export async function getPublicDraftShareSnapshot( + shareCode: string, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/public/roundtable-shares/${encodeURIComponent(shareCode)}`, + ); + if (!response.ok) throw await parseError(response, "无法打开该分享"); + return response.json() as Promise; +} + +/** Build the shareable public URL for a `view`-mode roundtable share code. */ +export function buildPublicDraftShareUrl(shareCode: string): string { + // The app uses HashRouter, so the public viewer lives at `#/roundtable-share/`. + 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 ? ( +