Compare commits
14
Commits
gzy_dev_new
...
ceshi-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11926db5 | ||
|
|
1ffead7ee2 | ||
|
|
a0a75a9964 | ||
|
|
37964d1f95 | ||
|
|
a9d66536c3 | ||
|
|
a968c3b5cd | ||
|
|
e52261fc3d | ||
|
|
4450f342d8 | ||
|
|
213dd841b5 | ||
|
|
816c46d447 | ||
|
|
db857ddd4d | ||
|
|
e475c37f1d | ||
|
|
151ecceb85 | ||
|
|
1555d5344b |
No files matched your search
@@ -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
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 步报告、流程图与放大弹窗的样式。 */
|
||||
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
|
||||
return readRuntimeEnv(key) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an integer setting supplied by ``runtime-config.js``.
|
||||
*
|
||||
* Runtime configuration is editable after the frontend is built, so validate
|
||||
* numeric values here instead of letting an invalid deployment value bypass a
|
||||
* UI constraint that the backend cannot accept.
|
||||
*/
|
||||
export const env = {
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
|
||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BotIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// The protocol page must still be escapable when no curated selector list
|
||||
// has been configured. Include the user's own and public agents in this
|
||||
// page's native fallback picker. A native select also remains interactive
|
||||
// in iframe/micro-frontend environments where a portal dialog can fail.
|
||||
const { agents: mineAgents } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const { agents: squareAgents } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const fallbackAgents = useMemo(() => {
|
||||
const uniqueAgents = new Map<
|
||||
string,
|
||||
(typeof selectorAgents)[number]
|
||||
>();
|
||||
for (const agent of [
|
||||
...selectorAgents,
|
||||
...mineAgents,
|
||||
...squareAgents,
|
||||
]) {
|
||||
uniqueAgents.set(agent.id, agent);
|
||||
}
|
||||
return [...uniqueAgents.values()];
|
||||
}, [mineAgents, selectorAgents, squareAgents]);
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Manual fallback for accidental protocol-page visits.
|
||||
*
|
||||
* A real Brainy integration drives this page with postMessage. A human who
|
||||
* reaches it directly should not be trapped here: move them to the standard
|
||||
* chat workspace, optionally opening the selected agent.
|
||||
*/
|
||||
const enterStandardChat = useCallback(
|
||||
(agentId?: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
params.delete("agent");
|
||||
if (agentId) {
|
||||
params.set("agent", agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}
|
||||
navigate(`${routeBase}/chats/new?${params.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[location.search, navigate, routeBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
选择智能体进入问答
|
||||
<div className="mt-4">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
|
||||
<BotIcon className="mr-2 size-3.5 shrink-0" />
|
||||
<select
|
||||
aria-label="选择智能体并进入问答"
|
||||
defaultValue=""
|
||||
disabled={isUploading || selectorLoading}
|
||||
onChange={(event) => {
|
||||
const agentId = event.target.value;
|
||||
if (agentId) enterStandardChat(agentId);
|
||||
}}
|
||||
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
|
||||
</option>
|
||||
{fallbackAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{desensitize(agent.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 text-[10px]"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{!selectorLoading && fallbackAgents.length === 0 && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
暂无可选智能体
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
|
||||
import {
|
||||
getRouteKeyLoginPassword,
|
||||
resolveRouteKey,
|
||||
resolveRouteKeyMapping,
|
||||
ROUTE_KEY_PARAM,
|
||||
} from '@/core/auth/route-key-login';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
import { readEmbedParam } from '@/core/embed/embed-session';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
@@ -33,9 +35,14 @@ import {
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
buildStandardChatRedirectPath,
|
||||
buildEmbeddedIframeRedirectPath,
|
||||
buildChatsIframeRedirectPath,
|
||||
hasIframePresentationOverride,
|
||||
isChatsIframeLogin,
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolvePostLoginRedirectPath,
|
||||
shouldUseChatsIframeProtocol,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
@@ -101,22 +108,42 @@ export default function LoginPage() {
|
||||
);
|
||||
const requestedChatMode =
|
||||
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
||||
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
|
||||
// Wujie is an application container, not the Brainy iframe protocol. It
|
||||
// must therefore land on the regular full ChatPage. Plugin links with
|
||||
// `from=iframe` are normalized into the explicit iframe protocol mode.
|
||||
const iframePresentationOverride = useMemo(
|
||||
() => hasIframePresentationOverride(searchParams.toString()),
|
||||
[searchParams],
|
||||
);
|
||||
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
|
||||
// `from=iframe` is the established entry contract for the dedicated iframe
|
||||
// page. A standard presentation flag is the one intentional exception: it
|
||||
// uses the normal full chat workspace so Header/PageSidebar visibility is
|
||||
// controlled by hideHeader/hideChrome/embed instead.
|
||||
const brainyIframeRequested = shouldUseChatsIframeProtocol(
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
// An explicit full-chat request and all standard presentation flags retain
|
||||
// the regular workspace. Otherwise native iframe/Wujie hosts use the
|
||||
// legacy dedicated AgentSelector entry, matching the established bare
|
||||
// iframe login integration.
|
||||
const fullChatRequested = useMemo(
|
||||
() =>
|
||||
!brainyIframeRequested &&
|
||||
(isWujieEnv() ||
|
||||
requestedChatMode === FULL_CHAT_MODE),
|
||||
[brainyIframeRequested, requestedChatMode, searchParams],
|
||||
requestedChatMode === FULL_CHAT_MODE ||
|
||||
// hideHeader/hideChrome/embed are explicit presentation contracts.
|
||||
// They must retain their query parameters and enter the standard
|
||||
// workspace even when a native iframe has no legacy `from` marker.
|
||||
iframePresentationOverride),
|
||||
[
|
||||
brainyIframeRequested,
|
||||
iframePresentationOverride,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
],
|
||||
);
|
||||
const isIframeLogin = useMemo(
|
||||
() =>
|
||||
brainyIframeRequested ||
|
||||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
|
||||
[brainyIframeRequested, fullChatRequested, searchParams],
|
||||
() => brainyIframeRequested,
|
||||
[brainyIframeRequested],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
@@ -127,22 +154,37 @@ export default function LoginPage() {
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const redirectPath = useMemo(
|
||||
() => {
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
// Keep the mode on the destination URL so PageRoutes can retain the
|
||||
// full chat workspace sidebar while hideChrome hides only the global
|
||||
// application chrome.
|
||||
if (brainyIframeRequested) {
|
||||
return buildChatsIframeRedirectPath(searchParams.toString());
|
||||
return !isChatsIframeLogin(searchParams.toString()) &&
|
||||
requestedChatMode !== IFRAME_CHAT_MODE
|
||||
? buildEmbeddedIframeRedirectPath(searchParams.toString())
|
||||
: buildChatsIframeRedirectPath(searchParams.toString());
|
||||
}
|
||||
if (fullChatRequested) {
|
||||
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
|
||||
return buildStandardChatRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
}
|
||||
// External iframe callers frequently also provide yUserId for login.
|
||||
// That identity parameter must not steal a deliberate iframe entry and
|
||||
// send it to the generic full-chat home before the route split above.
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
return resolvePostLoginRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
},
|
||||
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
|
||||
[
|
||||
brainyIframeRequested,
|
||||
fullChatRequested,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
yUserId,
|
||||
],
|
||||
);
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
@@ -152,6 +194,10 @@ export default function LoginPage() {
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
|
||||
const routeKeyMapping = useMemo(
|
||||
() => resolveRouteKeyMapping(routeKey),
|
||||
[routeKey],
|
||||
);
|
||||
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
|
||||
const configuredRouteLoginPassword = useMemo(
|
||||
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
|
||||
@@ -183,6 +229,14 @@ export default function LoginPage() {
|
||||
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
const [pathOnly, ownQuery = ''] = normalized.split('?');
|
||||
const target = new URLSearchParams(ownQuery);
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
pathOnly,
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
if (iframeEntryTarget) {
|
||||
return iframeEntryTarget;
|
||||
}
|
||||
// Keep the Wujie/full-chat decision on the target URL. The destination
|
||||
// route uses it to retain the full agent/history workspace sidebar.
|
||||
if (
|
||||
@@ -204,7 +258,7 @@ export default function LoginPage() {
|
||||
});
|
||||
const qs = target.toString();
|
||||
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
||||
}, [fullChatRequested, routeKeyTarget, searchParams]);
|
||||
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -243,10 +297,20 @@ export default function LoginPage() {
|
||||
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
|
||||
useEffect(() => {
|
||||
if (isEmbed || isIframeLogin) return;
|
||||
const themeValue = wujieProps.theme || externalParams.theme;
|
||||
// routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
|
||||
// `theme=dark-blue` 也能在登录完成前切换到指定主题。
|
||||
const themeValue =
|
||||
routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
|
||||
if (!themeValue) return;
|
||||
setTheme(resolveThemeFromQuery(themeValue));
|
||||
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
|
||||
}, [
|
||||
isEmbed,
|
||||
isIframeLogin,
|
||||
routeKeyMapping?.theme,
|
||||
wujieProps.theme,
|
||||
externalParams.theme,
|
||||
setTheme,
|
||||
]);
|
||||
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Add configurable position-roundtable roles.
|
||||
|
||||
Revision ID: 20260729_01
|
||||
Revises: 20260723_05
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260729_01"
|
||||
down_revision = "20260723_05"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(name: str) -> bool:
|
||||
return name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("position_roles"):
|
||||
return
|
||||
op.create_table(
|
||||
"position_roles",
|
||||
sa.Column("id", sa.String(length=128), primary_key=True),
|
||||
sa.Column("name", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("description", sa.String(length=512), nullable=False, server_default=""),
|
||||
sa.Column("role_type", sa.String(length=32), nullable=False, server_default="standard"),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_position_roles_name", "position_roles", ["name"])
|
||||
op.create_index("ix_position_roles_role_type", "position_roles", ["role_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("position_roles"):
|
||||
op.drop_table("position_roles")
|
||||
+22
-18
@@ -15,18 +15,6 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.agents.model import (
|
||||
AgentExtrasRow,
|
||||
AgentFavoriteRow,
|
||||
AgentRow,
|
||||
AgentSkillRow,
|
||||
)
|
||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||||
from deerflow.persistence.business_mapping.model import BusinessMappingRow
|
||||
from deerflow.persistence.concurrency.model import ConcurrencySampleRow
|
||||
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.knowledge.models import (
|
||||
KnowledgeEmbeddingRow,
|
||||
KnowledgeEntityRow,
|
||||
@@ -40,12 +28,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",
|
||||
|
||||
+38
@@ -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)
|
||||
+69
@@ -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
|
||||
+135
@@ -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
|
||||
+35
@@ -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),
|
||||
)
|
||||
+163
@@ -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
|
||||
+110
@@ -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}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""Position-roundtable role configuration persistence.
|
||||
|
||||
This is deliberately separate from :mod:`deerflow.persistence.positions`.
|
||||
The latter represents organisational positions and resource grants, while this
|
||||
package describes the work views used inside the position-roundtable product.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.position_roles.base import PositionRoleStore
|
||||
from deerflow.persistence.position_roles.memory import MemoryPositionRoleStore
|
||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||
from deerflow.persistence.position_roles.sql import PositionRoleRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryPositionRoleStore",
|
||||
"PositionRoleRepository",
|
||||
"PositionRoleRow",
|
||||
"PositionRoleStore",
|
||||
"make_position_role_store",
|
||||
]
|
||||
|
||||
|
||||
def make_position_role_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryPositionRoleStore()
|
||||
return PositionRoleRepository(session_factory)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"""Abstract storage for position-roundtable role configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PositionRoleStore(ABC):
|
||||
"""Global configuration shared by every position-roundtable session."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_roles(self) -> list[dict[str, Any]]:
|
||||
"""Return roles in presentation order."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def replace_all(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Atomically replace the editable role directory."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Insert only missing built-in roles, without overwriting edits."""
|
||||
raise NotImplementedError
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""In-memory fallback for position-roundtable role configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.position_roles.base import PositionRoleStore
|
||||
|
||||
|
||||
def _normalize(row: dict[str, Any], updated_by: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": str(row.get("name") or ""),
|
||||
"description": str(row.get("description") or ""),
|
||||
"role_type": str(row.get("role_type") or "standard"),
|
||||
"enabled": bool(row.get("enabled", True)),
|
||||
"is_builtin": bool(row.get("is_builtin", False)),
|
||||
"sort_order": int(row.get("sort_order", 0)),
|
||||
"updated_by": updated_by,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class MemoryPositionRoleStore(PositionRoleStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_roles(self) -> list[dict[str, Any]]:
|
||||
return [dict(row) for row in sorted(self._rows.values(), key=lambda item: (item["sort_order"], item["id"]))]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
self._rows = {
|
||||
str(row["id"]): _normalize(row, updated_by)
|
||||
for row in rows
|
||||
if row.get("id")
|
||||
}
|
||||
return await self.list_roles()
|
||||
|
||||
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for row in rows:
|
||||
row_id = str(row.get("id") or "")
|
||||
if row_id and row_id not in self._rows:
|
||||
self._rows[row_id] = _normalize(row)
|
||||
return await self.list_roles()
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""ORM model for configurable position-roundtable roles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class PositionRoleRow(Base):
|
||||
"""A work-view role in the position-roundtable product.
|
||||
|
||||
``role_type`` is intentionally small and stable: ``standard`` denotes a
|
||||
normal business-chain position; ``intent`` denotes the single special
|
||||
position responsible for initial intent identification and closing-agent
|
||||
entry. It is a configuration concern, not an organisation/RBAC position.
|
||||
"""
|
||||
|
||||
__tablename__ = "position_roles"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
description: Mapped[str] = mapped_column(String(512), default="")
|
||||
role_type: Mapped[str] = mapped_column(String(32), default="standard", index=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""SQLAlchemy storage for position-roundtable role configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.position_roles.base import PositionRoleStore
|
||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||
|
||||
|
||||
def _to_dict(row: PositionRoleRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
if isinstance(data.get("updated_at"), datetime):
|
||||
data["updated_at"] = data["updated_at"].isoformat()
|
||||
return data
|
||||
|
||||
|
||||
def _row_from(data: dict[str, Any], updated_by: str | None = None) -> PositionRoleRow:
|
||||
return PositionRoleRow(
|
||||
id=str(data["id"]),
|
||||
name=str(data.get("name") or ""),
|
||||
description=str(data.get("description") or ""),
|
||||
role_type=str(data.get("role_type") or "standard"),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
is_builtin=bool(data.get("is_builtin", False)),
|
||||
sort_order=int(data.get("sort_order", 0)),
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
|
||||
class PositionRoleRepository(PositionRoleStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_roles(self) -> list[dict[str, Any]]:
|
||||
statement = select(PositionRoleRow).order_by(PositionRoleRow.sort_order.asc(), PositionRoleRow.id.asc())
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(statement)).scalars()
|
||||
return [_to_dict(row) for row in rows]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
# Keep the final occurrence of duplicate ids so malformed UI payloads
|
||||
# cannot violate the primary-key constraint.
|
||||
by_id = {str(row["id"]): row for row in rows if row.get("id")}
|
||||
async with self._sf() as session:
|
||||
await session.execute(delete(PositionRoleRow))
|
||||
created = [_row_from(row, updated_by) for row in by_id.values()]
|
||||
session.add_all(created)
|
||||
await session.commit()
|
||||
for row in created:
|
||||
await session.refresh(row)
|
||||
return [_to_dict(row) for row in sorted(created, key=lambda item: (item.sort_order, item.id))]
|
||||
|
||||
async def ensure_seed(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Idempotently insert new built-ins without changing operator edits."""
|
||||
async with self._sf() as session:
|
||||
existing_ids = set((await session.execute(select(PositionRoleRow.id))).scalars())
|
||||
inserts = [_row_from(row) for row in rows if row.get("id") and str(row["id"]) not in existing_ids]
|
||||
if inserts:
|
||||
session.add_all(inserts)
|
||||
await session.commit()
|
||||
return await self.list_roles()
|
||||
+6
@@ -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
|
||||
|
||||
+9
-1
@@ -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]
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user