fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap
Skip post-commit ORM refresh on threads_meta creates so concurrent MySQL read/write-split deployments no longer fail with Could not refresh instance; clean up partial roundtable threads on checkpoint failure, and ship related position-roundtable/frontend build fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e52261fc3d
commit
a9d66536c3
12 files changed
+503
-127
No files matched your search
@@ -168,13 +168,6 @@ function BusinessChainCanvas({
|
||||
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
|
||||
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
|
||||
>
|
||||
<FlowNode
|
||||
title="任务理解智能体"
|
||||
description="任务理解岗"
|
||||
accent={NODE_COLORS[0]}
|
||||
completed
|
||||
width={nodeWidth}
|
||||
/>
|
||||
{stages.map((stage, stageIndex) => {
|
||||
const stageWidth = Math.max(
|
||||
nodeWidth,
|
||||
@@ -182,12 +175,14 @@ function BusinessChainCanvas({
|
||||
);
|
||||
return (
|
||||
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
|
||||
<FlowConnector
|
||||
branches={stage.seats.length}
|
||||
width={stageWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
/>
|
||||
{stageIndex > 0 && (
|
||||
<FlowConnector
|
||||
branches={stage.seats.length}
|
||||
width={stageWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
|
||||
{stage.seats.map((seat) => {
|
||||
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
|
||||
@@ -205,20 +200,6 @@ function BusinessChainCanvas({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
|
||||
<FlowNode
|
||||
title="方案总结智能体"
|
||||
description="内置总结节点"
|
||||
accent="#8b83f5"
|
||||
width={nodeWidth}
|
||||
/>
|
||||
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
|
||||
<FlowNode
|
||||
title="行动规划智能体"
|
||||
description="内置规划节点"
|
||||
accent="#78bce6"
|
||||
width={nodeWidth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { isEmbeddedHost } from "@/core/embed/frame-context";
|
||||
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
|
||||
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { getAPIClient } from "@/core/api/api-client";
|
||||
import { useAgent } from "@/core/agents";
|
||||
@@ -167,6 +169,131 @@ type SandboxThreadSnapshot = {
|
||||
signature: string;
|
||||
};
|
||||
|
||||
type PositionRoundtableParentReturnMessage = {
|
||||
source: "deerflow-position-roundtable";
|
||||
type: "deerflow:position-roundtable:return";
|
||||
payload: {
|
||||
route: string | null;
|
||||
fallback: "history-back";
|
||||
};
|
||||
};
|
||||
|
||||
type PositionRoundtableBridgeInitMessage = {
|
||||
source: "code-zk-roundtable-host";
|
||||
type: "code-zk:roundtable-bridge:init";
|
||||
};
|
||||
|
||||
const POSITION_ROUNDTABLE_BRIDGE_SOURCE = "code-zk-roundtable-host";
|
||||
const POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE = "code-zk:roundtable-bridge:init";
|
||||
const POSITION_ROUNDTABLE_BRIDGE_READY_TYPE = "deerflow:position-roundtable:bridge-ready";
|
||||
|
||||
function isEnabledEmbedFlag(value: string | null): boolean {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Some hosts only retain URL parameters after the login redirect. Treat those
|
||||
* established embed markers as an embedded context as well as checking the
|
||||
* actual browser frame/Wujie runtime.
|
||||
*/
|
||||
function hasEmbeddedRouteHint(searchParams: URLSearchParams): boolean {
|
||||
return (
|
||||
searchParams.get("from") === "iframe" ||
|
||||
searchParams.get("chatMode")?.trim().toLowerCase() === "iframe" ||
|
||||
isEnabledEmbedFlag(searchParams.get("embed")) ||
|
||||
isEnabledEmbedFlag(searchParams.get("hideChrome")) ||
|
||||
isEnabledEmbedFlag(searchParams.get("hideHeader"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `parentReturnTo` belongs to the embedding application, not this SPA. Keep
|
||||
* it constrained to a route/hash path so the same-origin fallback can never
|
||||
* turn an externally supplied URL into an arbitrary top-level navigation.
|
||||
*/
|
||||
function parentReturnRoute(value: string | null): string | null {
|
||||
const route = value?.trim();
|
||||
if (!route || route.length > 2048) return null;
|
||||
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request navigation from the embedding host. A real iframe parent is always
|
||||
* the nearest integration boundary, even if an outer layer happens to be
|
||||
* Wujie. This matters for Wujie host -> code-zk -> DeerFlow iframe: code-zk
|
||||
* owns the postMessage contract and its own router. Wujie's event bus is only
|
||||
* an additional fallback for a directly mounted Wujie child.
|
||||
*/
|
||||
function returnToEmbeddingParent(route: string | null, bridgePort?: MessagePort | null): void {
|
||||
const message: PositionRoundtableParentReturnMessage = {
|
||||
source: "deerflow-position-roundtable",
|
||||
type: "deerflow:position-roundtable:return",
|
||||
payload: { route, fallback: "history-back" },
|
||||
};
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// In a Wujie host -> code-zk -> DeerFlow topology, the DOM iframe can be
|
||||
// re-parented by Wujie. A port transferred by code-zk survives that DOM
|
||||
// topology change and is the only channel guaranteed to reach code-zk.
|
||||
if (bridgePort) {
|
||||
try {
|
||||
bridgePort.postMessage(message);
|
||||
console.info("[position-roundtable] posted return through MessageChannel bridge");
|
||||
return;
|
||||
} catch {
|
||||
// The owner may have unmounted/reloaded. Fall back to native bridges.
|
||||
}
|
||||
}
|
||||
|
||||
console.info("[position-roundtable] embedded return requested", {
|
||||
route,
|
||||
hasNativeParent: window.parent !== window,
|
||||
wujie: isWujieEnv(),
|
||||
});
|
||||
|
||||
if (window.parent === window) {
|
||||
// Some Wujie execution modes expose no meaningful native parent window.
|
||||
// In that direct-mount case the event bus is the only available bridge.
|
||||
if (isWujieEnv()) {
|
||||
emitWujieEvent("position-roundtable:return", message.payload);
|
||||
console.info("[position-roundtable] emitted Wujie-only return event");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.parent.location.origin === window.location.origin) {
|
||||
if (route?.startsWith("#")) {
|
||||
window.parent.location.hash = route;
|
||||
} else if (route) {
|
||||
window.parent.location.assign(route);
|
||||
} else {
|
||||
window.parent.history.back();
|
||||
}
|
||||
console.info("[position-roundtable] completed same-origin parent return");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin parent: fall through to the explicit postMessage contract.
|
||||
}
|
||||
|
||||
window.parent.postMessage(message, "*");
|
||||
console.info("[position-roundtable] posted return message to direct parent");
|
||||
|
||||
// A nested iframe must never skip the direct-parent notification merely
|
||||
// because Wujie markers were inherited or injected. Emitting as well keeps
|
||||
// directly mounted Wujie integrations compatible when their host listens.
|
||||
if (isWujieEnv()) {
|
||||
emitWujieEvent("position-roundtable:return", message.payload);
|
||||
console.info("[position-roundtable] emitted supplemental Wujie return event");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL parameters are externally supplied, so never cast the raw value into
|
||||
* page state without checking it against the position directory first.
|
||||
@@ -609,6 +736,50 @@ export default function PositionRoundtablePage({
|
||||
: rawSessionIdFromUrl;
|
||||
const forceNewSession =
|
||||
!standaloneDemoMode && searchParams.get("new") === "1";
|
||||
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
|
||||
const isEmbeddingContext = embeddedHost || hasEmbeddedRouteHint(searchParams);
|
||||
const embeddingParentReturnRoute = useMemo(
|
||||
() =>
|
||||
parentReturnRoute(
|
||||
searchParams.get("parentReturnTo") ??
|
||||
searchParams.get("parentRoute") ??
|
||||
searchParams.get("returnTo"),
|
||||
),
|
||||
[searchParamsString],
|
||||
);
|
||||
const embeddingBridgePortRef = useRef<MessagePort | null>(null);
|
||||
const handleReturnToEmbeddingParent = useCallback(() => {
|
||||
returnToEmbeddingParent(embeddingParentReturnRoute, embeddingBridgePortRef.current);
|
||||
}, [embeddingParentReturnRoute]);
|
||||
useEffect(() => {
|
||||
const receiveBridgePort = (event: MessageEvent<PositionRoundtableBridgeInitMessage>) => {
|
||||
const message = event.data;
|
||||
const bridgePort = event.ports?.[0];
|
||||
if (
|
||||
message?.source !== POSITION_ROUNDTABLE_BRIDGE_SOURCE ||
|
||||
message?.type !== POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE ||
|
||||
!bridgePort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
embeddingBridgePortRef.current?.close();
|
||||
embeddingBridgePortRef.current = bridgePort;
|
||||
bridgePort.start?.();
|
||||
bridgePort.postMessage({
|
||||
source: "deerflow-position-roundtable",
|
||||
type: POSITION_ROUNDTABLE_BRIDGE_READY_TYPE,
|
||||
});
|
||||
console.info("[position-roundtable] MessageChannel bridge connected");
|
||||
};
|
||||
|
||||
window.addEventListener("message", receiveBridgePort);
|
||||
return () => {
|
||||
window.removeEventListener("message", receiveBridgePort);
|
||||
embeddingBridgePortRef.current?.close();
|
||||
embeddingBridgePortRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
const [positionSession, setPositionSession] = useState<PositionRoundtableSession | null>(null);
|
||||
const positionSessionRef = useRef<PositionRoundtableSession | null>(null);
|
||||
const sessionCreationRef = useRef<Promise<PositionRoundtableSession> | null>(null);
|
||||
@@ -2273,6 +2444,17 @@ export default function PositionRoundtablePage({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isEmbeddingContext && (
|
||||
<TButton
|
||||
size="small"
|
||||
variant="outline"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
icon={<ArrowLeft className="size-3.5" />}
|
||||
onClick={handleReturnToEmbeddingParent}
|
||||
>
|
||||
返回
|
||||
</TButton>
|
||||
)}
|
||||
<TButton
|
||||
size="small"
|
||||
variant="outline"
|
||||
@@ -2443,14 +2625,9 @@ export default function PositionRoundtablePage({
|
||||
<BusinessChainFlow
|
||||
chain={activeChain}
|
||||
nodes={positionSession?.nodes ?? []}
|
||||
session={positionSession}
|
||||
activeNodeKey={activeNodeKey}
|
||||
onSelectIntent={
|
||||
activePosition.isIntentPosition ? openIntentFlowDetail : undefined
|
||||
}
|
||||
onSelectNode={openNodeFlowDetail}
|
||||
onSelectSeat={openSeatFlowDetail}
|
||||
onSelectBuiltin={handleSelectBuiltinStage}
|
||||
embedded
|
||||
/>
|
||||
)}
|
||||
@@ -4215,23 +4392,17 @@ const FLOW_NODE_GAP = 10;
|
||||
function BusinessChainFlow({
|
||||
chain,
|
||||
nodes,
|
||||
session,
|
||||
activeNodeKey,
|
||||
onSelectIntent,
|
||||
onSelectNode,
|
||||
onSelectSeat,
|
||||
onSelectBuiltin,
|
||||
className,
|
||||
embedded = false,
|
||||
}: {
|
||||
chain: BusinessChain | null;
|
||||
nodes: PositionRoundtableNode[];
|
||||
session: PositionRoundtableSession | null;
|
||||
activeNodeKey: string | null;
|
||||
onSelectIntent?: () => void;
|
||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
||||
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
|
||||
className?: string;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
@@ -4287,12 +4458,9 @@ function BusinessChainFlow({
|
||||
<ChainFlowCanvas
|
||||
chain={chain}
|
||||
nodes={nodes}
|
||||
session={session}
|
||||
activeNodeKey={activeNodeKey}
|
||||
onSelectIntent={onSelectIntent}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectSeat={onSelectSeat}
|
||||
onSelectBuiltin={onSelectBuiltin}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
|
||||
@@ -4307,16 +4475,7 @@ function BusinessChainFlow({
|
||||
<ChainFlowCanvas
|
||||
chain={chain}
|
||||
nodes={nodes}
|
||||
session={session}
|
||||
activeNodeKey={activeNodeKey}
|
||||
onSelectIntent={
|
||||
onSelectIntent
|
||||
? () => {
|
||||
onSelectIntent();
|
||||
setFlowDialogOpen(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSelectNode={(node) => {
|
||||
onSelectNode(node);
|
||||
setFlowDialogOpen(false);
|
||||
@@ -4325,10 +4484,6 @@ function BusinessChainFlow({
|
||||
onSelectSeat?.(seat, stageIndex, seatIndex);
|
||||
setFlowDialogOpen(false);
|
||||
}}
|
||||
onSelectBuiltin={(kind) => {
|
||||
onSelectBuiltin(kind);
|
||||
setFlowDialogOpen(false);
|
||||
}}
|
||||
className="h-full"
|
||||
expanded
|
||||
/>
|
||||
@@ -4344,23 +4499,17 @@ function BusinessChainFlow({
|
||||
function ChainFlowCanvas({
|
||||
chain,
|
||||
nodes,
|
||||
session,
|
||||
activeNodeKey,
|
||||
onSelectIntent,
|
||||
onSelectNode,
|
||||
onSelectSeat,
|
||||
onSelectBuiltin,
|
||||
className,
|
||||
expanded = false,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
nodes: PositionRoundtableNode[];
|
||||
session: PositionRoundtableSession | null;
|
||||
activeNodeKey: string | null;
|
||||
onSelectIntent?: () => void;
|
||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
||||
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
|
||||
className?: string;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
@@ -4376,12 +4525,6 @@ function ChainFlowCanvas({
|
||||
} | null>(null);
|
||||
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
|
||||
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
|
||||
const allDone = allExecutableNodesDone(nodes);
|
||||
const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
|
||||
const actionPlanStatus = builtinNodeStatus(
|
||||
session?.actionPlanSnapshot,
|
||||
snapshotStatus(session?.summarySnapshot) === "done",
|
||||
);
|
||||
const connectorColor =
|
||||
theme === "dark" ? "rgba(143, 196, 255, 0.95)" : "rgba(148, 163, 184, 0.88)";
|
||||
|
||||
@@ -4439,14 +4582,6 @@ function ChainFlowCanvas({
|
||||
className="flex min-h-full min-w-max flex-col items-center p-5"
|
||||
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
|
||||
>
|
||||
<CompactChainNode
|
||||
title="情报分析智能体"
|
||||
description="情报分析岗"
|
||||
accent="#3b82f6"
|
||||
nodeStatus={nodes.length > 0 ? "done" : "ready"}
|
||||
width={nodeWidth}
|
||||
onClick={onSelectIntent}
|
||||
/>
|
||||
{stages.map((stage, stageIndex) => {
|
||||
const stageWidth = Math.max(
|
||||
nodeWidth,
|
||||
@@ -4457,13 +4592,15 @@ function ChainFlowCanvas({
|
||||
key={`${stage.goal ?? "stage"}-${stageIndex}`}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<VerticalFlowConnector
|
||||
seatCount={stage.seats.length}
|
||||
width={stageWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
connectorColor={connectorColor}
|
||||
/>
|
||||
{stageIndex > 0 && (
|
||||
<VerticalFlowConnector
|
||||
seatCount={stage.seats.length}
|
||||
width={stageWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
connectorColor={connectorColor}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
|
||||
{stage.seats.map((seat, seatIndex) => {
|
||||
const node = nodes.find(
|
||||
@@ -4499,38 +4636,6 @@ function ChainFlowCanvas({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<VerticalFlowConnector
|
||||
seatCount={1}
|
||||
width={nodeWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
connectorColor={connectorColor}
|
||||
/>
|
||||
<CompactChainNode
|
||||
title="方案总结智能体"
|
||||
description="内置总结节点"
|
||||
accent="#6366f1"
|
||||
nodeStatus={summaryStatus}
|
||||
width={nodeWidth}
|
||||
titleAttr="对所有有效交付产物进行方案总结"
|
||||
onClick={() => onSelectBuiltin("summary")}
|
||||
/>
|
||||
<VerticalFlowConnector
|
||||
seatCount={1}
|
||||
width={nodeWidth}
|
||||
nodeWidth={nodeWidth}
|
||||
nodeGap={nodeGap}
|
||||
connectorColor={connectorColor}
|
||||
/>
|
||||
<CompactChainNode
|
||||
title="行动规划智能体"
|
||||
description="内置规划节点"
|
||||
accent="#0ea5e9"
|
||||
nodeStatus={actionPlanStatus}
|
||||
width={nodeWidth}
|
||||
titleAttr="基于方案总结和岗位产物拆解子任务"
|
||||
onClick={() => onSelectBuiltin("action_plan")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -82,8 +82,11 @@ import { listAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
setRuntimePositionOptions,
|
||||
type PositionOption,
|
||||
type PositionId,
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import { usePositionRoles } from "@/strategy-components/api/position-roles";
|
||||
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
@@ -160,6 +163,33 @@ export default function BusinessChainEditorPage() {
|
||||
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
|
||||
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
|
||||
const positionConfigMode = searchParams.get("mode") === "position";
|
||||
// This is the same directory the administrator edits in “岗位角色配置”.
|
||||
// The editor used to read the static POSITION_OPTIONS array below, which
|
||||
// meant a newly configured role never appeared in this seat dropdown.
|
||||
const { data: configuredPositionRoles = [] } = usePositionRoles({
|
||||
refreshOnMountOrFocus: positionConfigMode,
|
||||
});
|
||||
const positionOptions = useMemo<PositionOption[]>(
|
||||
() =>
|
||||
configuredPositionRoles.length > 0
|
||||
? [...configuredPositionRoles]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
isIntentPosition: role.roleType === "intent",
|
||||
enabled: role.enabled,
|
||||
isBuiltin: role.isBuiltin,
|
||||
sortOrder: role.sortOrder,
|
||||
}))
|
||||
: [...POSITION_OPTIONS],
|
||||
[configuredPositionRoles],
|
||||
);
|
||||
// Keep flow previews and historical seat labels on the same live directory.
|
||||
useEffect(() => {
|
||||
setRuntimePositionOptions(positionOptions);
|
||||
}, [positionOptions]);
|
||||
const requestedReturnTo = searchParams.get("returnTo");
|
||||
const returnPath =
|
||||
positionConfigMode && requestedReturnTo?.startsWith("/")
|
||||
@@ -1244,6 +1274,7 @@ export default function BusinessChainEditorPage() {
|
||||
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
|
||||
onSeatModeChange={setSeatModeCb}
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={setSeatPositionCb}
|
||||
onStageGoalChange={setGoal}
|
||||
goalOptimizingIdx={goalOptimizingIdx}
|
||||
@@ -1426,6 +1457,7 @@ function StagesColumn({
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onStageGoalChange,
|
||||
goalOptimizingIdx,
|
||||
@@ -1447,6 +1479,8 @@ function StagesColumn({
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
|
||||
positionConfigMode: boolean;
|
||||
/** Live directory from the administrator's position-role configuration. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||
@@ -1554,6 +1588,7 @@ function StagesColumn({
|
||||
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
|
||||
onSeatModeChange={onSeatModeChange}
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={onSeatPositionChange}
|
||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||
@@ -1587,6 +1622,7 @@ function StageSection({
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onGoalChange,
|
||||
goalOptimizing,
|
||||
@@ -1609,6 +1645,7 @@ function StageSection({
|
||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
positionConfigMode: boolean;
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onGoalChange: (goal: string) => void;
|
||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||
@@ -1774,6 +1811,7 @@ function StageSection({
|
||||
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
|
||||
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
|
||||
showPositionConfig={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||
/>
|
||||
@@ -1801,6 +1839,7 @@ function SeatStrip({
|
||||
onSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
showPositionConfig,
|
||||
positionOptions,
|
||||
onPositionChange,
|
||||
onDetail,
|
||||
}: {
|
||||
@@ -1822,12 +1861,41 @@ function SeatStrip({
|
||||
onSeatModeChange: (mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口展示的岗位配置。 */
|
||||
showPositionConfig: boolean;
|
||||
/** Current position directory, injected by the business-chain editor. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onPositionChange: (positionId: PositionId | null) => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
|
||||
const selectablePositionOptions = useMemo(() => {
|
||||
const assignedId = seat.position_id ?? null;
|
||||
const assigned = assignedId
|
||||
? positionOptions.find((position) => position.id === assignedId)
|
||||
: undefined;
|
||||
const enabled = positionOptions.filter((position) => position.enabled !== false);
|
||||
// Do not silently erase an archived configuration from a saved chain.
|
||||
// It remains visible but cannot be selected for another seat.
|
||||
const historical =
|
||||
assignedId && (!assigned || assigned.enabled === false)
|
||||
? [
|
||||
{
|
||||
label: assigned
|
||||
? `${assigned.name}(已停用)`
|
||||
: `${assignedId}(岗位已删除)`,
|
||||
value: assignedId,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{ label: "未分配", value: "" },
|
||||
...historical,
|
||||
...enabled.map((position) => ({ label: position.name, value: position.id })),
|
||||
];
|
||||
}, [positionOptions, seat.position_id]);
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -1880,13 +1948,7 @@ function SeatStrip({
|
||||
<TSelect
|
||||
size="small"
|
||||
value={seat.position_id ?? ""}
|
||||
options={[
|
||||
{ label: "未分配", value: "" },
|
||||
...POSITION_OPTIONS.map((position) => ({
|
||||
label: position.name,
|
||||
value: position.id,
|
||||
})),
|
||||
]}
|
||||
options={selectablePositionOptions}
|
||||
onChange={(value) =>
|
||||
onPositionChange(value ? (String(value) as PositionId) : null)
|
||||
}
|
||||
|
||||
@@ -158,12 +158,21 @@ export async function replacePositionRoles(roles: PositionRole[]): Promise<Posit
|
||||
return saved;
|
||||
}
|
||||
|
||||
export function usePositionRoles(options?: { enabled?: boolean }) {
|
||||
export function usePositionRoles(options?: {
|
||||
/** Whether this query should run. */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Consumers that edit business-chain seats need the latest shared directory
|
||||
* whenever their page is opened or focused.
|
||||
*/
|
||||
refreshOnMountOrFocus?: boolean;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: POSITION_ROLES_QUERY_KEY,
|
||||
queryFn: listPositionRoles,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: options?.refreshOnMountOrFocus ? "always" : true,
|
||||
refetchOnWindowFocus: options?.refreshOnMountOrFocus ? "always" : false,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user