页面嵌套修改
This commit is contained in:
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={() =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+45
@@ -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")
|
||||
+20
-18
@@ -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",
|
||||
|
||||
+27
@@ -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)
|
||||
+30
@@ -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
|
||||
+45
@@ -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()
|
||||
+37
@@ -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),
|
||||
)
|
||||
+66
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user