Compare commits

...
14 Commits
Author SHA1 Message Date
董宏凯 5f11926db5 ceshi 2026-08-07 12:30:20 +08:00
董宏凯 1ffead7ee2 岗位会商样式修改 2026-08-02 15:33:26 +08:00
董宏凯 a0a75a9964 Merge branch 'dhk-1' into 'master'
fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap

See merge request knowledge-base-bj/deerflow-code!16
2026-07-31 13:11:29 +00:00
董宏凯 37964d1f95 代码修改 2026-07-31 21:09:54 +08:00
董宏凯andCursor a9d66536c3 fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap
Skip post-commit ORM refresh on threads_meta creates so concurrent MySQL
read/write-split deployments no longer fail with Could not refresh instance;
clean up partial roundtable threads on checkpoint failure, and ship related
position-roundtable/frontend build fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 21:00:56 +08:00
董宏凯 a968c3b5cd Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-code!15
2026-07-29 14:27:46 +00:00
董宏凯 e52261fc3d fix: preserve full chat embed mode navigation 2026-07-29 22:25:42 +08:00
董宏凯 4450f342d8 页面嵌套修改 2026-07-29 21:53:32 +08:00
董宏凯 213dd841b5 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-07-28 19:18:52 +08:00
董宏凯 816c46d447 代码提交 2026-07-28 19:17:40 +08:00
g zy db857ddd4d Merge branch 'gzy_dev_new' into 'master'
fix: fix iframe agent selector width

See merge request knowledge-base-bj/deerflow-code!14
2026-07-28 03:30:12 +00:00
g zy e475c37f1d Merge branch 'gzy_dev_new' into 'master'
Gzy dev new

See merge request knowledge-base-bj/deerflow-code!13
2026-07-28 03:14:47 +00:00
董宏凯 151ecceb85 feat: 更新主题配置与系统设置 2026-07-27 21:31:09 +08:00
董宏凯 1555d5344b feat: 优化8BF业务链流程图与深色配置弹窗 2026-07-27 21:30:57 +08:00
74 changed files with 5608 additions and 574 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
# Never send source-control history, local dependencies, runtime state, or
# credentials to the Docker daemon. The offline images are built from the
# repository root by deploy/1panel-offline/Dockerfile.*.
.git
.runtime
.history
tmp
dist
# Credentials and environment-specific configuration are supplied at runtime
# through the offline delivery package, never copied into an image layer.
**/.env
**/.env.*
offline-backend-20260512/backend/.deer-flow
offline-backend-20260512/backend/logs
offline-backend-20260512/backend/docker-data
offline-backend-20260512/backend/dist
offline-backend-20260512/backend/wheelhouse
offline-backend-20260512/backend/offline-wheels
offline-backend-20260512/backend/.venv
offline-backend-20260512/backend/.pytest_cache
offline-backend-20260512/backend/.ruff_cache
# Reproducibly install frontend dependencies inside the image instead.
frontend-web/node_modules
frontend-web/dist
frontend-web/.vite
frontend-web/.offline-pack-build
frontend-web/.offline-extract-test
frontend-web/node_modules-offline-*.tar.gz
# Editor and operating-system metadata.
**/__pycache__
**/*.py[cod]
**/.DS_Store
+1 -1
View File
@@ -99,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
// MaxKey 鉴权地址:onlineTicket 换 token 使用。
VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn",
VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn",
// PG 中任务 iframe 地址。
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#fff;stroke-linejoin:round;}.b{stroke-linecap:round;}</style></defs><g transform="translate(-1)"><rect class="a" width="16" height="16" transform="translate(1)"/><path class="b" d="M7,42H19" transform="translate(-4 -28)"/><path class="b" d="M0,0H3" transform="translate(9.848 3.848) rotate(45)"/><path class="c" d="M11,12.907v2.427h2.439l6.894-6.9L17.9,6Z" transform="translate(-6.667 -4)"/></g></svg>

After

Width:  |  Height:  |  Size: 545 B

