页面嵌套修改

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);
},
}),
};
}
@@ -123,6 +123,11 @@ cascade pragmas. The router is mounted canonically below
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
Clients may retry the compatibility route only for a framework/proxy-level
`404 Not Found`; domain-level missing-session responses must remain real 404s.
`/api/intent/init` must create the empty Step-1 thread through
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
do not restore the old HTTP loopback to `/api/threads`, because deployed
Gateway ports/proxies may make the automatic page initialization fail while
later manual turns still work. Streaming stays on the existing loopback path.
### Harness / App Split
@@ -407,6 +412,10 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
### Business-mapping authorization
`/api/business-mapping` remains globally readable. Updates to 3Q, 6BF, and 7BF require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`.
### Position-collaboration foundation
The position-collaboration workspace is a separate frontend route and must not
@@ -1093,6 +1102,18 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi
## Documentation
### Position-roundtable role directory
`position_roles` is the global shared directory for position-roundtable work
views. It is deliberately separate from organisation/RBAC `positions`.
`app.gateway.deps.langgraph_runtime()` seeds the four stable built-in ids from
`app/gateway/position_role_defaults.py` via `ensure_seed()` (missing rows only;
never overwrite operator edits). The API is `GET/PUT /api/position-roles`;
writes are admin-only. Exactly one enabled `intent` role is required and owns
the initial intent workspace plus the summary/action-plan closing agents. Do
not delete a built-in id because historical chain nodes persist those ids;
custom role ids may be added and removed.
See `docs/` directory for detailed documentation:
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
+20 -1
View File
@@ -124,6 +124,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
@@ -478,7 +479,25 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
Its live intent conversation still uses the shared `/api/intent/init` and
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
Only the recovery snapshot layer is position-specific. Deployments must expose
`/api/intent/init` creates its empty thread directly through the in-process
thread store/checkpointer, matching multi-agent initialization and avoiding a
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. Only the recovery
snapshot layer is position-specific. Deployments must expose
either `/api/position-roundtable/*` or the compatibility mirror
`/api/multi-agent/position-roundtable/*`; updated clients probe both and only
retry when the first response is the framework/proxy-level `404 Not Found`.
## Position-roundtable role directory
The position-roundtable work views are persisted separately from organisation
positions/RBAC in `position_roles`. Gateway startup idempotently inserts the
four built-in ids (testing analysis, organisation test, solution design and
comprehensive test), but only when an id is missing: administrator changes to
the name, order, enabled state or role type are never overwritten.
`GET /api/position-roles` is consumed by the workbench and `PUT
/api/position-roles` is administrator-only. Exactly one enabled `intent` role
is required. It is the initial intent-identification workspace and owns the
summary/action-plan closing agents. Built-in rows retain stable ids so
historical chain nodes remain readable; they can be edited or disabled but not
deleted. Custom roles may be freely added or removed.
@@ -57,6 +57,7 @@ from app.gateway.routers import (
parallel_agents,
position_roundtable,
positions,
position_roles,
public_config,
public_embed,
public_leaderboard,
@@ -1485,7 +1486,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
)
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
app.include_router(business_mapping.router)
# Position-roundtable work-view role configuration at /api/position-roles.
app.include_router(position_roles.router)
# Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router)
@@ -264,6 +264,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -0,0 +1,47 @@
"""Built-in roles for the position-roundtable workbench.
These ids are part of the persisted business-chain contract. Administrators
may rename, reorder or disable the roles, but a startup seed only inserts
missing entries and therefore never overwrites production configuration.
"""
from __future__ import annotations
POSITION_ROLE_SEED: list[dict[str, object]] = [
{
"id": "intelligence",
"name": "测试分析岗",
"description": "识别任务意图、补齐约束并确认业务链条",
"role_type": "intent",
"enabled": True,
"is_builtin": True,
"sort_order": 0,
},
{
"id": "planning",
"name": "组织测试岗",
"description": "承接上游信息并进行组织测试",
"role_type": "standard",
"enabled": True,
"is_builtin": True,
"sort_order": 1,
},
{
"id": "solution",
"name": "方案设计岗",
"description": "承接上游结果并形成方案产物",
"role_type": "standard",
"enabled": True,
"is_builtin": True,
"sort_order": 2,
},
{
"id": "evaluation",
"name": "综合测试岗",
"description": "测试链条产物并提出综合建议",
"role_type": "standard",
"enabled": True,
"is_builtin": True,
"sort_order": 3,
},
]
@@ -26,6 +26,9 @@ from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
_EIGHTBF_BUSINESS_CODE = "8BF"
_EIGHTBF_MANAGER_USERNAME = "lqq"
# ── schemas ────────────────────────────────────────────────────────────────
@@ -70,6 +73,20 @@ def _is_admin(request: Request) -> bool:
return user is None or getattr(user, "system_role", None) == "admin"
def _username_of(request: Request) -> str:
"""Read the trusted login account name, not an externally supplied username."""
user = getattr(request.state, "user", None)
email = str(getattr(user, "email", "") or "")
return email.split("@", 1)[0].strip().lower()
def _can_update_mapping(request: Request, business_code: str) -> bool:
"""8BF's public chain is maintained exclusively by the lqq account."""
if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE:
return _username_of(request) == _EIGHTBF_MANAGER_USERNAME
return _is_admin(request)
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
@@ -119,17 +136,14 @@ async def _validate_public_chain_mapping(
async def list_mappings(request: Request) -> BusinessMappingListResponse:
store = _get_store(request)
rows = await store.list_mappings()
return BusinessMappingListResponse(
mappings=[BusinessMappingResponse(**r) for r in rows]
)
return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows])
@router.put("/{business_code}", response_model=BusinessMappingResponse)
async def update_mapping(
request: Request, business_code: str, body: BusinessMappingUpdateRequest
) -> BusinessMappingResponse:
if not _is_admin(request):
raise HTTPException(status_code=403, detail="Only administrators can update business mappings")
async def update_mapping(request: Request, business_code: str, body: BusinessMappingUpdateRequest) -> BusinessMappingResponse:
if not _can_update_mapping(request, business_code):
detail = "Only the lqq account can update the 8BF business mapping" if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE else "Only administrators can update business mappings"
raise HTTPException(status_code=403, detail=detail)
store = _get_store(request)
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
fields = body.model_dump(exclude_unset=True)
@@ -51,11 +51,11 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any, AsyncIterator
from collections.abc import AsyncIterator
from typing import Any
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
@@ -69,7 +69,7 @@ from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_ag
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_create_thread_direct,
_diagnose_exception,
_find_dispatch_message,
_find_trailing_ai_text,
@@ -311,21 +311,18 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
ensure_roundtable_functional_agents()
base = _loopback_base(request)
headers = _auth_headers(request)
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。
# Keep Step-1 initialization aligned with multi-agent initialization:
# create the empty thread in-process rather than looping back through
# 127.0.0.1:<gateway-port>. Intranet proxies and port mappings can make
# that loopback fail only for automatic page initialization. Streaming
# below still uses the TCP loopback so SSE remains incrementally flushed.
raw_task_context = payload.get("task_context")
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
try:
async with _make_loopback_client(request) as client:
thread_id = await _create_thread(
client,
base,
headers,
metadata=_intent_thread_metadata(task_context),
)
thread_id = await _create_thread_direct(
request,
metadata=_intent_thread_metadata(task_context),
)
except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
@@ -0,0 +1,148 @@
"""Global position-roundtable role configuration API.
This directory is intentionally different from ``/api/positions``: the
latter is the organisation/RBAC position model, whereas this router controls
the work views that appear in the 岗位多智能体会商 page.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import PositionRoleStore
router = APIRouter(prefix="/api/position-roles", tags=["position-roles"])
ROLE_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,127}$")
class PositionRole(BaseModel):
id: str = Field(..., min_length=1, max_length=128)
name: str = Field(..., min_length=1, max_length=128)
description: str = Field(default="", max_length=512)
role_type: Literal["standard", "intent"] = "standard"
enabled: bool = True
# Response values are preserved by the repository; clients must not use
# this flag to promote a custom role into a protected built-in role.
is_builtin: bool = False
sort_order: int = Field(default=0, ge=0)
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
value = value.strip()
if not ROLE_ID_PATTERN.fullmatch(value):
raise ValueError("岗位标识须以字母开头,只能包含字母、数字、- 或 _")
return value
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("岗位名称不能为空")
return value
class PositionRoleResponse(PositionRole):
updated_by: str | None = None
updated_at: datetime | str | None = None
class PositionRoleListResponse(BaseModel):
roles: list[PositionRoleResponse]
class PositionRoleReplaceRequest(BaseModel):
roles: list[PositionRole] = Field(default_factory=list)
def _get_store(request: Request) -> PositionRoleStore:
store = getattr(request.app.state, "position_role_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Position role store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins but retain the local auth-disabled developer mode."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="岗位角色配置仅限管理员")
def _validate_payload(
rows: list[PositionRole],
existing: list[dict[str, object]],
) -> list[dict[str, object]]:
"""Apply directory invariants before the atomic replacement.
Keeping built-ins prevents old business-chain sessions from becoming
unreadable. They remain fully editable (including disable/retype); custom
roles can be removed freely.
"""
if not rows:
raise HTTPException(status_code=422, detail="至少保留一个启用的岗位")
ids = [row.id for row in rows]
if len(ids) != len(set(ids)):
raise HTTPException(status_code=422, detail="岗位标识不能重复")
names = [row.name.casefold() for row in rows]
if len(names) != len(set(names)):
raise HTTPException(status_code=422, detail="岗位名称不能重复")
enabled = [row for row in rows if row.enabled]
intent_rows = [row for row in enabled if row.role_type == "intent"]
if len(intent_rows) != 1:
raise HTTPException(
status_code=422,
detail="请保留且仅保留一个启用的意图识别岗(它承载会商入口和收口智能体)",
)
builtin_ids = {str(item["id"]) for item in existing if item.get("is_builtin")}
missing_builtin = builtin_ids.difference(ids)
if missing_builtin:
raise HTTPException(
status_code=422,
detail="内置岗位不能删除;如暂不使用,请将它停用",
)
existing_builtin = {str(item["id"]): bool(item.get("is_builtin")) for item in existing}
return [
{
**row.model_dump(),
"is_builtin": existing_builtin.get(row.id, False),
"sort_order": index,
}
for index, row in enumerate(rows)
]
@router.get("", response_model=PositionRoleListResponse)
async def list_position_roles(request: Request) -> PositionRoleListResponse:
# Startup performs the same idempotent seed. Repeating it here makes an
# upgraded running instance recover gracefully when the table was created
# after startup or a deployment starts with an empty database.
rows = await _get_store(request).ensure_seed(POSITION_ROLE_SEED)
return PositionRoleListResponse(roles=[PositionRoleResponse(**row) for row in rows])
@router.put("", response_model=PositionRoleListResponse)
async def replace_position_roles(
request: Request,
body: PositionRoleReplaceRequest,
) -> PositionRoleListResponse:
await _require_admin(request)
store = _get_store(request)
normalized = _validate_payload(body.roles, await store.list_roles())
user_id = await get_current_user(request)
rows = await store.replace_all(normalized, updated_by=user_id)
return PositionRoleListResponse(roles=[PositionRoleResponse(**row) for row in rows])
@@ -331,15 +331,57 @@ def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [node for node in nodes if node.get("status") == "done"]
def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
def _all_nodes_delivered(
nodes: list[dict[str, Any]],
*,
intent_position_id: str | None = None,
) -> bool:
return bool(nodes) and all(
node.get("status") == "done" and (_is_intent_position_node(node) or _node_has_markdown_delivery(node))
node.get("status") == "done"
and (_is_intent_position_node(node, intent_position_id) or _node_has_markdown_delivery(node))
for node in nodes
)
def _is_intent_position_node(node: dict[str, Any]) -> bool:
return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"}
def _is_intent_position_node(
node: dict[str, Any],
intent_position_id: str | None = None,
) -> bool:
position_id = str(node.get("position_id") or "").strip()
if intent_position_id:
return position_id == intent_position_id
# Existing persisted sessions predate the configurable directory. Retain
# their historical interpretation until they are archived/recreated.
return position_id.lower() in {"intelligence", "intel", "intent"}
def _session_intent_position_id(session: dict[str, Any]) -> str | None:
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
value = chain.get("_intent_position_id")
return value.strip() if isinstance(value, str) and value.strip() else None
async def _configured_intent_position_id(request: Request) -> str:
"""Get the one enabled special role for a newly activated session.
The chosen id is frozen into ``chain_snapshot`` on activation. A later
configuration change therefore applies to future workspaces without
reinterpreting completion rules in an in-flight historical business chain.
"""
store = getattr(request.app.state, "position_role_store", None)
if store is not None:
rows = await store.list_roles()
selected = next(
(
row.get("id")
for row in rows
if row.get("enabled") and row.get("role_type") == "intent" and isinstance(row.get("id"), str)
),
None,
)
if isinstance(selected, str) and selected.strip():
return selected.strip()
return "intelligence"
def _is_markdown_artifact(artifact: Any) -> bool:
@@ -419,12 +461,17 @@ def _has_confirmed_intent(intent: Any) -> bool:
return bool(objective and isinstance(constraints, list))
def _prime_intent_nodes(nodes: list[dict[str, Any]], intent: Any) -> list[dict[str, Any]]:
def _prime_intent_nodes(
nodes: list[dict[str, Any]],
intent: Any,
*,
intent_position_id: str | None = None,
) -> list[dict[str, Any]]:
now_answer = _intent_node_answer(intent)
primed: list[dict[str, Any]] = []
for node in nodes:
next_node = dict(node)
if _is_intent_position_node(next_node):
if _is_intent_position_node(next_node, intent_position_id):
next_node.update(
{
"status": "done",
@@ -461,7 +508,7 @@ def _build_node_context_text(
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
agent_name, position_id = _seat_meta(session, current)
md_required = not _is_intent_position_node(current)
md_required = not _is_intent_position_node(current, _session_intent_position_id(session))
lines = [
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
"",
@@ -716,7 +763,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
),
"delivery_requirement": {
"type": "markdown_report",
"required": not _is_intent_position_node(current),
"required": not _is_intent_position_node(current, _session_intent_position_id(session)),
"completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
},
"task": session.get("task_snapshot") or {},
@@ -1137,16 +1184,26 @@ async def activate_session(
for seat in (chain.get("seats") if isinstance(chain.get("seats"), list) else [])
if seat.get("agent_id")
}
# Freeze the currently configured special role with this session. This
# prevents a later global config edit from changing the completion rule of
# an already running historical chain.
chain_snapshot = dict(chain)
intent_position_id = await _configured_intent_position_id(request)
chain_snapshot["_intent_position_id"] = intent_position_id
nodes = _chain_nodes(
chain,
chain_snapshot,
available_agent_ids=await _available_agent_ids(request, seat_ids),
)
nodes = _prime_intent_nodes(nodes, current.get("intent_snapshot"))
nodes = _prime_intent_nodes(
nodes,
current.get("intent_snapshot"),
intent_position_id=intent_position_id,
)
activated = await store.activate_session(
session_id,
user_id,
chain_id=body.chain_id,
chain_snapshot=chain,
chain_snapshot=chain_snapshot,
nodes=nodes,
)
if activated is None:
@@ -1215,7 +1272,7 @@ async def update_node_conversation(
node_key: str,
body: ConversationStateUpdateRequest,
) -> NodeResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
store, user_id, session = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
@@ -1247,7 +1304,7 @@ async def build_node_context(request: Request, session_id: str, node_key: str) -
async def complete_node_turn(
request: Request, session_id: str, node_key: str, body: CompleteTurnRequest
) -> NodeResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
store, user_id, session = await _require_writable_session(request, session_id)
node = await store.get_node(session_id, node_key, user_id)
if node is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
@@ -1268,7 +1325,7 @@ async def complete_node_turn(
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
next_status = (
"done"
if _is_intent_position_node(node)
if _is_intent_position_node(node, _session_intent_position_id(session))
else "done"
if has_markdown and (not requires_fresh_delivery or markdown_changed)
else "running"
@@ -1280,7 +1337,7 @@ async def complete_node_turn(
new_effective_delivery = (
next_status == "done"
and (
_is_intent_position_node(node)
_is_intent_position_node(node, _session_intent_position_id(session))
or node.get("status") != "done"
or markdown_changed
)
@@ -1384,7 +1441,7 @@ async def _prepare_builtin_run(
nodes = await store.list_nodes(session_id, user_id)
if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if not _all_nodes_delivered(nodes):
if not _all_nodes_delivered(nodes, intent_position_id=_session_intent_position_id(session)):
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付")
if kind == "action_plan":
@@ -1428,7 +1485,7 @@ async def _complete_builtin_run(
nodes = await store.list_nodes(session_id, user_id)
if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if not _all_nodes_delivered(nodes):
if not _all_nodes_delivered(nodes, intent_position_id=_session_intent_position_id(session)):
raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付")
current_revisions = _source_revisions(nodes)
if body.source_revisions != current_revisions:
@@ -35,9 +35,10 @@ from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
# Stored business chains support up to 20 seats. The frontend may choose a
# lower deployment-specific cap, but must never exceed this server contract.
MIN_SEATS = 2
MAX_SEATS = 8
MAX_SEATS = 20
# ── schemas ────────────────────────────────────────────────────────────────
@@ -183,7 +184,7 @@ def _get_store(request: Request):
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
"""Enforce 2 ≤ N ≤ 20 and reject duplicate agent ids within a chain."""
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
raise HTTPException(
status_code=400,
@@ -0,0 +1,45 @@
"""Add configurable position-roundtable roles.
Revision ID: 20260729_01
Revises: 20260723_05
Create Date: 2026-07-29
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260729_01"
down_revision = "20260723_05"
branch_labels = None
depends_on = None
def _has_table(name: str) -> bool:
return name in inspect(op.get_bind()).get_table_names()
def upgrade() -> None:
if _has_table("position_roles"):
return
op.create_table(
"position_roles",
sa.Column("id", sa.String(length=128), primary_key=True),
sa.Column("name", sa.String(length=128), nullable=False, unique=True),
sa.Column("description", sa.String(length=512), nullable=False, server_default=""),
sa.Column("role_type", sa.String(length=32), nullable=False, server_default="standard"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_position_roles_name", "position_roles", ["name"])
op.create_index("ix_position_roles_role_type", "position_roles", ["role_type"])
def downgrade() -> None:
if _has_table("position_roles"):
op.drop_table("position_roles")
@@ -15,18 +15,6 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory.
"""
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
KnowledgeEmbeddingRow,
KnowledgeEntityRow,
@@ -40,12 +28,26 @@ from deerflow.knowledge.models import (
KnowledgeSourceRow,
KnowledgeTagRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.position_roles.model import PositionRoleRow
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
@@ -54,16 +56,15 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
ScheduledTaskRow,
@@ -71,13 +72,13 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
from deerflow.persistence.task_buttons.model import TaskButtonRow
from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow
from deerflow.persistence.task_reports.model import TaskReportRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.task_buttons.model import TaskButtonRow
from deerflow.persistence.task_reports.model import TaskReportRow
from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
@@ -123,6 +124,7 @@ __all__ = [
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RoundtableTaskDraftRow",
"PositionRoleRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -0,0 +1,27 @@
"""Position-roundtable role configuration persistence.
This is deliberately separate from :mod:`deerflow.persistence.positions`.
The latter represents organisational positions and resource grants, while this
package describes the work views used inside the position-roundtable product.
"""
from __future__ import annotations
from deerflow.persistence.position_roles.base import PositionRoleStore
from deerflow.persistence.position_roles.memory import MemoryPositionRoleStore
from deerflow.persistence.position_roles.model import PositionRoleRow
from deerflow.persistence.position_roles.sql import PositionRoleRepository
__all__ = [
"MemoryPositionRoleStore",
"PositionRoleRepository",
"PositionRoleRow",
"PositionRoleStore",
"make_position_role_store",
]
def make_position_role_store(session_factory):
if session_factory is None:
return MemoryPositionRoleStore()
return PositionRoleRepository(session_factory)
@@ -0,0 +1,30 @@
"""Abstract storage for position-roundtable role configuration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class PositionRoleStore(ABC):
"""Global configuration shared by every position-roundtable session."""
@abstractmethod
async def list_roles(self) -> list[dict[str, Any]]:
"""Return roles in presentation order."""
raise NotImplementedError
@abstractmethod
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
"""Atomically replace the editable role directory."""
raise NotImplementedError
@abstractmethod
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Insert only missing built-in roles, without overwriting edits."""
raise NotImplementedError
@@ -0,0 +1,45 @@
"""In-memory fallback for position-roundtable role configuration."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.position_roles.base import PositionRoleStore
def _normalize(row: dict[str, Any], updated_by: str | None = None) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": str(row.get("name") or ""),
"description": str(row.get("description") or ""),
"role_type": str(row.get("role_type") or "standard"),
"enabled": bool(row.get("enabled", True)),
"is_builtin": bool(row.get("is_builtin", False)),
"sort_order": int(row.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryPositionRoleStore(PositionRoleStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_roles(self) -> list[dict[str, Any]]:
return [dict(row) for row in sorted(self._rows.values(), key=lambda item: (item["sort_order"], item["id"]))]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
self._rows = {
str(row["id"]): _normalize(row, updated_by)
for row in rows
if row.get("id")
}
return await self.list_roles()
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
for row in rows:
row_id = str(row.get("id") or "")
if row_id and row_id not in self._rows:
self._rows[row_id] = _normalize(row)
return await self.list_roles()
@@ -0,0 +1,37 @@
"""ORM model for configurable position-roundtable roles."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class PositionRoleRow(Base):
"""A work-view role in the position-roundtable product.
``role_type`` is intentionally small and stable: ``standard`` denotes a
normal business-chain position; ``intent`` denotes the single special
position responsible for initial intent identification and closing-agent
entry. It is a configuration concern, not an organisation/RBAC position.
"""
__tablename__ = "position_roles"
id: Mapped[str] = mapped_column(String(128), primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: Mapped[str] = mapped_column(String(512), default="")
role_type: Mapped[str] = mapped_column(String(32), default="standard", index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_by: Mapped[str | None] = mapped_column(String(64))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,66 @@
"""SQLAlchemy storage for position-roundtable role configuration."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.position_roles.base import PositionRoleStore
from deerflow.persistence.position_roles.model import PositionRoleRow
def _to_dict(row: PositionRoleRow) -> dict[str, Any]:
data = row.to_dict()
if isinstance(data.get("updated_at"), datetime):
data["updated_at"] = data["updated_at"].isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None = None) -> PositionRoleRow:
return PositionRoleRow(
id=str(data["id"]),
name=str(data.get("name") or ""),
description=str(data.get("description") or ""),
role_type=str(data.get("role_type") or "standard"),
enabled=bool(data.get("enabled", True)),
is_builtin=bool(data.get("is_builtin", False)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class PositionRoleRepository(PositionRoleStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_roles(self) -> list[dict[str, Any]]:
statement = select(PositionRoleRow).order_by(PositionRoleRow.sort_order.asc(), PositionRoleRow.id.asc())
async with self._sf() as session:
rows = (await session.execute(statement)).scalars()
return [_to_dict(row) for row in rows]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
# Keep the final occurrence of duplicate ids so malformed UI payloads
# cannot violate the primary-key constraint.
by_id = {str(row["id"]): row for row in rows if row.get("id")}
async with self._sf() as session:
await session.execute(delete(PositionRoleRow))
created = [_row_from(row, updated_by) for row in by_id.values()]
session.add_all(created)
await session.commit()
for row in created:
await session.refresh(row)
return [_to_dict(row) for row in sorted(created, key=lambda item: (item.sort_order, item.id))]
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Idempotently insert new built-ins without changing operator edits."""
async with self._sf() as session:
existing_ids = set((await session.execute(select(PositionRoleRow.id))).scalars())
inserts = [_row_from(row) for row in rows if row.get("id") and str(row["id"]) not in existing_ids]
if inserts:
session.add_all(inserts)
await session.commit()
return await self.list_roles()
@@ -38,7 +38,12 @@ class _ChainStore:
return self.chain
def _request(role: str, chain: dict[str, object] | None = None):
def _request(
role: str,
chain: dict[str, object] | None = None,
*,
email: str = "lqq@example.com",
):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
@@ -46,14 +51,29 @@ def _request(role: str, chain: dict[str, object] | None = None):
roundtable_chain_store=_ChainStore(chain),
)
),
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", system_role=role)),
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)),
)
@pytest.mark.asyncio
async def test_only_administrators_can_update_global_business_mappings() -> None:
async def test_only_administrators_can_update_non_8bf_business_mappings() -> None:
request = _request("user")
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
"6BF",
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
)
assert error.value.status_code == 403
assert request.app.state.business_mapping_store.calls == []
@pytest.mark.asyncio
async def test_only_lqq_can_update_the_8bf_business_mapping() -> None:
request = _request("admin", email="admin@example.com")
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
@@ -65,6 +85,20 @@ async def test_only_administrators_can_update_global_business_mappings() -> None
assert request.app.state.business_mapping_store.calls == []
@pytest.mark.asyncio
async def test_lqq_can_update_the_8bf_business_mapping_without_admin_role() -> None:
request = _request("user", email="lqq@example.com")
response = await business_mapping_router.update_mapping(
request,
"8BF",
business_mapping_router.BusinessMappingUpdateRequest(),
)
assert response.business_code == "8BF"
assert request.app.state.business_mapping_store.calls == [("8BF", {})]
@pytest.mark.asyncio
async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True})
@@ -80,9 +114,7 @@ async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_titl
assert response.chain_id == "chain-8bf"
assert response.chain_title == "岗位协同链"
assert request.app.state.business_mapping_store.calls == [
("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})
]
assert request.app.state.business_mapping_store.calls == [("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})]
@pytest.mark.asyncio
@@ -9,8 +9,8 @@ import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.app import create_app
from app.gateway import services as gateway_services
from app.gateway.app import create_app
from app.gateway.routers import intent as intent_router
from app.gateway.routers import position_roundtable as position_roundtable_router
from deerflow.persistence.base import Base
@@ -50,6 +50,42 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
assert position_roundtable_router.router.prefix == "/position-roundtable"
@pytest.mark.asyncio
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
"""Step 1 initialization must not depend on the deployed Gateway port."""
captured: dict[str, object] = {}
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
async def fake_create_thread_direct(request, *, metadata):
captured["request"] = request
captured["metadata"] = metadata
return "intent-thread-direct"
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
request = SimpleNamespace()
result = await intent_router.init_intent(
request,
{"task_context": {"task": {"id": "task-001"}}},
)
assert result == {
"status": "success",
"agent_id": intent_router.INTENT_AGENT_ID,
"thread_id": "intent-thread-direct",
}
assert captured["request"] is request
assert captured["metadata"] == {
**intent_router._ROUNDTABLE_THREAD_METADATA,
"taskId": "task-001",
"position_roundtable_task_id": "task-001",
"position_task_id": "task-001:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(