Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
This commit is contained in:
commit
7cc6aca398
14 files changed
+529
-119
No files matched your search
@@ -366,10 +366,15 @@ export function ArtifactFileDetail({
|
||||
!useWriteFileStreaming &&
|
||||
!isSkillFile &&
|
||||
!isMock;
|
||||
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个
|
||||
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间
|
||||
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。
|
||||
const canUseAiEditor = canEditArtifact;
|
||||
// 「AI 写作」md 展示开关:在 源码/预览 旁多一个切换项,支持划词「AI 改写」。
|
||||
// 深度研究等 write-file 虚拟预览没有 artifacts PATCH,但仍应提供该视图(本地 draft);
|
||||
// 流式写入中不挂载,避免编辑器在内容加载期间白屏。
|
||||
const canUseAiEditor =
|
||||
language === "markdown" &&
|
||||
!isSkillFile &&
|
||||
!isMock &&
|
||||
!thread.isLoading &&
|
||||
(canEditArtifact || useWriteFileStreaming);
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false);
|
||||
@@ -393,7 +398,9 @@ export function ArtifactFileDetail({
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact ? draftContent : (content ?? ""));
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -443,17 +450,26 @@ export function ArtifactFileDetail({
|
||||
setViewMode("code");
|
||||
}, [documentRewriteActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== "edit" || canUseAiEditor) return;
|
||||
setViewMode(isSupportPreview ? "preview" : "code");
|
||||
}, [canUseAiEditor, isSupportPreview, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits || documentRewriteActive) {
|
||||
if (
|
||||
(!canEditArtifact && !canUseAiEditor) ||
|
||||
hasLocalEdits ||
|
||||
documentRewriteActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]);
|
||||
}, [canEditArtifact, canUseAiEditor, content, documentRewriteActive, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1709,8 +1725,9 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
{viewMode === "edit" && canUseAiEditor && !documentRewriteActive && (
|
||||
<MarkdownAiEditor
|
||||
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
|
||||
markdown={displayContent ?? ""}
|
||||
// 编辑用原始内容(与源码模式一致,不脱敏);有 artifacts 保存能力时走头部「保存 Markdown」。
|
||||
// write-file 虚拟预览(如深度研究)仅本地 draft,切换预览/源码仍可见本次改动。
|
||||
markdown={(hasLocalEdits || canEditArtifact ? draftContent : content) ?? ""}
|
||||
onChange={(value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
|
||||
@@ -55,15 +55,18 @@ export interface ChainUpdatePayload {
|
||||
isPublic?: boolean;
|
||||
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
|
||||
enabled?: boolean;
|
||||
/** 「待审核」开关:true 提交审核 / false 清除(管理员发布时随 isPublic=true 一起提交);omit 不改。 */
|
||||
pendingReview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表筛选范围:
|
||||
* - `mine` = 我的;
|
||||
* - `public` = 所有已发布的公共链条;
|
||||
* - `pending` = 所有待审核(pendingReview=true)的链条(**仅管理员**;后端对非管理员退化为 `mine`);
|
||||
* - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。
|
||||
*/
|
||||
export type ChainScope = "mine" | "public" | "all";
|
||||
export type ChainScope = "mine" | "public" | "pending" | "all";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
|
||||
@@ -91,6 +94,7 @@ interface ServerChain {
|
||||
gather_first?: boolean;
|
||||
is_public?: boolean;
|
||||
enabled?: boolean;
|
||||
pending_review?: boolean;
|
||||
is_mine?: boolean;
|
||||
owner_name?: string | null;
|
||||
created_at: string | null;
|
||||
@@ -110,6 +114,8 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
gatherFirst: s.gather_first ?? false,
|
||||
isPublic: s.is_public ?? false,
|
||||
enabled: s.enabled ?? true,
|
||||
// 「待审核」标记:缺省 false(旧响应兼容)。
|
||||
pendingReview: s.pending_review ?? false,
|
||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||
isMine: s.is_mine ?? true,
|
||||
ownerName: s.owner_name ?? undefined,
|
||||
@@ -146,6 +152,7 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
|
||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||
if (p.enabled !== undefined) out.enabled = p.enabled;
|
||||
if (p.pendingReview !== undefined) out.pending_review = p.pendingReview;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -181,6 +188,16 @@ export async function setChainEnabled(id: string, enabled: boolean): Promise<Bus
|
||||
return updateChain(id, { enabled });
|
||||
}
|
||||
|
||||
/** 提交审核(owner 在编辑器发起):进入「待审核」,等待管理员发布。返回更新后的链条,404 返回 null。 */
|
||||
export async function submitChainForReview(id: string): Promise<BusinessChain | null> {
|
||||
return updateChain(id, { pendingReview: true });
|
||||
}
|
||||
|
||||
/** 管理员发布「待审核」链条:正式发布为公共并清除待审核标记。返回更新后的链条,404 返回 null。 */
|
||||
export async function approveChainReview(id: string): Promise<BusinessChain | null> {
|
||||
return updateChain(id, { isPublic: true, pendingReview: false });
|
||||
}
|
||||
|
||||
/** 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" });
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { listChains, setChainEnabled, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { listChains, setChainEnabled, setChainPublic, submitChainForReview, updateChain, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
@@ -84,6 +84,8 @@ export function BusinessChainListBody({
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all");
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
// 发布审核选择弹窗的目标链条(null = 关闭)。草稿点「发布」先选 确认审核 / 直接发布。
|
||||
const [reviewTarget, setReviewTarget] = useState<BusinessChain | null>(null);
|
||||
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
|
||||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||||
|
||||
@@ -140,25 +142,89 @@ export function BusinessChainListBody({
|
||||
};
|
||||
}, [active, scope]);
|
||||
|
||||
// 发布入口:已发布 → 直接取消发布(旧行为);审核中 → 撤回审核;草稿 → 弹窗选
|
||||
// 「确认审核 / 直接发布」(与链条编辑页一致)。
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
const updated = await setChainPublic(chain.id, !chain.isPublic);
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
} else {
|
||||
await refresh(scope);
|
||||
// 审核中 → 撤回审核,回到草稿。
|
||||
if (!chain.isPublic && chain.pendingReview) {
|
||||
if (!window.confirm(`「${chain.title || "未命名链条"}」正在审核中,确认撤回审核?`)) return;
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
const updated = await updateChain(chain.id, { pendingReview: false });
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
} else {
|
||||
await refresh(scope);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
return;
|
||||
}
|
||||
// 已发布 → 直接取消发布。
|
||||
if (chain.isPublic) {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
const updated = await setChainPublic(chain.id, false);
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
} else {
|
||||
await refresh(scope);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 草稿 → 弹窗选择。
|
||||
setReviewTarget(chain);
|
||||
},
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
// 弹窗「确认审核」:提交进入待审核,等待管理员发布。
|
||||
const handleSubmitReview = useCallback(async () => {
|
||||
const chain = reviewTarget;
|
||||
if (!chain) return;
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
const updated = await submitChainForReview(chain.id);
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
}
|
||||
setReviewTarget(null);
|
||||
if (!updated) await refresh(scope);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
}, [reviewTarget, refresh, scope]);
|
||||
|
||||
// 弹窗「直接发布」:立即对所有账号可见(旧行为)。
|
||||
const handleDirectPublish = useCallback(async () => {
|
||||
const chain = reviewTarget;
|
||||
if (!chain) return;
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
const updated = await setChainPublic(chain.id, true);
|
||||
if (updated) {
|
||||
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
}
|
||||
setReviewTarget(null);
|
||||
if (!updated) await refresh(scope);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
}, [reviewTarget, refresh, scope]);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setTogglingId(chain.id);
|
||||
@@ -273,6 +339,39 @@ export function BusinessChainListBody({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 发布审核选择弹窗:确认审核 → 待审核(管理员发布);直接发布 → 立即公共可见。 */}
|
||||
<Dialog open={reviewTarget !== null} onOpenChange={(o) => !o && setReviewTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GlobeIcon className="size-4 text-primary" />
|
||||
发布业务链条
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
是否需要审核?确认审核后「{reviewTarget?.title || "未命名链条"}」将进入「待审核」,由管理员在业务链条列表中发布;直接发布则立即对所有账号可见 / 选用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReviewTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDirectPublish}
|
||||
disabled={reviewTarget ? publishingId === reviewTarget.id : true}
|
||||
>
|
||||
直接发布
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={reviewTarget ? publishingId === reviewTarget.id : true}
|
||||
>
|
||||
确认审核
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -355,6 +454,11 @@ function ChainRow({
|
||||
公共
|
||||
</span>
|
||||
)}
|
||||
{chain.pendingReview && (
|
||||
<span className="shrink-0 rounded-full bg-amber-400/10 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-300">
|
||||
待审核
|
||||
</span>
|
||||
)}
|
||||
{!enabled && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
已停用
|
||||
@@ -375,13 +479,20 @@ function ChainRow({
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
|
||||
{/* 发布 / 取消发布 / 审核中撤回:本人的链条或管理员可操作。 */}
|
||||
{canManage && (
|
||||
<Button
|
||||
variant={chain.isPublic ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={onTogglePublic}
|
||||
disabled={publishing}
|
||||
title={
|
||||
chain.isPublic
|
||||
? "取消发布,收回为私有"
|
||||
: chain.pendingReview
|
||||
? "审核中,点击撤回审核"
|
||||
: "发布:可选提交审核(管理员发布)或直接发布"
|
||||
}
|
||||
className="h-8 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
{publishing ? (
|
||||
@@ -389,7 +500,7 @@ function ChainRow({
|
||||
) : (
|
||||
<GlobeIcon className="size-4" />
|
||||
)}
|
||||
{chain.isPublic ? "取消发布" : "发布"}
|
||||
{chain.isPublic ? "取消发布" : chain.pendingReview ? "审核中" : "发布"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */}
|
||||
|
||||
@@ -100,6 +100,12 @@ export interface BusinessChain {
|
||||
gatherFirst?: boolean;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/**
|
||||
* 「待审核」标记:true = 已在编辑器点「发布 → 确认审核」提交审核,等待管理员在
|
||||
* 业务链条列表「待审核」筛选里发布;管理员发布时清回 false 并置 isPublic=true。
|
||||
* 缺省 / false = 未在审核中(草稿或已发布)。
|
||||
*/
|
||||
pendingReview?: boolean;
|
||||
/**
|
||||
* 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
|
||||
* 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
|
||||
|
||||
@@ -66,6 +66,15 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -100,6 +109,7 @@ import {
|
||||
listChains,
|
||||
optimizeChainPrompt,
|
||||
setChainPublic,
|
||||
submitChainForReview,
|
||||
updateChain,
|
||||
} from "@/roundtable-planning/api/chains";
|
||||
import {
|
||||
@@ -217,6 +227,10 @@ export default function BusinessChainEditorPage() {
|
||||
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
|
||||
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
|
||||
const [currentChainPublic, setCurrentChainPublic] = useState(false);
|
||||
// 「待审核」状态:true = 已提交审核、等待管理员发布(发布按钮变「审核中」)。
|
||||
const [currentChainPending, setCurrentChainPending] = useState(false);
|
||||
// 发布审核确认弹窗(发布前选择「确认审核」或「直接发布」)。
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
|
||||
const [currentChainMine, setCurrentChainMine] = useState(true);
|
||||
// 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -349,6 +363,7 @@ export default function BusinessChainEditorPage() {
|
||||
const loadChain = useCallback((chain: BusinessChain) => {
|
||||
setCurrentChainId(chain.id);
|
||||
setCurrentChainPublic(chain.isPublic ?? false);
|
||||
setCurrentChainPending(chain.pendingReview ?? false);
|
||||
setCurrentChainMine(chain.isMine !== false);
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
@@ -362,6 +377,7 @@ export default function BusinessChainEditorPage() {
|
||||
const startNewChain = useCallback(() => {
|
||||
setCurrentChainId(null);
|
||||
setCurrentChainPublic(false);
|
||||
setCurrentChainPending(false);
|
||||
setCurrentChainMine(true);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
@@ -476,19 +492,6 @@ export default function BusinessChainEditorPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setSeatJobResponsibilitiesCb = useCallback((agentId: string, jobResponsibilities: string) => {
|
||||
setStageGroups((prev) =>
|
||||
prev.map((group) => ({
|
||||
...group,
|
||||
seats: group.seats.map((seat) =>
|
||||
seat.agent_id === agentId
|
||||
? { ...seat, job_responsibilities: jobResponsibilities }
|
||||
: seat,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
|
||||
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
|
||||
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
|
||||
@@ -694,7 +697,7 @@ export default function BusinessChainEditorPage() {
|
||||
seat_mode: s.seat_mode ?? null,
|
||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||
position_id: s.position_id ?? null,
|
||||
// 业务链席位岗位职责:会商智能体配置面板只读展示。
|
||||
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
|
||||
job_responsibilities: s.job_responsibilities?.trim() || null,
|
||||
};
|
||||
});
|
||||
@@ -777,31 +780,104 @@ export default function BusinessChainEditorPage() {
|
||||
}
|
||||
}, [currentChainId, title, startNewChain, refreshChains]);
|
||||
|
||||
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。
|
||||
// 发布入口:已发布 → 收回;审核中 → 撤回审核;未发布 → 弹窗选择「确认审核 / 直接发布」。
|
||||
const handleTogglePublish = useCallback(async () => {
|
||||
if (!currentChainId) {
|
||||
toast.warning("请先保存链条再发布");
|
||||
return;
|
||||
}
|
||||
const next = !currentChainPublic;
|
||||
// 已发布 → 收回为私有(旧行为不变)。
|
||||
if (currentChainPublic) {
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await setChainPublic(currentChainId, false);
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPublic(updated.isPublic ?? false);
|
||||
toast.success("已取消发布");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`取消发布失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 审核中 → 撤回审核,回到未发布草稿。
|
||||
if (currentChainPending) {
|
||||
if (!window.confirm("该链条正在审核中,确认撤回审核?")) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await updateChain(currentChainId, { pendingReview: false });
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPending(updated.pendingReview ?? false);
|
||||
toast.success("已撤回审核");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`撤回审核失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 未发布 → 弹窗选择审核 / 直接发布。
|
||||
setReviewDialogOpen(true);
|
||||
}, [currentChainId, currentChainPublic, currentChainPending, startNewChain, refreshChains]);
|
||||
|
||||
// 弹窗「确认审核」:提交进入待审核,等待管理员在业务链条列表「待审核」筛选里发布。
|
||||
const handleSubmitReview = useCallback(async () => {
|
||||
if (!currentChainId) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await setChainPublic(currentChainId, next);
|
||||
const updated = await submitChainForReview(currentChainId);
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPublic(updated.isPublic ?? next);
|
||||
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布");
|
||||
setCurrentChainPending(updated.pendingReview ?? true);
|
||||
setReviewDialogOpen(false);
|
||||
toast.success("已提交审核,等待管理员在业务链条列表发布");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
toast.error(`提交审核失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [currentChainId, currentChainPublic, startNewChain, refreshChains]);
|
||||
}, [currentChainId, startNewChain, refreshChains]);
|
||||
|
||||
// 弹窗「直接发布」:保持旧发布行为,立即对所有账号可见。
|
||||
const handleDirectPublish = useCallback(async () => {
|
||||
if (!currentChainId) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await setChainPublic(currentChainId, true);
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPublic(updated.isPublic ?? true);
|
||||
setReviewDialogOpen(false);
|
||||
toast.success("已发布为公共,所有账号都能查看 / 选用");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`发布失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [currentChainId, startNewChain, refreshChains]);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
||||
@@ -1099,18 +1175,26 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* 发布 / 取消发布:已保存且本人所有(或管理员)的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
|
||||
{/* 发布:未发布 → 弹窗选「确认审核 / 直接发布」;审核中 → 撤回;已发布 → 收回。 */}
|
||||
{currentChainId && (currentChainMine || isAdmin) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePublish}
|
||||
disabled={publishing}
|
||||
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"}
|
||||
title={
|
||||
currentChainPublic
|
||||
? "已发布为公共,点击收回为私有"
|
||||
: currentChainPending
|
||||
? "审核中,点击撤回审核"
|
||||
: "发布为公共:可选提交审核(管理员发布)或直接发布"
|
||||
}
|
||||
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",
|
||||
: currentChainPending
|
||||
? "border-amber-400/50 bg-amber-400/10 text-amber-600 hover:bg-amber-400/15 dark:text-amber-300"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
@@ -1119,7 +1203,7 @@ export default function BusinessChainEditorPage() {
|
||||
) : (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
{currentChainPublic ? "已发布" : "发布"}
|
||||
{currentChainPublic ? "已发布" : currentChainPending ? "审核中" : "发布"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1299,7 +1383,6 @@ export default function BusinessChainEditorPage() {
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={setSeatPositionCb}
|
||||
onSeatJobResponsibilitiesChange={setSeatJobResponsibilitiesCb}
|
||||
onStageGoalChange={setGoal}
|
||||
goalOptimizingIdx={goalOptimizingIdx}
|
||||
onOptimizeGoal={handleOptimizeStageGoal}
|
||||
@@ -1354,6 +1437,33 @@ export default function BusinessChainEditorPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 发布审核确认弹窗:确认审核 → 进入「待审核」由管理员发布;直接发布 → 立即公共可见。 */}
|
||||
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Globe className="size-4 text-primary" />
|
||||
发布业务链条
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
是否需要审核?确认审核后该链条将进入「待审核」,由管理员在业务链条列表中发布;
|
||||
直接发布则立即对所有账号可见 / 选用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReviewDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleDirectPublish} disabled={publishing}>
|
||||
直接发布
|
||||
</Button>
|
||||
<Button onClick={handleSubmitReview} disabled={publishing}>
|
||||
确认审核
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 页脚(步骤一):分层编排说明。步骤切换走顶部步骤导航。 */}
|
||||
{step === 1 && (
|
||||
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
|
||||
@@ -1484,7 +1594,6 @@ function StagesColumn({
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onSeatJobResponsibilitiesChange,
|
||||
onStageGoalChange,
|
||||
goalOptimizingIdx,
|
||||
onOptimizeGoal,
|
||||
@@ -1509,7 +1618,6 @@ function StagesColumn({
|
||||
/** Live directory from the administrator's position-role configuration. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
|
||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||
goalOptimizingIdx: number | null;
|
||||
@@ -1619,7 +1727,6 @@ function StagesColumn({
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={onSeatPositionChange}
|
||||
onSeatJobResponsibilitiesChange={onSeatJobResponsibilitiesChange}
|
||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||
anyGoalOptimizing={goalOptimizingIdx !== null}
|
||||
@@ -1655,7 +1762,6 @@ function StageSection({
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onSeatJobResponsibilitiesChange,
|
||||
onGoalChange,
|
||||
goalOptimizing,
|
||||
anyGoalOptimizing,
|
||||
@@ -1680,7 +1786,6 @@ function StageSection({
|
||||
positionConfigMode: boolean;
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
|
||||
onGoalChange: (goal: string) => void;
|
||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||
goalOptimizing: boolean;
|
||||
@@ -1848,9 +1953,6 @@ function StageSection({
|
||||
showPositionConfig={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
||||
onJobResponsibilitiesChange={(value) =>
|
||||
onSeatJobResponsibilitiesChange(seat.agent_id, value)
|
||||
}
|
||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||
/>
|
||||
))}
|
||||
@@ -1880,7 +1982,6 @@ function SeatStrip({
|
||||
showPositionConfig,
|
||||
positionOptions,
|
||||
onPositionChange,
|
||||
onJobResponsibilitiesChange,
|
||||
onDetail,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
@@ -1905,7 +2006,6 @@ function SeatStrip({
|
||||
/** Current position directory, injected by the business-chain editor. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onPositionChange: (positionId: PositionId | null) => void;
|
||||
onJobResponsibilitiesChange: (jobResponsibilities: string) => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@@ -1981,17 +2081,6 @@ function SeatStrip({
|
||||
{seat.description}
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-1.5 flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">岗位职责</span>
|
||||
<textarea
|
||||
value={seat.job_responsibilities ?? ""}
|
||||
onChange={(event) => onJobResponsibilitiesChange(event.currentTarget.value)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
placeholder="填写该席位在本业务链中的岗位职责"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1 text-[11px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
|
||||
|
||||
@@ -66,7 +66,7 @@ import { useRouteNavigate } from "@/core/route-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { approveChainReview, deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
@@ -165,18 +165,30 @@ export default function BusinessChainListPage() {
|
||||
}, []);
|
||||
|
||||
// ── 操作:发布 / 取消发布 ────────────────────────────────────────────────
|
||||
// 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记);
|
||||
// 其它筛选里 = 常规发布 / 取消发布。
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
await setChainPublic(chain.id, !chain.isPublic);
|
||||
await refresh(scope);
|
||||
await loadStats();
|
||||
toast.success(
|
||||
chain.isPublic
|
||||
? `已取消发布「${chain.title || "未命名链条"}」`
|
||||
: `「${chain.title || "未命名链条"}」已发布到公共选用库`,
|
||||
);
|
||||
const updated =
|
||||
scope === "pending"
|
||||
? await approveChainReview(chain.id)
|
||||
: await setChainPublic(chain.id, !chain.isPublic);
|
||||
if (updated) {
|
||||
await refresh(scope);
|
||||
await loadStats();
|
||||
toast.success(
|
||||
scope === "pending"
|
||||
? `「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库`
|
||||
: chain.isPublic
|
||||
? `已取消发布「${chain.title || "未命名链条"}」`
|
||||
: `「${chain.title || "未命名链条"}」已发布到公共选用库`,
|
||||
);
|
||||
} else {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
await refresh(scope);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
@@ -317,13 +329,18 @@ export default function BusinessChainListPage() {
|
||||
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="mb-6 flex flex-col gap-4 rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347] lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 我的 / 公共 /(管理员)全部 */}
|
||||
{/* 我的 / 公共 /(管理员)待审核、全部 */}
|
||||
<div className="flex self-start rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]">
|
||||
{(
|
||||
[
|
||||
["mine", "我的链条"],
|
||||
["public", "公共列表"],
|
||||
...(isAdmin ? ([["all", "全部链条"]] as const) : []),
|
||||
...(isAdmin
|
||||
? ([
|
||||
["pending", "待审核"],
|
||||
["all", "全部链条"],
|
||||
] as const)
|
||||
: []),
|
||||
] as [ChainScope, string][]
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
@@ -596,14 +613,20 @@ function EmptyState({
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-bold text-zinc-700 dark:text-[#C5CFE0]">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
{scope === "public"
|
||||
? "还没有公共链条"
|
||||
: scope === "pending"
|
||||
? "暂无待审核链条"
|
||||
: "还没有业务链条"}
|
||||
</h3>
|
||||
<p className="mt-1 pb-4 text-xs leading-relaxed text-zinc-400 dark:text-[#6E88B0]">
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的链条」里发布一条试试。"
|
||||
: "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"}
|
||||
: scope === "pending"
|
||||
? "业务链条在编辑器里点「发布 → 确认审核」后会进入这里,由管理员审核发布。"
|
||||
: "业务链条是可复用的有序智能体编排。点右上角「新建链条」去创建第一条。"}
|
||||
</p>
|
||||
{scope !== "public" && (
|
||||
{scope !== "public" && scope !== "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
@@ -663,7 +686,11 @@ function ChainCard({
|
||||
<UsersIcon className="mr-0.5 inline size-3" />
|
||||
<span>{chain.seats.length} 席位</span>
|
||||
</span>
|
||||
{chain.isPublic ? (
|
||||
{chain.pendingReview ? (
|
||||
<span className="rounded-full border border-amber-200/60 bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:border-[#FAAD14]/30 dark:bg-[#FAAD14]/15 dark:text-[#FAAD14]">
|
||||
待审核
|
||||
</span>
|
||||
) : chain.isPublic ? (
|
||||
<span className="rounded-full border border-emerald-200/40 bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:border-[#52C41A]/30 dark:bg-[#52C41A]/15 dark:text-[#52C41A]">
|
||||
已发布
|
||||
</span>
|
||||
@@ -940,6 +967,11 @@ function ChainTable({
|
||||
<td className="px-6 py-4 align-top">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#FF7875]">
|
||||
{chain.title || "未命名链条"}
|
||||
{chain.pendingReview && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:bg-[#FAAD14]/15 dark:text-[#FAAD14]">
|
||||
待审核
|
||||
</span>
|
||||
)}
|
||||
{chain.isPublic && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-1.5 py-0.5 text-[9px] font-medium text-emerald-600 dark:bg-[#52C41A]/15 dark:text-[#52C41A]">
|
||||
<GlobeIcon className="size-2.5" />
|
||||
|
||||
@@ -99,7 +99,8 @@ class NodeResponse(BaseModel):
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
# Older personal rows can already contain a mock task id; task_scoped is
|
||||
# the authoritative opt-in marker for cross-user task sharing.
|
||||
# the authoritative marker for task-bound rows. History is partitioned by
|
||||
# user, so it only controls snapshot-id locking and list bucketing now.
|
||||
external_task_id: str | None = None
|
||||
task_scoped: bool = False
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -248,7 +249,7 @@ def _task_snapshot_for_scope(
|
||||
*,
|
||||
task_scoped: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Lock the snapshot id only for an explicit shared task scope."""
|
||||
"""Lock the snapshot id only for an explicit task-bound scope."""
|
||||
if not task_scoped or not external_task_id:
|
||||
return dict(task_snapshot)
|
||||
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
|
||||
|
||||
@@ -6,7 +6,7 @@ following this order. Bound to the requesting user (not the browser), so it
|
||||
follows the account across devices/sessions — same model as roundtable drafts.
|
||||
|
||||
Routes (prefix ``/api/roundtable-chains``):
|
||||
GET "" list chains (``scope=mine``默认 / ``public`` / ``all`` 仅管理员)
|
||||
GET "" list chains (``scope=mine``默认 / ``public`` / ``pending`` / ``all`` 后两者仅管理员)
|
||||
POST "" create a chain
|
||||
GET "/{chain_id}" one chain
|
||||
PUT "/{chain_id}" partial update (title / description / seats)
|
||||
@@ -96,6 +96,9 @@ class ChainResponse(BaseModel):
|
||||
is_public: bool = False
|
||||
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
|
||||
enabled: bool = True
|
||||
# 「待审核」标记:True = owner 已提交审核(等待管理员在「待审核」筛选里发布);
|
||||
# 管理员发布时随 is_public=True 一起清回 False。缺省 False。
|
||||
pending_review: bool = False
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
is_mine: bool = True
|
||||
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
|
||||
@@ -140,6 +143,9 @@ class ChainUpdateRequest(BaseModel):
|
||||
is_public: bool | None = None
|
||||
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
|
||||
enabled: bool | None = None
|
||||
# 「待审核」开关(True = 提交审核,owner 在编辑器发起;False = 清除,管理员发布时
|
||||
# 随 is_public=True 一起提交)。omit 不改。
|
||||
pending_review: bool | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -303,13 +309,15 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
||||
``scope`` 查询参数(筛选):
|
||||
- ``mine``(默认):仅当前用户自己的链条;
|
||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
|
||||
- ``pending``:所有**待审核**(``pending_review=True``)链条(**仅管理员**;
|
||||
非管理员请求 ``pending`` 退化为 ``mine``);
|
||||
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
|
||||
其余值按 ``mine`` 处理。
|
||||
"""
|
||||
is_admin = _is_admin(request)
|
||||
normalized_scope = scope if scope in ("mine", "public", "all") else "mine"
|
||||
# 非管理员请求 all → 退化为 mine,绝不越权。
|
||||
if normalized_scope == "all" and not is_admin:
|
||||
normalized_scope = scope if scope in ("mine", "public", "pending", "all") else "mine"
|
||||
# 非管理员请求 pending / all → 退化为 mine,绝不越权。
|
||||
if normalized_scope in ("pending", "all") and not is_admin:
|
||||
normalized_scope = "mine"
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
|
||||
+7
-5
@@ -22,9 +22,9 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
class PositionRoundtableSessionRow(Base):
|
||||
__tablename__ = "position_roundtable_sessions"
|
||||
__table_args__ = (
|
||||
# History is read as "all conversations for one task, newest first".
|
||||
# `user_id` remains audit data only; it must not partition a shared
|
||||
# task workspace.
|
||||
# History is read as "one user's conversations for one task, newest
|
||||
# first". `user_id` partitions visibility; a shared task workspace no
|
||||
# longer exposes other users' collaboration records.
|
||||
Index(
|
||||
"ix_position_roundtable_sessions_task_updated",
|
||||
"external_task_id",
|
||||
@@ -33,8 +33,10 @@ class PositionRoundtableSessionRow(Base):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Creator audit field. `task_scoped`, not merely external_task_id, opts a
|
||||
# session into sharing. This keeps historical sidebar/mock rows private.
|
||||
# Owner field: every read/write path filters on it. `task_scoped` still
|
||||
# marks rows created from an explicit route taskId (locking the snapshot
|
||||
# task id and keeping them out of the unscoped personal list), but it no
|
||||
# longer grants cross-user access.
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
+4
-3
@@ -135,8 +135,8 @@ class PositionRoundtableRepository:
|
||||
|
||||
@staticmethod
|
||||
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
|
||||
"""Task-bound sessions are shared; unbound legacy sessions stay private."""
|
||||
return bool(row.task_scoped) or row.user_id == user_id
|
||||
"""History is partitioned by creator; even task-bound rows stay private."""
|
||||
return row.user_id == user_id
|
||||
|
||||
@staticmethod
|
||||
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
|
||||
@@ -344,11 +344,12 @@ class PositionRoundtableRepository:
|
||||
async def list_sessions(
|
||||
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List shared task history or the caller's legacy personal history."""
|
||||
"""List the caller's own task history or legacy personal history."""
|
||||
if external_task_id:
|
||||
stmt = select(PositionRoundtableSessionRow).where(
|
||||
PositionRoundtableSessionRow.external_task_id == external_task_id,
|
||||
PositionRoundtableSessionRow.task_scoped.is_(True),
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
else:
|
||||
stmt = select(PositionRoundtableSessionRow).where(
|
||||
|
||||
+6
@@ -69,6 +69,12 @@ class RoundtableChainRow(Base):
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
# 「待审核」标记:True = owner 已在编辑器提交审核(发布前需管理员在「待审核」筛选里确认
|
||||
# 发布);管理员发布时置回 False 并同时置 ``is_public=True``。缺省 False,存量链条不受影响。
|
||||
# 索引以支持管理端「待审核」列表筛选。
|
||||
pending_review: 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)
|
||||
)
|
||||
|
||||
+10
@@ -64,6 +64,8 @@ class RoundtableChainRepository:
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
# 启停状态:缺省 True(存量行 / 补列前视为启用)。
|
||||
"enabled": bool(getattr(row, "enabled", True)),
|
||||
# 「待审核」标记:True = owner 已提交审核、等待管理员发布;发布时清回 False。
|
||||
"pending_review": bool(getattr(row, "pending_review", False)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
|
||||
"owner_id": row.user_id,
|
||||
@@ -79,12 +81,16 @@ class RoundtableChainRepository:
|
||||
``scope``:
|
||||
- ``"mine"``(默认):仅当前用户自己的链条;
|
||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的;
|
||||
- ``"pending"``:所有**待审核**(``pending_review=True``)链条,**仅管理员**(由路由层
|
||||
校验后传 ``is_admin=True``;非管理员误传则按 ``"mine"`` 退化,绝不越权);
|
||||
- ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``;
|
||||
非管理员误传则按 ``"mine"`` 退化,绝不越权)。
|
||||
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
|
||||
"""
|
||||
if scope == "public":
|
||||
cond = RoundtableChainRow.is_public.is_(True)
|
||||
elif scope == "pending" and is_admin:
|
||||
cond = RoundtableChainRow.pending_review.is_(True)
|
||||
elif scope == "all" and is_admin:
|
||||
cond = None
|
||||
else:
|
||||
@@ -125,6 +131,7 @@ class RoundtableChainRepository:
|
||||
gather_first=bool(data.get("gather_first") or False),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
|
||||
pending_review=bool(data.get("pending_review") or False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -149,6 +156,7 @@ class RoundtableChainRepository:
|
||||
gather_first=_UNSET,
|
||||
is_public=_UNSET,
|
||||
enabled=_UNSET,
|
||||
pending_review=_UNSET,
|
||||
is_admin: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -176,6 +184,8 @@ class RoundtableChainRepository:
|
||||
row.is_public = bool(is_public)
|
||||
if enabled is not _UNSET and enabled is not None:
|
||||
row.enabled = bool(enabled)
|
||||
if pending_review is not _UNSET and pending_review is not None:
|
||||
row.pending_review = bool(pending_review)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
@@ -526,40 +526,48 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
|
||||
assert saved_node is not None
|
||||
assert saved_node["conversation_snapshot"] == node_conversation
|
||||
|
||||
# Position roundtable history is shared by taskId, so a collaborator can
|
||||
# read and remove the same task conversation without being its creator.
|
||||
assert await store.get_session(created["id"], "user-b") is not None
|
||||
assert await store.delete_session(created["id"], "user-b")
|
||||
# History is partitioned by creator: another user working the same taskId
|
||||
# can neither read nor remove this conversation.
|
||||
assert await store.get_session(created["id"], "user-b") is None
|
||||
assert not await store.delete_session(created["id"], "user-b")
|
||||
assert await store.get_session(created["id"], "user-a") is not None
|
||||
assert await store.delete_session(created["id"], "user-a")
|
||||
assert await store.get_session(created["id"], "user-a") is None
|
||||
assert await store.list_nodes(created["id"], "user-a") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store):
|
||||
shared = await store.create_session(
|
||||
async def test_task_scoped_history_is_partitioned_by_user_but_never_mixes_tasks(store):
|
||||
creator_session = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="external-task-100",
|
||||
task_snapshot={"id": "external-task-100", "title": "共享任务"},
|
||||
)
|
||||
await store.create_session(
|
||||
"user-b",
|
||||
external_task_id="external-task-200",
|
||||
task_snapshot={"id": "external-task-200", "title": "另一任务"},
|
||||
)
|
||||
|
||||
visible_to_creator = await store.list_sessions(
|
||||
"user-a", external_task_id="external-task-100"
|
||||
)
|
||||
assert [row["id"] for row in visible_to_creator] == [creator_session["id"]]
|
||||
|
||||
# The same taskId no longer exposes another user's rows: a collaborator
|
||||
# starts from an empty list and only ever sees rows they created.
|
||||
assert await store.list_sessions("user-b", external_task_id="external-task-100") == []
|
||||
collaborator_session = await store.create_session(
|
||||
"user-b",
|
||||
external_task_id="external-task-100",
|
||||
task_snapshot={"id": "external-task-100", "title": "协作者自己的会话"},
|
||||
)
|
||||
visible_to_collaborator = await store.list_sessions(
|
||||
"user-b", external_task_id="external-task-100"
|
||||
)
|
||||
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]]
|
||||
assert [row["id"] for row in visible_to_collaborator] == [collaborator_session["id"]]
|
||||
|
||||
updated = await store.update_session(
|
||||
shared["id"],
|
||||
assert await store.update_session(
|
||||
creator_session["id"],
|
||||
"user-b",
|
||||
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["task_snapshot"]["title"] == "协作者已更新"
|
||||
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
|
||||
) is None
|
||||
assert (await store.get_session(creator_session["id"], "user-a"))["task_snapshot"]["title"] == "共享任务"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -577,7 +585,7 @@ async def test_unscoped_history_keeps_original_user_isolation(store):
|
||||
|
||||
# Old client records could already carry a mock task id. The new database
|
||||
# marker keeps them personal after the migration; only a new explicit
|
||||
# route taskId creates a shared row.
|
||||
# route taskId creates a task-bound row.
|
||||
legacy_with_mock_task_id = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="legacy-mock-task-id",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""业务链条「发布审核」流程测试。
|
||||
|
||||
覆盖:
|
||||
- store:``pending_review`` 随 create(缺省 False)/ update 持久化;
|
||||
- store:``scope="pending"`` 仅管理员返回全部待审核链条;非管理员按 ``mine`` 退化;
|
||||
- 完整审核流:owner 提交审核 → 管理员在 pending 列表看到 → 发布(``is_public=True`` +
|
||||
``pending_review=False``)后从 pending 列表消失、进入 public 列表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
async def _make_repo():
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow # noqa: F401 注册表
|
||||
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
return RoundtableChainRepository(sf), engine
|
||||
|
||||
|
||||
_SEATS = [
|
||||
{"agent_id": "a", "name": "情报收集", "description": ""},
|
||||
{"agent_id": "b", "name": "环境评估", "description": ""},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_review_flag_roundtrip():
|
||||
repo, engine = await _make_repo()
|
||||
try:
|
||||
row = await repo.create_chain("u1", {"id": "c1", "title": "链", "seats": _SEATS})
|
||||
# 缺省 False。
|
||||
assert row["pending_review"] is False
|
||||
|
||||
# owner 提交审核 → True。
|
||||
updated = await repo.update_chain("c1", "u1", pending_review=True)
|
||||
assert updated is not None and updated["pending_review"] is True
|
||||
|
||||
# 撤回 / 管理员发布清回 → False。
|
||||
updated = await repo.update_chain("c1", "u1", pending_review=False)
|
||||
assert updated is not None and updated["pending_review"] is False
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_pending_lists_all_users_pending_for_admin():
|
||||
repo, engine = await _make_repo()
|
||||
try:
|
||||
await repo.create_chain("u1", {"id": "c1", "title": "U1 待审", "seats": _SEATS})
|
||||
await repo.create_chain("u1", {"id": "c2", "title": "U1 草稿", "seats": _SEATS})
|
||||
await repo.create_chain("u2", {"id": "c3", "title": "U2 待审", "seats": _SEATS})
|
||||
await repo.update_chain("c1", "u1", pending_review=True)
|
||||
await repo.update_chain("c3", "u2", pending_review=True)
|
||||
|
||||
# 管理员 pending → 只含两条待审核链条(跨账号)。
|
||||
rows = await repo.list_chains("admin", scope="pending", is_admin=True)
|
||||
assert {r["id"] for r in rows} == {"c1", "c3"}
|
||||
|
||||
# 非管理员误传 pending(is_admin=False)→ 退化为 mine:只见自己的(c1/c2),
|
||||
# 绝不越权看到他人待审核链条。
|
||||
rows = await repo.list_chains("u1", scope="pending", is_admin=False)
|
||||
assert {r["id"] for r in rows} == {"c1", "c2"}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_review_flow_owner_submit_admin_publish():
|
||||
repo, engine = await _make_repo()
|
||||
try:
|
||||
await repo.create_chain("u1", {"id": "c1", "title": "走审核的链", "seats": _SEATS})
|
||||
|
||||
# ① owner 提交审核。
|
||||
await repo.update_chain("c1", "u1", pending_review=True)
|
||||
|
||||
# ② 管理员在「待审核」列表看到。
|
||||
pending = await repo.list_chains("admin", scope="pending", is_admin=True)
|
||||
assert [r["id"] for r in pending] == ["c1"]
|
||||
|
||||
# ③ 管理员审核发布:is_public=True + 清除待审核标记。
|
||||
published = await repo.update_chain(
|
||||
"c1", "admin", is_public=True, pending_review=False, is_admin=True
|
||||
)
|
||||
assert published is not None
|
||||
assert published["is_public"] is True
|
||||
assert published["pending_review"] is False
|
||||
# owner 不变。
|
||||
assert published["owner_id"] == "u1"
|
||||
|
||||
# ④ 发布后:pending 列表为空,public 列表可见(任何账号)。
|
||||
assert await repo.list_chains("admin", scope="pending", is_admin=True) == []
|
||||
public = await repo.list_chains("u2", scope="public")
|
||||
assert [r["id"] for r in public] == ["c1"]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
Reference in new issue
Block a user