feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选
后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。 前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
05d14bb364
commit
7affaaf7c0
10 files changed
+373
-44
No files matched your search
@@ -33,8 +33,13 @@ export interface ChainUpdatePayload {
|
||||
seats?: ChainSeat[];
|
||||
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
||||
stages?: string[][] | null;
|
||||
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */
|
||||
export type ChainScope = "mine" | "public";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
@@ -55,6 +60,8 @@ interface ServerChain {
|
||||
description: string | null;
|
||||
seats: ChainSeat[];
|
||||
stages?: string[][] | null;
|
||||
is_public?: boolean;
|
||||
is_mine?: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -66,6 +73,9 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
description: s.description ?? undefined,
|
||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||
isPublic: s.is_public ?? false,
|
||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||
isMine: s.is_mine ?? true,
|
||||
createdAt: s.created_at ?? undefined,
|
||||
updatedAt: s.updated_at ?? undefined,
|
||||
};
|
||||
@@ -88,18 +98,29 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
if (p.seats !== undefined) out.seats = p.seats;
|
||||
// Forward stages even when explicitly null (clears layering server-side).
|
||||
if (p.stages !== undefined) out.stages = p.stages;
|
||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the current user's chains, newest first. */
|
||||
export async function listChains(): Promise<BusinessChain[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
/**
|
||||
* List chains, newest first. `scope`:
|
||||
* - `"mine"`(默认):当前用户自己的链条;
|
||||
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
|
||||
*/
|
||||
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
|
||||
const url = scope === "public" ? `${BASE()}?scope=public` : BASE();
|
||||
const res = await apiFetch(url, { method: "GET" });
|
||||
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
||||
return (data.chains ?? []).map(toChain);
|
||||
}
|
||||
|
||||
/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */
|
||||
export async function setChainPublic(id: string, isPublic: boolean): Promise<BusinessChain | null> {
|
||||
return updateChain(id, { isPublic });
|
||||
}
|
||||
|
||||
/** Fetch one chain. Returns null on 404. */
|
||||
export async function getChain(id: string): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
|
||||
* 会看到实时数据。
|
||||
*/
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
GlobeIcon,
|
||||
LayersIcon,
|
||||
Loader2Icon,
|
||||
SettingsIcon,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { listChains } from "../api/chains";
|
||||
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
@@ -47,16 +48,32 @@ export function BusinessChainListDialog({
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 筛选:我的 / 公共(已发布)。
|
||||
const [scope, setScope] = useState<ChainScope>("mine");
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
|
||||
const refresh = useCallback(async (s: ChainScope) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await listChains(s);
|
||||
setChains(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await listChains();
|
||||
const list = await listChains(scope);
|
||||
if (!cancelled) setChains(list);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -67,7 +84,22 @@ export function BusinessChainListDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, scope]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
await setChainPublic(chain.id, !chain.isPublic);
|
||||
await refresh(scope);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
},
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -78,10 +110,29 @@ export function BusinessChainListDialog({
|
||||
业务链条
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -106,7 +161,9 @@ export function BusinessChainListDialog({
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -128,28 +185,61 @@ export function BusinessChainListDialog({
|
||||
|
||||
function ChainRow({
|
||||
chain,
|
||||
publishing,
|
||||
onSettings,
|
||||
onTogglePublic,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
publishing: boolean;
|
||||
onSettings: () => void;
|
||||
onTogglePublic: () => void;
|
||||
}) {
|
||||
const isMine = chain.isMine !== false;
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
||||
{/* 标题行 + 设置按钮 */}
|
||||
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{chain.seats.length} 席
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSettings}
|
||||
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
设置
|
||||
</Button>
|
||||
{chain.isPublic && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<GlobeIcon className="size-3" />
|
||||
公共
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
|
||||
{isMine && (
|
||||
<Button
|
||||
variant={chain.isPublic ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={onTogglePublic}
|
||||
disabled={publishing}
|
||||
className="h-8 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
{publishing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GlobeIcon className="size-4" />
|
||||
)}
|
||||
{chain.isPublic ? "取消发布" : "发布"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
|
||||
{isMine && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSettings}
|
||||
className="h-8 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
设置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chain.description && (
|
||||
|
||||
@@ -29,7 +29,7 @@ import { cn } from "@/lib/utils";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { filterRecommendCandidates } from "../api/recommend";
|
||||
import { listChains } from "../api/chains";
|
||||
import { listChains, type ChainScope } from "../api/chains";
|
||||
import {
|
||||
CHAIN_MIN_SEATS,
|
||||
chainTagClasses,
|
||||
@@ -55,16 +55,21 @@ export function BusinessChainPicker({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// 筛选:我的 / 公共(已发布)。
|
||||
const [scope, setScope] = useState<ChainScope>("mine");
|
||||
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
|
||||
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
|
||||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedId(null); // 切换筛选后清空选中(旧选中可能不在新列表里)。
|
||||
void (async () => {
|
||||
try {
|
||||
const [chainList, agents] = await Promise.all([
|
||||
listChains(),
|
||||
listChains(scope),
|
||||
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
|
||||
]);
|
||||
if (cancelled) return;
|
||||
@@ -86,7 +91,7 @@ export function BusinessChainPicker({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [scope]);
|
||||
|
||||
const isExisting = useMemo(
|
||||
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
|
||||
@@ -136,6 +141,24 @@ export function BusinessChainPicker({
|
||||
return (
|
||||
<>
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
|
||||
@@ -149,15 +172,20 @@ export function BusinessChainPicker({
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
|
||||
之后即可在这里选择使用。
|
||||
{scope === "public"
|
||||
? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。"
|
||||
: "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
去配置业务链条
|
||||
</Button>
|
||||
{scope === "mine" && (
|
||||
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
去配置业务链条
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 py-0.5">
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface BusinessChain {
|
||||
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
||||
*/
|
||||
stages?: string[][] | null;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
isMine?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GitBranch,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Layers,
|
||||
Loader2,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
createChain,
|
||||
deleteChain,
|
||||
listChains,
|
||||
setChainPublic,
|
||||
updateChain,
|
||||
} from "@/roundtable-planning/api/chains";
|
||||
import {
|
||||
@@ -130,6 +132,10 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
|
||||
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
|
||||
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
|
||||
const [currentChainPublic, setCurrentChainPublic] = useState(false);
|
||||
const [currentChainMine, setCurrentChainMine] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||
@@ -218,6 +224,8 @@ export default function BusinessChainEditorPage() {
|
||||
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
|
||||
const loadChain = useCallback((chain: BusinessChain) => {
|
||||
setCurrentChainId(chain.id);
|
||||
setCurrentChainPublic(chain.isPublic ?? false);
|
||||
setCurrentChainMine(chain.isMine !== false);
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
setStageGroups(chainToStageGroups(chain));
|
||||
@@ -225,6 +233,8 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
const startNewChain = useCallback(() => {
|
||||
setCurrentChainId(null);
|
||||
setCurrentChainPublic(false);
|
||||
setCurrentChainMine(true);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStageGroups([]);
|
||||
@@ -462,6 +472,32 @@ export default function BusinessChainEditorPage() {
|
||||
}
|
||||
}, [currentChainId, title, startNewChain, refreshChains]);
|
||||
|
||||
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。
|
||||
const handleTogglePublish = useCallback(async () => {
|
||||
if (!currentChainId) {
|
||||
toast.warning("请先保存链条再发布");
|
||||
return;
|
||||
}
|
||||
const next = !currentChainPublic;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await setChainPublic(currentChainId, next);
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPublic(updated.isPublic ?? next);
|
||||
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [currentChainId, currentChainPublic, startNewChain, refreshChains]);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
||||
|
||||
@@ -587,6 +623,29 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
|
||||
{currentChainId && currentChainMine && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePublish}
|
||||
disabled={publishing}
|
||||
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
currentChainPublic
|
||||
? "border-emerald-400/50 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/15 dark:text-emerald-400"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
{publishing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
{currentChainPublic ? "已发布" : "发布"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
|
||||
@@ -49,6 +49,10 @@ class ChainResponse(BaseModel):
|
||||
# Optional layered orchestration: list of stages, each a list of agent_ids
|
||||
# run in parallel (stages run serially). None/absent = linear chain.
|
||||
stages: list[list[str]] | None = None
|
||||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||
is_public: bool = False
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
is_mine: bool = True
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
@@ -73,6 +77,8 @@ class ChainUpdateRequest(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=2048)
|
||||
seats: list[ChainSeatSchema] | None = None
|
||||
stages: list[list[str]] | None = None
|
||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -149,10 +155,18 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
|
||||
|
||||
|
||||
@router.get("", response_model=ChainListResponse)
|
||||
async def list_chains(request: Request) -> ChainListResponse:
|
||||
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
|
||||
"""列出业务链条。
|
||||
|
||||
``scope`` 查询参数(筛选):
|
||||
- ``mine``(默认):仅当前用户自己的链条;
|
||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
|
||||
其余值按 ``mine`` 处理。
|
||||
"""
|
||||
normalized_scope = scope if scope in ("mine", "public") else "mine"
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_chains(user_id)
|
||||
rows = await store.list_chains(user_id, scope=normalized_scope)
|
||||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||||
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""Merge chain_stages and admin_leaderboard_daily_stats migration heads.
|
||||
|
||||
Two independent migrations diverged from ``20260607_02``: ``20260607_03``
|
||||
(roundtable_chains.stages) and ``20260608_01`` (admin_leaderboard_daily_stats +
|
||||
analytics indexes). This no-op merge revision joins both lineages so
|
||||
``upgrade head`` succeeds.
|
||||
|
||||
Revision ID: 20260608_02
|
||||
Revises: 20260607_03, 20260608_01
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
revision = "20260608_02"
|
||||
down_revision = ("20260607_03", "20260608_01")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"""Add is_public column to roundtable_chains.
|
||||
|
||||
「发布」business chains: ``is_public=True`` makes a chain visible to **all
|
||||
accounts** under the "公共" filter (read + select-into-roundtable only; edit /
|
||||
delete stay owner-only). Defaults to ``0`` (private). ``create_all`` adds the
|
||||
column for brand-new DBs; this revision patches DBs that already have the table.
|
||||
|
||||
Chained after the merge head ``20260608_02`` (chain_stages + leaderboard) so the
|
||||
revision graph stays single-headed — otherwise ``alembic upgrade head`` aborts
|
||||
with "multiple heads" and the auto-migrate on startup silently falls back to the
|
||||
create_all schema (which lacks this column → 500 on list).
|
||||
|
||||
Revision ID: 20260608_03
|
||||
Revises: 20260608_02
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260608_03"
|
||||
down_revision = "20260608_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 _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "is_public"):
|
||||
op.add_column(
|
||||
"roundtable_chains",
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_roundtable_chains_is_public", "roundtable_chains", ["is_public"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "is_public"):
|
||||
with op.batch_alter_table("roundtable_chains") as batch:
|
||||
batch.drop_index("ix_roundtable_chains_is_public")
|
||||
op.drop_column("roundtable_chains", "is_public")
|
||||
+6
-1
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -39,6 +39,11 @@ class RoundtableChainRow(Base):
|
||||
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
|
||||
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
||||
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0", index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
+31
-8
@@ -44,7 +44,7 @@ class RoundtableChainRepository:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
|
||||
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
@@ -52,27 +52,46 @@ class RoundtableChainRepository:
|
||||
"seats": _loads(row.seats) or [],
|
||||
# None when absent → frontend reads it as "linear chain" (no layering).
|
||||
"stages": _loads(row.stages),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
async def list_chains(
|
||||
self, user_id: str, *, scope: str = "mine", limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出链条。
|
||||
|
||||
``scope``:
|
||||
- ``"mine"``(默认):仅当前用户自己的链条;
|
||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
|
||||
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
|
||||
"""
|
||||
if scope == "public":
|
||||
cond = RoundtableChainRow.is_public.is_(True)
|
||||
else:
|
||||
cond = RoundtableChainRow.user_id == user_id
|
||||
stmt = (
|
||||
select(RoundtableChainRow)
|
||||
.where(RoundtableChainRow.user_id == user_id)
|
||||
.where(cond)
|
||||
.order_by(RoundtableChainRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_dict(row) for row in result.scalars()]
|
||||
return [self._to_dict(row, user_id) for row in result.scalars()]
|
||||
|
||||
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_dict(row)
|
||||
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
||||
return None
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
@@ -83,6 +102,7 @@ class RoundtableChainRepository:
|
||||
description=(data.get("description") or None),
|
||||
seats=_dumps(data.get("seats") or []),
|
||||
stages=_dumps(data.get("stages")),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -90,7 +110,7 @@ class RoundtableChainRepository:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def update_chain(
|
||||
self,
|
||||
@@ -101,6 +121,7 @@ class RoundtableChainRepository:
|
||||
description=_UNSET,
|
||||
seats=_UNSET,
|
||||
stages=_UNSET,
|
||||
is_public=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
@@ -114,10 +135,12 @@ class RoundtableChainRepository:
|
||||
row.seats = _dumps(seats)
|
||||
if stages is not _UNSET:
|
||||
row.stages = _dumps(stages)
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
|
||||
Reference in new issue
Block a user