diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts
index 85d6a8ac..4be2479f 100644
--- a/frontend-web/src/roundtable-planning/api/chains.ts
+++ b/frontend-web/src/roundtable-planning/api/chains.ts
@@ -72,6 +72,7 @@ interface ServerChain {
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
+ owner_name?: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -88,6 +89,7 @@ function toChain(s: ServerChain): BusinessChain {
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
+ ownerName: s.owner_name ?? undefined,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx
index 107d33d7..3f96a2cb 100644
--- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx
+++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx
@@ -16,6 +16,7 @@ import {
LayersIcon,
Loader2Icon,
SettingsIcon,
+ UserIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -227,6 +228,8 @@ function ChainRow({
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
+ // 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
+ const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return (
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
@@ -274,6 +277,16 @@ function ChainRow({
+ {ownerName && (
+
+
+ 配置人:{ownerName}
+
+ )}
+
{chain.description && (
{chain.description}
)}
diff --git a/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx
new file mode 100644
index 00000000..c5a49ebb
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx
@@ -0,0 +1,462 @@
+/**
+ * 业务链条「精美流程图」(编辑器第一步右侧预览)。
+ *
+ * 把分层编排画成一张可交互的画布:
+ * - 列 = Stage(层间串行),列内节点 = 席位(同层并行);首列是「总控协调」。
+ * - 节点之间用**直角折线**(H→V→H 肘形)连接,末端带箭头。
+ * - 每个「步骤之间」的连线上挂一枚依赖标签 = 目标 Stage 的「本阶段目标」(用户填了才显示)。
+ * - 点击节点卡片 → `onNodeClick`(外层打开 `AgentDetailSheet` 抽屉看详情)。
+ * - 画布支持:鼠标滚轮缩放(以光标为锚点)、按住拖动平移、一键适应、全屏。
+ * - **全屏时**右下角显示缩略图(minimap):整图概览 + 当前视口框,点击可快速定位。
+ *
+ * 纯展示 / 交互组件,不持有业务数据;坐标布局在 `useMemo` 里一次算好,缩放平移只改
+ * 外层 transform,所以重绘极轻。席位配色复用 `getAvatarTypeFor` + `getAvatar`。
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Crosshair,
+ Maximize2,
+ Minimize2,
+ Minus,
+ Plus,
+ X,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+import { getAvatar } from "./Avatars";
+import { getAvatarTypeFor } from "../lib/roundtable-constants";
+
+export interface ChainFlowSeat {
+ agent_id: string;
+ name: string;
+ description?: string;
+}
+
+export interface ChainFlowStage {
+ seats: ChainFlowSeat[];
+ /** 「本阶段目标」——作为进入本阶段那根连线的依赖标签;空 = 不显示。 */
+ goal?: string | null;
+}
+
+export interface ChainFlowDiagramProps {
+ stages: ChainFlowStage[];
+ /** 点击席位节点卡片(总控节点不触发)。 */
+ onNodeClick?: (agentId: string, name?: string) => void;
+ className?: string;
+}
+
+// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
+const NODE_W = 188;
+const NODE_H = 66;
+const COL_GAP = 116;
+const ROW_GAP = 22;
+const PAD = 52;
+const COORDINATOR_ID = "__coordinator__";
+
+const MIN_SCALE = 0.25;
+const MAX_SCALE = 2.6;
+
+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
+
+interface PlacedNode {
+ id: string;
+ name: string;
+ description?: string;
+ avatarType: string;
+ x: number;
+ y: number;
+ cy: number;
+ leftX: number;
+ rightX: number;
+}
+
+export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
+ // ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
+ const layout = useMemo(() => {
+ // 列:总控 + 各 Stage。
+ const rawCols: Array<{ kind: "coordinator" | "stage"; goal: string | null; nodes: Omit[] }> = [];
+ rawCols.push({
+ kind: "coordinator",
+ goal: null,
+ nodes: [{ id: COORDINATOR_ID, name: "总控协调", avatarType: "human" }],
+ });
+ let order = 0;
+ for (const st of stages) {
+ const nodes = st.seats.map((s) => ({
+ id: s.agent_id,
+ name: s.name || s.agent_id,
+ description: s.description,
+ avatarType: getAvatarTypeFor(s.agent_id, order++),
+ }));
+ rawCols.push({ kind: "stage", goal: (st.goal ?? "").trim() || null, nodes });
+ }
+
+ const heights = rawCols.map((c) => c.nodes.length * NODE_H + Math.max(0, c.nodes.length - 1) * ROW_GAP);
+ const contentH = Math.max(NODE_H, ...heights);
+ const worldW = PAD * 2 + rawCols.length * NODE_W + Math.max(0, rawCols.length - 1) * COL_GAP;
+ const worldH = PAD * 2 + contentH;
+ const centerY = PAD + contentH / 2;
+
+ const cols = rawCols.map((c, k) => {
+ const x = PAD + k * (NODE_W + COL_GAP);
+ const h = heights[k];
+ const yStart = PAD + (contentH - h) / 2;
+ const nodes: PlacedNode[] = c.nodes.map((n, i) => {
+ const y = yStart + i * (NODE_H + ROW_GAP);
+ return { ...n, x, y, cy: y + NODE_H / 2, leftX: x, rightX: x + NODE_W };
+ });
+ return { ...c, nodes };
+ });
+
+ const edges: Array<{ id: string; d: string }> = [];
+ const labels: Array<{ id: string; x: number; y: number; text: string }> = [];
+ for (let k = 0; k < cols.length - 1; k++) {
+ const a = cols[k];
+ const b = cols[k + 1];
+ const midX = (a.nodes[0].rightX + b.nodes[0].leftX) / 2;
+ for (const na of a.nodes) {
+ for (const nb of b.nodes) {
+ edges.push({
+ id: `${k}:${na.id}->${nb.id}`,
+ d: `M ${na.rightX} ${na.cy} H ${midX} V ${nb.cy} H ${nb.leftX}`,
+ });
+ }
+ }
+ if (b.goal) labels.push({ id: `lbl-${k}`, x: midX, y: centerY, text: b.goal });
+ }
+
+ const allNodes = cols.flatMap((c) => c.nodes);
+ return { cols, edges, labels, worldW, worldH, allNodes };
+ }, [stages]);
+
+ // ── 视口 / 缩放平移状态 ──────────────────────────────────────────────────
+ const viewportRef = useRef(null);
+ const [vp, setVp] = useState({ w: 0, h: 0 });
+ const [view, setView] = useState({ scale: 1, tx: 0, ty: 0 });
+ const [fullscreen, setFullscreen] = useState(false);
+ const [grabbing, setGrabbing] = useState(false);
+ const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
+
+ // 视口尺寸跟踪(全屏切换会换挂载节点,故依赖 fullscreen 重新绑定)。
+ useEffect(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+ const measure = () => setVp({ w: el.clientWidth, h: el.clientHeight });
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [fullscreen]);
+
+ // 适应视图:把整图缩放居中到视口内。
+ const fit = useCallback(() => {
+ if (!vp.w || !vp.h) return;
+ const s = clamp(Math.min(vp.w / layout.worldW, vp.h / layout.worldH) * 0.9, MIN_SCALE, 1.4);
+ setView({ scale: s, tx: (vp.w - layout.worldW * s) / 2, ty: (vp.h - layout.worldH * s) / 2 });
+ }, [vp.w, vp.h, layout.worldW, layout.worldH]);
+
+ // 视口尺寸 / 图结构 / 全屏变化时自动适应一次。
+ useEffect(() => {
+ fit();
+ }, [fit]);
+
+ // 全屏时 Esc 退出。
+ useEffect(() => {
+ if (!fullscreen) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setFullscreen(false);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [fullscreen]);
+
+ // 滚轮缩放(以光标为锚点)。native 监听以便 preventDefault。
+ useEffect(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ const rect = el.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ setView((v) => {
+ const next = clamp(v.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12), MIN_SCALE, MAX_SCALE);
+ const k = next / v.scale;
+ return { scale: next, tx: mx - k * (mx - v.tx), ty: my - k * (my - v.ty) };
+ });
+ };
+ el.addEventListener("wheel", onWheel, { passive: false });
+ return () => el.removeEventListener("wheel", onWheel);
+ }, [fullscreen]);
+
+ // 以视口中心为锚点的按钮缩放。
+ const zoomBy = useCallback(
+ (f: number) => {
+ setView((v) => {
+ const next = clamp(v.scale * f, MIN_SCALE, MAX_SCALE);
+ const k = next / v.scale;
+ const cx = vp.w / 2;
+ const cy = vp.h / 2;
+ return { scale: next, tx: cx - k * (cx - v.tx), ty: cy - k * (cy - v.ty) };
+ });
+ },
+ [vp.w, vp.h],
+ );
+
+ // ── 缩略图(minimap,仅全屏)─────────────────────────────────────────────
+ const MM_W = 196;
+ const MM_H = 132;
+ const mmScale = Math.min(MM_W / layout.worldW, MM_H / layout.worldH);
+ const mmW = layout.worldW * mmScale;
+ const mmH = layout.worldH * mmScale;
+ // 当前视口在世界坐标里的矩形(再乘 mmScale 投到缩略图)。
+ const viewRect = {
+ x: (-view.tx / view.scale) * mmScale,
+ y: (-view.ty / view.scale) * mmScale,
+ w: (vp.w / view.scale) * mmScale,
+ h: (vp.h / view.scale) * mmScale,
+ };
+ const onMinimapClick = useCallback(
+ (e: React.MouseEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const wx = (e.clientX - rect.left) / mmScale;
+ const wy = (e.clientY - rect.top) / mmScale;
+ setView((v) => ({ ...v, tx: vp.w / 2 - wx * v.scale, ty: vp.h / 2 - wy * v.scale }));
+ },
+ [mmScale, vp.w, vp.h],
+ );
+
+ // ── 画布渲染 ─────────────────────────────────────────────────────────────
+ const canvas = (
+ {
+ if (e.button !== 0) return;
+ panRef.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
+ setGrabbing(true);
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ }}
+ onPointerMove={(e) => {
+ const p = panRef.current;
+ if (!p) return;
+ setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.x), ty: p.ty + (e.clientY - p.y) }));
+ }}
+ onPointerUp={() => {
+ panRef.current = null;
+ setGrabbing(false);
+ }}
+ onPointerCancel={() => {
+ panRef.current = null;
+ setGrabbing(false);
+ }}
+ >
+ {/* 世界层:所有节点 / 连线随 transform 一起缩放平移 */}
+
+
+
+ {/* 节点卡片 */}
+ {layout.cols.flatMap((col) =>
+ col.nodes.map((n) => {
+ const isCoordinator = n.id === COORDINATOR_ID;
+ return (
+
e.stopPropagation()}
+ onClick={() => {
+ if (isCoordinator) return;
+ // 全屏时先退出(详情抽屉 z-50 会被 z-[100] 全屏层盖住),再打开抽屉。
+ if (fullscreen) setFullscreen(false);
+ onNodeClick?.(n.id, n.name);
+ }}
+ >
+
+
{getAvatar(n.avatarType, "h-9 w-9")}
+
+
{n.name}
+
+ {isCoordinator ? "按阶段派活 · 综合收口" : n.description || n.id}
+
+
+
+
+ );
+ }),
+ )}
+
+ {/* 依赖标签(步骤之间的连线上) */}
+ {layout.labels.map((l) => (
+
+ ))}
+
+
+ {/* 工具条:缩小 / 放大 / 适应 / 全屏 */}
+
+
zoomBy(1.2)}>
+
+
+
zoomBy(1 / 1.2)}>
+
+
+
+
+
+
setFullscreen((f) => !f)}
+ >
+ {fullscreen ? : }
+
+
+
+ {/* 缩放比例徽标 */}
+
+ {Math.round(view.scale * 100)}%
+
+
+ {/* 全屏退出按钮(左上) */}
+ {fullscreen && (
+
+ )}
+
+ {/* 缩略图:仅全屏时右下角 */}
+ {fullscreen && (
+
+
+ {/* 节点缩略块 */}
+ {layout.allNodes.map((n) => (
+
+ ))}
+ {/* 当前视口框 */}
+
+
+
+ )}
+
+ );
+
+ // 全屏:固定定位铺满视口(position:fixed 自带脱离文档流,无需 portal)。
+ if (fullscreen) {
+ return {canvas}
;
+ }
+ return {canvas}
;
+}
+
+// ── 工具按钮 ────────────────────────────────────────────────────────────────
+
+function ToolButton({
+ title,
+ onClick,
+ children,
+}: {
+ title: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts
index d550b4a6..414ea3cf 100644
--- a/frontend-web/src/roundtable-planning/lib/business-chain.ts
+++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts
@@ -52,6 +52,8 @@ export interface BusinessChain {
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
+ /** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
+ ownerName?: string;
createdAt?: string;
updatedAt?: string;
}
diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx
index 283338e8..fcefdb7a 100644
--- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx
@@ -18,7 +18,7 @@
*
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
@@ -113,6 +113,7 @@ import {
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
+import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
const POOL_PREFIX = "pool::";
@@ -161,6 +162,8 @@ export default function BusinessChainEditorPage() {
const [activeDrag, setActiveDrag] = useState(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
+ // 两步向导:1=基本信息(标题/描述/总控提示词),2=链条编排(双栏拖拽编辑器)。
+ const [step, setStep] = useState<1 | 2>(1);
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
const showDetail = useCallback((id: string, name?: string) => {
@@ -205,6 +208,16 @@ export default function BusinessChainEditorPage() {
[seatNameById],
);
+ // 步骤一右侧「精美流程图」的数据源:过滤空 stage,带上各阶段目标作为依赖标签。
+ const flowStages = useMemo(
+ () =>
+ stageGroups
+ .filter((g) => g.seats.length > 0)
+ .map((g) => ({ seats: g.seats, goal: g.goal ?? null })),
+ [stageGroups],
+ );
+ const hasFlowContent = flowStages.length > 0;
+
// ─── 链条列表(一次性)─────────────────────────────────────────────────
const refreshChains = useCallback(async () => {
try {
@@ -252,6 +265,7 @@ export default function BusinessChainEditorPage() {
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain));
+ setStep(1);
}, []);
const startNewChain = useCallback(() => {
@@ -262,6 +276,7 @@ export default function BusinessChainEditorPage() {
setDescription("");
setCoordinatorPrompt("");
setStageGroups([]);
+ setStep(1);
}, []);
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
@@ -615,6 +630,100 @@ export default function BusinessChainEditorPage() {
// ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
+ // 步骤一左侧「基本信息」表单(标题 / 描述 / 总控提示词,描述与总控均为多行文本)。
+ // 抽成元素:无编排内容时居中单列,有内容时与右侧流程图并排(左列)。
+ const basicInfoForm = (
+
+ {/* 标题 */}
+
+
+ 标题
+
+ setTitle(e.target.value)}
+ placeholder="如:应急方案三段链"
+ maxLength={120}
+ className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
+ />
+
+
+ {/* 描述(多行文本,与总控提示词同款)。 */}
+
+
+ 描述
+
+ 链条用途说明:解决什么问题、适用什么场景(可选)
+
+
+
+
+ {/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
+
+
+
总控提示词
+
+
+ 写给总控:怎么派活、做什么(仅分层研讨生效,可选)
+
+
+
+
+
+
+ );
+
return (
{/* 顶栏 */}
@@ -786,94 +895,70 @@ export default function BusinessChainEditorPage() {
- {/* 标题 + 描述 */}
-
-
-
- {/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
-
-
-
总控提示词
-
-
- 写给总控:怎么派活、做什么(仅分层研讨生效,可选)
-
-
-
-
-
+ {s.n}
+
+ {s.label}
+
+
+ ))}
+
- {/* 双栏拖拽编辑器 */}
+ {/* 步骤一:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
+ {step === 1 && (
+
+ {hasFlowContent ? (
+ <>
+ {/* 左:基本信息(这几个描述信息) */}
+
+ {/* 右:编排流程图(直角连线 / 点节点看详情 / 连线显示依赖 / 缩放平移全屏) */}
+
+ >
+ ) : (
+
+ {basicInfoForm}
+
+ 还没有编排内容。点击上方「② 链条编排」去配置链条,保存后这里会显示流程图预览。
+
+
+ )}
+
+ )}
+
+ {/* 双栏拖拽编辑器(步骤二)*/}
+ {step === 2 && (
+ )}
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
- {previewStages.length > 0 && (
+ {step === 2 && previewStages.length > 0 && (
编排预览
@@ -1028,10 +1114,12 @@ export default function BusinessChainEditorPage() {
}}
/>
- {/* 页脚 */}
-
+ {/* 页脚(步骤二):分层编排说明。步骤切换走顶部步骤导航。 */}
+ {step === 2 && (
+
+ )}
);
}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
index c1c6ba25..2f2db415 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
@@ -23,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
-from app.gateway.deps import get_config
+from app.gateway.deps import get_config, get_local_provider
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
@@ -61,6 +61,10 @@ class ChainResponse(BaseModel):
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
+ # 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
+ # 用 LocalAuthProvider 解析;解析失败时为 None,前端回退到短 id 展示。
+ owner_id: str | None = None
+ owner_name: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -103,6 +107,31 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
+async def _enrich_owner_names(rows: list[dict]) -> None:
+ """把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
+
+ 与 knowledge 路由的 ``_enrich_actor_names`` 同思路:批量解析、按 id 缓存、best-effort
+ (解析失败 / provider 不可用时 ``owner_name`` 留空,前端回退到短 id)。
+ """
+ try:
+ provider = get_local_provider()
+ except Exception:
+ return
+ cache: dict[str, str | None] = {}
+ for row in rows:
+ uid = row.get("owner_id")
+ if not uid:
+ row["owner_name"] = None
+ continue
+ if uid not in cache:
+ try:
+ user = await provider.get_user(uid)
+ cache[uid] = user.email if user else None
+ except Exception:
+ cache[uid] = None
+ row["owner_name"] = cache[uid]
+
+
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
@@ -211,6 +240,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope)
+ await _enrich_owner_names(rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -233,6 +263,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
},
)
+ await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -243,6 +274,7 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse:
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
+ await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -265,6 +297,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
+ await _enrich_owner_names([row])
return ChainResponse(**row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
index d44bd3f5..1b86b9cd 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py
@@ -59,6 +59,8 @@ class RoundtableChainRepository:
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", 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,
"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,
}