你好
-
- 选择智能体进入问答
+
-
-
-
- {!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={}
/>
+
+
+
+ ) : (
+
+ )
+ }
+ />
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 && (