Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
a642ba91fa
21 files changed
+1587
-47
No files matched your search
@@ -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() {
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only roundtable result viewer — no authentication. */}
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
element={<PublicRoundtableSharePage />}
|
||||
/>
|
||||
{/* Public read-only scheduled-task runs viewer — no authentication. */}
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
||||
|
||||
function openDraft(
|
||||
draft: DraftMeta,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
进行中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (draft.status === "awaiting_input") {
|
||||
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
|
||||
}
|
||||
if (draft.status === "error") {
|
||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
第 {draft.furthestStep} 步
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// 分享对话框 —— 正在分享的草稿 id。
|
||||
const [shareId, setShareId] = useState<string | null>(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 (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>多智能体会商</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && drafts.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
drafts.slice(0, 10).map((draft) => (
|
||||
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openDraft(draft, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{draft.title || "未命名草稿"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<StatusBadge draft={draft} />
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(draft.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background top-2"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">更多</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-44 rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameValue(draft.title || "");
|
||||
}}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>重命名</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>分享</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>删除</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名会商</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="输入新名称"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享对话框 */}
|
||||
<ShareRoundtableDraftDialog
|
||||
draftId={shareId}
|
||||
open={shareId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<DraftShareMode>("view");
|
||||
const [share, setShare] = useState<DraftShare | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分享会商</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isView
|
||||
? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。"
|
||||
: "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="bg-muted flex gap-1 rounded-lg p-1">
|
||||
{(
|
||||
[
|
||||
{ value: "view" as const, label: "只读链接" },
|
||||
{ value: "import" as const, label: "导入副本" },
|
||||
]
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
mode === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!share ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在生成分享…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{isView ? "只读链接" : "分享码"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isView ? (
|
||||
<Input
|
||||
readOnly
|
||||
value={publicUrl}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
|
||||
{share.share_code}
|
||||
</code>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void handleCopy()}>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
{isView ? "复制链接" : "复制码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isView ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(publicUrl, "_blank", "noopener")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
在新标签页打开
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={revokeShare.isPending}
|
||||
onClick={() => void handleRevoke()}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
撤销分享
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<DraftSharePreview | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入会商</DialogTitle>
|
||||
<DialogDescription>
|
||||
输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setPreview(null);
|
||||
}}
|
||||
placeholder="粘贴分享码或链接"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handlePreview()}
|
||||
disabled={previewShare.isPending || !input.trim()}
|
||||
>
|
||||
{previewShare.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
预览
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p className="font-medium">{preview.title || "未命名会商"}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
来自用户 {preview.shared_by}
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={importShare.isPending}
|
||||
>
|
||||
{importShare.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
{importShare.isPending ? "正在导入…" : "导入到我的账号"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentRoundtableList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
|
||||
@@ -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<Error> {
|
||||
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<DraftShare> {
|
||||
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<DraftShare>;
|
||||
}
|
||||
|
||||
/** List the current user's own roundtable-draft share links. */
|
||||
export async function listDraftShares(): Promise<DraftShare[]> {
|
||||
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<void> {
|
||||
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<DraftSharePreview> {
|
||||
const response = await apiFetch(
|
||||
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "无法读取该分享");
|
||||
return response.json() as Promise<DraftSharePreview>;
|
||||
}
|
||||
|
||||
/** Import the roundtable draft behind a share link into the current account. */
|
||||
export async function importDraftShare(shareCode: string): Promise<DraftImportResult> {
|
||||
const response = await apiFetch(
|
||||
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "导入失败");
|
||||
return response.json() as Promise<DraftImportResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PublicDraftShareSnapshot> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/public/roundtable-shares/${encodeURIComponent(shareCode)}`,
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "无法打开该分享");
|
||||
return response.json() as Promise<PublicDraftShareSnapshot>;
|
||||
}
|
||||
|
||||
/** 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/<code>`.
|
||||
return `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/<code>` 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 (
|
||||
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载分享内容…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-lg font-medium">分享不可用</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
该分享链接不存在、已被撤销,或不是只读分享。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-base font-semibold">
|
||||
{data.title || "未命名会商"}
|
||||
</h1>
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
|
||||
只读分享
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这是一份多智能体会商的结果报告快照(只读),你可以查看全部内容,但无法继续研讨。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
{data.has_report ? (
|
||||
<iframe
|
||||
// No `allow-scripts` — the report is self-contained (inline CSS + inline
|
||||
// SVG charts) and must never run as a script in the app origin.
|
||||
sandbox=""
|
||||
title={data.title || "会商结果报告"}
|
||||
srcDoc={data.html}
|
||||
className="size-full border-0 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
|
||||
<p className="text-base font-medium text-foreground">暂无结果报告</p>
|
||||
<p>这次会商还没有生成第三步「结果绘制」的方案报告。</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
@@ -335,16 +334,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
结果绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") window.print();
|
||||
}}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||
>
|
||||
<Printer className="w-3.5 h-3.5 text-slate-500" />
|
||||
打印 / 另存 PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadKey((k) => k + 1)}
|
||||
|
||||
@@ -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<DraftMeta[]>([]);
|
||||
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 };
|
||||
}
|
||||
@@ -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<boolean>(false);
|
||||
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
|
||||
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
|
||||
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
|
||||
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// 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<string>).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() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 导入会商弹窗:输入分享码 → 复制副本 → 加载到当前页面继续研讨 */}
|
||||
<ImportRoundtableDraftDialog
|
||||
open={isImportOpen}
|
||||
onOpenChange={setIsImportOpen}
|
||||
onImported={(draftId) => {
|
||||
void drafts.refresh();
|
||||
if (draftId) drafts.handleLoadDraft(draftId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
|
||||
<DispatchChainJobModal
|
||||
jobId={chainModalJobId}
|
||||
@@ -718,42 +756,6 @@ export default function RoundtablePlanningPage() {
|
||||
新建任务
|
||||
</button>
|
||||
|
||||
{currentStep !== 2 && (
|
||||
<>
|
||||
<button
|
||||
id="start-clear-btn"
|
||||
onClick={() => {
|
||||
setCurrentStep(1);
|
||||
triggerToast("已切换至第一阶段:人机问答意图澄清");
|
||||
}}
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
开始澄清
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="human-intervention-btn"
|
||||
onClick={() => {
|
||||
setRoles((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === 1
|
||||
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
triggerToast("指挥专家状态变更!人工介入切换");
|
||||
}}
|
||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
|
||||
人工介入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="save-draft-header-btn"
|
||||
onClick={drafts.handleManualSave}
|
||||
@@ -763,6 +765,19 @@ export default function RoundtablePlanningPage() {
|
||||
保存草稿
|
||||
</button>
|
||||
|
||||
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
|
||||
{false && (
|
||||
<button
|
||||
id="import-roundtable-btn"
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
title="输入分享码,把别人分享的会商复制到我的账号"
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-slate-500" />
|
||||
导入会商
|
||||
</button>
|
||||
)}
|
||||
|
||||
<HistoryDropdown
|
||||
innerRef={drafts.historyDropdownRef}
|
||||
isOpen={drafts.isHistoryOpen}
|
||||
|
||||
@@ -245,6 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` → 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/<code>`. 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). |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
+57
@@ -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")
|
||||
+2
@@ -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",
|
||||
|
||||
+22
@@ -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)
|
||||
+56
@@ -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
|
||||
+59
@@ -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
|
||||
+38
@@ -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))
|
||||
+84
@@ -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
|
||||
Reference in new issue
Block a user