页面嵌套修改

This commit is contained in:
董宏凯 committed 2026-07-29 21:53:32 +08:00
1 parent 213dd841b5
commit 4450f342d8
38 files changed
+2020 -309

No files matched your search

@@ -10,6 +10,7 @@ import {
Gauge,
ListTree,
MousePointerClick,
BriefcaseBusiness,
Settings,
ShieldAlert,
ShieldCheck,
@@ -235,6 +236,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
>
<BriefcaseBusiness className="size-4" />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
@@ -540,6 +549,12 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -234,6 +234,14 @@ export function WorkspaceNavMenu() {
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/position-roles")}
>
<BriefcaseIcon />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
+36 -7
View File
@@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env";
*
* Add a new entry here when an external system needs a short ``routeKey``.
* Targets must remain internal SPA routes; LoginPage validates them again
* before navigating.
* before navigating. The optional display settings are converted into the
* existing route parameters on the destination URL.
*/
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = {
a: "/page/canvas/ai-writing",
"8bf": "/page/strategy/qa/8bf-roundtable",
export interface RouteKeyMapping {
/** Internal SPA route to open after sign-in. */
path: string;
/** Add `hideChrome=1` to hide the global header and page sidebar. */
hideChrome?: boolean;
/** Add the existing `theme` route parameter and apply it during sign-in. */
theme?: string;
}
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
a: { path: "/page/canvas/ai-writing" },
"8bf": {
path: "/page/strategy/qa/8bf-roundtable",
hideChrome: true,
theme: "dark-blue",
},
};
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
@@ -26,8 +40,23 @@ export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
export function resolveRouteKeyMapping(
routeKey: string | null | undefined,
): RouteKeyMapping | undefined {
const key = routeKey?.trim();
if (!key) return "";
return ROUTE_KEY_MAPPINGS[key] ?? "";
if (!key) return undefined;
return ROUTE_KEY_MAPPINGS[key];
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const mapping = resolveRouteKeyMapping(routeKey);
if (!mapping) return "";
const [path, ownQuery = ""] = mapping.path.split("?");
const query = new URLSearchParams(ownQuery);
if (mapping.hideChrome) query.set("hideChrome", "1");
if (mapping.theme) query.set("theme", mapping.theme);
const serializedQuery = query.toString();
return serializedQuery ? `${path}?${serializedQuery}` : path;
}
@@ -0,0 +1,24 @@
/**
* Whether the application is currently hosted by another page.
*
* This intentionally covers both a native `<iframe>` and Wujie's child-app
* runtime. Accessing `window.top` is safe across origins for identity
* comparison, but the guard keeps the login/route bootstrap resilient to
* restrictive browser hosts.
*/
export function isEmbeddedHost(): boolean {
if (typeof window === "undefined") return false;
const hostWindow = window as Window & {
__POWERED_BY_WUJIE__?: boolean;
};
if (hostWindow.__POWERED_BY_WUJIE__) return true;
try {
return window.self !== window.top;
} catch {
// Some sandboxed or cross-origin hosts deny top-window access. They are
// still embedded contexts and should use the same safe entry route.
return true;
}
}
@@ -2,13 +2,18 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildStandardChatRedirectPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath,
buildChatsIframeThreadPath,
hasIframePresentationOverride,
isExplicitBrainyIframeMode,
isChatsIframeWorkspacePath,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath,
resolveForcedThemeForPath,
resolveStandardChatFallback,
shouldUseChatsIframeProtocol,
} from "./chats-iframe-route.ts";
test("matches only workspace chats_iframe routes", () => {
@@ -37,7 +42,7 @@ test("forces light theme for landing and chats_iframe routes only", () => {
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
});
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
assert.equal(isExplicitBrainyIframeMode(""), false);
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
@@ -45,34 +50,72 @@ test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
});
test("upgrades legacy iframe-source routes to the iframe protocol", () => {
test("keeps bare from=iframe on the original dedicated iframe protocol", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe",
),
null,
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true);
assert.equal(hasIframePresentationOverride("?from=iframe"), false);
});
test("uses the dedicated AgentSelector route for a bare embedded host", () => {
assert.equal(shouldUseChatsIframeProtocol("", true), true);
assert.equal(
buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"",
true,
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"",
true,
),
"/page/workspace/chats_iframe/thread-123?from=iframe",
);
assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false);
assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false);
assert.equal(
buildStandardChatRedirectPath("?hideChrome=1"),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("routes legacy iframe links with standard presentation flags to full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideHeader=1",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats_iframe/new?from=iframe&hideChrome=1&chatMode=iframe",
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("honors an explicit chat mode over the legacy iframe source", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=full",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?chatMode=full",
);
});
test("treats a blank chat mode as absent for the legacy iframe source", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=%20%20",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false);
assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true);
});
test("redirects chats_iframe routes without the iframe source to standard full chat", () => {
@@ -139,32 +182,53 @@ test("forces the iframe theme on the login route without changing normal login",
),
undefined,
);
assert.equal(
resolveForcedThemeForPath(
"/login",
"?from=iframe&hideChrome=1&theme=dark-blue",
),
undefined,
);
});
test("preserves the iframe theme in the post-login redirect", () => {
test("does not add chatMode=iframe to the legacy post-login redirect", () => {
assert.equal(
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
buildChatsIframeRedirectPath("?from=iframe"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
buildChatsIframeRedirectPath(
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
});
test("keeps the iframe redirect ahead of login-method fallbacks", () => {
test("uses full chat after login when a legacy iframe link sets presentation", () => {
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&theme=dark-blue&yUserId=user-1",
"/page/workspace/chats/new",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&hideChrome=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&embed=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
@@ -179,7 +243,40 @@ test("preserves iframe theme when a new chat becomes a thread", () => {
"thread-123",
"?from=iframe&theme=dark-blue&ticket=secret",
),
"/page/workspace/chats_iframe/thread-123?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
);
});
test("normalizes direct regular-chat legacy links according to the same split", () => {
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe",
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
});
test("builds a standard full-chat URL without leaking the legacy marker", () => {
assert.equal(
buildStandardChatRedirectPath(
"?from=iframe&hideHeader=1&ticket=secret",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
});
@@ -2,15 +2,111 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** Keep the accepted URL booleans aligned with embed-session.ts. */
function isEnabledPresentationParam(search: string, key: string): boolean {
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function isChatsIframeLogin(search: string): boolean {
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
}
function isExplicitFullChatMode(search: string): boolean {
return (
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
);
}
/**
* A bare login URL embedded by an external host has no query parameter to
* survive the login redirect. Add the legacy source marker before building
* the private chat URL so refreshes and subsequent thread navigation retain
* the selected dedicated iframe workspace.
*/
export function buildEmbeddedIframeRedirectPath(search: string): string {
const source = toSearchParams(search);
if (!source.has("from")) {
source.set("from", IFRAME_LOGIN_SOURCE);
}
return buildChatsIframeRedirectPath(source.toString());
}
/**
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
* the two concerns separate: this only answers whether the caller explicitly
* asked PageLayout to alter global chrome, not whether it is an iframe page.
*/
export function hasIframePresentationOverride(search: string): boolean {
return (
isEnabledPresentationParam(search, "hideHeader") ||
isEnabledPresentationParam(search, "hideChrome") ||
isEnabledPresentationParam(search, "embed")
);
}
/**
* The private `chats_iframe` transport is the legacy default for
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
* that also supplies one of the standard presentation flags deliberately uses
* the ordinary full chat workspace instead.
*/
export function shouldUseChatsIframeProtocol(
search: string,
embeddedHost = false,
): boolean {
return (
!hasIframePresentationOverride(search) &&
(isExplicitBrainyIframeMode(search) ||
isChatsIframeLogin(search) ||
// The historical bare iframe integration enters `/login/admin` without
// any route parameters. Treat that embedded context as the legacy
// dedicated AgentSelector page, unless the caller explicitly selected
// the standard full-chat mode.
(embeddedHost && !isExplicitFullChatMode(search)))
);
}
/**
* Build the regular full-chat destination for a legacy iframe entry that has
* opted into a standard presentation mode. Keeping the display flags in the
* address bar makes direct links and refreshes deterministic; `from` is
* intentionally omitted so the legacy protocol cannot take over again.
*/
export function buildStandardChatRedirectPath(
search: string,
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
): string {
const source = toSearchParams(search);
const [pathname, existingQuery = ""] = fallbackPath.split("?");
const target = new URLSearchParams(existingQuery);
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
const value = source.get(key)?.trim();
if (value && !target.has(key)) {
target.set(key, value);
}
}
// `theme` is meaningful only for embed mode, but preserving it here keeps a
// refreshed embedded page on the same forced theme before session storage is
// restored.
const theme = source.get("theme")?.trim();
if (theme && target.get("embed")) {
target.set("theme", theme);
}
target.set("chatMode", "full");
const query = target.toString();
return query ? `${pathname}?${query}` : pathname;
}
/**
* Preserve only the presentation parameters consumed by the iframe workspace.
* Login credentials must never be copied into the post-login address bar.
@@ -24,13 +120,11 @@ export function buildChatsIframeRedirectPath(search: string): string {
}
const requestedMode = source.get("chatMode")?.trim();
if (requestedMode) {
if (
requestedMode?.toLowerCase() === "iframe" ||
(!isChatsIframeLogin(search) && requestedMode)
) {
target.set("chatMode", requestedMode);
} else if (isChatsIframeLogin(search)) {
// Plugin links historically identify this protocol with `from=iframe`.
// Normalize that legacy marker into the explicit mode before handing the
// URL to the workspace route, so it does not fall back to the full chat.
target.set("chatMode", "iframe");
}
const theme = source.get("theme")?.trim();
@@ -57,8 +151,12 @@ export function resolvePostLoginRedirectPath(
search: string,
fallbackPath: string,
): string {
return isChatsIframeLogin(search)
? buildChatsIframeRedirectPath(search)
if (shouldUseChatsIframeProtocol(search)) {
return buildChatsIframeRedirectPath(search);
}
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
? buildStandardChatRedirectPath(search, fallbackPath)
: fallbackPath;
}
@@ -69,13 +167,88 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean {
);
}
/**
* A `from=iframe` link can arrive directly at a regular chat URL instead of
* passing through LoginPage. Normalize both entry paths to the same dedicated
* protocol workspace, including an existing thread id when one is supplied.
*/
export function resolveChatsIframeEntryRedirect(
pathname: string,
search: string,
embeddedHost = false,
): string | null {
const isStandardChatPath =
pathname === STANDARD_CHATS_PATH ||
pathname === `${STANDARD_CHATS_PATH}/` ||
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
// Standard presentation flags deliberately bypass the private iframe
// protocol. Normalize direct legacy URLs once so the result stays on the
// normal full-chat workspace and retains its agent/history sidebar.
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return isStandardChatPath
? buildStandardChatRedirectPath(search, pathname)
: null;
}
const inferredEmbeddedProtocol =
embeddedHost &&
!hasIframePresentationOverride(search) &&
!isExplicitFullChatMode(search) &&
!isChatsIframeLogin(search) &&
!isExplicitBrainyIframeMode(search);
const protocolSearch = inferredEmbeddedProtocol
? `?${new URLSearchParams({
...Object.fromEntries(toSearchParams(search)),
from: IFRAME_LOGIN_SOURCE,
}).toString()}`
: search;
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
protocolSearch,
embeddedHost,
);
if (!requestsIframeProtocol) {
return null;
}
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
return buildChatsIframeRedirectPath(protocolSearch);
}
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
return null;
}
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
if (!routeSuffix || routeSuffix.includes("/")) {
return null;
}
if (routeSuffix === "new") {
return buildChatsIframeRedirectPath(protocolSearch);
}
try {
return buildChatsIframeThreadPath(
"/page/workspace",
decodeURIComponent(routeSuffix),
protocolSearch,
);
} catch {
return null;
}
}
/**
* The lightweight iframe page is a private transport for the Brainy
* postMessage protocol, not a general-purpose embedded chat page.
*
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
* iframes and micro-frontend containers from accidentally landing on the
* protocol-only page.
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
* iframe/Wujie embedding without either marker remains standard chat.
*/
export function isExplicitBrainyIframeMode(search: string): boolean {
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
@@ -95,25 +268,32 @@ export function resolveStandardChatFallback(
pathname: string,
search: string,
): string | null {
if (!isChatsIframeWorkspacePath(pathname) || isExplicitBrainyIframeMode(search)) {
if (!isChatsIframeWorkspacePath(pathname)) {
return null;
}
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
const params = new URLSearchParams(normalizedSearch);
const requestedMode = params.get("chatMode")?.trim();
if (isChatsIframeLogin(search) && !requestedMode) {
params.set("chatMode", "iframe");
const targetSearch = params.toString();
return targetSearch ? `${pathname}?${targetSearch}` : pathname;
}
const routePrefix = "/page/workspace/chats_iframe/";
const routeSuffix = pathname.slice(routePrefix.length);
const targetPath =
routeSuffix && routeSuffix !== "new"
? `/page/workspace/chats/${routeSuffix}`
: "/page/workspace/chats/new";
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return buildStandardChatRedirectPath(search, targetPath);
}
if (shouldUseChatsIframeProtocol(search)) {
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
// is an implementation detail and adding it caused a self-redirect loop.
return null;
}
params.delete("from");
params.set("chatMode", "full");
const targetSearch = params.toString();
@@ -131,7 +311,7 @@ export function resolveForcedThemeForPath(
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if (
isChatsIframeWorkspacePath(pathname) ||
(isLoginPath && isChatsIframeLogin(search))
(isLoginPath && shouldUseChatsIframeProtocol(search))
) {
const rawTheme = toSearchParams(search).get("theme")?.trim();
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
@@ -270,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-position-roles",
label: "岗位角色配置",
icon: Briefcase,
path: "/page/strategy/admin/position-roles",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -26,7 +26,9 @@ const BUSINESS_CODE = "8BF";
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin";
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
@@ -46,7 +48,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
try {
const [mappings, availableChains] = await Promise.all([
listBusinessMappings(),
listChains(isAdmin ? "all" : "public"),
listChains("public"),
]);
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
@@ -59,15 +61,15 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally {
setIsLoading(false);
}
}, [isAdmin]);
}, []);
useEffect(() => {
void loadConfiguration();
}, [loadConfiguration]);
const handleSave = useCallback(async () => {
if (!isAdmin) {
setError("仅管理员可以配置 8BF 业务链条");
if (!canConfigureChain) {
setError("仅 lqq 账号可以配置 8BF 业务链条");
return;
}
const selected = chains.find((chain) => chain.id === selectedChainId);
@@ -90,11 +92,11 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally {
setIsSaving(false);
}
}, [chains, isAdmin, selectedChainId]);
}, [canConfigureChain, chains, selectedChainId]);
const toolbarSlot = (
<>
{isAdmin && (
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
@@ -104,7 +106,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
配置业务链
</button>
)}
{isAdmin && isSettingsOpen && (
{canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
className="eightbf-roundtable__settings-dialog"
+7
View File
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
return readRuntimeEnv(key) === "true";
}
/**
* Read an integer setting supplied by ``runtime-config.js``.
*
* Runtime configuration is editable after the frontend is built, so validate
* numeric values here instead of letting an invalid deployment value bypass a
* UI constraint that the backend cannot accept.
*/
export const env = {
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
+8 -96
View File
@@ -2,14 +2,12 @@
import {
BookMarkedIcon,
BotIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
type BrainyChatSubmitMessage,
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() {
const routeBase = useRouteBase();
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
useState<PendingIframeSubmit | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
// The protocol page must still be escapable when no curated selector list
// has been configured. Include the user's own and public agents in this
// page's native fallback picker. A native select also remains interactive
// in iframe/micro-frontend environments where a portal dialog can fail.
const { agents: mineAgents } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 100,
});
const { agents: squareAgents } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 100,
});
const fallbackAgents = useMemo(() => {
const uniqueAgents = new Map<
string,
(typeof selectorAgents)[number]
>();
for (const agent of [
...selectorAgents,
...mineAgents,
...squareAgents,
]) {
uniqueAgents.set(agent.id, agent);
}
return [...uniqueAgents.values()];
}, [mineAgents, selectorAgents, squareAgents]);
const { data: pluginManagementSettings } = usePluginManagementSettings();
const pendingRequestIdRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
saveLastAgentId(agentId);
}, []);
/**
* Manual fallback for accidental protocol-page visits.
*
* A real Brainy integration drives this page with postMessage. A human who
* reaches it directly should not be trapped here: move them to the standard
* chat workspace, optionally opening the selected agent.
*/
const enterStandardChat = useCallback(
(agentId?: string) => {
const params = new URLSearchParams(location.search);
params.delete("from");
params.set("chatMode", "full");
params.delete("agent");
if (agentId) {
params.set("agent", agentId);
saveLastAgentId(agentId);
}
navigate(`${routeBase}/chats/new?${params.toString()}`, {
replace: true,
});
},
[location.search, navigate, routeBase],
);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
</div>
</div>
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div>
<div className="mt-2 text-sm text-muted-foreground">
选择智能体进入问答
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
<BotIcon className="mr-2 size-3.5 shrink-0" />
<select
aria-label="选择智能体并进入问答"
defaultValue=""
disabled={isUploading || selectorLoading}
onChange={(event) => {
const agentId = event.target.value;
if (agentId) enterStandardChat(agentId);
}}
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
>
<option value="" disabled>
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
</option>
{fallbackAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{desensitize(agent.name)}
</option>
))}
</select>
<span
aria-hidden
className="pointer-events-none absolute right-3 text-[10px]"
>
▾
</span>
</label>
</div>
{!selectorLoading && fallbackAgents.length === 0 && (
<div className="mt-3 text-xs text-muted-foreground">
暂无可选智能体
</div>
)}
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
+81 -17
View File
@@ -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(() => {
@@ -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<PositionRole[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(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 <Navigate to="/page/workspace/chats/new" replace />;
const update = (id: string, patch: Partial<PositionRole>) =>
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 (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
<header className="border-border/70 flex flex-wrap items-center justify-between gap-4 border-b bg-gradient-to-r from-[var(--color-main,#0062D9)]/[0.08] via-background to-background px-6 py-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-main,#0062D9)]/10 text-[var(--color-main,#0062D9)]">
<BriefcaseBusiness className="h-5 w-5" />
</div>
<div className="min-w-0">
<h1 className="text-foreground text-lg font-semibold tracking-tight">岗位角色配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理岗位多智能体会商中的岗位名称、职责、性质、启停与顺序;不影响组织岗位、成员或权限。
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setRows((current) => [...current, makeNewRole(current.length)])}>
<Plus className="h-4 w-4" /> 添加岗位
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending || isLoading}>
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{save.isPending ? "保存中…" : "保存配置"}
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
<section className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ListChecks className="size-4 text-[var(--color-main,#0062D9)]" />已启用岗位</div>
<div className="mt-2 flex items-baseline gap-1"><strong className="text-2xl font-semibold text-foreground">{enabledCount}</strong><span className="text-sm text-muted-foreground">个</span></div>
</div>
<div className="rounded-md border border-[var(--color-main,#0062D9)]/30 bg-[var(--color-main,#0062D9)]/[0.05] px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-[var(--color-main,#0062D9)]"><CircleDot className="size-4" />会商入口岗位</div>
<div className="mt-2 truncate text-base font-semibold text-foreground">{intentRole?.name ?? "尚未配置"}</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">意图识别与收口智能体入口</p>
</div>
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ShieldCheck className="size-4 text-emerald-600 dark:text-emerald-400" />内置岗位</div>
<div className="mt-2 text-base font-semibold text-foreground">{rows.filter((row) => row.isBuiltin).length} 个稳定席位</div>
<p className="mt-0.5 text-xs text-muted-foreground">可改名、调整性质或停用,不可删除</p>
</div>
</section>
{usingFallback && (
<section className="flex items-start justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
<div className="flex gap-2.5 text-amber-900 dark:text-amber-200">
<Info className="mt-0.5 size-4 shrink-0" />
<p>当前显示的是内置岗位兜底数据。后端接口尚未返回目录;待后端升级并启动后,系统会自动把这四个岗位写入数据库。</p>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void refetch()}>重新连接</Button>
</section>
)}
<section className="border-border/70 bg-muted/35 flex gap-3 rounded-md border px-4 py-3 text-sm">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-main,#0062D9)]" />
<div className="text-muted-foreground leading-6">
<span className="text-foreground font-medium">意图识别岗</span>
是会商入口:它负责任务意图识别,并显示“方案总结”“行动规划”两个收口智能体。系统必须保留且仅保留一个启用的意图识别岗。
内置岗位的稳定标识用于兼容历史业务链,不能删除,但可改名、调整性质或停用。
</div>
</section>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed py-14 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在加载岗位角色配置…
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed py-14 text-center text-sm">
<p className="text-destructive">{error instanceof Error ? error.message : "岗位角色配置加载失败"}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>重新加载</Button>
</div>
) : (
<section className="rounded-md border border-border/70 bg-card p-4">
<div className="mb-4 flex flex-wrap items-end justify-between gap-2 border-b border-border/70 pb-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground"><Bot className="size-4 text-[var(--color-main,#0062D9)]" />岗位目录</h2>
<p className="mt-1 text-xs text-muted-foreground">拖动前可先用上下箭头调整顺序;保存后会立即影响岗位会商的选择与流程。</p>
</div>
<span className="text-xs text-muted-foreground">共 {rows.length} 个岗位</span>
</div>
<div className="grid gap-3">
{rows.map((row, index) => (
<article key={row.id} className={cn("grid grid-cols-[2.5rem_minmax(0,1fr)_11rem_8rem_auto] items-start gap-4 rounded-md border p-4 transition-colors max-lg:grid-cols-[2.5rem_minmax(0,1fr)_11rem] max-md:grid-cols-1 max-md:gap-3", row.roleType === "intent" ? "border-[var(--color-main,#0062D9)]/45 bg-[var(--color-main,#0062D9)]/[0.045]" : "border-border/70 bg-background hover:border-[var(--color-main,#0062D9)]/30")}>
<div className={cn("flex size-10 items-center justify-center rounded-md", row.roleType === "intent" ? "bg-[var(--color-main,#0062D9)]/15 text-[var(--color-main,#0062D9)]" : "bg-muted text-muted-foreground")}>
{row.roleType === "intent" ? <CircleDot className="size-5" /> : <BriefcaseBusiness className="size-5" />}
</div>
<div className="min-w-0 space-y-2.5">
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor={`role-name-${row.id}`}>岗位名称</label>
<Input
id={`role-name-${row.id}`}
value={row.name}
onChange={(event) => update(row.id, { name: event.target.value })}
maxLength={128}
className="font-medium"
placeholder="岗位名称"
/>
{row.isBuiltin && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">内置</span>}
</div>
<label className="sr-only" htmlFor={`role-description-${row.id}`}>岗位职责说明</label>
<Input
id={`role-description-${row.id}`}
value={row.description}
onChange={(event) => update(row.id, { description: event.target.value })}
maxLength={512}
className="text-sm"
placeholder="该岗位在会商流程中的职责说明"
/>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">岗位标识:</span>
{row.isBuiltin ? (
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px]">{row.id}</code>
) : (
<Input
aria-label={`${row.name || "新岗位"}的岗位标识`}
value={row.id}
onChange={(event) => update(row.id, { id: event.target.value })}
maxLength={128}
className="h-7 max-w-xs font-mono text-xs"
/>
)}
</div>
</div>
<div className="space-y-1.5 max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)] max-md:items-center">
<span className="text-xs text-muted-foreground md:hidden">岗位性质</span>
<Select value={row.roleType} onValueChange={(value) => changeRoleType(row.id, value as PositionRoleType)}>
<SelectTrigger aria-label={`${row.name || "岗位"}的岗位性质`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">普通执行岗</SelectItem>
<SelectItem value="intent">意图识别岗(特殊)</SelectItem>
</SelectContent>
</Select>
{row.roleType === "intent" && (
<p className="text-[11px] leading-4 text-[var(--color-main,#0062D9)]">会商入口与收口智能体</p>
)}
</div>
<div className="flex min-h-9 items-center gap-2 max-lg:col-start-2 max-md:col-auto max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-xs text-muted-foreground md:hidden">启用状态</span>
<Switch checked={row.enabled} onCheckedChange={(checked) => toggleEnabled(row.id, checked)} aria-label={`${row.name || "岗位"}${row.enabled ? "已启用" : "已停用"}`} />
<span className={cn("text-xs", row.enabled ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{row.enabled ? "已启用" : "已停用"}
</span>
</div>
<div className="flex items-center justify-end gap-1 max-lg:col-start-3 max-md:col-auto max-md:justify-start">
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === 0} onClick={() => move(row.id, "up")} aria-label={`上移 ${row.name}`}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === rows.length - 1} onClick={() => move(row.id, "down")} aria-label={`下移 ${row.name}`}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" disabled={row.isBuiltin} onClick={() => remove(row)} aria-label={`删除 ${row.name}`} title={row.isBuiltin ? "内置岗位不能删除,可停用" : "删除岗位"}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</article>
))}
</div>
<footer className="border-t border-border/70 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-[-2px] text-emerald-600 dark:text-emerald-400" />
当前已启用 {rows.filter((row) => row.enabled).length} 个岗位,其中意图识别岗:{rows.find((row) => row.id === enabledIntentId)?.name ?? "未配置"}。
</footer>
</section>
)}
</div>
</div>
</div>
);
}
+11
View File
@@ -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() {
)
}
/>
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === "admin" ? (
<PositionRolesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/login-whitelist"
element={
+108 -23
View File
@@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route';
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
@@ -240,13 +245,20 @@ function NewChatEntryRoute({
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
// Do not infer the chat experience from window.self !== window.top here.
// A generic `/chats/new` deep link must render the same standard chat both
// top-level and inside an iframe. The dedicated iframe protocol page remains
// available only through its explicit `/chats_iframe/...` route (LoginPage
// selects it for `from=iframe`). Otherwise a normal deep link was silently
// redirected to IframeChatPage, whose new-thread state intentionally has no
// manually selected agent and therefore stopped at "选择 Agent".
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
// 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 (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
@@ -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 <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={compactChrome} />
</WorkspaceLayout>
);
}
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 <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
/**
* `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({
<Route
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} />
</WorkspaceLayout>
<ChatsIndexEntryRoute
layoutProps={layoutProps}
compactChrome={headerHidden && !compactActionsHidden}
/>
}
/>
<Route
@@ -352,13 +431,7 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="chats_iframe/new"
@@ -665,6 +738,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -207,7 +207,7 @@ src/position-roundtable/demo/position-roundtable-full.json
## 9. 开发与排查清单
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。
1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
@@ -226,3 +226,11 @@ pnpm build
```powershell
git diff --check
```
## 11. 岗位角色配置
岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐
`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、
启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并
显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用;
自定义岗位可以新增、排序和删除。
@@ -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 ?? "未分配岗位";
@@ -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<string, unknown>): 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<PositionId>(() => positionIdFromUrl);
const standaloneDemoMode = isPositionRoundtableStandaloneMode();
const { data: configuredPositionRoles = [] } = usePositionRoles({
enabled: !standaloneDemoMode,
});
const configuredPositionOptions = useMemo<PositionOption[]>(
() =>
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<PositionId>(() => 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<PositionBuiltinAgentKind | null>(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) => (
<div className="position-roundtable-intent-page contents">
@@ -2517,6 +2542,7 @@ export default function PositionRoundtablePage({
{activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && (
<aside className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md">
<RightWorkspace
positions={configuredPositionOptions}
selectedAgent={selectedAgent}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
@@ -2561,6 +2587,7 @@ export default function PositionRoundtablePage({
>
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[#032650] dark:text-[#c5cfe0]">
<RightWorkspace
positions={configuredPositionOptions}
selectedAgent={selectedAgent}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
@@ -3348,7 +3375,7 @@ function PositionDeliveryOverview({
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt: intentCompletedAt || session?.updatedAt,
status: intent ? "done" : "ready",
accent: "#8b5cf6",
@@ -4644,6 +4671,7 @@ function CompactChainNode({
}
function RightWorkspace({
positions,
selectedAgent,
selectedAgentId,
onSelectAgent,
@@ -4665,6 +4693,7 @@ function RightWorkspace({
onTabChange,
standaloneDemo = false,
}: {
positions: readonly PositionOption[];
selectedAgent: Agent | null;
selectedAgentId: string | null;
onSelectAgent: (agentId: string) => void;
@@ -4782,6 +4811,7 @@ function RightWorkspace({
) : (
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<AnalysisResults
positions={positions}
intentObjective={intentObjective}
intentCompletedAt={intentCompletedAt}
nodes={nodes}
@@ -5098,7 +5128,7 @@ function buildIntentResultItem(
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt,
status: intent ? "done" : "ready",
};
@@ -5207,6 +5237,7 @@ function buildBuiltinResultItem(
}
function AnalysisResults({
positions,
intentObjective,
intentCompletedAt,
nodes,
@@ -5217,6 +5248,7 @@ function AnalysisResults({
onOpenBuiltinChat,
readOnly = false,
}: {
positions: readonly PositionOption[];
intentObjective?: string;
intentCompletedAt: string;
nodes: PositionRoundtableNode[];
@@ -5258,7 +5290,7 @@ function AnalysisResults({
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intentObjective,
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt: undefined,
status: "done",
});
@@ -5410,11 +5442,11 @@ function AnalysisResults({
const positionOptions = useMemo(
() => [
{ label: "全部岗位", value: "all" },
...POSITIONS.filter((position) => resultItems.some((item) => item.positionId === position.id)).map(
...positions.filter((position) => resultItems.some((item) => item.positionId === position.id)).map(
(position) => ({ label: position.name, value: position.id }),
),
],
[resultItems],
[positions, resultItems],
);
const nodeOptions = useMemo(
() => [
@@ -142,9 +142,9 @@ export interface SeatSelectionResult {
gatherFirst?: boolean;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
/** 业务链条席位数硬约束,必须与后端持久化校验保持一致。 */
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
export const CHAIN_MAX_SEATS = 20;
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
@@ -0,0 +1,181 @@
/** 岗位角色配置 API:岗位会商工作视角,不等同于组织岗位/RBAC。 */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
export type PositionRoleType = "standard" | "intent";
export interface PositionRole {
id: string;
name: string;
description: string;
roleType: PositionRoleType;
enabled: boolean;
isBuiltin: boolean;
sortOrder: number;
updatedBy?: string | null;
updatedAt?: string | null;
/** True only when the API is unavailable and the UI is showing built-ins. */
isFallback?: boolean;
}
interface ServerPositionRole {
id: string;
name: string;
description: string;
role_type: PositionRoleType;
enabled: boolean;
is_builtin: boolean;
sort_order: number;
updated_by?: string | null;
updated_at?: string | null;
}
const BASE = () => `${getBackendBaseURL()}/api/position-roles`;
export const POSITION_ROLES_QUERY_KEY = ["position-roles"] as const;
/**
* Safe first-run directory for the position-roundtable workbench.
*
* The backend persists exactly the same values in ``position_roles``. Keeping
* this client copy means an old/offline gateway cannot turn the administrator
* page or the roundtable selector into an empty screen during deployment.
*/
export const DEFAULT_POSITION_ROLES: readonly PositionRole[] = [
{
id: "intelligence",
name: "测试分析岗",
description: "识别任务意图、补齐约束并确认业务链条",
roleType: "intent",
enabled: true,
isBuiltin: true,
sortOrder: 0,
},
{
id: "planning",
name: "组织测试岗",
description: "承接上游信息并进行组织测试",
roleType: "standard",
enabled: true,
isBuiltin: true,
sortOrder: 1,
},
{
id: "solution",
name: "方案设计岗",
description: "承接上游结果并形成方案产物",
roleType: "standard",
enabled: true,
isBuiltin: true,
sortOrder: 2,
},
{
id: "evaluation",
name: "综合测试岗",
description: "测试链条产物并提出综合建议",
roleType: "standard",
enabled: true,
isBuiltin: true,
sortOrder: 3,
},
];
function defaultRoles(): PositionRole[] {
return DEFAULT_POSITION_ROLES.map((role) => ({ ...role, isFallback: true }));
}
function fromServer(row: ServerPositionRole): PositionRole {
return {
id: row.id,
name: row.name ?? "",
description: row.description ?? "",
roleType: row.role_type ?? "standard",
enabled: row.enabled ?? true,
isBuiltin: row.is_builtin ?? false,
sortOrder: row.sort_order ?? 0,
updatedBy: row.updated_by ?? null,
updatedAt: row.updated_at ?? null,
};
}
function toServer(row: PositionRole): ServerPositionRole {
return {
id: row.id,
name: row.name,
description: row.description,
role_type: row.roleType,
enabled: row.enabled,
is_builtin: row.isBuiltin,
sort_order: row.sortOrder,
};
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const detail =
payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail)
: `${context} failed (${response.status})`;
throw new Error(detail);
}
return (await response.json()) as T;
}
export async function listPositionRoles(): Promise<PositionRole[]> {
try {
const response = await apiFetch(BASE(), { method: "GET" });
const payload = await readJson<{ roles: ServerPositionRole[] }>(response, "list position roles");
const roles = (payload.roles ?? []).map(fromServer);
// An empty directory is never valid: it means an old gateway is running
// or the first-run seed has not completed yet. Keep the page usable.
return roles.length > 0 ? roles : defaultRoles();
} catch {
// Do not leave administrators with an empty configuration screen when an
// older deployment has not received the new router/migration yet.
return defaultRoles();
}
}
export async function replacePositionRoles(roles: PositionRole[]): Promise<PositionRole[]> {
const response = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roles: roles.map(toServer) }),
});
const payload = await readJson<{ roles: ServerPositionRole[] }>(response, "save position roles");
const saved = (payload.roles ?? []).map(fromServer);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-岗位角色配置-保存",
url: "/api/position-roles",
reqParam: { roles },
returnParam: saved,
});
return saved;
}
export function usePositionRoles(options?: { enabled?: boolean }) {
return useQuery({
queryKey: POSITION_ROLES_QUERY_KEY,
queryFn: listPositionRoles,
staleTime: 30_000,
refetchOnWindowFocus: false,
enabled: options?.enabled ?? true,
});
}
export function usePositionRoleActions() {
const queryClient = useQueryClient();
return {
save: useMutation({
mutationFn: replacePositionRoles,
onSuccess: (roles) => {
queryClient.setQueryData(POSITION_ROLES_QUERY_KEY, roles);
},
}),
};
}