@@ -607,6 +607,27 @@ html.lightyellow .light-topnav-item--active::after {
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
/* Runtime themes use their configurable header gradient rather than the
red/blue banner artwork. Navigation copy follows an automatically chosen
contrasting foreground so both pale and deep brand colours remain legible. */
html[data-custom-theme="true"] .light-topnav-item,
html[data-custom-theme="true"] .top-submenu-item {
color: var(--navigation-foreground);
}
html[data-custom-theme="true"] .light-topnav-item:hover,
html[data-custom-theme="true"] .light-topnav-item--active,
html[data-custom-theme="true"] .light-topnav-item--active:hover,
html[data-custom-theme="true"] .top-submenu-item:hover,
html[data-custom-theme="true"] .top-submenu-item--active {
color: var(--navigation-foreground);
text-shadow: none;
}
html[data-custom-theme="true"] .light-topnav-item--active::after {
background: var(--navigation-foreground);
}
.light-sidebar {
position: fixed;
left: 0;
@@ -10,6 +10,8 @@ import {
Gauge,
ListTree,
MousePointerClick,
FileText,
BriefcaseBusiness,
Settings,
ShieldAlert,
ShieldCheck,
@@ -235,6 +237,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</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 +558,20 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</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" />
@@ -26,7 +26,7 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
*/
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
@@ -43,6 +43,8 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
: tone === "banner-light"
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: tone === "banner-custom"
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
@@ -83,6 +83,7 @@ import {
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -185,15 +186,24 @@ export function GeneralQaSidebar() {
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent");
const currentSearch = searchParams.toString();
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
navigate(
buildFullChatNavigationPath(
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
currentSearch,
),
);
},
[navigate],
[currentSearch, navigate],
);
// ── 最近的对话取数 + 搜索 ──
@@ -241,7 +251,7 @@ export function GeneralQaSidebar() {
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } });
};
const handleDelete = useCallback(
@@ -257,10 +267,17 @@ export function GeneralQaSidebar() {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
[
agentIdFromPath,
currentSearch,
deleteThread,
router,
threadIdFromPath,
threads,
],
);
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
@@ -314,7 +331,7 @@ export function GeneralQaSidebar() {
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
<Link
href={NEW_CHAT_PATH}
href={newChatHref}
onClick={handleNewChatClick}
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
className={cn(
@@ -415,7 +432,7 @@ export function GeneralQaSidebar() {
);
})}
<Link
href={MORE_AGENTS_HREF}
href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
@@ -454,7 +471,10 @@ export function GeneralQaSidebar() {
</div>
) : (
filteredThreads.map((thread) => {
const href = listPathOfThread(thread);
const href = buildFullChatNavigationPath(
listPathOfThread(thread),
currentSearch,
);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const pinned = isThreadPinned(thread);
@@ -576,7 +596,7 @@ export function GeneralQaSidebar() {
)}
{!query && (
<Link
href={MORE_CHATS_HREF}
href={moreChatsHref}
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>更多最近对话</span>
@@ -70,6 +70,18 @@ const ADVANCED_COLOR_FIELDS: Array<{
description: "主按钮悬停时的背景色",
token: "--theme-button-hover",
},
{
key: "navigation_gradient_start",
label: "导航渐变起点",
description: "全量模式顶部导航栏左侧的渐变色",
token: "--navigation-gradient-start",
},
{
key: "navigation_gradient_end",
label: "导航渐变终点",
description: "全量模式顶部导航栏右侧的渐变色",
token: "--navigation-gradient-end",
},
{
key: "selected_background",
label: "选中背景",
@@ -268,14 +280,20 @@ function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
style={tokens}
aria-label={`${theme.name} 的颜色预览`}
>
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--background)] px-3 py-2">
<div
className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2"
style={{
backgroundImage:
"linear-gradient(105deg, var(--navigation-gradient-start) 0%, var(--primary) 52%, var(--navigation-gradient-end) 100%)",
}}
>
<span
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
className="flex size-5 items-center justify-center rounded-md bg-white/25 text-[10px] font-bold text-[var(--navigation-foreground)] ring-2 ring-white/35"
aria-hidden
>
C
</span>
<span className="text-xs font-semibold text-[var(--foreground)]">
<span className="text-xs font-semibold text-[var(--navigation-foreground)]">
实时预览
</span>
<span
@@ -234,6 +234,14 @@ export function WorkspaceNavMenu() {
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/position-roles")}
>
<BriefcaseIcon />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
+4 -1
View File
@@ -229,6 +229,8 @@ function markExplicitChoice(): void {
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
/** Resolved visual mode; custom themes keep their own light/dark setting. */
baseMode: 'light' | 'dark';
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
@@ -309,6 +311,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme
: customTheme?.id ?? 'light';
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
useEffect(() => {
if (typeof document === 'undefined') return;
@@ -377,7 +380,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
return (
<ThemeContext.Provider
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
value={{ theme, baseMode, themeConfig, toggleTheme, setTheme, mounted }}
>
{children}
</ThemeContext.Provider>
+36 -7
View File
@@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env";
*
* Add a new entry here when an external system needs a short ``routeKey``.
* Targets must remain internal SPA routes; LoginPage validates them again
* before navigating.
* before navigating. The optional display settings are converted into the
* existing route parameters on the destination URL.
*/
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = {
a: "/page/canvas/ai-writing",
"8bf": "/page/strategy/qa/8bf-roundtable",
export interface RouteKeyMapping {
/** Internal SPA route to open after sign-in. */
path: string;
/** Add `hideChrome=1` to hide the global header and page sidebar. */
hideChrome?: boolean;
/** Add the existing `theme` route parameter and apply it during sign-in. */
theme?: string;
}
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
a: { path: "/page/canvas/ai-writing" },
"8bf": {
path: "/page/strategy/qa/8bf-roundtable",
hideChrome: true,
theme: "dark-blue",
},
};
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
@@ -26,8 +40,23 @@ export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
export function resolveRouteKeyMapping(
routeKey: string | null | undefined,
): RouteKeyMapping | undefined {
const key = routeKey?.trim();
if (!key) return "";
return ROUTE_KEY_MAPPINGS[key] ?? "";
if (!key) return undefined;
return ROUTE_KEY_MAPPINGS[key];
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const mapping = resolveRouteKeyMapping(routeKey);
if (!mapping) return "";
const [path, ownQuery = ""] = mapping.path.split("?");
const query = new URLSearchParams(ownQuery);
if (mapping.hideChrome) query.set("hideChrome", "1");
if (mapping.theme) query.set("theme", mapping.theme);
const serializedQuery = query.toString();
return serializedQuery ? `${path}?${serializedQuery}` : path;
}
@@ -0,0 +1,24 @@
/**
* Whether the application is currently hosted by another page.
*
* This intentionally covers both a native `<iframe>` and Wujie's child-app
* runtime. Accessing `window.top` is safe across origins for identity
* comparison, but the guard keeps the login/route bootstrap resilient to
* restrictive browser hosts.
*/
export function isEmbeddedHost(): boolean {
if (typeof window === "undefined") return false;
const hostWindow = window as Window & {
__POWERED_BY_WUJIE__?: boolean;
};
if (hostWindow.__POWERED_BY_WUJIE__) return true;
try {
return window.self !== window.top;
} catch {
// Some sandboxed or cross-origin hosts deny top-window access. They are
// still embedded contexts and should use the same safe entry route.
return true;
}
}
@@ -2,13 +2,19 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildStandardChatRedirectPath,
buildFullChatNavigationPath,
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 +43,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 +51,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 +183,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 +244,61 @@ 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",
);
});
test("keeps full-chat presentation flags when the sidebar opens an agent or thread", () => {
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/new?agent=agent-1",
"?hideChrome=1&chatMode=full",
),
"/page/workspace/chats/new?agent=agent-1&hideChrome=1&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/thread-123",
"?embed=1&theme=dark-blue&chatMode=full",
),
"/page/workspace/chats/thread-123?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath("/page/workspace/chats/new", ""),
"/page/workspace/chats/new",
);
});
@@ -2,15 +2,131 @@ 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;
}
/**
* Keep an explicitly selected standard-chat presentation while navigating
* between `/chats/new` and a concrete chat thread. Without this, a click in
* the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host
* then looks like the legacy bare integration and is incorrectly redirected
* to the private AgentSelector workspace.
*
* This deliberately whitelists presentation flags through
* `buildStandardChatRedirectPath` instead of copying arbitrary login/query
* values into user-facing chat URLs.
*/
export function buildFullChatNavigationPath(
destinationPath: string,
search: string,
): string {
return hasIframePresentationOverride(search) || isExplicitFullChatMode(search)
? buildStandardChatRedirectPath(search, destinationPath)
: destinationPath;
}
/**
* Preserve only the presentation parameters consumed by the iframe workspace.
* Login credentials must never be copied into the post-login address bar.
@@ -24,13 +140,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 +171,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 +187,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 +288,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 +331,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: "定时任务管理",
@@ -291,6 +291,8 @@ export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
navigation_gradient_start?: string | null;
navigation_gradient_end?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
@@ -13,6 +13,8 @@ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"navigation_gradient_start",
"navigation_gradient_end",
"selected_background",
"border",
"muted",
@@ -146,6 +148,18 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
// Keep the same left → main-colour → right treatment used by the built-in
// themes. These two values remain automatic unless the administrator opens
// “更多配色” and explicitly overrides one of them.
const defaultNavigationGradientStart = mix(primary, BLACK, isDark ? 0.46 : 0.3);
const defaultNavigationGradientEnd = mix(primary, WHITE, isDark ? 0.14 : 0.42);
const navigationGradientStart = optionalHexColor(overrides.navigation_gradient_start)
? toRgb(overrides.navigation_gradient_start!)
: defaultNavigationGradientStart;
const navigationGradientEnd = optionalHexColor(overrides.navigation_gradient_end)
? toRgb(overrides.navigation_gradient_end!)
: defaultNavigationGradientEnd;
const navigationForeground = readableForeground(mix(navigationGradientStart, navigationGradientEnd, 0.5));
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
@@ -168,6 +182,8 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const navigationGradientStartHex = toHex(navigationGradientStart);
const navigationGradientEndHex = toHex(navigationGradientEnd);
const sidebarHex = toHex(sidebar);
return {
@@ -177,6 +193,9 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--navigation-gradient-start": navigationGradientStartHex,
"--navigation-gradient-end": navigationGradientEndHex,
"--navigation-foreground": navigationForeground,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Loader2, Save, Settings2, X } from "lucide-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";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
@@ -25,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[]>([]);
@@ -34,6 +37,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -41,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 只允许绑定已发布业务链;后端同样会进行二次校验。
@@ -54,13 +61,17 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally {
setIsLoading(false);
}
}, [isAdmin]);
}, []);
useEffect(() => {
void loadConfiguration();
}, [loadConfiguration]);
const handleSave = useCallback(async () => {
if (!canConfigureChain) {
setError("仅 lqq 账号可以配置 8BF 业务链条");
return;
}
const selected = chains.find((chain) => chain.id === selectedChainId);
if (!selected) {
setError("请选择一条已发布的业务链");
@@ -81,17 +92,11 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally {
setIsSaving(false);
}
}, [chains, selectedChainId]);
}, [canConfigureChain, chains, selectedChainId]);
const toolbarSlot = (
<>
<div className="eightbf-roundtable__chain-status" aria-live="polite">
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
<span className="eightbf-roundtable__chain-status-value">
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
</span>
</div>
{isAdmin && (
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
@@ -101,7 +106,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
配置业务链
</button>
)}
{isSettingsOpen && (
{canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
className="eightbf-roundtable__settings-dialog"
@@ -137,7 +142,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
))}
</select>
</label>
{error && <p className="eightbf-roundtable__settings-error">{error}</p>}
{error && <p className="eightbf-roundtable__settings-error" role="alert">{error}</p>}
<div className="eightbf-roundtable__settings-actions">
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
@@ -148,7 +153,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
</section>
</div>
)}
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
</>
);
@@ -159,6 +163,13 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockBusinessChain
storageTaskId={storageTaskId}
toolbarSlot={toolbarSlot}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
className="eightbf-roundtable__business-chain-flow"
/>
}
rootClassName="eightbf-roundtable"
/>
);
@@ -21,4 +21,10 @@
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
@@ -31,4 +31,49 @@
.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; }
.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; }
/* 管理员业务链配置弹窗:深色主题下与会商工作区使用同一套深蓝表面与高对比文字。 */
.dark .eightbf-roundtable .eightbf-roundtable__settings-mask {
background: rgb(2 12 30 / 72%);
backdrop-filter: blur(3px);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-dialog {
border: 1px solid rgb(69 195 243 / 42%);
background: #082b57;
box-shadow: 0 24px 56px rgb(0 0 0 / 45%);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-heading h2 { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close { color: #a9bddb; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close:hover { color: #fff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-tip { color: #b9c9df; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field { color: #e6effc; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select {
border-color: rgb(143 196 255 / 48%);
background: #0d3768;
color: #f1f7ff;
color-scheme: dark;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select:focus {
border-color: #65d6c6;
outline: 2px solid rgb(101 214 198 / 28%);
outline-offset: 1px;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field option { background: #0d3768; color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-error { color: #fca5a5; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child {
color: #b9c9df;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child:hover {
background: rgb(143 196 255 / 12%);
color: #fff;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child {
background: #167d74;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child:hover {
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
+7
View File
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
return readRuntimeEnv(key) === "true";
}
/**
* Read an integer setting supplied by ``runtime-config.js``.
*
* Runtime configuration is editable after the frontend is built, so validate
* numeric values here instead of letting an invalid deployment value bypass a
* UI constraint that the backend cannot accept.
*/
export const env = {
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
+8 -96
View File
@@ -2,14 +2,12 @@
import {
BookMarkedIcon,
BotIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
type BrainyChatSubmitMessage,
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() {
const routeBase = useRouteBase();
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
useState<PendingIframeSubmit | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
// The protocol page must still be escapable when no curated selector list
// has been configured. Include the user's own and public agents in this
// page's native fallback picker. A native select also remains interactive
// in iframe/micro-frontend environments where a portal dialog can fail.
const { agents: mineAgents } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 100,
});
const { agents: squareAgents } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 100,
});
const fallbackAgents = useMemo(() => {
const uniqueAgents = new Map<
string,
(typeof selectorAgents)[number]
>();
for (const agent of [
...selectorAgents,
...mineAgents,
...squareAgents,
]) {
uniqueAgents.set(agent.id, agent);
}
return [...uniqueAgents.values()];
}, [mineAgents, selectorAgents, squareAgents]);
const { data: pluginManagementSettings } = usePluginManagementSettings();
const pendingRequestIdRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
saveLastAgentId(agentId);
}, []);
/**
* Manual fallback for accidental protocol-page visits.
*
* A real Brainy integration drives this page with postMessage. A human who
* reaches it directly should not be trapped here: move them to the standard
* chat workspace, optionally opening the selected agent.
*/
const enterStandardChat = useCallback(
(agentId?: string) => {
const params = new URLSearchParams(location.search);
params.delete("from");
params.set("chatMode", "full");
params.delete("agent");
if (agentId) {
params.set("agent", agentId);
saveLastAgentId(agentId);
}
navigate(`${routeBase}/chats/new?${params.toString()}`, {
replace: true,
});
},
[location.search, navigate, routeBase],
);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
</div>
</div>
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div>
<div className="mt-2 text-sm text-muted-foreground">
选择智能体进入问答
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
<BotIcon className="mr-2 size-3.5 shrink-0" />
<select
aria-label="选择智能体并进入问答"
defaultValue=""
disabled={isUploading || selectorLoading}
onChange={(event) => {
const agentId = event.target.value;
if (agentId) enterStandardChat(agentId);
}}
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
>
<option value="" disabled>
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
</option>
{fallbackAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{desensitize(agent.name)}
</option>
))}
</select>
<span
aria-hidden
className="pointer-events-none absolute right-3 text-[10px]"
>
▾
</span>
</label>
</div>
{!selectorLoading && fallbackAgents.length === 0 && (
<div className="mt-3 text-xs text-muted-foreground">
暂无可选智能体
</div>
)}
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
+81 -17
View File
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
import {
getRouteKeyLoginPassword,
resolveRouteKey,
resolveRouteKeyMapping,
ROUTE_KEY_PARAM,
} from '@/core/auth/route-key-login';
import {
@@ -25,6 +26,7 @@ import {
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { readEmbedParam } from '@/core/embed/embed-session';
import {
GENERAL_QA_ROUTE,
@@ -33,9 +35,14 @@ import {
persistToolsToken,
} from '@/core/auth/yuser-login';
import {
buildStandardChatRedirectPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath,
hasIframePresentationOverride,
isChatsIframeLogin,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath,
shouldUseChatsIframeProtocol,
} from '@/core/page-layout/chats-iframe-route';
import {
isSidebarEnabledFromSearch,
@@ -101,22 +108,42 @@ export default function LoginPage() {
);
const requestedChatMode =
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
// Wujie is an application container, not the Brainy iframe protocol. It
// must therefore land on the regular full ChatPage. Plugin links with
// `from=iframe` are normalized into the explicit iframe protocol mode.
const iframePresentationOverride = useMemo(
() => hasIframePresentationOverride(searchParams.toString()),
[searchParams],
);
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
// `from=iframe` is the established entry contract for the dedicated iframe
// page. A standard presentation flag is the one intentional exception: it
// uses the normal full chat workspace so Header/PageSidebar visibility is
// controlled by hideHeader/hideChrome/embed instead.
const brainyIframeRequested = shouldUseChatsIframeProtocol(
searchParams.toString(),
embeddedHost,
);
// An explicit full-chat request and all standard presentation flags retain
// the regular workspace. Otherwise native iframe/Wujie hosts use the
// legacy dedicated AgentSelector entry, matching the established bare
// iframe login integration.
const fullChatRequested = useMemo(
() =>
!brainyIframeRequested &&
(isWujieEnv() ||
requestedChatMode === FULL_CHAT_MODE),
[brainyIframeRequested, requestedChatMode, searchParams],
requestedChatMode === FULL_CHAT_MODE ||
// hideHeader/hideChrome/embed are explicit presentation contracts.
// They must retain their query parameters and enter the standard
// workspace even when a native iframe has no legacy `from` marker.
iframePresentationOverride),
[
brainyIframeRequested,
iframePresentationOverride,
requestedChatMode,
searchParams,
],
);
const isIframeLogin = useMemo(
() =>
brainyIframeRequested ||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
[brainyIframeRequested, fullChatRequested, searchParams],
() => brainyIframeRequested,
[brainyIframeRequested],
);
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
@@ -127,22 +154,37 @@ export default function LoginPage() {
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const redirectPath = useMemo(
() => {
if (yUserId) return GENERAL_QA_ROUTE;
// Keep the mode on the destination URL so PageRoutes can retain the
// full chat workspace sidebar while hideChrome hides only the global
// application chrome.
if (brainyIframeRequested) {
return buildChatsIframeRedirectPath(searchParams.toString());
return !isChatsIframeLogin(searchParams.toString()) &&
requestedChatMode !== IFRAME_CHAT_MODE
? buildEmbeddedIframeRedirectPath(searchParams.toString())
: buildChatsIframeRedirectPath(searchParams.toString());
}
if (fullChatRequested) {
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
return buildStandardChatRedirectPath(
searchParams.toString(),
DEFAULT_REDIRECT_PATH,
);
}
// External iframe callers frequently also provide yUserId for login.
// That identity parameter must not steal a deliberate iframe entry and
// send it to the generic full-chat home before the route split above.
if (yUserId) return GENERAL_QA_ROUTE;
return resolvePostLoginRedirectPath(
searchParams.toString(),
DEFAULT_REDIRECT_PATH,
);
},
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
[
brainyIframeRequested,
fullChatRequested,
requestedChatMode,
searchParams,
yUserId,
],
);
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
@@ -152,6 +194,10 @@ export default function LoginPage() {
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? '';
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
const routeKeyMapping = useMemo(
() => resolveRouteKeyMapping(routeKey),
[routeKey],
);
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
const configuredRouteLoginPassword = useMemo(
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
@@ -183,6 +229,14 @@ export default function LoginPage() {
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
pathOnly,
searchParams.toString(),
embeddedHost,
);
if (iframeEntryTarget) {
return iframeEntryTarget;
}
// Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar.
if (
@@ -204,7 +258,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [fullChatRequested, routeKeyTarget, searchParams]);
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -243,10 +297,20 @@ export default function LoginPage() {
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
useEffect(() => {
if (isEmbed || isIframeLogin) return;
const themeValue = wujieProps.theme || externalParams.theme;
// routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
// `theme=dark-blue` 也能在登录完成前切换到指定主题。
const themeValue =
routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
if (!themeValue) return;
setTheme(resolveThemeFromQuery(themeValue));
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
}, [
isEmbed,
isIframeLogin,
routeKeyMapping?.theme,
wujieProps.theme,
externalParams.theme,
setTheme,
]);
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
@@ -0,0 +1,706 @@
"use client";
/**
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
AlertTriangle,
Copy,
FileText,
Loader2,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
import {
NOTIFICATION_TEMPLATE_VARIABLES,
NotificationTemplateConflictError,
previewNotificationTemplate,
useNotificationTemplateActions,
useNotificationTemplates,
type NotificationTemplate,
type NotificationTemplateInput,
type PreviewResult,
} from "@/strategy-components/api/notification-templates";
interface FormState {
name: string;
titleTemplate: string;
contentTemplate: string;
tagsText: string;
enabled: boolean;
}
interface ConflictState {
server: NotificationTemplate;
mine: FormState;
}
const emptyForm = (): FormState => ({
name: "",
titleTemplate: "",
contentTemplate: "",
tagsText: "",
enabled: true,
});
function formFromTemplate(t: NotificationTemplate): FormState {
return {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tagsText: (t.tags ?? []).join(", "),
enabled: t.enabled,
};
}
function parseTags(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const part of text.split(/[,,]/)) {
const tag = part.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
out.push(tag);
}
return out;
}
function toInput(form: FormState): NotificationTemplateInput {
return {
name: form.name,
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
tags: parseTags(form.tagsText),
enabled: form.enabled,
};
}
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
userName: "张三",
taskName: "示例任务",
taskId: "T-001",
dueDate: "2026-08-10",
};
export default function NotificationTemplatesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const actions = useNotificationTemplateActions();
const [keyword, setKeyword] = useState("");
const [keywordApplied, setKeywordApplied] = useState("");
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
const [tagFilter, setTagFilter] = useState("");
const [page, setPage] = useState(1);
const pageSize = 10;
const listParams = useMemo(
() => ({
keyword: keywordApplied || undefined,
enabled:
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
tag: tagFilter.trim() || undefined,
page,
pageSize,
}),
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
);
const { data, isLoading, isError, error, refetch, isFetching } =
useNotificationTemplates(listParams);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saveError, setSaveError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const saving =
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
// 实时预览(编辑弹窗打开时)
useEffect(() => {
if (!dialogOpen) return;
let cancelled = false;
const timer = window.setTimeout(() => {
setPreviewLoading(true);
void previewNotificationTemplate({
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
variables: previewVars,
})
.then((r) => {
if (!cancelled) setPreview(r);
})
.catch(() => {
if (!cancelled) setPreview(null);
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
if (!isAdmin) {
return <Navigate to="/page/workspace/chats/new" replace />;
}
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const openEdit = (t: NotificationTemplate) => {
setEditing(t);
setForm(formFromTemplate(t));
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const applySearch = () => {
setPage(1);
setKeywordApplied(keyword.trim());
};
const handleSave = async (opts?: { forceVersion?: number }) => {
setSaveError(null);
const input = toInput(form);
try {
if (editing) {
const version = opts?.forceVersion ?? editing.version;
const updated = await actions.update.mutateAsync({
id: editing.id,
input: { ...input, expectedVersion: version },
});
setEditing(updated);
setConflict(null);
toast.success("模板已保存");
setDialogOpen(false);
} else {
await actions.create.mutateAsync(input);
toast.success("模板已创建");
setDialogOpen(false);
}
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
setConflict({ server: e.server, mine: form });
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
return;
}
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast.error(msg);
}
};
const handleReloadFromServer = () => {
if (!conflict) return;
setEditing(conflict.server);
setForm(formFromTemplate(conflict.server));
setConflict(null);
setSaveError(null);
toast.info("已加载服务器最新版本");
};
const handleForceOverwrite = async () => {
if (!conflict) return;
await handleSave({ forceVersion: conflict.server.version });
};
const handleToggle = async (t: NotificationTemplate) => {
try {
await actions.update.mutateAsync({
id: t.id,
input: {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tags: t.tags,
enabled: !t.enabled,
expectedVersion: t.version,
},
});
toast.success(t.enabled ? "已停用" : "已启用");
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
toast.error("版本冲突,请刷新列表后重试");
void refetch();
return;
}
toast.error(e instanceof Error ? e.message : "启停失败");
}
};
const handleDuplicate = async (t: NotificationTemplate) => {
try {
await actions.duplicate.mutateAsync(t.id);
toast.success("已复制");
} catch (e) {
toast.error(e instanceof Error ? e.message : "复制失败");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await actions.remove.mutateAsync(deleteTarget.id);
toast.success("已删除");
setDeleteTarget(null);
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
const token = `{{${name}}}`;
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<FileText className="size-5" />
通知模板管理
</h1>
<p className="mt-1 text-sm text-muted-foreground">
维护消息通知的标题/正文模板。支持变量:
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
{`{{${v}}}`}
</code>
))}
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
新建模板
</Button>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="min-w-[200px] flex-1">
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
<div className="flex gap-2">
<Input
value={keyword}
placeholder="搜索名称 / 标题 / 正文"
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applySearch();
}}
/>
<Button variant="secondary" onClick={applySearch}>
<Search className="size-4" />
</Button>
</div>
</div>
<div className="w-36">
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
<Select
value={enabledFilter}
onValueChange={(v) => {
setEnabledFilter(v as "all" | "true" | "false");
setPage(1);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="true">已启用</SelectItem>
<SelectItem value="false">已停用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
<Input
value={tagFilter}
placeholder="精确匹配标签"
onChange={(e) => {
setTagFilter(e.target.value);
setPage(1);
}}
/>
</div>
</div>
{isError ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
<AlertTriangle className="size-8 text-destructive" />
<p className="text-sm text-destructive">
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
? "无权限访问通知模板管理"
: error instanceof Error
? error.message
: "加载失败"}
</p>
<Button variant="outline" onClick={() => void refetch()}>
重试
</Button>
</div>
) : isLoading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<FileText className="size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">暂无通知模板</p>
<Button onClick={openCreate}>
<Plus className="size-4" />
创建第一个模板
</Button>
</div>
) : (
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">名称</th>
<th className="px-3 py-2 font-medium">标题模板</th>
<th className="px-3 py-2 font-medium">标签</th>
<th className="px-3 py-2 font-medium">版本</th>
<th className="px-3 py-2 font-medium">启用</th>
<th className="px-3 py-2 font-medium text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((t) => (
<tr key={t.id} className="border-t">
<td className="px-3 py-2.5 font-medium">{t.name}</td>
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
{t.titleTemplate}
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{t.tags.length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
t.tags.map((tag) => (
<span
key={tag}
className="rounded bg-muted px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
<td className="px-3 py-2.5">
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(t)}
title="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void handleDuplicate(t)}
title="复制"
>
<Copy className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteTarget(t)}
title="删除"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<span>
共 {total} 条 · 第 {page}/{totalPages} 页
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
)}
{/* 新建 / 编辑 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
<Input
value={form.name}
maxLength={50}
placeholder="2~50 个字符"
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
<Input
value={form.titleTemplate}
maxLength={120}
placeholder="如:任务 {{taskName}} 即将到期"
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "titleTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
<Textarea
value={form.contentTemplate}
rows={7}
maxLength={5000}
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "contentTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
标签(逗号分隔,最多 5 个)
</label>
<Input
value={form.tagsText}
placeholder="提醒, 任务"
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
/>
<span className="text-sm">启用</span>
{editing && (
<span className="ml-auto text-xs text-muted-foreground">
当前编辑版本 v{editing.version}
</span>
)}
</div>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="text-sm font-medium">实时预览</div>
<div className="grid grid-cols-2 gap-2">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<div key={v}>
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
<Input
value={previewVars[v] ?? ""}
onChange={(e) =>
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="rounded border bg-background p-3 text-sm">
{previewLoading && !preview ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
预览中…
</div>
) : (
<>
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
<div className="whitespace-pre-wrap text-muted-foreground">
{preview?.content || "(正文预览)"}
</div>
{preview && preview.missingVariables.length > 0 && (
<p className="mt-2 text-xs text-amber-600">
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
</p>
)}
</>
)}
</div>
</div>
</div>
{saveError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{saveError}
</div>
)}
{conflict && (
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
<AlertTriangle className="size-4" />
版本冲突 — 请选择如何处理
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.server.contentTemplate}
</div>
</div>
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">我的编辑内容</div>
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.mine.contentTemplate}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleReloadFromServer}>
重新加载
</Button>
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
覆盖保存
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
取消
</Button>
<Button
variant="destructive"
disabled={actions.remove.isPending}
onClick={() => void handleDelete()}
>
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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>
);
}
+22
View File
@@ -45,6 +45,8 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
import NotificationTemplatesPage from "./NotificationTemplatesPage";
import PositionRolesPage from "./PositionRolesPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -437,6 +439,26 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/notification-templates"
element={
getStoredAuth()?.system_role === "admin" ? (
<NotificationTemplatesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === "admin" ? (
<PositionRolesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/login-whitelist"
element={
+108 -23
View File
@@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route';
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
@@ -240,13 +245,20 @@ function NewChatEntryRoute({
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
// Do not infer the chat experience from window.self !== window.top here.
// A generic `/chats/new` deep link must render the same standard chat both
// top-level and inside an iframe. The dedicated iframe protocol page remains
// available only through its explicit `/chats_iframe/...` route (LoginPage
// selects it for `from=iframe`). Otherwise a normal deep link was silently
// redirected to IframeChatPage, whose new-thread state intentionally has no
// manually selected agent and therefore stopped at "选择 Agent".
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
// A bare external iframe historically opens the dedicated AgentSelector
// page. `resolveChatsIframeEntryRedirect` records that inferred context as
// `from=iframe`; explicit full-chat/presentation parameters stay here.
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
@@ -256,12 +268,78 @@ function NewChatEntryRoute({
);
}
function ChatsIndexEntryRoute({
layoutProps,
compactChrome,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
compactChrome: boolean;
}) {
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={compactChrome} />
</WorkspaceLayout>
);
}
function ChatThreadEntryRoute({
layoutProps,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
* Legacy links may still target it (or carry `from=iframe` during login), but
* ordinary iframe/Wujie embedding needs the normal full chat workspace.
* `chatMode=iframe` is the only explicit opt-in for integrations that really
* implement the Brainy protocol.
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
* remains on the normal full chat workspace.
*/
function IframeChatEntryRoute({
layoutProps,
@@ -341,9 +419,10 @@ export default function WorkspaceRoutes({
<Route
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} />
</WorkspaceLayout>
<ChatsIndexEntryRoute
layoutProps={layoutProps}
compactChrome={headerHidden && !compactActionsHidden}
/>
}
/>
<Route
@@ -352,13 +431,7 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
/>
<Route
path="chats_iframe/new"
@@ -665,6 +738,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
@@ -207,7 +207,7 @@ src/position-roundtable/demo/position-roundtable-full.json
## 9. 开发与排查清单
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。
1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
@@ -226,3 +226,11 @@ pnpm build
```powershell
git diff --check
```
## 11. 岗位角色配置
岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐
`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、
启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并
显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用;
自定义岗位可以新增、排序和删除。
@@ -130,10 +130,86 @@ interface ServerSession {
nodes?: ServerNode[];
}
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`;
const PRIMARY_API_PATH = "/api/position-roundtable";
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
/**
* Some intranet gateways only forward the long-established `/api/multi-agent`
* namespace. The position workspace was added later under its own top-level
* prefix, so a frontend-only release can work locally but receive FastAPI's
* route-level `{"detail":"Not Found"}` after deployment.
*
* Retry only that exact "route is absent" response against the compatibility
* namespace. A domain 404 such as "Position roundtable session not found"
* remains a real 404 and is never hidden by this routing fallback.
*/
let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null;
function replaceApiPath(url: string, apiPath: string): string {
return url.replace(PRIMARY_API_PATH, apiPath);
}
async function isMissingRoute(response: Response): Promise<boolean> {
if (response.status !== 404) return false;
const raw = await response.clone().text().catch(() => "");
try {
const parsed = JSON.parse(raw) as { detail?: unknown };
return parsed?.detail === "Not Found";
} catch {
const normalized = raw.trim();
return (
normalized === "Not Found" ||
/<title>\s*404 Not Found\s*<\/title>/i.test(normalized)
);
}
}
async function positionApiFetch(
input: string,
init?: RequestInit,
): Promise<Response> {
if (resolvedApiPath === COMPAT_API_PATH) {
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
return compatibilityResponse;
}
// A gateway may be reloaded while the SPA remains open. Re-probe the
// canonical route instead of pinning the tab to a now-stale route choice.
resolvedApiPath = null;
}
const primaryResponse = await apiFetch(input, init);
if (!(await isMissingRoute(primaryResponse))) {
resolvedApiPath = PRIMARY_API_PATH;
return primaryResponse;
}
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
resolvedApiPath = COMPAT_API_PATH;
return compatibilityResponse;
}
// Preserve the canonical response/error when neither route exists. This
// keeps diagnostics stable and identifies an outdated backend release.
return primaryResponse;
}
async function readJson<T>(response: Response, label: string): Promise<T> {
if (!response.ok) {
if (await isMissingRoute(response)) {
throw new Error(
`${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` +
`已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`,
);
}
const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`);
}
@@ -219,7 +295,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson<ServerSession[]>(
await apiFetch(`${BASE()}/sessions${params}`),
await positionApiFetch(`${BASE()}/sessions${params}`),
"list position sessions",
);
return result.map(toSession);
@@ -230,7 +306,7 @@ export async function createPositionSession(input: {
taskSnapshot: Record<string, unknown>;
}): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions`, {
await positionApiFetch(`${BASE()}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -244,8 +320,8 @@ export async function createPositionSession(input: {
}
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
if (response.status === 404) {
const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
if (response.status === 404 && !(await isMissingRoute(response))) {
// Consume the body so the connection can be reused, then expose a typed
// error that the page can resolve by selecting another history record.
await response.text().catch(() => "");
@@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot(
taskSnapshot: Record<string, unknown>,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }),
@@ -279,7 +355,7 @@ export async function savePositionIntent(
},
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -299,7 +375,7 @@ export async function activatePositionSession(
): Promise<PositionRoundtableSession> {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
@@ -311,7 +387,7 @@ export async function activatePositionSession(
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST",
}),
"archive position session",
@@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
}
export async function deletePositionSession(sessionId: string): Promise<void> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
// DELETE is deliberately idempotent from the UI's perspective. A stale
// history row that is already gone still needs to be removed locally.
if (response.status === 404) {
if (response.status === 404 && !(await isMissingRoute(response))) {
await response.text().catch(() => "");
return;
}
@@ -338,7 +414,7 @@ export async function bindPositionNodeThread(
threadId: string,
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{
method: "POST",
@@ -360,7 +436,7 @@ export async function savePositionNodeConversation(
},
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
{
method: "PUT",
@@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation(
): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/state" : "action-plan/state";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +492,7 @@ export async function seedPositionDemoSession(
payload: PositionDemoSeedPayload,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/demo/seed`, {
await positionApiFetch(`${BASE()}/demo/seed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -437,7 +513,7 @@ export async function getPositionNodeContext(
nodeKey: string,
): Promise<Record<string, unknown>> {
return readJson<Record<string, unknown>>(
await apiFetch(
await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" },
),
@@ -456,7 +532,7 @@ export async function completePositionNodeTurn(
): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson<ServerNode>(
await apiFetch(
await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{
method: "POST",
@@ -479,7 +555,7 @@ export async function rejectPositionNode(
input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{
method: "POST",
@@ -533,7 +609,7 @@ async function prepareBuiltinRun(
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
@@ -557,7 +633,7 @@ async function completeBuiltinRun(
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -0,0 +1,286 @@
/**
* 岗位会商风格的只读业务链流程图。
*
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
const FLOW_NODE_GAP = 12;
type FlowStage = {
goal: string | null;
seats: ChainSeat[];
};
export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
const seatsById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
if (chain.stages?.length) {
return chain.stages
.map((ids, index) => ({
goal: chain.stageGoals?.[index] ?? null,
seats: ids
.map((id) => seatsById.get(id))
.filter((seat): seat is ChainSeat => Boolean(seat)),
}))
.filter((stage) => stage.seats.length > 0);
}
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
>
<Search className="size-[18px]" strokeWidth={2.5} />
</button>
)}
</div>
{!chain ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-[#e8d0cc] px-5 text-center text-xs leading-6 text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
{loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
</div>
</DialogContent>
</Dialog>
</>
)}
</section>
);
}
function BusinessChainCanvas({
chain,
expanded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setPanning(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// The browser may have already released the pointer capture.
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
style={{
backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
title="按住鼠标拖动画布;双击复位"
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setView({ tx: drag.tx + event.clientX - drag.x, ty: drag.ty + event.clientY - drag.y });
}}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
);
return (
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
{stageIndex > 0 && (
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
)}
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat) => {
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}
function FlowConnector({
branches,
width,
nodeWidth,
nodeGap,
}: {
branches: number;
width: number;
nodeWidth: number;
nodeGap: number;
}) {
const connectorStyle = { backgroundColor: "rgba(148,163,184,.88)" };
if (branches <= 1) {
return (
<span className="relative mb-1 h-8 w-0.5 rounded-full" style={connectorStyle}>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
);
}
const nodeCenter = nodeWidth / 2;
return (
<div className="relative mb-1 h-9" style={{ width }}>
<span className="absolute left-1/2 top-0 h-3.5 w-0.5 -translate-x-1/2 rounded-full" style={connectorStyle} />
<span className="absolute top-3.5 h-0.5 rounded-full" style={{ left: nodeCenter, right: nodeCenter, ...connectorStyle }} />
{Array.from({ length: branches }).map((_, index) => (
<span
key={index}
className="absolute top-3.5 bottom-0 w-0.5 -translate-x-1/2 rounded-full"
style={{
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
...connectorStyle,
}}
>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
))}
</div>
);
}
function FlowNode({
title,
description,
accent,
completed = false,
width,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
)}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
</p>
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</article>
);
}
@@ -97,6 +97,7 @@ export function getPositionDemoTask(): PositionTask {
direction: String(task.direction ?? ""),
source: String(task.source ?? "演示数据"),
issuedAt: String(task.issuedAt ?? ""),
priority: String(task.priority ?? "紧急"),
};
}
@@ -13,6 +13,7 @@ export interface PositionTask {
direction: string;
source: string;
issuedAt: string;
priority?: string;
}
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
@@ -53,6 +54,7 @@ export function taskFromPositionSnapshot(
title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction),
priority: value(["priority", "level", "urgency"], fallback.priority ?? "紧急"),
source: (() => {
const source = value(["source", "taskSource", "sourceName"], fallback.source);
return source === "mock" ? "本系统创建" : source;
@@ -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 ?? "未分配岗位";
@@ -61,6 +61,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/contexts/ThemeContext";
import { isEmbeddedHost } from "@/core/embed/frame-context";
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { getAPIClient } from "@/core/api/api-client";
import { useAgent } from "@/core/agents";
@@ -72,6 +74,7 @@ import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import expandLogo from "@/assets/position-roundtable/expand-logo.svg";
import editTaskIcon from "@/assets/position-roundtable/edit-task.svg";
import { listChains } from "@/roundtable-planning/api/chains";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
@@ -95,11 +98,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 +146,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 = {
@@ -167,16 +170,135 @@ type SandboxThreadSnapshot = {
signature: string;
};
type PositionRoundtableParentReturnMessage = {
source: "deerflow-position-roundtable";
type: "deerflow:position-roundtable:return";
payload: {
route: string | null;
fallback: "history-back";
};
};
type PositionRoundtableBridgeInitMessage = {
source: "code-zk-roundtable-host";
type: "code-zk:roundtable-bridge:init";
};
const POSITION_ROUNDTABLE_BRIDGE_SOURCE = "code-zk-roundtable-host";
const POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE = "code-zk:roundtable-bridge:init";
const POSITION_ROUNDTABLE_BRIDGE_READY_TYPE = "deerflow:position-roundtable:bridge-ready";
function isEnabledEmbedFlag(value: string | null): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
/**
* Some hosts only retain URL parameters after the login redirect. Treat those
* established embed markers as an embedded context as well as checking the
* actual browser frame/Wujie runtime.
*/
function hasEmbeddedRouteHint(searchParams: URLSearchParams): boolean {
return (
searchParams.get("from") === "iframe" ||
searchParams.get("chatMode")?.trim().toLowerCase() === "iframe" ||
isEnabledEmbedFlag(searchParams.get("embed")) ||
isEnabledEmbedFlag(searchParams.get("hideChrome")) ||
isEnabledEmbedFlag(searchParams.get("hideHeader"))
);
}
/**
* `parentReturnTo` belongs to the embedding application, not this SPA. Keep
* it constrained to a route/hash path so the same-origin fallback can never
* turn an externally supplied URL into an arbitrary top-level navigation.
*/
function parentReturnRoute(value: string | null): string | null {
const route = value?.trim();
if (!route || route.length > 2048) return null;
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
return null;
}
return route;
}
/**
* Request navigation from the embedding host. A real iframe parent is always
* the nearest integration boundary, even if an outer layer happens to be
* Wujie. This matters for Wujie host -> code-zk -> DeerFlow iframe: code-zk
* owns the postMessage contract and its own router. Wujie's event bus is only
* an additional fallback for a directly mounted Wujie child.
*/
function returnToEmbeddingParent(route: string | null, bridgePort?: MessagePort | null): void {
const message: PositionRoundtableParentReturnMessage = {
source: "deerflow-position-roundtable",
type: "deerflow:position-roundtable:return",
payload: { route, fallback: "history-back" },
};
if (typeof window === "undefined") return;
// In a Wujie host -> code-zk -> DeerFlow topology, the DOM iframe can be
// re-parented by Wujie. A port transferred by code-zk survives that DOM
// topology change and is the only channel guaranteed to reach code-zk.
if (bridgePort) {
try {
bridgePort.postMessage(message);
console.info("[position-roundtable] posted return through MessageChannel bridge");
return;
} catch {
// The owner may have unmounted/reloaded. Fall back to native bridges.
}
}
console.info("[position-roundtable] embedded return requested", {
route,
hasNativeParent: window.parent !== window,
wujie: isWujieEnv(),
});
if (window.parent === window) {
// Some Wujie execution modes expose no meaningful native parent window.
// In that direct-mount case the event bus is the only available bridge.
if (isWujieEnv()) {
emitWujieEvent("position-roundtable:return", message.payload);
console.info("[position-roundtable] emitted Wujie-only return event");
}
return;
}
try {
if (window.parent.location.origin === window.location.origin) {
if (route?.startsWith("#")) {
window.parent.location.hash = route;
} else if (route) {
window.parent.location.assign(route);
} else {
window.parent.history.back();
}
console.info("[position-roundtable] completed same-origin parent return");
return;
}
} catch {
// Cross-origin parent: fall through to the explicit postMessage contract.
}
window.parent.postMessage(message, "*");
console.info("[position-roundtable] posted return message to direct parent");
// A nested iframe must never skip the direct-parent notification merely
// because Wujie markers were inherited or injected. Emitting as well keeps
// directly mounted Wujie integrations compatible when their host listens.
if (isWujieEnv()) {
emitWujieEvent("position-roundtable:return", message.payload);
console.info("[position-roundtable] emitted supplemental Wujie return event");
}
}
/**
* 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 +441,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 +680,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 =
@@ -584,6 +737,50 @@ export default function PositionRoundtablePage({
: rawSessionIdFromUrl;
const forceNewSession =
!standaloneDemoMode && searchParams.get("new") === "1";
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
const isEmbeddingContext = embeddedHost || hasEmbeddedRouteHint(searchParams);
const embeddingParentReturnRoute = useMemo(
() =>
parentReturnRoute(
searchParams.get("parentReturnTo") ??
searchParams.get("parentRoute") ??
searchParams.get("returnTo"),
),
[searchParamsString],
);
const embeddingBridgePortRef = useRef<MessagePort | null>(null);
const handleReturnToEmbeddingParent = useCallback(() => {
returnToEmbeddingParent(embeddingParentReturnRoute, embeddingBridgePortRef.current);
}, [embeddingParentReturnRoute]);
useEffect(() => {
const receiveBridgePort = (event: MessageEvent<PositionRoundtableBridgeInitMessage>) => {
const message = event.data;
const bridgePort = event.ports?.[0];
if (
message?.source !== POSITION_ROUNDTABLE_BRIDGE_SOURCE ||
message?.type !== POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE ||
!bridgePort
) {
return;
}
embeddingBridgePortRef.current?.close();
embeddingBridgePortRef.current = bridgePort;
bridgePort.start?.();
bridgePort.postMessage({
source: "deerflow-position-roundtable",
type: POSITION_ROUNDTABLE_BRIDGE_READY_TYPE,
});
console.info("[position-roundtable] MessageChannel bridge connected");
};
window.addEventListener("message", receiveBridgePort);
return () => {
window.removeEventListener("message", receiveBridgePort);
embeddingBridgePortRef.current?.close();
embeddingBridgePortRef.current = null;
};
}, []);
const [positionSession, setPositionSession] = useState<PositionRoundtableSession | null>(null);
const positionSessionRef = useRef<PositionRoundtableSession | null>(null);
const sessionCreationRef = useRef<Promise<PositionRoundtableSession> | null>(null);
@@ -648,14 +845,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" }),
@@ -875,6 +1072,7 @@ export default function PositionRoundtablePage({
const nextTask: PositionTask = {
...task,
title,
priority: taskDraft.priority?.trim() || task.priority || "紧急",
description: taskDraft.description.trim(),
direction: taskDraft.direction.trim() || task.direction,
source: taskDraft.source.trim() || task.source,
@@ -1026,7 +1224,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 +2247,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 +2266,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 +2286,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 +2300,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">
@@ -2248,6 +2446,17 @@ export default function PositionRoundtablePage({
</div>
<div className="flex shrink-0 items-center gap-2">
{isEmbeddingContext && (
<TButton
size="small"
variant="outline"
className="shrink-0 whitespace-nowrap"
icon={<ArrowLeft className="size-3.5" />}
onClick={handleReturnToEmbeddingParent}
>
返回
</TButton>
)}
<TButton
size="small"
variant="outline"
@@ -2387,49 +2596,43 @@ export default function PositionRoundtablePage({
<aside
aria-hidden={leftSidebarHidden || undefined}
className={cn(
"position-roundtable-sidebar-scrollbar custom-scrollbar relative flex min-h-0 shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-sm transition-[width,opacity,transform,padding,border-width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none dark:rounded-none dark:border-0 dark:bg-transparent dark:text-[#c5cfe0] dark:shadow-none",
"position-roundtable-sidebar-scrollbar custom-scrollbar relative flex min-h-0 shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border border-slate-200 shadow-sm transition-[width,opacity,transform,padding,border-width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none dark:rounded-none dark:border-0 dark:bg-transparent dark:text-[#c5cfe0] dark:shadow-none",
leftSidebarHidden
? "w-0 translate-x-4 !border-0 !p-0 opacity-0 pointer-events-none"
: "w-[360px] translate-x-0 p-3 opacity-100 dark:p-0",
)}
>
<TaskAnalysisPanel
task={task}
intent={frozenIntent ?? intent.intentReady}
taskEditable={!isArchivedSession}
intentEditable={!isArchivedSession}
onEditTask={openTaskEditor}
onEditIntent={openIntentEditor}
collapsed={false}
onCollapsedChange={setLeftPanelCollapsed}
className="min-h-full"
>
{activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && (
<BusinessChainSelector
chains={chains}
loading={chainsLoading}
value={selectedChainId}
onChange={setSelectedChainId}
onConfigure={openPositionConfig}
className="min-h-0 flex-1"
{activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && (
<PositionRoundtableEntrySidebar
task={task}
editable={!isArchivedSession}
onEditTask={openTaskEditor}
chains={chains}
loading={chainsLoading}
value={selectedChainId}
onChange={setSelectedChainId}
onConfigure={openPositionConfig}
/>
)}
{(!activePosition.isIntentPosition || isIntelligenceDeliveryOverview) && (
<>
<TaskInformationCard
task={task}
editable={!isArchivedSession}
onEdit={openTaskEditor}
collapsed={false}
onCollapsedChange={() => undefined}
/>
)}
{(!activePosition.isIntentPosition || isIntelligenceDeliveryOverview) && (
<BusinessChainFlow
chain={activeChain}
nodes={positionSession?.nodes ?? []}
session={positionSession}
activeNodeKey={activeNodeKey}
onSelectIntent={
activePosition.isIntentPosition ? openIntentFlowDetail : undefined
}
onSelectNode={openNodeFlowDetail}
onSelectSeat={openSeatFlowDetail}
onSelectBuiltin={handleSelectBuiltinStage}
embedded
/>
)}
</TaskAnalysisPanel>
</>
)}
</aside>
{leftSidebarHidden && (
@@ -2517,6 +2720,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 +2765,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 +3553,7 @@ function PositionDeliveryOverview({
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt: intentCompletedAt || session?.updatedAt,
status: intent ? "done" : "ready",
accent: "#8b5cf6",
@@ -3674,6 +3879,82 @@ function TaskAnalysisPanel({
);
}
function PositionRoundtableEntrySidebar({
task,
editable,
onEditTask,
chains,
loading,
value,
onChange,
onConfigure,
}: {
task: PositionTask;
editable: boolean;
onEditTask: () => void;
chains: BusinessChain[];
loading: boolean;
value: string;
onChange: (value: string) => void;
onConfigure: (chainId: string) => void;
}) {
const displayDescription =
task.id === DEFAULT_MOCK_POSITION_TASK_ID
? "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。"
: desensitize(task.description);
return (
<div className="position-roundtable-entry-sidebar">
<section className="position-roundtable-entry-task-card">
{editable && (
<button type="button" className="position-roundtable-entry-edit" onClick={onEditTask} aria-label="编辑任务信息">
<img src={editTaskIcon} alt="" />
</button>
)}
<h2 title={desensitize(task.title)}>{desensitize(task.title)}</h2>
<div className="position-roundtable-entry-tags">
<span>{desensitize(task.priority || "紧急")}</span>
<span>{desensitize(task.source)}</span>
<span>{desensitize(task.direction)}</span>
</div>
<p>{desensitize(task.description)}</p>
</section>
<section className="position-roundtable-entry-chain-card">
<header>
<span aria-hidden="true" />
<h2>作业链条选择</h2>
<b>{chains.length}</b>
</header>
<div className="position-roundtable-entry-chain-list">
{loading ? (
<div className="position-roundtable-entry-empty"><Loader2 className="size-4 animate-spin" />正在加载作业链条…</div>
) : chains.length === 0 ? (
<div className="position-roundtable-entry-empty">暂无可用作业链条</div>
) : (
chains.map((chain) => {
const selected = chain.id === value;
return (
<article key={chain.id} className={selected ? "is-selected" : undefined}>
<button type="button" onClick={() => onChange(selected ? "" : chain.id)} aria-pressed={selected}>
<i aria-hidden="true" />
<span>{desensitize(chain.title)}</span>
<em>{chain.seats.length} 席</em>
<small>{desensitize(chain.description || "点击选择该业务链")}</small>
</button>
<button type="button" className="position-roundtable-entry-config" onClick={() => onConfigure(chain.id)} aria-label={`配置岗位:${desensitize(chain.title)}`}>
<Settings className="size-3.5" />
</button>
</article>
);
})
)}
</div>
</section>
</div>
);
}
function TaskInformationCard({
task,
editable,
@@ -3688,8 +3969,34 @@ function TaskInformationCard({
onCollapsedChange: (collapsed: boolean) => void;
}) {
return (
<section className="group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(43,143,254,0.6)] dark:bg-[#032650] dark:shadow-none">
<div className="relative flex h-10 items-center border-b border-rose-100 bg-[linear-gradient(89.65deg,rgba(245,34,45,0.10)_0.03%,rgba(245,34,45,0)_99.97%)] px-4 [&_h2]:!text-slate-900 dark:!bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] dark:border-b-[#2b8ffe]/60 dark:[&_h2]:!text-white dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<section className="position-roundtable-task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none">
<div className="position-roundtable-task-card__content relative z-10">
<h2
title={desensitize(task.title)}
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
style={{ fontFamily: "'Noto Sans SC', sans-serif" }}
>
<span className="line-clamp-1">{desensitize(task.title)}</span>
</h2>
<div
data-task-tags
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
style={{ fontFamily: "'Noto Sans SC', sans-serif" }}
>
<span className="rounded-[2px] border border-[#cf1322] bg-[rgba(207,19,34,0.5)] px-2 py-0.5 text-center text-[16px] font-light leading-4 text-white">紧急</span>
<span className="rounded-[2px] border border-[#4bcdff] bg-[rgba(75,205,255,0.3)] px-2 py-0.5 text-[16px] font-light leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]">{desensitize(task.source)}</span>
<span className="rounded-[2px] border border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] px-2 py-0.5 text-[16px] font-light leading-[18px] text-[#a2e1f9]">{desensitize(task.direction)}</span>
</div>
<p
data-task-description
title={desensitize(task.description)}
className="mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]"
style={{ fontFamily: "'Noto Sans SC', sans-serif" }}
>
{desensitize(task.description)}
</p>
</div>
<div style={{ display: "none" }} className="relative flex h-10 items-center border-b border-rose-100 bg-[linear-gradient(89.65deg,rgba(245,34,45,0.10)_0.03%,rgba(245,34,45,0)_99.97%)] px-4 [&_h2]:!text-slate-900 dark:!bg-[linear-gradient(89.65deg,#0062d9_0.03%,#0062d900_99.97%)] dark:border-b-[#2b8ffe]/60 dark:[&_h2]:!text-white dark:after:absolute dark:after:inset-x-0 dark:after:bottom-[-1px] dark:after:h-px dark:after:bg-[#45c3f3]/85 dark:after:content-['']">
<span
aria-hidden="true"
className="mr-3 h-5 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
@@ -3710,8 +4017,9 @@ function TaskInformationCard({
</div>
</div>
<div
style={{ display: "none" }}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"hidden grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
)}
>
@@ -3769,6 +4077,20 @@ function TaskInformationEditDialog({
<DialogTitle>编辑任务信息</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
<div className="grid grid-cols-3 gap-3">
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">标签一</span>
<TInput value={task.priority ?? ""} onChange={(value) => onChange({ ...task, priority: String(value) })} placeholder="例如:紧急" />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">标签二</span>
<TInput value={task.source} onChange={(value) => onChange({ ...task, source: String(value) })} placeholder="例如:本系统创建" />
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">标签三</span>
<TInput value={task.direction} onChange={(value) => onChange({ ...task, direction: String(value) })} placeholder="例如:暑期活动" />
</label>
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务名称</span>
<TInput
@@ -3844,19 +4166,25 @@ function BusinessChainSelector({
const [relationOpenId, setRelationOpenId] = useState<string | null>(null);
return (
<section className={cn("group/business-chain flex min-h-0 flex-col", className)}>
<div className="relative flex h-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
<section className={cn("position-roundtable-chain-selector group/business-chain flex min-h-0 flex-col rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none [&>div>h3:first-of-type]:hidden", className)}>
<div className="relative flex h-8 shrink-0 items-center">
<span
aria-hidden="true"
className="mr-3 h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167.47deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167.47deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<h3 className="text-lg font-[550] tracking-wide text-slate-900 dark:text-white">业务链</h3>
<h3
className="text-[18px] font-medium leading-7 tracking-wide text-slate-900 dark:text-white"
style={{ fontFamily: "'Noto Sans SC', sans-serif" }}
>
作业链条选择
</h3>
<span className="ml-auto flex size-6 items-center justify-center rounded-[4px] bg-slate-100 text-xs font-semibold tabular-nums text-slate-500 dark:bg-[#0b386d] dark:text-[#83c1ff]">
{chains.length}
</span>
</div>
<div className="business-chain-scrollbar min-h-0 flex-1 space-y-2.5 overflow-y-auto py-4 pr-1 custom-scrollbar">
<div className="business-chain-scrollbar min-h-0 flex-1 space-y-2.5 overflow-y-auto pt-4 pr-1 custom-scrollbar">
{loading ? (
<div className="flex h-20 items-center justify-center gap-2 text-xs text-slate-400 dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
@@ -4188,23 +4516,17 @@ const FLOW_NODE_GAP = 10;
function BusinessChainFlow({
chain,
nodes,
session,
activeNodeKey,
onSelectIntent,
onSelectNode,
onSelectSeat,
onSelectBuiltin,
className,
embedded = false,
}: {
chain: BusinessChain | null;
nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null;
onSelectIntent?: () => void;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
className?: string;
embedded?: boolean;
}) {
@@ -4260,12 +4582,9 @@ function BusinessChainFlow({
<ChainFlowCanvas
chain={chain}
nodes={nodes}
session={session}
activeNodeKey={activeNodeKey}
onSelectIntent={onSelectIntent}
onSelectNode={onSelectNode}
onSelectSeat={onSelectSeat}
onSelectBuiltin={onSelectBuiltin}
className="min-h-0 flex-1"
/>
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
@@ -4280,16 +4599,7 @@ function BusinessChainFlow({
<ChainFlowCanvas
chain={chain}
nodes={nodes}
session={session}
activeNodeKey={activeNodeKey}
onSelectIntent={
onSelectIntent
? () => {
onSelectIntent();
setFlowDialogOpen(false);
}
: undefined
}
onSelectNode={(node) => {
onSelectNode(node);
setFlowDialogOpen(false);
@@ -4298,10 +4608,6 @@ function BusinessChainFlow({
onSelectSeat?.(seat, stageIndex, seatIndex);
setFlowDialogOpen(false);
}}
onSelectBuiltin={(kind) => {
onSelectBuiltin(kind);
setFlowDialogOpen(false);
}}
className="h-full"
expanded
/>
@@ -4317,23 +4623,17 @@ function BusinessChainFlow({
function ChainFlowCanvas({
chain,
nodes,
session,
activeNodeKey,
onSelectIntent,
onSelectNode,
onSelectSeat,
onSelectBuiltin,
className,
expanded = false,
}: {
chain: BusinessChain;
nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null;
onSelectIntent?: () => void;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
className?: string;
expanded?: boolean;
}) {
@@ -4349,12 +4649,6 @@ function ChainFlowCanvas({
} | null>(null);
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
const allDone = allExecutableNodesDone(nodes);
const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
const actionPlanStatus = builtinNodeStatus(
session?.actionPlanSnapshot,
snapshotStatus(session?.summarySnapshot) === "done",
);
const connectorColor =
theme === "dark" ? "rgba(143, 196, 255, 0.95)" : "rgba(148, 163, 184, 0.88)";
@@ -4412,14 +4706,6 @@ function ChainFlowCanvas({
className="flex min-h-full min-w-max flex-col items-center p-5"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<CompactChainNode
title="情报分析智能体"
description="情报分析岗"
accent="#3b82f6"
nodeStatus={nodes.length > 0 ? "done" : "ready"}
width={nodeWidth}
onClick={onSelectIntent}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
@@ -4430,13 +4716,15 @@ function ChainFlowCanvas({
key={`${stage.goal ?? "stage"}-${stageIndex}`}
className="flex flex-col items-center"
>
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
{stageIndex > 0 && (
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
)}
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat, seatIndex) => {
const node = nodes.find(
@@ -4472,38 +4760,6 @@ function ChainFlowCanvas({
</div>
);
})}
<VerticalFlowConnector
seatCount={1}
width={nodeWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
<CompactChainNode
title="方案总结智能体"
description="内置总结节点"
accent="#6366f1"
nodeStatus={summaryStatus}
width={nodeWidth}
titleAttr="对所有有效交付产物进行方案总结"
onClick={() => onSelectBuiltin("summary")}
/>
<VerticalFlowConnector
seatCount={1}
width={nodeWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
<CompactChainNode
title="行动规划智能体"
description="内置规划节点"
accent="#0ea5e9"
nodeStatus={actionPlanStatus}
width={nodeWidth}
titleAttr="基于方案总结和岗位产物拆解子任务"
onClick={() => onSelectBuiltin("action_plan")}
/>
</div>
</div>
);
@@ -4644,6 +4900,7 @@ function CompactChainNode({
}
function RightWorkspace({
positions,
selectedAgent,
selectedAgentId,
onSelectAgent,
@@ -4665,6 +4922,7 @@ function RightWorkspace({
onTabChange,
standaloneDemo = false,
}: {
positions: readonly PositionOption[];
selectedAgent: Agent | null;
selectedAgentId: string | null;
onSelectAgent: (agentId: string) => void;
@@ -4782,6 +5040,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 +5357,7 @@ function buildIntentResultItem(
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt,
status: intent ? "done" : "ready",
};
@@ -5207,6 +5466,7 @@ function buildBuiltinResultItem(
}
function AnalysisResults({
positions,
intentObjective,
intentCompletedAt,
nodes,
@@ -5217,6 +5477,7 @@ function AnalysisResults({
onOpenBuiltinChat,
readOnly = false,
}: {
positions: readonly PositionOption[];
intentObjective?: string;
intentCompletedAt: string;
nodes: PositionRoundtableNode[];
@@ -5258,7 +5519,7 @@ function AnalysisResults({
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intentObjective,
positionId: DEFAULT_POSITION_ID,
positionId: intentPositionIdFor(),
updatedAt: undefined,
status: "done",
});
@@ -5410,11 +5671,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(
() => [
@@ -69,3 +69,120 @@
.position-roundtable-intent-model-switch {
border-radius: var(--prt-intent-radius);
}
/* 岗位会商第一步左侧栏:使用独立样式,避免与通用 Tailwind 面板规则相互覆盖。 */
.dark .position-roundtable-task-card {
padding: 10px 15px !important;
background: #032650;
border-color: rgba(0, 110, 254, 0.5);
}
.dark .position-roundtable-task-card [data-task-description] {
color: #96bbf8 !important;
}
.dark .position-roundtable-chain-selector {
background: #032650;
border-color: rgba(0, 110, 254, 0.5);
}
/* First-step entry rail is a self-contained redraw; do not inherit legacy panel rules. */
.dark .position-roundtable-sidebar-scrollbar:has(.position-roundtable-entry-sidebar) {
display: block;
overflow: hidden;
padding: 0;
background: transparent !important;
}
.position-roundtable-entry-sidebar {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 20px;
font-family: "Noto Sans SC", sans-serif;
}
.position-roundtable-entry-task-card,
.position-roundtable-entry-chain-card {
box-sizing: border-box;
border: 1px solid rgba(0, 110, 254, 0.5);
border-radius: 4px;
background: #032650;
}
.position-roundtable-entry-task-card {
position: relative;
flex: none;
padding: 10px 15px;
}
.position-roundtable-entry-task-card h2 {
margin: 0;
overflow: hidden;
color: #fff;
font-size: 18px;
font-weight: 500;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-entry-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.position-roundtable-entry-tags span {
display: inline-flex;
box-sizing: border-box;
min-height: 28px;
align-items: center;
padding: 3px 8px;
border: 1px solid;
border-radius: 2px;
font-size: 16px;
font-weight: 300;
line-height: 20px;
white-space: nowrap;
}
.position-roundtable-entry-tags span:nth-child(1) { border-color: #cf1322; background: rgba(207, 19, 34, 0.5); color: #fff; }
.position-roundtable-entry-tags span:nth-child(2) { border-color: #4bcdff; background: rgba(75, 205, 255, 0.3); color: #4bcdff; }
.position-roundtable-entry-tags span:nth-child(3) { border-color: #a2e1f9; background: rgba(162, 225, 249, 0.3); color: #a2e1f9; }
.position-roundtable-entry-task-card > p {
display: -webkit-box;
margin: 12px 0 0;
overflow: hidden;
color: #96bbf8;
color: #96bbf8 !important;
font-size: 16px;
line-height: 22px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.position-roundtable-entry-edit { position: absolute; top: 8px; right: 8px; display: none; width: 16px; height: 16px; padding: 0; cursor: pointer; color: #4bcdff; }
.position-roundtable-entry-edit img { display: block; width: 16px; height: 16px; }
.position-roundtable-entry-task-card:hover .position-roundtable-entry-edit,
.position-roundtable-entry-edit:focus-visible { display: inline-flex; }
.position-roundtable-entry-chain-card { display: flex; min-height: 0; flex: 1; flex-direction: column; padding: 10px 15px; }
.position-roundtable-entry-chain-card header { display: flex; height: 36px; flex: none; align-items: center; gap: 10px; }
.position-roundtable-entry-chain-card header > span { width: 4px; height: 16px; border-radius: 2px 0 2px 0; background: linear-gradient(165.96deg, #a2e1f9 0%, #45c3f3 100%); }
.position-roundtable-entry-chain-card h2 { margin: 0; color: #fff; font-size: 18px; font-weight: 500; line-height: 28px; }
.position-roundtable-entry-chain-card header b { margin-left: auto; padding: 2px 9px; border-radius: 4px; background: #0b234c; color: #a2e1f9; font-size: 16px; line-height: 24px; }
.position-roundtable-entry-chain-list { min-height: 0; flex: 1; margin-top: 10px; overflow-y: auto; }
.position-roundtable-entry-chain-list article { position: relative; margin-bottom: 10px; border: 1px solid rgba(43, 143, 254, 0.35); border-radius: 4px; background: rgba(11, 56, 109, 0.45); }
.position-roundtable-entry-chain-list article.is-selected { border-color: #2b8ffe; background: rgba(43, 143, 254, 0.18); box-shadow: 0 0 0 1px rgba(43, 143, 254, 0.22); }
.position-roundtable-entry-chain-list article > button:first-child { display: grid; width: 100%; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; padding: 14px; color: #d8e6f8; text-align: left; }
.position-roundtable-entry-chain-list i { width: 14px; height: 14px; align-self: center; border: 4px solid #2b8ffe; border-radius: 50%; box-sizing: border-box; }
.position-roundtable-entry-chain-list article:not(.is-selected) i { border: 1px solid #83b6ed; }
.position-roundtable-entry-chain-list article > button:first-child > span { overflow: hidden; font-size: 16px; font-weight: 500; line-height: 22px; text-overflow: ellipsis; white-space: nowrap; }
.position-roundtable-entry-chain-list em { border-radius: 16px; background: rgba(162, 225, 249, 0.16); padding: 2px 8px; color: #a2e1f9; font-size: 14px; font-style: normal; line-height: 20px; }
.position-roundtable-entry-chain-list small { grid-column: 2 / 4; color: #96bbf8; font-size: 14px; line-height: 22px; }
.position-roundtable-entry-config { position: absolute; top: 10px; right: 10px; display: inline-flex; padding: 4px; color: #83c1ff; }
.position-roundtable-entry-empty { display: flex; min-height: 80px; align-items: center; justify-content: center; gap: 8px; color: #96bbf8; font-size: 14px; }
@@ -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`。
@@ -82,8 +82,11 @@ import { listAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
POSITION_OPTIONS,
setRuntimePositionOptions,
type PositionOption,
type PositionId,
} from "@/position-roundtable/lib/positions";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import {
filterRecommendCandidates,
@@ -160,6 +163,33 @@ export default function BusinessChainEditorPage() {
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
const positionConfigMode = searchParams.get("mode") === "position";
// This is the same directory the administrator edits in “岗位角色配置”.
// The editor used to read the static POSITION_OPTIONS array below, which
// meant a newly configured role never appeared in this seat dropdown.
const { data: configuredPositionRoles = [] } = usePositionRoles({
refreshOnMountOrFocus: positionConfigMode,
});
const positionOptions = useMemo<PositionOption[]>(
() =>
configuredPositionRoles.length > 0
? [...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],
);
// Keep flow previews and historical seat labels on the same live directory.
useEffect(() => {
setRuntimePositionOptions(positionOptions);
}, [positionOptions]);
const requestedReturnTo = searchParams.get("returnTo");
const returnPath =
positionConfigMode && requestedReturnTo?.startsWith("/")
@@ -1244,6 +1274,7 @@ export default function BusinessChainEditorPage() {
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatModeChange={setSeatModeCb}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
onSeatPositionChange={setSeatPositionCb}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
@@ -1426,6 +1457,7 @@ function StagesColumn({
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
onSeatPositionChange,
onStageGoalChange,
goalOptimizingIdx,
@@ -1447,6 +1479,8 @@ function StagesColumn({
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
positionConfigMode: boolean;
/** Live directory from the administrator's position-role configuration. */
positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
@@ -1554,6 +1588,7 @@ function StagesColumn({
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatModeChange={onSeatModeChange}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
onSeatPositionChange={onSeatPositionChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
@@ -1587,6 +1622,7 @@ function StageSection({
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
onSeatPositionChange,
onGoalChange,
goalOptimizing,
@@ -1609,6 +1645,7 @@ function StageSection({
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
positionConfigMode: boolean;
positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
@@ -1774,6 +1811,7 @@ function StageSection({
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
showPositionConfig={positionConfigMode}
positionOptions={positionOptions}
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/>
@@ -1801,6 +1839,7 @@ function SeatStrip({
onSkillDirectiveChange,
onSeatModeChange,
showPositionConfig,
positionOptions,
onPositionChange,
onDetail,
}: {
@@ -1822,12 +1861,41 @@ function SeatStrip({
onSeatModeChange: (mode: SeatMode | null) => void;
/** 仅岗位协同入口展示的岗位配置。 */
showPositionConfig: boolean;
/** Current position directory, injected by the business-chain editor. */
positionOptions: readonly PositionOption[];
onPositionChange: (positionId: PositionId | null) => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
const selectablePositionOptions = useMemo(() => {
const assignedId = seat.position_id ?? null;
const assigned = assignedId
? positionOptions.find((position) => position.id === assignedId)
: undefined;
const enabled = positionOptions.filter((position) => position.enabled !== false);
// Do not silently erase an archived configuration from a saved chain.
// It remains visible but cannot be selected for another seat.
const historical =
assignedId && (!assigned || assigned.enabled === false)
? [
{
label: assigned
? `${assigned.name}(已停用)`
: `${assignedId}(岗位已删除)`,
value: assignedId,
disabled: true,
},
]
: [];
return [
{ label: "未分配", value: "" },
...historical,
...enabled.map((position) => ({ label: position.name, value: position.id })),
];
}, [positionOptions, seat.position_id]);
return (
<div
ref={setNodeRef}
@@ -1880,13 +1948,7 @@ function SeatStrip({
<TSelect
size="small"
value={seat.position_id ?? ""}
options={[
{ label: "未分配", value: "" },
...POSITION_OPTIONS.map((position) => ({
label: position.name,
value: position.id,
})),
]}
options={selectablePositionOptions}
onChange={(value) =>
onPositionChange(value ? (String(value) as PositionId) : null)
}
@@ -296,6 +296,8 @@ export default function RoundtablePlanningPage({
storageTaskId,
/** 供独立业务页在原页头部插入自己的操作入口。 */
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
}: {
@@ -304,6 +306,7 @@ export default function RoundtablePlanningPage({
lockBusinessChain?: boolean;
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
rootClassName?: string;
}) {
const { theme } = useTheme();
@@ -2170,6 +2173,7 @@ export default function RoundtablePlanningPage({
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
/>
{/* MIDDLE VIEWPORT */}
@@ -2729,6 +2733,8 @@ interface LeftSidebarProps {
lockBusinessChain?: boolean;
/** 独立页面锁定显示的业务链。 */
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2779,6 +2785,7 @@ function LeftSidebar({
entryRailBusinessCode,
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2921,6 +2928,7 @@ function LeftSidebar({
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
/>
)}
</section>
@@ -2975,6 +2983,7 @@ function RoundtableEntryRail({
businessCode,
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -2989,6 +2998,7 @@ function RoundtableEntryRail({
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3035,7 +3045,7 @@ function RoundtableEntryRail({
useEffect(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
if (deeplink || lockBusinessChain) {
void refresh();
return;
}
@@ -3068,7 +3078,12 @@ function RoundtableEntryRail({
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-3",
lockBusinessChain && "overflow-y-auto pr-1 custom-scrollbar",
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
isXdfx ? (
@@ -3110,6 +3125,8 @@ function RoundtableEntryRail({
</div>
</div>
)
) : lockBusinessChain && businessChainSlot ? (
businessChainSlot
) : (
/* 业务链(公共列表,内部滚动) */
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
+45 -18
View File
@@ -29,10 +29,12 @@ import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean) =>
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
cn(
"size-8 shrink-0 rounded-sm",
isDark
isCustomTheme
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: isPaleLightTheme
? "text-[var(--foreground)] hover:bg-white/55 hover:text-[var(--foreground)] active:bg-white/75"
@@ -48,9 +50,22 @@ type HeaderProps = {
};
const Header = ({ collapsed }: HeaderProps) => {
const { theme } = useTheme();
const isDark = theme === "dark";
const { theme, baseMode } = useTheme();
const isDark = baseMode === "dark";
const isCustomTheme = theme.startsWith("custom-theme-");
const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow";
const headerTone = isCustomTheme
? "banner-custom"
: isDark
? "banner-dark"
: isPaleLightTheme
? "content"
: "banner-light";
const headerTextClass = isCustomTheme
? "text-[var(--navigation-foreground)]"
: isPaleLightTheme
? "text-[var(--foreground)]"
: "text-white";
const darkLogoPosition =
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
const isDarkLogoOnLeft = darkLogoPosition === "left";
@@ -152,11 +167,11 @@ const Header = ({ collapsed }: HeaderProps) => {
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = (
<>
<CompactShortcutButtons tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
<ThemeSwitcher tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
<CompactShortcutButtons tone={headerTone} />
<ThemeSwitcher tone={headerTone} />
<LayoutModeSwitcher
tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"}
tone={headerTone}
fallback="full"
/>
@@ -165,7 +180,7 @@ const Header = ({ collapsed }: HeaderProps) => {
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark, isPaleLightTheme), "relative")}
className={cn(iconButtonClass(isDark, isPaleLightTheme, isCustomTheme), "relative")}
title="通知"
>
<Bell className="size-4" />
@@ -186,7 +201,13 @@ const Header = ({ collapsed }: HeaderProps) => {
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : isPaleLightTheme ? "bg-[var(--brand-900)]/25" : "bg-white/30",
isCustomTheme
? "bg-[var(--navigation-foreground)]/25"
: isDark
? "bg-blue-800/30"
: isPaleLightTheme
? "bg-[var(--brand-900)]/25"
: "bg-white/30",
)}
aria-hidden
/>
@@ -196,7 +217,9 @@ const Header = ({ collapsed }: HeaderProps) => {
variant="ghost"
className={cn(
"h-8 gap-2 rounded-sm px-2",
isDark
isCustomTheme
? "text-[var(--navigation-foreground)] hover:bg-white/20 active:bg-white/30"
: isDark
? "text-slate-200 hover:bg-slate-800/50"
: isPaleLightTheme
? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75"
@@ -206,7 +229,9 @@ const Header = ({ collapsed }: HeaderProps) => {
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
isCustomTheme
? "bg-white/25"
: isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
)}
@@ -214,16 +239,16 @@ const Header = ({ collapsed }: HeaderProps) => {
<User
className={cn(
"size-4",
isDark ? "text-white" : "text-[var(--brand-900)]",
isDark && !isCustomTheme ? "text-white" : "text-[var(--brand-900)]",
)}
/>
</div>
<div className="hidden text-left sm:block">
<p className={cn("text-sm leading-tight font-medium", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
<p className={cn("text-sm leading-tight font-medium", headerTextClass)}>
{displayName}
</p>
{positionName && (
<p className={cn("text-[11px] leading-tight", isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/70")}>
<p className={cn("text-[11px] leading-tight", isCustomTheme ? "text-[var(--navigation-foreground)]/75" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/70")}>
{positionName}
</p>
)}
@@ -231,7 +256,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/80",
isCustomTheme ? "text-[var(--navigation-foreground)]/80" : isDark ? "text-slate-500" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/80",
)}
/>
</Button>
@@ -323,7 +348,7 @@ const Header = ({ collapsed }: HeaderProps) => {
onError={handleBrandLogoError}
/>
</div>
<span className={cn("whitespace-nowrap text-lg font-semibold", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
{desensitizeText("CM业务中心")}
</span>
</div>
@@ -342,7 +367,9 @@ const Header = ({ collapsed }: HeaderProps) => {
<div
className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
isCustomTheme
? "bg-white/25"
: isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: isPaleLightTheme
? "bg-white/55"
@@ -358,7 +385,7 @@ const Header = ({ collapsed }: HeaderProps) => {
/>
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
<span className={cn("text-sm font-semibold", headerTextClass)}>
{desensitizeText("CM业务中心")}
</span>
)}
@@ -0,0 +1,338 @@
/**
* 通知模板管理 API 客户端 + React Query hooks。
*
* 后端:`/api/notification-templates`(见 app/gateway/routers/notification_templates.py)。
* 全局共享;读/写均仅 admin。snake_case ⇄ camelCase 在此边界收敛。
*/
import {
keepPreviousData,
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 const NOTIFICATION_TEMPLATE_VARIABLES = [
"userName",
"taskName",
"taskId",
"dueDate",
] as const;
export type NotificationTemplateVariable =
(typeof NOTIFICATION_TEMPLATE_VARIABLES)[number];
export interface NotificationTemplate {
id: string;
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
version: number;
createdBy: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface NotificationTemplateInput {
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
}
export interface NotificationTemplateUpdateInput extends NotificationTemplateInput {
expectedVersion: number;
}
export interface ListNotificationTemplatesParams {
keyword?: string;
enabled?: boolean | null;
tag?: string;
page?: number;
pageSize?: number;
}
export interface NotificationTemplateListResult {
items: NotificationTemplate[];
total: number;
page: number;
pageSize: number;
}
export interface PreviewResult {
title: string;
content: string;
missingVariables: string[];
}
/** 409 乐观锁冲突:携带服务端最新完整记录。 */
export class NotificationTemplateConflictError extends Error {
readonly server: NotificationTemplate;
constructor(server: NotificationTemplate, message = "版本冲突") {
super(message);
this.name = "NotificationTemplateConflictError";
this.server = server;
}
}
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
interface ServerTemplate {
id: string;
name: string;
title_template: string;
content_template: string;
tags: string[];
enabled: boolean;
version: number;
created_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
function toTemplate(s: ServerTemplate): NotificationTemplate {
return {
id: s.id,
name: s.name,
titleTemplate: s.title_template,
contentTemplate: s.content_template,
tags: s.tags ?? [],
enabled: s.enabled ?? true,
version: s.version ?? 1,
createdBy: s.created_by ?? null,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
function toBody(input: NotificationTemplateInput & { expectedVersion?: number }) {
const body: Record<string, unknown> = {
name: input.name,
title_template: input.titleTemplate,
content_template: input.contentTemplate,
tags: input.tags,
enabled: input.enabled,
};
if (input.expectedVersion !== undefined) {
body.expected_version = input.expectedVersion;
}
return body;
}
function detailMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== "object" || !("detail" in data)) return fallback;
const detail = (data as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) {
const msg = (detail as { message: unknown }).message;
if (typeof msg === "string") return msg;
}
return fallback;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (res.status === 204) return undefined as T;
const data = await res.json().catch(() => null as unknown);
if (!res.ok) {
if (res.status === 409 && data && typeof data === "object" && "detail" in data) {
const detail = (data as { detail: unknown }).detail;
if (detail && typeof detail === "object" && "server" in detail) {
const server = (detail as { server: ServerTemplate }).server;
if (server && typeof server === "object" && "id" in server) {
throw new NotificationTemplateConflictError(
toTemplate(server),
detailMessage(data, "版本冲突"),
);
}
}
}
throw new Error(detailMessage(data, `${ctx} failed (${res.status})`));
}
return data as T;
}
export async function listNotificationTemplates(
params: ListNotificationTemplatesParams = {},
): Promise<NotificationTemplateListResult> {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.enabled === true || params.enabled === false) {
qs.set("enabled", String(params.enabled));
}
if (params.tag) qs.set("tag", params.tag);
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("page_size", String(params.pageSize));
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{
items: ServerTemplate[];
total: number;
page: number;
page_size: number;
}>(res, "list notification templates");
const result: NotificationTemplateListResult = {
items: (data.items ?? []).map(toTemplate),
total: data.total ?? 0,
page: data.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? 20,
};
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-通知模板-列表",
url: `${BASE()}${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: { total: result.total, page: result.page },
});
return result;
}
export async function getNotificationTemplate(id: string): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
const tpl = toTemplate(await readJson<ServerTemplate>(res, "get notification template"));
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-通知模板-查看",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id },
returnParam: tpl,
});
return tpl;
}
export async function createNotificationTemplate(
input: NotificationTemplateInput,
): Promise<NotificationTemplate> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "create notification template"));
writeYLog({
optType: OptType.create,
optDetail: "CM助手-全量-通知模板-创建",
url: BASE(),
reqParam: input,
returnParam: tpl,
});
return tpl;
}
export async function updateNotificationTemplate(
id: string,
input: NotificationTemplateUpdateInput,
): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "update notification template"));
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-通知模板-修改",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id, input },
returnParam: tpl,
});
return tpl;
}
export async function deleteNotificationTemplate(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
await readJson<void>(res, "delete notification template");
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-通知模板-删除",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id },
});
}
export async function duplicateNotificationTemplate(
id: string,
): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/duplicate`, {
method: "POST",
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "duplicate notification template"));
writeYLog({
optType: OptType.create,
optDetail: "CM助手-全量-通知模板-复制",
url: `${BASE()}/${encodeURIComponent(id)}/duplicate`,
reqParam: { id },
returnParam: tpl,
});
return tpl;
}
export async function previewNotificationTemplate(input: {
titleTemplate: string;
contentTemplate: string;
variables: Record<string, string>;
}): Promise<PreviewResult> {
const res = await apiFetch(`${BASE()}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title_template: input.titleTemplate,
content_template: input.contentTemplate,
variables: input.variables,
}),
});
const data = await readJson<{
title: string;
content: string;
missing_variables: string[];
}>(res, "preview notification template");
return {
title: data.title,
content: data.content,
missingVariables: data.missing_variables ?? [],
};
}
const KEY = "notification-templates";
export function useNotificationTemplates(params: ListNotificationTemplatesParams) {
return useQuery({
queryKey: [
KEY,
"list",
params.keyword ?? "",
params.enabled === true ? "1" : params.enabled === false ? "0" : "",
params.tag ?? "",
params.page ?? 1,
params.pageSize ?? 20,
],
queryFn: () => listNotificationTemplates(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useNotificationTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [KEY] });
return {
create: useMutation({ mutationFn: createNotificationTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ id, input }: { id: string; input: NotificationTemplateUpdateInput }) =>
updateNotificationTemplate(id, input),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteNotificationTemplate, onSuccess: invalidate }),
duplicate: useMutation({
mutationFn: duplicateNotificationTemplate,
onSuccess: invalidate,
}),
};
}
@@ -0,0 +1,190 @@
/** 岗位角色配置 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?: {
/** Whether this query should run. */
enabled?: boolean;
/**
* Consumers that edit business-chain seats need the latest shared directory
* whenever their page is opened or focused.
*/
refreshOnMountOrFocus?: boolean;
}) {
return useQuery({
queryKey: POSITION_ROLES_QUERY_KEY,
queryFn: listPositionRoles,
staleTime: 30_000,
refetchOnMount: options?.refreshOnMountOrFocus ? "always" : true,
refetchOnWindowFocus: options?.refreshOnMountOrFocus ? "always" : 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);
},
}),
};
}
+10
View File
@@ -1849,6 +1849,16 @@ html.lightyellow .gq-history-item--active .bg-primary {
/* Runtime-created themes deliberately use semantic variables rather than a
generated class per colour. These compatibility rules keep the selected,
hovered and compact-navigation states readable on Chrome 109 as well. */
html[data-custom-theme="true"] .strategy-app-header {
background-color: var(--primary);
background-image: linear-gradient(
105deg,
var(--navigation-gradient-start) 0%,
var(--primary) 52%,
var(--navigation-gradient-end) 100%
);
}
html[data-custom-theme="true"] .agent-avatar {
background-color: var(--accent);
color: var(--accent-foreground);
+5 -1
View File
@@ -45,7 +45,11 @@ export default defineConfig({
},
],
},
"base":"./",
// Keep the current portable-file default, while allowing container builds to
// emit absolute asset URLs. The latter is required for BrowserRouter pages
// behind Nginx: a request to /page/... must still load /assets/... rather
// than /page/assets/....
base: process.env.VITE_ASSET_BASE ?? "./",
build: {
cssMinify: "lightningcss",
target: "chrome100",
+52 -4
View File
@@ -109,8 +109,8 @@ CI runs these regression tests for every pull request via [.github/workflows/bac
### Position roundtable recovery persistence
`/api/position-roundtable` uses LangGraph threads as the live execution source
and stores an additional SQL recovery copy in
`/api/position-roundtable` uses the shared `/api/intent/*` LangGraph flow as
the live Step-1 execution source and stores an additional SQL recovery copy in
`position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy
contains complete intent/node/summary/action-plan conversations, unsent
composer drafts, workflow snapshots, and bounded full text for generated
@@ -118,7 +118,34 @@ Markdown/JSON artifacts. This lets history and downstream closing agents keep
working when container-local checkpoint or sandbox volumes are replaced.
Revision `20260723_05` adds the conversation columns. Session deletion
explicitly removes child nodes so SQLite deployments do not depend on FK
cascade pragmas.
cascade pragmas. The router is mounted canonically below
`/api/position-roundtable` and, for intranet gateway allowlists, below the
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. If checkpoint creation fails or the parallel seat
task is cancelled, the helper compensates by deleting any partial checkpoint and
metadata row (the cancellation branch runs under `asyncio.shield` so a second
cancellation cannot abort it half-way). Streaming stays on the existing loopback
path.
`ThreadMetaStore.create` never issues a post-commit `session.refresh()`. Every
`ThreadMetaRow` column is assigned before commit and the session factory uses
`expire_on_commit=False`, so the returned dict is built from the in-memory row.
The removed read-back ran on a *different* pooled connection than the INSERT,
which a MySQL read/write-splitting endpoint can route to a lagging replica — the
row comes back empty and SQLAlchemy raises `Could not refresh instance`, surfacing
as `500 "Failed to create thread"`. Concurrency makes that far more likely, so a
single-user deployment never sees it. No caller uses the return value, and all
seven call sites (`multi_agent._create_thread_direct`, `services.start_run`,
`routers/threads.create_thread`, `roundtable_inprocess_gateway`,
`scheduler/service`, `thread_shares`, `authz`) benefit. Keep new `create`
implementations read-back-free; `tests/test_position_roundtable_sessions.py::
test_thread_meta_create_never_reads_back_after_commit` guards this.
### Harness / App Split
@@ -403,6 +430,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
@@ -411,6 +442,8 @@ integration point is the existing `/api/roundtable-chains` store: each `seats` J
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
migration). The original roundtable only reads its established seat fields and
therefore remains unchanged. The separate `/api/position-roundtable` router
(also mounted at `/api/multi-agent/position-roundtable` for intranet gateway
compatibility)
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
and direct-agent node indexes in `position_roundtable_nodes` (migration
`20260721_01`). Message history remains in the established LangGraph thread
@@ -1009,7 +1042,10 @@ When using `make dev` from root, the frontend automatically connects through ngi
the built-in default theme. A row is `CustomThemePreset` (`id`, `name`,
six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`,
`sort_order`). `overrides` is intentionally a short interaction-only list:
`button_hover`, `selected_background`, `border`, `muted`, `muted_text`, `destructive`, `brand_title`, and `primary_foreground`.
`button_hover`, `navigation_gradient_start`, `navigation_gradient_end`,
`selected_background`, `border`, `muted`, `muted_text`, `destructive`,
`brand_title`, and `primary_foreground`. The navigation gradient endpoints
default from `primary` and are only stored when an administrator overrides them.
All values must be six-digit hex strings; the frontend derives every remaining
semantic CSS/TDesign token so admins do not maintain a full raw token sheet.
Ids must be `custom-theme-*`, names and ids are unique, and the router limits
@@ -1084,6 +1120,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
+38 -3
View File
@@ -124,7 +124,8 @@ 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/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. |
| `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 |
| `GET /api/memory/config` | Memory configuration |
@@ -422,8 +423,11 @@ themes through `PUT /api/system-settings/appearance`. Each theme stores a
safe six-digit `primary` colour, an optional primary-button `text_color`,
`light`/`dark` mode, name, enabled flag and sort order. The frontend derives
the rest of the visual tokens; the optional `overrides` object only exposes
eight interaction colours (`button_hover`, `selected_background`, `border`,
`muted`, `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for exceptional brand requirements. A custom theme id
ten interaction colours (`button_hover`, `navigation_gradient_start`,
`navigation_gradient_end`, `selected_background`, `border`, `muted`,
`muted_text`, `destructive`, `brand_title`, `primary_foreground`) for
exceptional brand requirements. The two navigation values override the
automatically derived top-bar gradient. A custom theme id
must use the `custom-theme-` prefix. Disabled or removed custom themes cannot
remain the system default and automatically fall back to `light`.
@@ -472,3 +476,34 @@ unsent composer drafts, and generated text deliverables. LangGraph checkpoints
remain the execution source, but SQL history continues to render after a
checkpoint or sandbox-output volume is replaced. Apply Alembic revision
`20260723_05` (or simply upgrade to `head`) before deploying this version.
Its live intent conversation still uses the shared `/api/intent/init` and
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
`/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. If initial checkpoint
creation fails or is cancelled, bootstrap removes both the partial checkpoint and
its thread metadata row. Thread metadata creation issues no post-commit ORM
read-back on any code path: all returned fields are assigned before commit, and
skipping the extra read keeps a MySQL read/write-splitting endpoint from sending
it to a lagging replica (which fails the request with
`Could not refresh instance`). 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.
@@ -51,12 +51,14 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
notification_templates,
open_3qfx,
open_chat,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
position_roles,
public_config,
public_embed,
public_leaderboard,
@@ -857,9 +859,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
return
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
if store is None or run_mgr is None:
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
return
@@ -880,16 +882,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
try:
# Runtime toggle (admin can turn sampling off without a restart).
# mtime-cached read, so this is cheap every tick.
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
ticks += 1
if ticks % purge_every_ticks == 0:
from datetime import UTC, datetime, timedelta
@@ -1337,18 +1339,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
return v in ("1", "true", "yes", "on") or is_auth_disabled()
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# Headers the LangGraph SDK / browser clients need to read from cross-origin
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
@@ -1407,10 +1409,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
@@ -1470,15 +1472,26 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
app.include_router(position_roundtable.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
# Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Global business ? agent / chain mapping at /api/business-mapping
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)
@@ -1496,6 +1509,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
app.include_router(notification_templates.router)
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
app.include_router(taskcop_tasks.router)
@@ -255,15 +255,23 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
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
@@ -287,17 +295,21 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.notification_templates import make_notification_template_store
app.state.notification_template_store = make_notification_template_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -381,11 +393,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
try:
yield
@@ -399,10 +411,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
# ---------------------------------------------------------------------------
@@ -428,12 +440,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
def get_store(request: Request):
@@ -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
@@ -442,10 +442,30 @@ async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] |
"parents": {},
"created_at": now,
}
async def _cleanup_partial_thread() -> None:
delete_checkpoint = getattr(checkpointer, "adelete_thread", None)
if callable(delete_checkpoint):
try:
await delete_checkpoint(thread_id)
except Exception:
logger.exception("[init-direct] partial checkpoint cleanup failed for %s", thread_id)
try:
await thread_store.delete(thread_id)
except Exception:
logger.exception("[init-direct] partial thread_meta cleanup failed for %s", thread_id)
try:
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
except asyncio.CancelledError:
# A sibling seat may fail while this task is between metadata commit and
# checkpoint creation. Compensate before propagating cancellation, under
# shield so a second cancellation cannot abort the cleanup half-way.
await asyncio.shield(_cleanup_partial_thread())
raise
except Exception:
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
await _cleanup_partial_thread()
raise HTTPException(status_code=500, detail="Failed to create thread")
return thread_id
@@ -0,0 +1,307 @@
"""Notification template management API (通知模板管理).
Global / shared templates for admin-authored notification messages. Every
endpoint (including reads) requires ``system_role == "admin"``.
Routes (prefix ``/api/notification-templates``):
GET "" list (keyword / enabled / tag / page / page_size)
POST "" create
GET "/{id}" read one
PUT "/{id}" update (optimistic lock via expected_version)
DELETE "/{id}" delete
POST "/{id}/duplicate" duplicate
POST "/preview" render title/content with variables
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.notification_templates import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
TemplateVariableError,
VersionConflictError,
preview_templates,
validate_templates,
)
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
# ── schemas ──────────────────────────────────────────────────────────────
class NotificationTemplateResponse(BaseModel):
id: str
name: str
title_template: str
content_template: str
tags: list[str] = Field(default_factory=list)
enabled: bool = True
version: int = 1
created_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class NotificationTemplateListResponse(BaseModel):
items: list[NotificationTemplateResponse]
total: int
page: int
page_size: int
class NotificationTemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
class NotificationTemplateUpdateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
expected_version: int = Field(..., ge=1)
class PreviewRequest(BaseModel):
title_template: str = Field(..., min_length=0, max_length=120)
content_template: str = Field(..., min_length=0, max_length=5000)
variables: dict[str, str] = Field(default_factory=dict)
class PreviewResponse(BaseModel):
title: str
content: str
missing_variables: list[str] = Field(default_factory=list)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> NotificationTemplateStore:
store = getattr(request.app.state, "notification_template_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Notification template store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
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 _normalize_name(name: str) -> str:
cleaned = (name or "").strip()
if not (2 <= len(cleaned) <= 50):
raise HTTPException(status_code=422, detail="名称去除首尾空格后长度须为 2~50")
return cleaned
def _normalize_tags(tags: list[str] | None) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for raw in tags or []:
tag = (raw or "").strip()
if not tag:
continue
if not (1 <= len(tag) <= 20):
raise HTTPException(status_code=422, detail="单个标签长度须为 1~20")
if tag in seen:
continue
seen.add(tag)
out.append(tag)
if len(out) > 5:
raise HTTPException(status_code=422, detail="标签最多 5 个")
return out
def _validate_lengths(title: str, content: str) -> None:
if not (1 <= len(title) <= 120):
raise HTTPException(status_code=422, detail="标题模板长度须为 1~120")
if not (1 <= len(content) <= 5000):
raise HTTPException(status_code=422, detail="正文模板长度须为 1~5000")
def _validate_variables(title: str, content: str) -> None:
try:
validate_templates(title, content)
except TemplateVariableError as exc:
raise HTTPException(status_code=422, detail=exc.message) from exc
def _to_response(row: dict) -> NotificationTemplateResponse:
return NotificationTemplateResponse(**row)
async def _unique_duplicate_name(store: NotificationTemplateStore, original: str) -> str:
base = f"{original} - 副本"
if not await store.name_exists(base):
return base
n = 2
while True:
candidate = f"{original} - 副本 {n}"
if not await store.name_exists(candidate):
return candidate
n += 1
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=NotificationTemplateListResponse)
async def list_notification_templates(
request: Request,
keyword: str | None = Query(default=None),
enabled: bool | None = Query(default=None),
tag: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> NotificationTemplateListResponse:
await _require_admin(request)
store = _get_store(request)
items, total = await store.list_templates(
keyword=keyword,
enabled=enabled,
tag=tag,
page=page,
page_size=page_size,
)
return NotificationTemplateListResponse(
items=[_to_response(r) for r in items],
total=total,
page=page,
page_size=page_size,
)
@router.post("/preview", response_model=PreviewResponse)
async def preview_notification_template(request: Request, body: PreviewRequest) -> PreviewResponse:
await _require_admin(request)
result = preview_templates(body.title_template, body.content_template, body.variables)
return PreviewResponse(**result) # type: ignore[arg-type]
@router.post("", response_model=NotificationTemplateResponse, status_code=201)
async def create_notification_template(
request: Request,
body: NotificationTemplateCreateRequest,
) -> NotificationTemplateResponse:
await _require_admin(request)
name = _normalize_name(body.name)
tags = _normalize_tags(body.tags)
_validate_lengths(body.title_template, body.content_template)
_validate_variables(body.title_template, body.content_template)
store = _get_store(request)
user_id = await get_current_user(request)
try:
row = await store.create_template(
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
"created_by": user_id,
}
)
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
return _to_response(row)
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
async def get_notification_template(request: Request, template_id: str) -> NotificationTemplateResponse:
await _require_admin(request)
store = _get_store(request)
row = await store.get_template(template_id)
if row is None:
raise HTTPException(status_code=404, detail="模板不存在")
return _to_response(row)
@router.put("/{template_id}", response_model=NotificationTemplateResponse)
async def update_notification_template(
request: Request,
template_id: str,
body: NotificationTemplateUpdateRequest,
) -> NotificationTemplateResponse:
await _require_admin(request)
name = _normalize_name(body.name)
tags = _normalize_tags(body.tags)
_validate_lengths(body.title_template, body.content_template)
_validate_variables(body.title_template, body.content_template)
store = _get_store(request)
try:
row = await store.update_template(
template_id,
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
},
expected_version=body.expected_version,
)
except VersionConflictError as exc:
raise HTTPException(
status_code=409,
detail={"message": "版本冲突,请重新加载或覆盖保存", "server": exc.server},
) from exc
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="模板不存在")
return _to_response(row)
@router.delete("/{template_id}", status_code=204)
async def delete_notification_template(request: Request, template_id: str) -> Response:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_template(template_id)
if not deleted:
raise HTTPException(status_code=404, detail="模板不存在")
return Response(status_code=204)
@router.post("/{template_id}/duplicate", response_model=NotificationTemplateResponse, status_code=201)
async def duplicate_notification_template(
request: Request,
template_id: str,
) -> NotificationTemplateResponse:
await _require_admin(request)
store = _get_store(request)
source = await store.get_template(template_id)
if source is None:
raise HTTPException(status_code=404, detail="模板不存在")
new_name = await _unique_duplicate_name(store, source["name"])
user_id = await get_current_user(request)
try:
row = await store.create_template(
{
"name": new_name,
"title_template": source["title_template"],
"content_template": source["content_template"],
"tags": list(source.get("tags") or []),
"enabled": source.get("enabled", True),
"created_by": user_id,
}
)
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
return _to_response(row)
@@ -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])
@@ -21,7 +21,9 @@ from pydantic import BaseModel, Field
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"])
# The application mounts this router below both `/api` (canonical) and the
# long-established `/api/multi-agent` namespace (intranet compatibility).
router = APIRouter(prefix="/position-roundtable", tags=["position-roundtable"])
NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"}
_HANDOFF_TEXT_BUDGET = 12_000
@@ -329,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:
@@ -417,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",
@@ -459,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 = [
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
"",
@@ -714,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 {},
@@ -1135,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:
@@ -1213,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")
@@ -1245,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")
@@ -1266,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"
@@ -1278,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
)
@@ -1382,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":
@@ -1426,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,
@@ -57,6 +57,14 @@ class CustomThemeColorOverrides(BaseModel):
"""
button_hover: str | None = Field(default=None, description="Primary button hover colour")
navigation_gradient_start: str | None = Field(
default=None,
description="Left colour for the configurable top-navigation gradient",
)
navigation_gradient_end: str | None = Field(
default=None,
description="Right colour for the configurable top-navigation gradient",
)
selected_background: str | None = Field(default=None, description="Selected/active item background")
border: str | None = Field(default=None, description="Card and input border colour")
muted: str | None = Field(default=None, description="Muted/secondary surface colour")
@@ -71,6 +79,8 @@ class CustomThemeColorOverrides(BaseModel):
@field_validator(
"button_hover",
"navigation_gradient_start",
"navigation_gradient_end",
"selected_background",
"border",
"muted",
@@ -0,0 +1,45 @@
"""Add configurable position-roundtable roles.
Revision ID: 20260729_01
Revises: 20260723_05
Create Date: 2026-07-29
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260729_01"
down_revision = "20260723_05"
branch_labels = None
depends_on = None
def _has_table(name: str) -> bool:
return name in inspect(op.get_bind()).get_table_names()
def upgrade() -> None:
if _has_table("position_roles"):
return
op.create_table(
"position_roles",
sa.Column("id", sa.String(length=128), primary_key=True),
sa.Column("name", sa.String(length=128), nullable=False, unique=True),
sa.Column("description", sa.String(length=512), nullable=False, server_default=""),
sa.Column("role_type", sa.String(length=32), nullable=False, server_default="standard"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_position_roles_name", "position_roles", ["name"])
op.create_index("ix_position_roles_role_type", "position_roles", ["role_type"])
def downgrade() -> None:
if _has_table("position_roles"):
op.drop_table("position_roles")
@@ -15,18 +15,6 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory.
"""
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
KnowledgeEmbeddingRow,
KnowledgeEntityRow,
@@ -40,12 +28,27 @@ 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.notification_templates.model import NotificationTemplateRow
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 +57,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 +73,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
@@ -109,6 +111,7 @@ __all__ = [
"LlmCallMetricRow",
"MenuOverrideRow",
"NotificationRow",
"NotificationTemplateRow",
"ParallelAgentTaskRow",
"PositionRoundtableNodeRow",
"PositionRoundtableSessionRow",
@@ -123,6 +126,7 @@ __all__ = [
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RoundtableTaskDraftRow",
"PositionRoleRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -0,0 +1,38 @@
"""Notification template persistence (通知模板管理)."""
from __future__ import annotations
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
from deerflow.persistence.notification_templates.variables import (
ALLOWED_VARIABLES,
TemplateVariableError,
preview_templates,
validate_templates,
)
__all__ = [
"ALLOWED_VARIABLES",
"DuplicateNotificationTemplateNameError",
"MemoryNotificationTemplateStore",
"NotificationTemplateRepository",
"NotificationTemplateRow",
"NotificationTemplateStore",
"TemplateVariableError",
"VersionConflictError",
"make_notification_template_store",
"preview_templates",
"validate_templates",
]
def make_notification_template_store(session_factory):
if session_factory is None:
return MemoryNotificationTemplateStore()
return NotificationTemplateRepository(session_factory)
@@ -0,0 +1,69 @@
"""Abstract notification-template persistence API.
Global / shared store: no ``user_id`` scoping. The router gates every endpoint
behind admin. ``created_by`` is audit-only.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class DuplicateNotificationTemplateNameError(ValueError):
"""Raised when creating/updating would collide on the unique name."""
class VersionConflictError(Exception):
"""Raised when ``expected_version`` does not match the stored version."""
def __init__(self, server: dict[str, Any]) -> None:
super().__init__("version conflict")
self.server = server
class NotificationTemplateStore(ABC):
@abstractmethod
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
"""Return ``(items, total)`` newest-first with optional filters."""
raise NotImplementedError
@abstractmethod
async def get_template(self, template_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a template. Raises ``DuplicateNotificationTemplateNameError``."""
raise NotImplementedError
@abstractmethod
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
"""Update with optimistic lock. Returns None if missing.
Raises ``VersionConflictError`` (with ``.server``) on mismatch, or
``DuplicateNotificationTemplateNameError`` on a name clash.
"""
raise NotImplementedError
@abstractmethod
async def delete_template(self, template_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
raise NotImplementedError
@@ -0,0 +1,135 @@
"""In-memory notification-template store (tests / no-DB)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
_WRITABLE = {
"name",
"title_template",
"content_template",
"tags",
"enabled",
}
class MemoryNotificationTemplateStore(NotificationTemplateStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
@staticmethod
def _public(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
out["tags"] = list(out.get("tags") or [])
return out
def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return any(r["name"] == name and r["id"] != exclude_id for r in self._rows.values())
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
return self._name_taken(name, exclude_id=exclude_id)
def _matches(
self,
row: dict[str, Any],
*,
keyword: str | None,
enabled: bool | None,
tag: str | None,
) -> bool:
if enabled is not None and bool(row.get("enabled")) is not enabled:
return False
if tag is not None and tag not in (row.get("tags") or []):
return False
if keyword:
needle = keyword.strip().lower()
if needle:
hay = " ".join(
[
str(row.get("name") or ""),
str(row.get("title_template") or ""),
str(row.get("content_template") or ""),
]
).lower()
if needle not in hay:
return False
return True
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
rows = [
r
for r in self._rows.values()
if self._matches(r, keyword=keyword, enabled=enabled, tag=tag)
]
rows.sort(key=lambda r: r.get("created_at", ""), reverse=True)
total = len(rows)
page = max(1, page)
page_size = max(1, page_size)
start = (page - 1) * page_size
items = [self._public(r) for r in rows[start : start + page_size]]
return items, total
async def get_template(self, template_id: str) -> dict[str, Any] | None:
row = self._rows.get(template_id)
return self._public(row) if row is not None else None
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
if self._name_taken(data["name"]):
raise DuplicateNotificationTemplateNameError(data["name"])
now = datetime.now(UTC).isoformat()
template_id = data.get("id") or uuid.uuid4().hex[:12]
row = {
"id": template_id,
"name": data["name"],
"title_template": data["title_template"],
"content_template": data["content_template"],
"tags": list(data.get("tags") or []),
"enabled": data.get("enabled", True),
"version": 1,
"created_by": data.get("created_by"),
"created_at": now,
"updated_at": now,
}
self._rows[template_id] = row
return self._public(row)
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
row = self._rows.get(template_id)
if row is None:
return None
if int(row["version"]) != int(expected_version):
raise VersionConflictError(self._public(row))
new_name = data.get("name")
if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=template_id):
raise DuplicateNotificationTemplateNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
row[key] = value
row["version"] = int(row["version"]) + 1
row["updated_at"] = datetime.now(UTC).isoformat()
return self._public(row)
async def delete_template(self, template_id: str) -> bool:
return self._rows.pop(template_id, None) is not None
@@ -0,0 +1,35 @@
"""ORM model for notification templates (通知模板管理).
Global / shared — no ``user_id`` scoping. ``created_by`` is audit-only.
All reads/writes are admin-gated at the router layer.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class NotificationTemplateRow(Base):
__tablename__ = "notification_templates"
__table_args__ = (UniqueConstraint("name", name="uq_notification_templates_name"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(50))
title_template: Mapped[str] = mapped_column(String(120))
content_template: Mapped[str] = mapped_column(Text)
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
version: Mapped[int] = mapped_column(Integer, default=1)
created_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,163 @@
"""SQLAlchemy-backed notification-template store (global / shared)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
_WRITABLE = {
"name",
"title_template",
"content_template",
"tags",
"enabled",
}
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
data["tags"] = list(data.get("tags") or [])
return data
class NotificationTemplateRepository(NotificationTemplateStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
async with self._sf() as session:
return await self._name_taken(session, name, exclude_id=exclude_id)
async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool:
stmt = select(NotificationTemplateRow.id).where(NotificationTemplateRow.name == name)
if exclude_id is not None:
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
result = await session.execute(stmt.limit(1))
return result.first() is not None
def _filter_stmt(
self,
*,
keyword: str | None,
enabled: bool | None,
tag: str | None,
):
stmt = select(NotificationTemplateRow)
if enabled is not None:
stmt = stmt.where(NotificationTemplateRow.enabled.is_(enabled))
if keyword:
needle = f"%{keyword.strip()}%"
if keyword.strip():
stmt = stmt.where(
or_(
NotificationTemplateRow.name.ilike(needle),
NotificationTemplateRow.title_template.ilike(needle),
NotificationTemplateRow.content_template.ilike(needle),
)
)
# Tag filter is applied in Python (PortableJSON differs across backends).
return stmt
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
page = max(1, page)
page_size = max(1, page_size)
stmt = self._filter_stmt(keyword=keyword, enabled=enabled, tag=tag)
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
rows = [_to_dict(r) for r in result.scalars()]
if tag is not None:
rows = [r for r in rows if tag in (r.get("tags") or [])]
total = len(rows)
start = (page - 1) * page_size
return rows[start : start + page_size], total
async def get_template(self, template_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
return _to_dict(row) if row is not None else None
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
row = NotificationTemplateRow(
id=data.get("id") or uuid.uuid4().hex[:12],
name=data["name"],
title_template=data["title_template"],
content_template=data["content_template"],
tags=list(data.get("tags") or []),
enabled=data.get("enabled", True),
version=1,
created_by=data.get("created_by"),
)
async with self._sf() as session:
if await self._name_taken(session, row.name):
raise DuplicateNotificationTemplateNameError(row.name)
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateNotificationTemplateNameError(row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
return None
if int(row.version) != int(expected_version):
raise VersionConflictError(_to_dict(row))
new_name = data.get("name")
if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=template_id):
raise DuplicateNotificationTemplateNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
setattr(row, key, value)
row.version = int(row.version) + 1
row.updated_at = datetime.now(UTC)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateNotificationTemplateNameError(new_name or row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def delete_template(self, template_id: str) -> bool:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,110 @@
"""Template variable validation and preview helpers for notification templates.
Supported placeholders (exact names, no spaces):
{{userName}} {{taskName}} {{taskId}} {{dueDate}}
"""
from __future__ import annotations
import re
ALLOWED_VARIABLES: frozenset[str] = frozenset({"userName", "taskName", "taskId", "dueDate"})
# Captures the inner of a well-formed {{...}} pair (no nested braces).
_VAR_RE = re.compile(r"\{\{([^{}]*)\}\}")
# Valid identifier: letters then letters/digits (no spaces / punctuation).
_IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
class TemplateVariableError(ValueError):
"""Raised when a template contains an invalid / unsupported / unclosed variable."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
def validate_template_text(text: str) -> None:
"""Validate all ``{{var}}`` occurrences in *text*.
Raises ``TemplateVariableError`` with a Chinese message pointing at the
offending variable or position.
"""
if not text:
return
i = 0
while True:
start = text.find("{{", i)
if start < 0:
break
end = text.find("}}", start + 2)
if end < 0:
raise TemplateVariableError(f"未闭合的变量(位置 {start})")
inner = text[start + 2 : end]
token = "{{" + inner + "}}"
if not inner or not _IDENT_RE.fullmatch(inner):
raise TemplateVariableError(f"变量格式错误:{token}")
if inner not in ALLOWED_VARIABLES:
raise TemplateVariableError(f"不支持的变量:{token}")
i = end + 2
def validate_templates(*texts: str) -> None:
"""Validate every template string; first error wins."""
for text in texts:
validate_template_text(text or "")
def extract_variables(text: str) -> list[str]:
"""Return ordered unique allowed-looking variable names found in *text*."""
seen: set[str] = set()
out: list[str] = []
for match in _VAR_RE.finditer(text or ""):
name = match.group(1)
if name in seen:
continue
seen.add(name)
out.append(name)
return out
def render_template(text: str, variables: dict[str, str] | None) -> tuple[str, list[str]]:
"""Replace known placeholders; keep missing ones and report their names.
Returns ``(rendered, missing_variables)``. Missing names are unique and
appear in first-seen order.
"""
vars_map = variables or {}
missing: list[str] = []
seen_missing: set[str] = set()
def _repl(match: re.Match[str]) -> str:
name = match.group(1)
if name in vars_map and vars_map[name] is not None:
return str(vars_map[name])
if name not in seen_missing:
seen_missing.add(name)
missing.append(name)
return match.group(0)
rendered = _VAR_RE.sub(_repl, text or "")
return rendered, missing
def preview_templates(
title_template: str,
content_template: str,
variables: dict[str, str] | None = None,
) -> dict[str, object]:
"""Render title + content; merge missing variable lists (order-preserving)."""
title, missing_title = render_template(title_template, variables)
content, missing_content = render_template(content_template, variables)
seen: set[str] = set()
missing: list[str] = []
for name in missing_title + missing_content:
if name in seen:
continue
seen.add(name)
missing.append(name)
return {"title": title, "content": content, "missing_variables": missing}
@@ -0,0 +1,27 @@
"""Position-roundtable role configuration persistence.
This is deliberately separate from :mod:`deerflow.persistence.positions`.
The latter represents organisational positions and resource grants, while this
package describes the work views used inside the position-roundtable product.
"""
from __future__ import annotations
from deerflow.persistence.position_roles.base import PositionRoleStore
from deerflow.persistence.position_roles.memory import MemoryPositionRoleStore
from deerflow.persistence.position_roles.model import PositionRoleRow
from deerflow.persistence.position_roles.sql import PositionRoleRepository
__all__ = [
"MemoryPositionRoleStore",
"PositionRoleRepository",
"PositionRoleRow",
"PositionRoleStore",
"make_position_role_store",
]
def make_position_role_store(session_factory):
if session_factory is None:
return MemoryPositionRoleStore()
return PositionRoleRepository(session_factory)
@@ -0,0 +1,30 @@
"""Abstract storage for position-roundtable role configuration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class PositionRoleStore(ABC):
"""Global configuration shared by every position-roundtable session."""
@abstractmethod
async def list_roles(self) -> list[dict[str, Any]]:
"""Return roles in presentation order."""
raise NotImplementedError
@abstractmethod
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
"""Atomically replace the editable role directory."""
raise NotImplementedError
@abstractmethod
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Insert only missing built-in roles, without overwriting edits."""
raise NotImplementedError
@@ -0,0 +1,45 @@
"""In-memory fallback for position-roundtable role configuration."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.position_roles.base import PositionRoleStore
def _normalize(row: dict[str, Any], updated_by: str | None = None) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": str(row.get("name") or ""),
"description": str(row.get("description") or ""),
"role_type": str(row.get("role_type") or "standard"),
"enabled": bool(row.get("enabled", True)),
"is_builtin": bool(row.get("is_builtin", False)),
"sort_order": int(row.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryPositionRoleStore(PositionRoleStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_roles(self) -> list[dict[str, Any]]:
return [dict(row) for row in sorted(self._rows.values(), key=lambda item: (item["sort_order"], item["id"]))]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
self._rows = {
str(row["id"]): _normalize(row, updated_by)
for row in rows
if row.get("id")
}
return await self.list_roles()
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
for row in rows:
row_id = str(row.get("id") or "")
if row_id and row_id not in self._rows:
self._rows[row_id] = _normalize(row)
return await self.list_roles()
@@ -0,0 +1,37 @@
"""ORM model for configurable position-roundtable roles."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class PositionRoleRow(Base):
"""A work-view role in the position-roundtable product.
``role_type`` is intentionally small and stable: ``standard`` denotes a
normal business-chain position; ``intent`` denotes the single special
position responsible for initial intent identification and closing-agent
entry. It is a configuration concern, not an organisation/RBAC position.
"""
__tablename__ = "position_roles"
id: Mapped[str] = mapped_column(String(128), primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: Mapped[str] = mapped_column(String(512), default="")
role_type: Mapped[str] = mapped_column(String(32), default="standard", index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_by: Mapped[str | None] = mapped_column(String(64))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,66 @@
"""SQLAlchemy storage for position-roundtable role configuration."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.position_roles.base import PositionRoleStore
from deerflow.persistence.position_roles.model import PositionRoleRow
def _to_dict(row: PositionRoleRow) -> dict[str, Any]:
data = row.to_dict()
if isinstance(data.get("updated_at"), datetime):
data["updated_at"] = data["updated_at"].isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None = None) -> PositionRoleRow:
return PositionRoleRow(
id=str(data["id"]),
name=str(data.get("name") or ""),
description=str(data.get("description") or ""),
role_type=str(data.get("role_type") or "standard"),
enabled=bool(data.get("enabled", True)),
is_builtin=bool(data.get("is_builtin", False)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class PositionRoleRepository(PositionRoleStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_roles(self) -> list[dict[str, Any]]:
statement = select(PositionRoleRow).order_by(PositionRoleRow.sort_order.asc(), PositionRoleRow.id.asc())
async with self._sf() as session:
rows = (await session.execute(statement)).scalars()
return [_to_dict(row) for row in rows]
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
# Keep the final occurrence of duplicate ids so malformed UI payloads
# cannot violate the primary-key constraint.
by_id = {str(row["id"]): row for row in rows if row.get("id")}
async with self._sf() as session:
await session.execute(delete(PositionRoleRow))
created = [_row_from(row, updated_by) for row in by_id.values()]
session.add_all(created)
await session.commit()
for row in created:
await session.refresh(row)
return [_to_dict(row) for row in sorted(created, key=lambda item: (item.sort_order, item.id))]
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Idempotently insert new built-ins without changing operator edits."""
async with self._sf() as session:
existing_ids = set((await session.execute(select(PositionRoleRow.id))).scalars())
inserts = [_row_from(row) for row in rows if row.get("id") and str(row["id"]) not in existing_ids]
if inserts:
session.add_all(inserts)
await session.commit()
return await self.list_roles()
@@ -30,6 +30,12 @@ class ThreadMetaStore(abc.ABC):
display_name: str | None = None,
metadata: dict | None = None,
) -> dict:
"""Create one metadata row.
Implementations must assemble the returned dict from the values passed
in, without a post-commit read-back: on MySQL such a read can be routed
to a lagging replica behind a read/write-splitting endpoint and fail.
"""
pass
@abc.abstractmethod
@@ -45,6 +45,7 @@ class ThreadMetaRepository(ThreadMetaStore):
assistant_id=assistant_id,
user_id=resolved_user_id,
display_name=display_name,
status="idle",
metadata_json=metadata or {},
created_at=now,
updated_at=now,
@@ -52,7 +53,14 @@ class ThreadMetaRepository(ThreadMetaStore):
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
# Deliberately no session.refresh(): every column above is assigned
# explicitly and the session factory uses expire_on_commit=False, so
# the in-memory row is already complete. The re-SELECT would run on a
# different pooled connection than the INSERT, which a MySQL
# read/write-splitting endpoint can route to a lagging replica --
# the row comes back empty and SQLAlchemy raises
# "Could not refresh instance". Concurrency makes that far more
# likely, so a single-user deployment never sees it.
return self._row_to_dict(row)
async def get(
@@ -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
@@ -2,18 +2,24 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import deps as gateway_deps
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 multi_agent
from app.gateway.routers import position_roundtable as position_roundtable_router
from deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
@pytest_asyncio.fixture
@@ -35,6 +41,56 @@ def test_reject_node_request_allows_an_optional_reason():
assert request.reason == ""
def test_position_roundtable_router_has_intranet_compatibility_prefix():
"""The workspace must survive proxies that only expose /api/multi-agent."""
app = create_app()
registrations = {
(route.include_context.prefix, route.include_context.include_in_schema)
for route in app.routes
if getattr(route, "original_router", None) is position_roundtable_router.router
}
assert ("/api", True) in registrations
assert ("/api/multi-agent", False) in registrations
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(
@@ -920,3 +976,120 @@ async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
)
assert error.value.status_code == 409
assert "未解锁" in str(error.value.detail)
class _ThreadMetaSessionProbe:
def __init__(self, events: list[str]) -> None:
self._events = events
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def add(self, row) -> None:
self._events.append(f"add:{row.thread_id}")
async def commit(self) -> None:
self._events.append("commit")
async def refresh(self, row) -> None:
self._events.append(f"refresh:{row.thread_id}")
@pytest.mark.asyncio
async def test_thread_meta_create_never_reads_back_after_commit():
"""No caller may depend on a post-commit re-SELECT of the new row.
On MySQL behind a read/write-splitting endpoint that read can land on a
lagging replica, come back empty, and raise "Could not refresh instance".
"""
events: list[str] = []
repo = ThreadMetaRepository(lambda: _ThreadMetaSessionProbe(events)) # type: ignore[arg-type]
direct = await repo.create(
"roundtable-thread",
user_id=None,
metadata={"thread_type": "roundtable"},
)
ordinary = await repo.create("ordinary-thread", user_id=None, display_name="对话")
assert events == [
"add:roundtable-thread",
"commit",
"add:ordinary-thread",
"commit",
]
# The returned dict is assembled from the in-memory row, so it must still be
# complete for every column -- including ones no caller passed in.
assert direct["status"] == "idle"
assert direct["metadata"] == {"thread_type": "roundtable"}
assert direct["created_at"] and direct["updated_at"]
assert ordinary["status"] == "idle"
assert ordinary["display_name"] == "对话"
class _ThreadStoreProbe:
def __init__(self) -> None:
self.created: list[tuple[str, dict[str, Any]]] = []
self.deleted: list[str] = []
async def create(self, thread_id: str, **kwargs) -> dict[str, Any]:
self.created.append((thread_id, kwargs))
return {"thread_id": thread_id}
async def delete(self, thread_id: str) -> None:
self.deleted.append(thread_id)
class _FailingCheckpointer:
def __init__(self, exc: BaseException) -> None:
self._exc = exc
self.deleted: list[str] = []
async def aput(self, *args, **kwargs):
raise self._exc
async def adelete_thread(self, thread_id: str) -> None:
self.deleted.append(thread_id)
@pytest.mark.asyncio
async def test_direct_thread_creation_cleans_failed_checkpoint(monkeypatch):
store = _ThreadStoreProbe()
checkpointer = _FailingCheckpointer(RuntimeError("checkpoint unavailable"))
monkeypatch.setattr(gateway_deps, "get_thread_store", lambda request: store)
monkeypatch.setattr(gateway_deps, "get_checkpointer", lambda request: checkpointer)
with pytest.raises(HTTPException) as exc_info:
await multi_agent._create_thread_direct(
SimpleNamespace(),
metadata={"thread_type": "roundtable"},
)
assert exc_info.value.status_code == 500
assert len(store.created) == 1
thread_id, create_kwargs = store.created[0]
assert create_kwargs["metadata"] == {"thread_type": "roundtable"}
assert store.deleted == [thread_id]
assert checkpointer.deleted == [thread_id]
@pytest.mark.asyncio
async def test_direct_thread_creation_cleans_metadata_when_cancelled(monkeypatch):
store = _ThreadStoreProbe()
checkpointer = _FailingCheckpointer(asyncio.CancelledError())
monkeypatch.setattr(gateway_deps, "get_thread_store", lambda request: store)
monkeypatch.setattr(gateway_deps, "get_checkpointer", lambda request: checkpointer)
with pytest.raises(asyncio.CancelledError):
await multi_agent._create_thread_direct(
SimpleNamespace(),
metadata={"thread_type": "roundtable"},
)
thread_id, _ = store.created[0]
assert store.deleted == [thread_id]
assert checkpointer.deleted == [thread_id]
+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>code-zk · Wujie 返回跳转复现</title>
<style>
:root { color-scheme: light; font-family: "Microsoft YaHei", system-ui, sans-serif; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: #f5f7fb; }
body { display: flex; flex-direction: column; overflow: hidden; }
.toolbar { height: 52px; flex: 0 0 52px; display: flex; align-items: center; gap: 12px; padding: 0 16px; background: #fff; border-bottom: 1px solid #dfe5ef; color: #1f2937; }
.toolbar strong { font-size: 15px; }
.toolbar span { color: #64748b; font-size: 13px; }
button { border: 1px solid #cbd5e1; border-radius: 4px; background: #fff; color: #334155; padding: 6px 12px; cursor: pointer; }
button:hover { border-color: #3b82f6; color: #2563eb; }
#status { margin-left: auto; }
#mount { flex: 1 1 auto; min-height: 0; position: relative; }
#mount > iframe, #mount > [data-wujie] { width: 100%; height: 100%; border: 0; display: block; }
.loading { position: absolute; inset: 0; display: grid; place-items: center; color: #64748b; background: #f8fafc; }
.error { margin: 24px; padding: 16px; border: 1px solid #fecaca; border-radius: 4px; background: #fff1f2; color: #b91c1c; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="toolbar">
<strong>code-zk · Wujie 返回跳转复现</strong>
<span>子应用:localhost:5175/chMoreMode</span>
<button id="reload" type="button">重新加载</button>
<span id="status">准备加载</span>
</div>
<main id="mount"><div class="loading">正在加载 Wujie 子应用…</div></main>
<script type="module">
const mount = document.getElementById("mount");
const status = document.getElementById("status");
const childUrl = "http://localhost:5175/chMoreMode";
const query = new URLSearchParams(window.location.search);
const returnTarget = query.get("code-zk-wujie-return-repro") || "http://localhost:5175/list";
let dispose = null;
function setStatus(value) { status.textContent = value; }
function showError(error) {
mount.innerHTML = "";
const panel = document.createElement("div");
panel.className = "error";
panel.textContent = `Wujie 子应用加载失败\n${error instanceof Error ? error.message : String(error)}`;
mount.appendChild(panel);
setStatus("加载失败");
}
async function load() {
if (typeof dispose === "function") {
try { dispose(); } catch (_) {}
dispose = null;
}
mount.innerHTML = '<div class="loading">正在加载 Wujie 子应用…</div>';
setStatus("加载中");
try {
const { startApp } = await import("http://localhost:5175/node_modules/wujie/esm/index.js");
mount.innerHTML = "";
dispose = await startApp({
name: "code-zk-wujie-repro",
url: childUrl,
el: mount,
alive: true,
sync: true,
props: {
source: "wujie-repro",
parentReturnTo: returnTarget,
},
loadError: (url, error) => showError(`${url}\n${error?.message || error}`),
});
setStatus("已加载");
} catch (error) {
showError(error);
}
}
document.getElementById("reload").addEventListener("click", load);
window.addEventListener("message", (event) => {
const message = event.data;
if (message?.source !== "deerflow-position-roundtable" || message?.type !== "deerflow:position-roundtable:return") return;
const target = message.payload?.route || returnTarget;
window.location.href = target;
});
void load();
</script>
</body>
</html>