你好
-
- 选择智能体进入问答
+
-
-
-
- {!selectorLoading && fallbackAgents.length === 0 && (
-
- 暂无可选智能体
-
- )}
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx
index 5acae55c..e6a92fcc 100644
--- a/frontend-web/src/pages/LoginPage.tsx
+++ b/frontend-web/src/pages/LoginPage.tsx
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
import {
getRouteKeyLoginPassword,
resolveRouteKey,
+ resolveRouteKeyMapping,
ROUTE_KEY_PARAM,
} from '@/core/auth/route-key-login';
import {
@@ -25,6 +26,7 @@ import {
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
+import { isEmbeddedHost } from '@/core/embed/frame-context';
import { readEmbedParam } from '@/core/embed/embed-session';
import {
GENERAL_QA_ROUTE,
@@ -33,9 +35,14 @@ import {
persistToolsToken,
} from '@/core/auth/yuser-login';
import {
+ buildStandardChatRedirectPath,
+ buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath,
+ hasIframePresentationOverride,
isChatsIframeLogin,
+ resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath,
+ shouldUseChatsIframeProtocol,
} from '@/core/page-layout/chats-iframe-route';
import {
isSidebarEnabledFromSearch,
@@ -101,22 +108,42 @@ export default function LoginPage() {
);
const requestedChatMode =
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
- const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
- // Wujie is an application container, not the Brainy iframe protocol. It
- // must therefore land on the regular full ChatPage. Plugin links with
- // `from=iframe` are normalized into the explicit iframe protocol mode.
+ const iframePresentationOverride = useMemo(
+ () => hasIframePresentationOverride(searchParams.toString()),
+ [searchParams],
+ );
+ const embeddedHost = useMemo(() => isEmbeddedHost(), []);
+ // `from=iframe` is the established entry contract for the dedicated iframe
+ // page. A standard presentation flag is the one intentional exception: it
+ // uses the normal full chat workspace so Header/PageSidebar visibility is
+ // controlled by hideHeader/hideChrome/embed instead.
+ const brainyIframeRequested = shouldUseChatsIframeProtocol(
+ searchParams.toString(),
+ embeddedHost,
+ );
+ // An explicit full-chat request and all standard presentation flags retain
+ // the regular workspace. Otherwise native iframe/Wujie hosts use the
+ // legacy dedicated AgentSelector entry, matching the established bare
+ // iframe login integration.
const fullChatRequested = useMemo(
() =>
!brainyIframeRequested &&
(isWujieEnv() ||
- requestedChatMode === FULL_CHAT_MODE),
- [brainyIframeRequested, requestedChatMode, searchParams],
+ requestedChatMode === FULL_CHAT_MODE ||
+ // hideHeader/hideChrome/embed are explicit presentation contracts.
+ // They must retain their query parameters and enter the standard
+ // workspace even when a native iframe has no legacy `from` marker.
+ iframePresentationOverride),
+ [
+ brainyIframeRequested,
+ iframePresentationOverride,
+ requestedChatMode,
+ searchParams,
+ ],
);
const isIframeLogin = useMemo(
- () =>
- brainyIframeRequested ||
- (!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
- [brainyIframeRequested, fullChatRequested, searchParams],
+ () => brainyIframeRequested,
+ [brainyIframeRequested],
);
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
@@ -127,22 +154,37 @@ export default function LoginPage() {
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const redirectPath = useMemo(
() => {
- if (yUserId) return GENERAL_QA_ROUTE;
// Keep the mode on the destination URL so PageRoutes can retain the
// full chat workspace sidebar while hideChrome hides only the global
// application chrome.
if (brainyIframeRequested) {
- return buildChatsIframeRedirectPath(searchParams.toString());
+ return !isChatsIframeLogin(searchParams.toString()) &&
+ requestedChatMode !== IFRAME_CHAT_MODE
+ ? buildEmbeddedIframeRedirectPath(searchParams.toString())
+ : buildChatsIframeRedirectPath(searchParams.toString());
}
if (fullChatRequested) {
- return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
+ return buildStandardChatRedirectPath(
+ searchParams.toString(),
+ DEFAULT_REDIRECT_PATH,
+ );
}
+ // External iframe callers frequently also provide yUserId for login.
+ // That identity parameter must not steal a deliberate iframe entry and
+ // send it to the generic full-chat home before the route split above.
+ if (yUserId) return GENERAL_QA_ROUTE;
return resolvePostLoginRedirectPath(
searchParams.toString(),
DEFAULT_REDIRECT_PATH,
);
},
- [brainyIframeRequested, fullChatRequested, searchParams, yUserId],
+ [
+ brainyIframeRequested,
+ fullChatRequested,
+ requestedChatMode,
+ searchParams,
+ yUserId,
+ ],
);
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
@@ -152,6 +194,10 @@ export default function LoginPage() {
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? '';
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
+ const routeKeyMapping = useMemo(
+ () => resolveRouteKeyMapping(routeKey),
+ [routeKey],
+ );
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
const configuredRouteLoginPassword = useMemo(
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
@@ -183,6 +229,14 @@ export default function LoginPage() {
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
+ const iframeEntryTarget = resolveChatsIframeEntryRedirect(
+ pathOnly,
+ searchParams.toString(),
+ embeddedHost,
+ );
+ if (iframeEntryTarget) {
+ return iframeEntryTarget;
+ }
// Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar.
if (
@@ -204,7 +258,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
- }, [fullChatRequested, routeKeyTarget, searchParams]);
+ }, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -243,10 +297,20 @@ export default function LoginPage() {
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
useEffect(() => {
if (isEmbed || isIframeLogin) return;
- const themeValue = wujieProps.theme || externalParams.theme;
+ // routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
+ // `theme=dark-blue` 也能在登录完成前切换到指定主题。
+ const themeValue =
+ routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
if (!themeValue) return;
setTheme(resolveThemeFromQuery(themeValue));
- }, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
+ }, [
+ isEmbed,
+ isIframeLogin,
+ routeKeyMapping?.theme,
+ wujieProps.theme,
+ externalParams.theme,
+ setTheme,
+ ]);
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
diff --git a/frontend-web/src/pages/PositionRolesPage.tsx b/frontend-web/src/pages/PositionRolesPage.tsx
new file mode 100644
index 00000000..55dce487
--- /dev/null
+++ b/frontend-web/src/pages/PositionRolesPage.tsx
@@ -0,0 +1,362 @@
+"use client";
+
+/**
+ * 岗位角色配置(仅管理员)。
+ *
+ * 管理岗位会商使用的“工作视角”而非组织岗位/RBAC:一个启用的意图识别岗
+ * 承担第一阶段意图确认,并承载方案总结、行动规划两个收口智能体入口。
+ */
+
+import { useEffect, useMemo, useState } from "react";
+import { Navigate } from "react-router-dom";
+import {
+ ArrowDown,
+ ArrowUp,
+ Bot,
+ BriefcaseBusiness,
+ CircleDot,
+ Info,
+ ListChecks,
+ Loader2,
+ Plus,
+ Save,
+ ShieldCheck,
+ Trash2,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { getStoredAuth } from "@/core/auth";
+import { cn } from "@/lib/utils";
+import {
+ type PositionRole,
+ type PositionRoleType,
+ usePositionRoleActions,
+ usePositionRoles,
+} from "@/strategy-components/api/position-roles";
+
+let sequence = 0;
+const newRoleId = () => `custom-${Date.now()}-${++sequence}`;
+const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
+
+function serialize(rows: PositionRole[]): string {
+ return JSON.stringify(
+ rows.map((row, index) => ({
+ ...row,
+ sortOrder: index,
+ name: row.name.trim(),
+ description: row.description.trim(),
+ })),
+ );
+}
+
+function makeNewRole(sortOrder: number): PositionRole {
+ return {
+ id: newRoleId(),
+ name: "新岗位",
+ description: "请填写该岗位在会商流程中的职责",
+ roleType: "standard",
+ enabled: true,
+ isBuiltin: false,
+ sortOrder,
+ };
+}
+
+export default function PositionRolesPage() {
+ const isAdmin = getStoredAuth()?.system_role === "admin";
+ const { data: serverRows = [], isLoading, isError, error, refetch } = usePositionRoles();
+ const { save } = usePositionRoleActions();
+ const [rows, setRows] = useState
([]);
+ const [loadedKey, setLoadedKey] = useState(null);
+
+ const sourceKey = useMemo(() => serialize(serverRows), [serverRows]);
+ useEffect(() => {
+ if (sourceKey !== loadedKey) {
+ setRows(serverRows.map((row, index) => ({ ...row, sortOrder: index })));
+ setLoadedKey(sourceKey);
+ }
+ }, [loadedKey, serverRows, sourceKey]);
+
+ const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
+ const enabledIntentId = rows.find((row) => row.enabled && row.roleType === "intent")?.id;
+ const usingFallback = serverRows.some((row) => row.isFallback);
+ const enabledCount = rows.filter((row) => row.enabled).length;
+ const intentRole = rows.find((row) => row.id === enabledIntentId);
+
+ if (!isAdmin) return ;
+
+ const update = (id: string, patch: Partial) =>
+ setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
+
+ const changeRoleType = (id: string, roleType: PositionRoleType) => {
+ setRows((current) =>
+ current.map((row) =>
+ row.id === id
+ ? { ...row, roleType, enabled: roleType === "intent" ? true : row.enabled }
+ : roleType === "intent" && row.roleType === "intent"
+ ? { ...row, roleType: "standard" }
+ : row,
+ ),
+ );
+ };
+
+ const toggleEnabled = (id: string, enabled: boolean) => {
+ const target = rows.find((row) => row.id === id);
+ if (!enabled && target?.roleType === "intent" && enabledIntentId === id) {
+ toast.info("请先将另一个岗位设为“意图识别岗”,再停用当前岗位");
+ return;
+ }
+ update(id, { enabled });
+ };
+
+ const move = (id: string, direction: "up" | "down") => {
+ const index = rows.findIndex((row) => row.id === id);
+ const nextIndex = direction === "up" ? index - 1 : index + 1;
+ if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) return;
+ const next = [...rows];
+ [next[index], next[nextIndex]] = [next[nextIndex], next[index]];
+ setRows(next.map((row, order) => ({ ...row, sortOrder: order })));
+ };
+
+ const remove = (row: PositionRole) => {
+ if (row.isBuiltin) {
+ toast.info("内置岗位为兼容历史业务链而保留;如不再使用可先停用它");
+ return;
+ }
+ if (row.id === enabledIntentId) {
+ toast.info("请先将另一个岗位设为“意图识别岗”后再删除");
+ return;
+ }
+ setRows((current) => current.filter((item) => item.id !== row.id).map((item, index) => ({ ...item, sortOrder: index })));
+ };
+
+ const validate = (): PositionRole[] | null => {
+ if (!rows.length) {
+ toast.error("至少需要保留一个岗位");
+ return null;
+ }
+ const normalized = rows.map((row, sortOrder) => ({
+ ...row,
+ id: row.id.trim(),
+ name: row.name.trim(),
+ description: row.description.trim(),
+ sortOrder,
+ }));
+ if (normalized.some((row) => !ID_PATTERN.test(row.id))) {
+ toast.error("岗位标识须以字母开头,只能包含字母、数字、- 或 _");
+ return null;
+ }
+ if (normalized.some((row) => !row.name)) {
+ toast.error("请填写每个岗位的名称");
+ return null;
+ }
+ if (new Set(normalized.map((row) => row.id)).size !== normalized.length) {
+ toast.error("岗位标识不能重复");
+ return null;
+ }
+ if (new Set(normalized.map((row) => row.name.toLocaleLowerCase())).size !== normalized.length) {
+ toast.error("岗位名称不能重复");
+ return null;
+ }
+ if (normalized.filter((row) => row.enabled && row.roleType === "intent").length !== 1) {
+ toast.error("请保留且仅保留一个启用的意图识别岗");
+ return null;
+ }
+ return normalized;
+ };
+
+ const handleSave = async () => {
+ const payload = validate();
+ if (!payload) return;
+ try {
+ await save.mutateAsync(payload);
+ setRows(payload);
+ toast.success("岗位角色配置已保存,会商页面将按新配置显示");
+ } catch (reason) {
+ toast.error(reason instanceof Error ? reason.message : "保存岗位角色配置失败");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
岗位角色配置
+
+ 管理岗位多智能体会商中的岗位名称、职责、性质、启停与顺序;不影响组织岗位、成员或权限。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
已启用岗位
+
{enabledCount}个
+
+
+
会商入口岗位
+
{intentRole?.name ?? "尚未配置"}
+
意图识别与收口智能体入口
+
+
+
内置岗位
+
{rows.filter((row) => row.isBuiltin).length} 个稳定席位
+
可改名、调整性质或停用,不可删除
+
+
+
+ {usingFallback && (
+
+
+
+
当前显示的是内置岗位兜底数据。后端接口尚未返回目录;待后端升级并启动后,系统会自动把这四个岗位写入数据库。
+
+
+
+ )}
+
+
+
+
+ 意图识别岗
+ 是会商入口:它负责任务意图识别,并显示“方案总结”“行动规划”两个收口智能体。系统必须保留且仅保留一个启用的意图识别岗。
+ 内置岗位的稳定标识用于兼容历史业务链,不能删除,但可改名、调整性质或停用。
+
+
+
+ {isLoading ? (
+
+ 正在加载岗位角色配置…
+
+ ) : isError ? (
+
+
{error instanceof Error ? error.message : "岗位角色配置加载失败"}
+
+
+ ) : (
+
+
+
+
岗位目录
+
拖动前可先用上下箭头调整顺序;保存后会立即影响岗位会商的选择与流程。
+
+
共 {rows.length} 个岗位
+
+
+ {rows.map((row, index) => (
+
+
+ {row.roleType === "intent" ? : }
+
+
+
+
+
岗位性质
+
+ {row.roleType === "intent" && (
+
会商入口与收口智能体
+ )}
+
+
+
+ 启用状态
+ toggleEnabled(row.id, checked)} aria-label={`${row.name || "岗位"}${row.enabled ? "已启用" : "已停用"}`} />
+
+ {row.enabled ? "已启用" : "已停用"}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
index 90d37b3c..ac61092d 100644
--- a/frontend-web/src/pages/StrategyRoutes.tsx
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
+import PositionRolesPage from "./PositionRolesPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -437,6 +438,16 @@ export default function StrategyRoutes() {
)
}
/>
+
+ ) : (
+
+ )
+ }
+ />
;
+ }
+
+ // A bare external iframe historically opens the dedicated AgentSelector
+ // page. `resolveChatsIframeEntryRedirect` records that inferred context as
+ // `from=iframe`; explicit full-chat/presentation parameters stay here.
return (
@@ -256,12 +268,78 @@ function NewChatEntryRoute({
);
}
+function ChatsIndexEntryRoute({
+ layoutProps,
+ compactChrome,
+}: {
+ layoutProps: {
+ pageSidebarCollapsed: boolean;
+ pageSidebarHidden: boolean;
+ workspaceSidebarHidden: boolean;
+ headerHidden: boolean;
+ compactWorkspaceSidebar: boolean;
+ compactActionsHidden: boolean;
+ pageSidebarDark: boolean;
+ forceWorkspaceSidebarCollapsed: boolean;
+ };
+ compactChrome: boolean;
+}) {
+ const location = useLocation();
+ const iframeEntryTarget = resolveChatsIframeEntryRedirect(
+ location.pathname,
+ location.search,
+ isEmbeddedHost(),
+ );
+
+ if (iframeEntryTarget) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
+
+function ChatThreadEntryRoute({
+ layoutProps,
+}: {
+ layoutProps: {
+ pageSidebarCollapsed: boolean;
+ pageSidebarHidden: boolean;
+ workspaceSidebarHidden: boolean;
+ headerHidden: boolean;
+ compactWorkspaceSidebar: boolean;
+ compactActionsHidden: boolean;
+ pageSidebarDark: boolean;
+ forceWorkspaceSidebarCollapsed: boolean;
+ };
+}) {
+ const location = useLocation();
+ const iframeEntryTarget = resolveChatsIframeEntryRedirect(
+ location.pathname,
+ location.search,
+ isEmbeddedHost(),
+ );
+
+ if (iframeEntryTarget) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
/**
- * `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
- * Legacy links may still target it (or carry `from=iframe` during login), but
- * ordinary iframe/Wujie embedding needs the normal full chat workspace.
- * `chatMode=iframe` is the only explicit opt-in for integrations that really
- * implement the Brainy protocol.
+ * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
+ * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
+ * remains on the normal full chat workspace.
*/
function IframeChatEntryRoute({
layoutProps,
@@ -341,9 +419,10 @@ export default function WorkspaceRoutes({
-
-
+
}
/>
-
-
-
-
- }
+ element={}
/>
+
+
+
+ ) : (
+
+ )
+ }
+ />
`${getBackendBaseURL()}/api/position-roundtable`;
+const PRIMARY_API_PATH = "/api/position-roundtable";
+const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
+const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
+
+/**
+ * Some intranet gateways only forward the long-established `/api/multi-agent`
+ * namespace. The position workspace was added later under its own top-level
+ * prefix, so a frontend-only release can work locally but receive FastAPI's
+ * route-level `{"detail":"Not Found"}` after deployment.
+ *
+ * Retry only that exact "route is absent" response against the compatibility
+ * namespace. A domain 404 such as "Position roundtable session not found"
+ * remains a real 404 and is never hidden by this routing fallback.
+ */
+let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null;
+
+function replaceApiPath(url: string, apiPath: string): string {
+ return url.replace(PRIMARY_API_PATH, apiPath);
+}
+
+async function isMissingRoute(response: Response): Promise {
+ if (response.status !== 404) return false;
+ const raw = await response.clone().text().catch(() => "");
+ try {
+ const parsed = JSON.parse(raw) as { detail?: unknown };
+ return parsed?.detail === "Not Found";
+ } catch {
+ const normalized = raw.trim();
+ return (
+ normalized === "Not Found" ||
+ /\s*404 Not Found\s*<\/title>/i.test(normalized)
+ );
+ }
+}
+
+async function positionApiFetch(
+ input: string,
+ init?: RequestInit,
+): Promise {
+ if (resolvedApiPath === COMPAT_API_PATH) {
+ const compatibilityResponse = await apiFetch(
+ replaceApiPath(input, COMPAT_API_PATH),
+ init,
+ );
+ if (!(await isMissingRoute(compatibilityResponse))) {
+ return compatibilityResponse;
+ }
+ // A gateway may be reloaded while the SPA remains open. Re-probe the
+ // canonical route instead of pinning the tab to a now-stale route choice.
+ resolvedApiPath = null;
+ }
+
+ const primaryResponse = await apiFetch(input, init);
+ if (!(await isMissingRoute(primaryResponse))) {
+ resolvedApiPath = PRIMARY_API_PATH;
+ return primaryResponse;
+ }
+
+ const compatibilityResponse = await apiFetch(
+ replaceApiPath(input, COMPAT_API_PATH),
+ init,
+ );
+ if (!(await isMissingRoute(compatibilityResponse))) {
+ resolvedApiPath = COMPAT_API_PATH;
+ return compatibilityResponse;
+ }
+
+ // Preserve the canonical response/error when neither route exists. This
+ // keeps diagnostics stable and identifies an outdated backend release.
+ return primaryResponse;
+}
async function readJson(response: Response, label: string): Promise {
if (!response.ok) {
+ if (await isMissingRoute(response)) {
+ throw new Error(
+ `${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` +
+ `已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`,
+ );
+ }
const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`);
}
@@ -219,7 +295,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
export async function listPositionSessions(externalTaskId?: string): Promise {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson(
- await apiFetch(`${BASE()}/sessions${params}`),
+ await positionApiFetch(`${BASE()}/sessions${params}`),
"list position sessions",
);
return result.map(toSession);
@@ -230,7 +306,7 @@ export async function createPositionSession(input: {
taskSnapshot: Record;
}): Promise {
const result = await readJson(
- await apiFetch(`${BASE()}/sessions`, {
+ await positionApiFetch(`${BASE()}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -244,8 +320,8 @@ export async function createPositionSession(input: {
}
export async function getPositionSession(sessionId: string): Promise {
- const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
- if (response.status === 404) {
+ const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
+ if (response.status === 404 && !(await isMissingRoute(response))) {
// Consume the body so the connection can be reused, then expose a typed
// error that the page can resolve by selecting another history record.
await response.text().catch(() => "");
@@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot(
taskSnapshot: Record,
): Promise {
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }),
@@ -279,7 +355,7 @@ export async function savePositionIntent(
},
): Promise {
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -299,7 +375,7 @@ export async function activatePositionSession(
): Promise {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
@@ -311,7 +387,7 @@ export async function activatePositionSession(
export async function archivePositionSession(sessionId: string): Promise {
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST",
}),
"archive position session",
@@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise {
- const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
+ const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
// DELETE is deliberately idempotent from the UI's perspective. A stale
// history row that is already gone still needs to be removed locally.
- if (response.status === 404) {
+ if (response.status === 404 && !(await isMissingRoute(response))) {
await response.text().catch(() => "");
return;
}
@@ -338,7 +414,7 @@ export async function bindPositionNodeThread(
threadId: string,
): Promise {
const result = await readJson(
- await apiFetch(
+ await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{
method: "POST",
@@ -360,7 +436,7 @@ export async function savePositionNodeConversation(
},
): Promise {
const result = await readJson(
- await apiFetch(
+ await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
{
method: "PUT",
@@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation(
): Promise {
const path = kind === "summary" ? "summary/state" : "action-plan/state";
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +492,7 @@ export async function seedPositionDemoSession(
payload: PositionDemoSeedPayload,
): Promise {
const result = await readJson(
- await apiFetch(`${BASE()}/demo/seed`, {
+ await positionApiFetch(`${BASE()}/demo/seed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -437,7 +513,7 @@ export async function getPositionNodeContext(
nodeKey: string,
): Promise> {
return readJson>(
- await apiFetch(
+ await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" },
),
@@ -456,7 +532,7 @@ export async function completePositionNodeTurn(
): Promise {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson(
- await apiFetch(
+ await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{
method: "POST",
@@ -479,7 +555,7 @@ export async function rejectPositionNode(
input: { reason?: string; title?: string },
): Promise {
const result = await readJson(
- await apiFetch(
+ await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{
method: "POST",
@@ -533,7 +609,7 @@ async function prepareBuiltinRun(
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
@@ -557,7 +633,7 @@ async function completeBuiltinRun(
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
diff --git a/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx b/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx
new file mode 100644
index 00000000..3395bb87
--- /dev/null
+++ b/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx
@@ -0,0 +1,305 @@
+/**
+ * 岗位会商风格的只读业务链流程图。
+ *
+ * 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
+ * 不会读取或写入岗位会商的会话、岗位节点状态。
+ */
+import { useRef, useState } from "react";
+import { CheckCircle2, Circle, Search } from "lucide-react";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
+import { cn } from "@/lib/utils";
+
+const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
+const FLOW_NODE_WIDTH = 184;
+const FLOW_NODE_GAP = 12;
+
+type FlowStage = {
+ goal: string | null;
+ seats: ChainSeat[];
+};
+
+export interface PositionBusinessChainFlowProps {
+ chain: BusinessChain | null;
+ loading?: boolean;
+ className?: string;
+}
+
+function getFlowStages(chain: BusinessChain): FlowStage[] {
+ const seatsById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
+ if (chain.stages?.length) {
+ return chain.stages
+ .map((ids, index) => ({
+ goal: chain.stageGoals?.[index] ?? null,
+ seats: ids
+ .map((id) => seatsById.get(id))
+ .filter((seat): seat is ChainSeat => Boolean(seat)),
+ }))
+ .filter((stage) => stage.seats.length > 0);
+ }
+ return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
+}
+
+export function PositionBusinessChainFlow({
+ chain,
+ loading = false,
+ className,
+}: PositionBusinessChainFlowProps) {
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
业务链条
+
+ {chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
+
+
+
+ {chain && (
+
+ )}
+
+
+ {!chain ? (
+
+ {loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+function BusinessChainCanvas({
+ chain,
+ expanded = false,
+ className,
+}: {
+ chain: BusinessChain;
+ expanded?: boolean;
+ className?: string;
+}) {
+ const [view, setView] = useState({ tx: 0, ty: 0 });
+ const [panning, setPanning] = useState(false);
+ const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
+ const stages = getFlowStages(chain);
+ const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
+ const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
+ let colorIndex = 0;
+
+ const endPan = (event: React.PointerEvent) => {
+ if (!dragRef.current) return;
+ dragRef.current = null;
+ setPanning(false);
+ try {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ } catch {
+ // The browser may have already released the pointer capture.
+ }
+ };
+
+ return (
+ setView({ tx: 0, ty: 0 })}
+ onPointerDown={(event) => {
+ if (event.button !== 0) return;
+ dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
+ setPanning(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ }}
+ onPointerMove={(event) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ setView({ tx: drag.tx + event.clientX - drag.x, ty: drag.ty + event.clientY - drag.y });
+ }}
+ onPointerUp={endPan}
+ onPointerCancel={endPan}
+ >
+
+
+ {stages.map((stage, stageIndex) => {
+ const stageWidth = Math.max(
+ nodeWidth,
+ stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
+ );
+ return (
+
+
+
+ {stage.seats.map((seat) => {
+ const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
+
+function FlowConnector({
+ branches,
+ width,
+ nodeWidth,
+ nodeGap,
+}: {
+ branches: number;
+ width: number;
+ nodeWidth: number;
+ nodeGap: number;
+}) {
+ const connectorStyle = { backgroundColor: "rgba(148,163,184,.88)" };
+ if (branches <= 1) {
+ return (
+
+
+
+ );
+ }
+
+ const nodeCenter = nodeWidth / 2;
+ return (
+
+
+
+ {Array.from({ length: branches }).map((_, index) => (
+
+
+
+ ))}
+
+ );
+}
+
+function FlowNode({
+ title,
+ description,
+ accent,
+ completed = false,
+ width,
+}: {
+ title: string;
+ description: string;
+ accent: string;
+ completed?: boolean;
+ width: number;
+}) {
+ const StatusIcon = completed ? CheckCircle2 : Circle;
+ return (
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ );
+}
diff --git a/frontend-web/src/position-roundtable/lib/positions.ts b/frontend-web/src/position-roundtable/lib/positions.ts
index 68f5feca..1ecf55dc 100644
--- a/frontend-web/src/position-roundtable/lib/positions.ts
+++ b/frontend-web/src/position-roundtable/lib/positions.ts
@@ -1,39 +1,90 @@
/**
- * 岗位协同的第一版岗位目录。
+ * 岗位会商的工作视角目录。
*
- * 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id,
- * 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。
+ * 这不是组织/RBAC 岗位。稳定 id 会写入业务链节点,因此服务端的
+ * 「岗位角色配置」只允许变更展示属性;本模块保留一份内置回退,确保
+ * 后端短暂不可用时历史会商仍能正常展示。
*/
-export const POSITION_OPTIONS = [
+
+export type PositionId = string;
+
+export interface PositionOption {
+ id: PositionId;
+ name: string;
+ description: string;
+ isIntentPosition: boolean;
+ enabled?: boolean;
+ isBuiltin?: boolean;
+ sortOrder?: number;
+}
+
+export const POSITION_OPTIONS: readonly PositionOption[] = [
{
id: "intelligence",
name: "测试分析岗",
description: "识别任务意图、补齐约束并确认业务链条",
isIntentPosition: true,
+ enabled: true,
+ isBuiltin: true,
+ sortOrder: 0,
},
{
id: "planning",
name: "组织测试岗",
description: "承接上游信息并进行组织测试",
isIntentPosition: false,
+ enabled: true,
+ isBuiltin: true,
+ sortOrder: 1,
},
{
id: "solution",
name: "方案设计岗",
description: "承接上游结果并形成方案产物",
isIntentPosition: false,
+ enabled: true,
+ isBuiltin: true,
+ sortOrder: 2,
},
{
id: "evaluation",
name: "综合测试岗",
description: "测试链条产物并提出综合建议",
isIntentPosition: false,
+ enabled: true,
+ isBuiltin: true,
+ sortOrder: 3,
},
-] as const;
-
-export type PositionId = (typeof POSITION_OPTIONS)[number]["id"];
+];
export const DEFAULT_POSITION_ID: PositionId = "intelligence";
+let runtimePositionOptions: readonly PositionOption[] = POSITION_OPTIONS;
+
+/**
+ * 供独立的流程图、产物抽屉等非页面级组件读取当前展示名。
+ * 调用方传入空数组时保留内置回退,避免接口暂时失败导致旧会话显示“未分配岗位”。
+ */
+export function setRuntimePositionOptions(options: readonly PositionOption[]): void {
+ runtimePositionOptions = options.length ? options : POSITION_OPTIONS;
+}
+
+export function getRuntimePositionOptions(): readonly PositionOption[] {
+ return runtimePositionOptions;
+}
+
+export function intentPositionIdFor(options: readonly PositionOption[] = runtimePositionOptions): PositionId {
+ return options.find((position) => position.isIntentPosition)?.id ?? DEFAULT_POSITION_ID;
+}
+
+export function resolvePositionId(
+ value: string | null | undefined,
+ options: readonly PositionOption[] = runtimePositionOptions,
+): PositionId {
+ return options.some((position) => position.id === value)
+ ? String(value)
+ : intentPositionIdFor(options);
+}
+
export const positionNameById = (positionId: string | null | undefined): string =>
- POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位";
+ runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位";
diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx
index 5c60105a..91c774de 100644
--- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx
+++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx
@@ -95,11 +95,15 @@ import {
} from "@/position-roundtable/components/PositionBuiltinAgentChat";
import type { PositionBuiltinAgentSwitchOption } from "@/position-roundtable/components/PositionAgentSwitcher";
import {
- DEFAULT_POSITION_ID,
POSITION_OPTIONS,
+ intentPositionIdFor,
positionNameById,
+ resolvePositionId,
+ setRuntimePositionOptions,
+ type PositionOption,
type PositionId,
} from "@/position-roundtable/lib/positions";
+import { usePositionRoles } from "@/strategy-components/api/position-roles";
import {
activatePositionSession,
completePositionActionPlan,
@@ -139,11 +143,7 @@ import {
isPositionRoundtableStandaloneMode,
} from "@/position-roundtable/demo/position-roundtable-demo";
-/**
- * 第一版先固定岗位,等岗位接口与权限范围确认后只替换常量数据源。
- * 情报分析岗复用原会商 Step 1,承担意图识别。
- */
-const POSITIONS = POSITION_OPTIONS;
+/** 岗位目录来自全局角色配置;意图识别岗复用原会商 Step 1。 */
type WorkspaceTab = "agents" | "results";
type NodeVisualStatus = PositionRoundtableNode["status"] | undefined;
type PositionArtifactPreview = {
@@ -171,12 +171,6 @@ type SandboxThreadSnapshot = {
* URL parameters are externally supplied, so never cast the raw value into
* page state without checking it against the position directory first.
*/
-function resolvePositionId(value: string | null): PositionId {
- return POSITIONS.some((position) => position.id === value)
- ? (value as PositionId)
- : DEFAULT_POSITION_ID;
-}
-
function normalizePreviewArtifactPath(path: string): string {
let normalized = path.trim();
if (normalized.startsWith("write-file:")) {
@@ -319,7 +313,7 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean {
}
function isIntentPositionNode(node: PositionRoundtableNode): boolean {
- return node.positionId === DEFAULT_POSITION_ID;
+ return node.positionId === intentPositionIdFor();
}
function artifactIsMarkdown(artifact: Record): boolean {
@@ -558,9 +552,40 @@ export default function PositionRoundtablePage({
const [searchParams, setSearchParams] = useSearchParams();
const { theme } = useTheme();
const isDark = theme === "dark";
- const positionIdFromUrl = resolvePositionId(searchParams.get("positionId"));
- const [positionId, setPositionId] = useState(() => positionIdFromUrl);
const standaloneDemoMode = isPositionRoundtableStandaloneMode();
+ const { data: configuredPositionRoles = [] } = usePositionRoles({
+ enabled: !standaloneDemoMode,
+ });
+ const configuredPositionOptions = useMemo(
+ () =>
+ configuredPositionRoles.length
+ ? 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],
+ );
+ // The switcher only exposes enabled roles. The complete directory stays in
+ // the runtime cache so archived chains can still resolve a disabled role's
+ // historical display name.
+ const positions = useMemo(
+ () => configuredPositionOptions.filter((position) => position.enabled !== false),
+ [configuredPositionOptions],
+ );
+ // Flowchart, delivery dialog and helper functions outside this component use
+ // the same live directory to resolve position names / the special role.
+ setRuntimePositionOptions(configuredPositionOptions);
+ const intentPositionId = intentPositionIdFor(positions);
+ const positionIdFromUrl = resolvePositionId(searchParams.get("positionId"), positions);
+ const [positionId, setPositionId] = useState(() => positionIdFromUrl);
const demoModeEnabled = isPositionRoundtableDemoEnabled();
const demoTask = useMemo(() => getPositionDemoTask(), []);
const taskIdFromUrl =
@@ -648,14 +673,14 @@ export default function PositionRoundtablePage({
});
const [intentSaving, setIntentSaving] = useState(false);
const [intentCompletedAt, setIntentCompletedAt] = useState("");
- const activePosition = POSITIONS.find((item) => item.id === positionId)!;
+ const activePosition = positions.find((item) => item.id === positionId) ?? positions[0] ?? POSITION_OPTIONS[0]!;
const currentPositionOptions = useMemo(
() =>
- POSITIONS.map((position) => ({
+ positions.map((position) => ({
label: position.name,
value: position.id,
})),
- [],
+ [positions],
);
const currentPositionSelectStyle = useMemo(
() => ({ width: 210, minWidth: 210, flex: "0 0 210px" }),
@@ -1026,7 +1051,7 @@ export default function PositionRoundtablePage({
);
const summaryDone = snapshotStatus(positionSession?.summarySnapshot) === "done";
const actionPlanDone = snapshotStatus(positionSession?.actionPlanSnapshot) === "done";
- const showClosureAgents = positionId === DEFAULT_POSITION_ID;
+ const showClosureAgents = positionId === intentPositionId;
const [activeBuiltinKind, setActiveBuiltinKind] = useState(null);
const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0);
// Stage selection only arms the automatic first question. The key is
@@ -2049,8 +2074,8 @@ export default function PositionRoundtablePage({
(kind: ClosureStageKind) => {
// The closing agents are part of the testing-analysis workspace only;
// ordinary positions keep a single business-chain node conversation.
- if (positionId !== DEFAULT_POSITION_ID) {
- toast.info("收口智能体仅在测试分析岗中开放使用。");
+ if (positionId !== intentPositionId) {
+ toast.info("收口智能体仅在意图识别岗中开放使用。");
return;
}
if (!allPositionDeliveriesDone) {
@@ -2068,7 +2093,7 @@ export default function PositionRoundtablePage({
setActiveBuiltinKind(kind);
setPendingBuiltinAutoStartKind(kind);
},
- [allPositionDeliveriesDone, closeSandbox, positionId, summaryDone],
+ [allPositionDeliveriesDone, closeSandbox, intentPositionId, positionId, summaryDone],
);
const handleSelectPositionNode = useCallback(
(nextNode: PositionRoundtableNode) => {
@@ -2088,9 +2113,9 @@ export default function PositionRoundtablePage({
setActiveBuiltinKind(null);
setSelectedAgentId(null);
setActiveNodeKey(null);
- setRightPanelOpen(positionIdFromUrl === DEFAULT_POSITION_ID);
+ setRightPanelOpen(positionIdFromUrl === intentPositionId);
setLeftPanelCollapsed(false);
- }, [closeSandbox, positionId, positionIdFromUrl]);
+ }, [closeSandbox, intentPositionId, positionId, positionIdFromUrl]);
const handlePositionChange = useCallback(
(value: unknown) => {
// 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表,
@@ -2102,13 +2127,13 @@ export default function PositionRoundtablePage({
setActiveBuiltinKind(null);
setSelectedAgentId(null);
setActiveNodeKey(null);
- setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID);
+ setRightPanelOpen(nextPositionId === intentPositionId);
setLeftPanelCollapsed(false);
const next = new URLSearchParams(searchParamsString);
next.set("positionId", nextPositionId);
setSearchParams(next, { replace: true });
},
- [closeSandbox, searchParamsString, setSearchParams],
+ [closeSandbox, intentPositionId, searchParamsString, setSearchParams],
);
const renderIntentChatPanel = (chainStarted: boolean) => (
@@ -2517,6 +2542,7 @@ export default function PositionRoundtablePage({
{activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && (