Compare commits
14
Commits
| 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",
|
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
|
||||||
|
|
||||||
// MaxKey 鉴权地址:onlineTicket 换 token 使用。
|
// 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 地址。
|
// PG 中任务 iframe 地址。
|
||||||
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
|
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)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
/* ── 侧边栏:二级图标卡片列(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 {
|
.light-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
ListTree,
|
ListTree,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
|
FileText,
|
||||||
|
BriefcaseBusiness,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -235,6 +237,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||||||
@@ -540,6 +558,20 @@ function LegacyAccordionSidebar({
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
|||||||
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
||||||
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
|
* - "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)
|
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
|
||||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
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"
|
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||||
: tone === "banner-light"
|
: tone === "banner-light"
|
||||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
? "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";
|
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||||
|
|
||||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import {
|
|||||||
sortThreadsPinnedFirst,
|
sortThreadsPinnedFirst,
|
||||||
titleOfThread,
|
titleOfThread,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
|
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
@@ -185,15 +186,24 @@ export function GeneralQaSidebar() {
|
|||||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const activeAgentId = searchParams.get("agent");
|
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,
|
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agentId: string) => {
|
(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>) => {
|
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -257,10 +267,17 @@ export function GeneralQaSidebar() {
|
|||||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
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) => {
|
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
|
||||||
@@ -314,7 +331,7 @@ export function GeneralQaSidebar() {
|
|||||||
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
|
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
|
||||||
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
|
(不要混 white,否则深蓝主题下会变成浅色块)。 */}
|
||||||
<Link
|
<Link
|
||||||
href={NEW_CHAT_PATH}
|
href={newChatHref}
|
||||||
onClick={handleNewChatClick}
|
onClick={handleNewChatClick}
|
||||||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -415,7 +432,7 @@ export function GeneralQaSidebar() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Link
|
<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"
|
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>
|
<span>{t.common.more}</span>
|
||||||
@@ -454,7 +471,10 @@ export function GeneralQaSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredThreads.map((thread) => {
|
filteredThreads.map((thread) => {
|
||||||
const href = listPathOfThread(thread);
|
const href = buildFullChatNavigationPath(
|
||||||
|
listPathOfThread(thread),
|
||||||
|
currentSearch,
|
||||||
|
);
|
||||||
const isActive = href === pathname;
|
const isActive = href === pathname;
|
||||||
const isBoot = isBootstrapThread(thread);
|
const isBoot = isBootstrapThread(thread);
|
||||||
const pinned = isThreadPinned(thread);
|
const pinned = isThreadPinned(thread);
|
||||||
@@ -576,7 +596,7 @@ export function GeneralQaSidebar() {
|
|||||||
)}
|
)}
|
||||||
{!query && (
|
{!query && (
|
||||||
<Link
|
<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"
|
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>
|
<span>更多最近对话</span>
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
|||||||
description: "主按钮悬停时的背景色",
|
description: "主按钮悬停时的背景色",
|
||||||
token: "--theme-button-hover",
|
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",
|
key: "selected_background",
|
||||||
label: "选中背景",
|
label: "选中背景",
|
||||||
@@ -268,14 +280,20 @@ function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
|
|||||||
style={tokens}
|
style={tokens}
|
||||||
aria-label={`${theme.name} 的颜色预览`}
|
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
|
<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
|
aria-hidden
|
||||||
>
|
>
|
||||||
C
|
C
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-semibold text-[var(--foreground)]">
|
<span className="text-xs font-semibold text-[var(--navigation-foreground)]">
|
||||||
实时预览
|
实时预览
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -234,6 +234,14 @@ export function WorkspaceNavMenu() {
|
|||||||
岗位管理
|
岗位管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/position-roles")}
|
||||||
|
>
|
||||||
|
<BriefcaseIcon />
|
||||||
|
岗位角色配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ function markExplicitChoice(): void {
|
|||||||
// 主题上下文
|
// 主题上下文
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
/** Resolved visual mode; custom themes keep their own light/dark setting. */
|
||||||
|
baseMode: 'light' | 'dark';
|
||||||
themeConfig: ThemeConfig;
|
themeConfig: ThemeConfig;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setTheme: (theme: ThemeType) => void;
|
setTheme: (theme: ThemeType) => void;
|
||||||
@@ -309,6 +311,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
||||||
? requestedTheme
|
? requestedTheme
|
||||||
: customTheme?.id ?? 'light';
|
: customTheme?.id ?? 'light';
|
||||||
|
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
@@ -377,7 +380,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
value={{ theme, baseMode, themeConfig, toggleTheme, setTheme, mounted }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env";
|
|||||||
*
|
*
|
||||||
* Add a new entry here when an external system needs a short ``routeKey``.
|
* Add a new entry here when an external system needs a short ``routeKey``.
|
||||||
* Targets must remain internal SPA routes; LoginPage validates them again
|
* 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>> = {
|
export interface RouteKeyMapping {
|
||||||
a: "/page/canvas/ai-writing",
|
/** Internal SPA route to open after sign-in. */
|
||||||
"8bf": "/page/strategy/qa/8bf-roundtable",
|
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``. */
|
/** 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") ?? "";
|
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();
|
const key = routeKey?.trim();
|
||||||
if (!key) return "";
|
if (!key) return undefined;
|
||||||
return ROUTE_KEY_MAPPINGS[key] ?? "";
|
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 test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildStandardChatRedirectPath,
|
||||||
|
buildFullChatNavigationPath,
|
||||||
|
buildEmbeddedIframeRedirectPath,
|
||||||
buildChatsIframeRedirectPath,
|
buildChatsIframeRedirectPath,
|
||||||
buildChatsIframeThreadPath,
|
buildChatsIframeThreadPath,
|
||||||
|
hasIframePresentationOverride,
|
||||||
isExplicitBrainyIframeMode,
|
isExplicitBrainyIframeMode,
|
||||||
isChatsIframeWorkspacePath,
|
isChatsIframeWorkspacePath,
|
||||||
|
resolveChatsIframeEntryRedirect,
|
||||||
resolvePostLoginRedirectPath,
|
resolvePostLoginRedirectPath,
|
||||||
resolveForcedThemeForPath,
|
resolveForcedThemeForPath,
|
||||||
resolveStandardChatFallback,
|
resolveStandardChatFallback,
|
||||||
|
shouldUseChatsIframeProtocol,
|
||||||
} from "./chats-iframe-route.ts";
|
} from "./chats-iframe-route.ts";
|
||||||
|
|
||||||
test("matches only workspace chats_iframe routes", () => {
|
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);
|
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(""), false);
|
||||||
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
|
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
|
||||||
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), 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);
|
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(
|
assert.equal(
|
||||||
resolveStandardChatFallback(
|
resolveStandardChatFallback(
|
||||||
"/page/workspace/chats_iframe/new",
|
"/page/workspace/chats_iframe/new",
|
||||||
"?from=iframe&hideChrome=1",
|
"?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(
|
assert.equal(
|
||||||
resolveStandardChatFallback(
|
resolveStandardChatFallback(
|
||||||
"/page/workspace/chats_iframe/new",
|
"/page/workspace/chats_iframe/new",
|
||||||
"?from=iframe&chatMode=full",
|
"?from=iframe&embed=1&theme=dark-blue",
|
||||||
),
|
),
|
||||||
"/page/workspace/chats/new?chatMode=full",
|
"/page/workspace/chats/new?embed=1&theme=dark-blue&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",
|
|
||||||
);
|
);
|
||||||
|
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", () => {
|
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,
|
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(
|
assert.equal(
|
||||||
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
|
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(
|
assert.equal(
|
||||||
buildChatsIframeRedirectPath("?from=iframe"),
|
buildChatsIframeRedirectPath("?from=iframe"),
|
||||||
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
|
"/page/workspace/chats_iframe/new?from=iframe",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildChatsIframeRedirectPath(
|
buildChatsIframeRedirectPath(
|
||||||
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
|
"?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(
|
assert.equal(
|
||||||
resolvePostLoginRedirectPath(
|
resolvePostLoginRedirectPath(
|
||||||
"?from=iframe&theme=dark-blue&yUserId=user-1",
|
"?from=iframe&theme=dark-blue&yUserId=user-1",
|
||||||
"/page/workspace/chats/new",
|
"/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(
|
assert.equal(
|
||||||
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
|
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",
|
"thread-123",
|
||||||
"?from=iframe&theme=dark-blue&ticket=secret",
|
"?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 CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
||||||
const IFRAME_LOGIN_SOURCE = "iframe";
|
const IFRAME_LOGIN_SOURCE = "iframe";
|
||||||
|
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
||||||
|
|
||||||
function toSearchParams(search: string): URLSearchParams {
|
function toSearchParams(search: string): URLSearchParams {
|
||||||
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
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 {
|
export function isChatsIframeLogin(search: string): boolean {
|
||||||
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
|
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.
|
* Preserve only the presentation parameters consumed by the iframe workspace.
|
||||||
* Login credentials must never be copied into the post-login address bar.
|
* 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();
|
const requestedMode = source.get("chatMode")?.trim();
|
||||||
if (requestedMode) {
|
if (
|
||||||
|
requestedMode?.toLowerCase() === "iframe" ||
|
||||||
|
(!isChatsIframeLogin(search) && requestedMode)
|
||||||
|
) {
|
||||||
target.set("chatMode", 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();
|
const theme = source.get("theme")?.trim();
|
||||||
@@ -57,8 +171,12 @@ export function resolvePostLoginRedirectPath(
|
|||||||
search: string,
|
search: string,
|
||||||
fallbackPath: string,
|
fallbackPath: string,
|
||||||
): string {
|
): string {
|
||||||
return isChatsIframeLogin(search)
|
if (shouldUseChatsIframeProtocol(search)) {
|
||||||
? buildChatsIframeRedirectPath(search)
|
return buildChatsIframeRedirectPath(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
|
||||||
|
? buildStandardChatRedirectPath(search, fallbackPath)
|
||||||
: 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
|
* The lightweight iframe page is a private transport for the Brainy
|
||||||
* postMessage protocol, not a general-purpose embedded chat page.
|
* postMessage protocol, not a general-purpose embedded chat page.
|
||||||
*
|
*
|
||||||
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
|
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
|
||||||
* iframes and micro-frontend containers from accidentally landing on the
|
* iframe/Wujie embedding without either marker remains standard chat.
|
||||||
* protocol-only page.
|
|
||||||
*/
|
*/
|
||||||
export function isExplicitBrainyIframeMode(search: string): boolean {
|
export function isExplicitBrainyIframeMode(search: string): boolean {
|
||||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||||
@@ -95,25 +288,32 @@ export function resolveStandardChatFallback(
|
|||||||
pathname: string,
|
pathname: string,
|
||||||
search: string,
|
search: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!isChatsIframeWorkspacePath(pathname) || isExplicitBrainyIframeMode(search)) {
|
if (!isChatsIframeWorkspacePath(pathname)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
||||||
const params = new URLSearchParams(normalizedSearch);
|
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 routePrefix = "/page/workspace/chats_iframe/";
|
||||||
const routeSuffix = pathname.slice(routePrefix.length);
|
const routeSuffix = pathname.slice(routePrefix.length);
|
||||||
const targetPath =
|
const targetPath =
|
||||||
routeSuffix && routeSuffix !== "new"
|
routeSuffix && routeSuffix !== "new"
|
||||||
? `/page/workspace/chats/${routeSuffix}`
|
? `/page/workspace/chats/${routeSuffix}`
|
||||||
: "/page/workspace/chats/new";
|
: "/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.delete("from");
|
||||||
params.set("chatMode", "full");
|
params.set("chatMode", "full");
|
||||||
const targetSearch = params.toString();
|
const targetSearch = params.toString();
|
||||||
@@ -131,7 +331,7 @@ export function resolveForcedThemeForPath(
|
|||||||
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
||||||
if (
|
if (
|
||||||
isChatsIframeWorkspacePath(pathname) ||
|
isChatsIframeWorkspacePath(pathname) ||
|
||||||
(isLoginPath && isChatsIframeLogin(search))
|
(isLoginPath && shouldUseChatsIframeProtocol(search))
|
||||||
) {
|
) {
|
||||||
const rawTheme = toSearchParams(search).get("theme")?.trim();
|
const rawTheme = toSearchParams(search).get("theme")?.trim();
|
||||||
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
|
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
|
||||||
|
|||||||
@@ -270,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/admin/positions",
|
path: "/page/strategy/admin/positions",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mgmt-position-roles",
|
||||||
|
label: "岗位角色配置",
|
||||||
|
icon: Briefcase,
|
||||||
|
path: "/page/strategy/admin/position-roles",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mgmt-admin-scheduled-tasks",
|
id: "mgmt-admin-scheduled-tasks",
|
||||||
label: "定时任务管理",
|
label: "定时任务管理",
|
||||||
|
|||||||
@@ -291,6 +291,8 @@ export type CustomThemeBaseMode = "light" | "dark";
|
|||||||
|
|
||||||
export interface CustomThemeColorOverrides {
|
export interface CustomThemeColorOverrides {
|
||||||
button_hover?: string | null;
|
button_hover?: string | null;
|
||||||
|
navigation_gradient_start?: string | null;
|
||||||
|
navigation_gradient_end?: string | null;
|
||||||
selected_background?: string | null;
|
selected_background?: string | null;
|
||||||
border?: string | null;
|
border?: string | null;
|
||||||
muted?: string | null;
|
muted?: string | null;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
|||||||
|
|
||||||
export const CUSTOM_THEME_ADVANCED_KEYS = [
|
export const CUSTOM_THEME_ADVANCED_KEYS = [
|
||||||
"button_hover",
|
"button_hover",
|
||||||
|
"navigation_gradient_start",
|
||||||
|
"navigation_gradient_end",
|
||||||
"selected_background",
|
"selected_background",
|
||||||
"border",
|
"border",
|
||||||
"muted",
|
"muted",
|
||||||
@@ -146,6 +148,18 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
|||||||
const primaryHover = optionalHexColor(overrides.button_hover)
|
const primaryHover = optionalHexColor(overrides.button_hover)
|
||||||
? toRgb(overrides.button_hover!)
|
? toRgb(overrides.button_hover!)
|
||||||
: defaultPrimaryHover;
|
: 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 primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
|
||||||
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
|
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
|
||||||
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
|
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
|
||||||
@@ -168,6 +182,8 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
|||||||
const secondaryHex = toHex(secondary);
|
const secondaryHex = toHex(secondary);
|
||||||
const primaryHoverHex = toHex(primaryHover);
|
const primaryHoverHex = toHex(primaryHover);
|
||||||
const primaryActiveHex = toHex(primaryActive);
|
const primaryActiveHex = toHex(primaryActive);
|
||||||
|
const navigationGradientStartHex = toHex(navigationGradientStart);
|
||||||
|
const navigationGradientEndHex = toHex(navigationGradientEnd);
|
||||||
const sidebarHex = toHex(sidebar);
|
const sidebarHex = toHex(sidebar);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -177,6 +193,9 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
|||||||
"--brand-900": actionHex,
|
"--brand-900": actionHex,
|
||||||
"--brand-950": actionActiveHex,
|
"--brand-950": actionActiveHex,
|
||||||
"--theme-button-hover": primaryHoverHex,
|
"--theme-button-hover": primaryHoverHex,
|
||||||
|
"--navigation-gradient-start": navigationGradientStartHex,
|
||||||
|
"--navigation-gradient-end": navigationGradientEndHex,
|
||||||
|
"--navigation-foreground": navigationForeground,
|
||||||
"--theme-selected-background": accentHex,
|
"--theme-selected-background": accentHex,
|
||||||
"--background": backgroundHex,
|
"--background": backgroundHex,
|
||||||
"--foreground": foreground,
|
"--foreground": foreground,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
import { listChains } from "@/roundtable-planning/api/chains";
|
import { listChains } from "@/roundtable-planning/api/chains";
|
||||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
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 }) {
|
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
|
||||||
const auth = getStoredAuth();
|
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 storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
|
||||||
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
@@ -34,6 +37,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
const [selectedChainId, setSelectedChainId] = useState("");
|
const [selectedChainId, setSelectedChainId] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const loadConfiguration = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -41,7 +48,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
try {
|
try {
|
||||||
const [mappings, availableChains] = await Promise.all([
|
const [mappings, availableChains] = await Promise.all([
|
||||||
listBusinessMappings(),
|
listBusinessMappings(),
|
||||||
listChains(isAdmin ? "all" : "public"),
|
listChains("public"),
|
||||||
]);
|
]);
|
||||||
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
|
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
|
||||||
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
|
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
|
||||||
@@ -54,13 +61,17 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isAdmin]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadConfiguration();
|
void loadConfiguration();
|
||||||
}, [loadConfiguration]);
|
}, [loadConfiguration]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canConfigureChain) {
|
||||||
|
setError("仅 lqq 账号可以配置 8BF 业务链条");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selected = chains.find((chain) => chain.id === selectedChainId);
|
const selected = chains.find((chain) => chain.id === selectedChainId);
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
setError("请选择一条已发布的业务链");
|
setError("请选择一条已发布的业务链");
|
||||||
@@ -81,17 +92,11 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [chains, selectedChainId]);
|
}, [canConfigureChain, chains, selectedChainId]);
|
||||||
|
|
||||||
const toolbarSlot = (
|
const toolbarSlot = (
|
||||||
<>
|
<>
|
||||||
<div className="eightbf-roundtable__chain-status" aria-live="polite">
|
{canConfigureChain && (
|
||||||
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
|
|
||||||
<span className="eightbf-roundtable__chain-status-value">
|
|
||||||
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="eightbf-roundtable__chain-settings-button"
|
className="eightbf-roundtable__chain-settings-button"
|
||||||
@@ -101,7 +106,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
配置业务链
|
配置业务链
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isSettingsOpen && (
|
{canConfigureChain && isSettingsOpen && (
|
||||||
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||||
<section
|
<section
|
||||||
className="eightbf-roundtable__settings-dialog"
|
className="eightbf-roundtable__settings-dialog"
|
||||||
@@ -137,7 +142,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<div className="eightbf-roundtable__settings-actions">
|
||||||
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
|
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
|
||||||
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
|
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
|
||||||
@@ -148,7 +153,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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
|
lockBusinessChain
|
||||||
storageTaskId={storageTaskId}
|
storageTaskId={storageTaskId}
|
||||||
toolbarSlot={toolbarSlot}
|
toolbarSlot={toolbarSlot}
|
||||||
|
entryRailBusinessChainSlot={
|
||||||
|
<PositionBusinessChainFlow
|
||||||
|
chain={configuredChain}
|
||||||
|
loading={isLoading}
|
||||||
|
className="eightbf-roundtable__business-chain-flow"
|
||||||
|
/>
|
||||||
|
}
|
||||||
rootClassName="eightbf-roundtable"
|
rootClassName="eightbf-roundtable"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,4 +21,10 @@
|
|||||||
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
|
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
|
||||||
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
|
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
|
||||||
|
|
||||||
|
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
|
||||||
|
height: 500px;
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
|
/* 可在此继续覆盖第 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: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; }
|
.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 步报告、流程图与放大弹窗的样式。 */
|
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
|
||||||
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
|
|||||||
return readRuntimeEnv(key) === "true";
|
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 = {
|
export const env = {
|
||||||
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
|
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
|
||||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
|
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BookMarkedIcon,
|
BookMarkedIcon,
|
||||||
BotIcon,
|
|
||||||
BrainCircuitIcon,
|
BrainCircuitIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
ShrinkIcon,
|
ShrinkIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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 { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||||
import { getAccessToken } from "@/core/auth";
|
import { getAccessToken } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
|||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
type BrainyChatSubmitMessage,
|
type BrainyChatSubmitMessage,
|
||||||
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
|
|||||||
|
|
||||||
export default function IframeChatPage() {
|
export default function IframeChatPage() {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||||
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
|
|||||||
useState<PendingIframeSubmit | null>(null);
|
useState<PendingIframeSubmit | null>(null);
|
||||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||||
useAgentSelector();
|
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 { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||||
const pendingRequestIdRef = useRef<string | null>(null);
|
const pendingRequestIdRef = useRef<string | null>(null);
|
||||||
const handleSubmitRef = useRef<
|
const handleSubmitRef = useRef<
|
||||||
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
|
|||||||
saveLastAgentId(agentId);
|
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(() => {
|
useEffect(() => {
|
||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLandingState && mounted && (
|
{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="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>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
|
|||||||
import {
|
import {
|
||||||
getRouteKeyLoginPassword,
|
getRouteKeyLoginPassword,
|
||||||
resolveRouteKey,
|
resolveRouteKey,
|
||||||
|
resolveRouteKeyMapping,
|
||||||
ROUTE_KEY_PARAM,
|
ROUTE_KEY_PARAM,
|
||||||
} from '@/core/auth/route-key-login';
|
} from '@/core/auth/route-key-login';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from '@/core/auth/task-deeplink';
|
} from '@/core/auth/task-deeplink';
|
||||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||||
|
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||||
import { readEmbedParam } from '@/core/embed/embed-session';
|
import { readEmbedParam } from '@/core/embed/embed-session';
|
||||||
import {
|
import {
|
||||||
GENERAL_QA_ROUTE,
|
GENERAL_QA_ROUTE,
|
||||||
@@ -33,9 +35,14 @@ import {
|
|||||||
persistToolsToken,
|
persistToolsToken,
|
||||||
} from '@/core/auth/yuser-login';
|
} from '@/core/auth/yuser-login';
|
||||||
import {
|
import {
|
||||||
|
buildStandardChatRedirectPath,
|
||||||
|
buildEmbeddedIframeRedirectPath,
|
||||||
buildChatsIframeRedirectPath,
|
buildChatsIframeRedirectPath,
|
||||||
|
hasIframePresentationOverride,
|
||||||
isChatsIframeLogin,
|
isChatsIframeLogin,
|
||||||
|
resolveChatsIframeEntryRedirect,
|
||||||
resolvePostLoginRedirectPath,
|
resolvePostLoginRedirectPath,
|
||||||
|
shouldUseChatsIframeProtocol,
|
||||||
} from '@/core/page-layout/chats-iframe-route';
|
} from '@/core/page-layout/chats-iframe-route';
|
||||||
import {
|
import {
|
||||||
isSidebarEnabledFromSearch,
|
isSidebarEnabledFromSearch,
|
||||||
@@ -101,22 +108,42 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
const requestedChatMode =
|
const requestedChatMode =
|
||||||
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
||||||
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
|
const iframePresentationOverride = useMemo(
|
||||||
// Wujie is an application container, not the Brainy iframe protocol. It
|
() => hasIframePresentationOverride(searchParams.toString()),
|
||||||
// must therefore land on the regular full ChatPage. Plugin links with
|
[searchParams],
|
||||||
// `from=iframe` are normalized into the explicit iframe protocol mode.
|
);
|
||||||
|
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(
|
const fullChatRequested = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!brainyIframeRequested &&
|
!brainyIframeRequested &&
|
||||||
(isWujieEnv() ||
|
(isWujieEnv() ||
|
||||||
requestedChatMode === FULL_CHAT_MODE),
|
requestedChatMode === FULL_CHAT_MODE ||
|
||||||
[brainyIframeRequested, requestedChatMode, searchParams],
|
// 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(
|
const isIframeLogin = useMemo(
|
||||||
() =>
|
() => brainyIframeRequested,
|
||||||
brainyIframeRequested ||
|
[brainyIframeRequested],
|
||||||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
|
|
||||||
[brainyIframeRequested, fullChatRequested, searchParams],
|
|
||||||
);
|
);
|
||||||
const resolvedUsername = useMemo(() => {
|
const resolvedUsername = useMemo(() => {
|
||||||
const raw = username?.trim();
|
const raw = username?.trim();
|
||||||
@@ -127,22 +154,37 @@ export default function LoginPage() {
|
|||||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||||
const redirectPath = useMemo(
|
const redirectPath = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (yUserId) return GENERAL_QA_ROUTE;
|
|
||||||
// Keep the mode on the destination URL so PageRoutes can retain the
|
// Keep the mode on the destination URL so PageRoutes can retain the
|
||||||
// full chat workspace sidebar while hideChrome hides only the global
|
// full chat workspace sidebar while hideChrome hides only the global
|
||||||
// application chrome.
|
// application chrome.
|
||||||
if (brainyIframeRequested) {
|
if (brainyIframeRequested) {
|
||||||
return buildChatsIframeRedirectPath(searchParams.toString());
|
return !isChatsIframeLogin(searchParams.toString()) &&
|
||||||
|
requestedChatMode !== IFRAME_CHAT_MODE
|
||||||
|
? buildEmbeddedIframeRedirectPath(searchParams.toString())
|
||||||
|
: buildChatsIframeRedirectPath(searchParams.toString());
|
||||||
}
|
}
|
||||||
if (fullChatRequested) {
|
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(
|
return resolvePostLoginRedirectPath(
|
||||||
searchParams.toString(),
|
searchParams.toString(),
|
||||||
DEFAULT_REDIRECT_PATH,
|
DEFAULT_REDIRECT_PATH,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
|
[
|
||||||
|
brainyIframeRequested,
|
||||||
|
fullChatRequested,
|
||||||
|
requestedChatMode,
|
||||||
|
searchParams,
|
||||||
|
yUserId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||||
@@ -152,6 +194,10 @@ export default function LoginPage() {
|
|||||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||||
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
|
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
|
||||||
|
const routeKeyMapping = useMemo(
|
||||||
|
() => resolveRouteKeyMapping(routeKey),
|
||||||
|
[routeKey],
|
||||||
|
);
|
||||||
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
|
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
|
||||||
const configuredRouteLoginPassword = useMemo(
|
const configuredRouteLoginPassword = useMemo(
|
||||||
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
|
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
|
||||||
@@ -183,6 +229,14 @@ export default function LoginPage() {
|
|||||||
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
|
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
|
||||||
const [pathOnly, ownQuery = ''] = normalized.split('?');
|
const [pathOnly, ownQuery = ''] = normalized.split('?');
|
||||||
const target = new URLSearchParams(ownQuery);
|
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
|
// Keep the Wujie/full-chat decision on the target URL. The destination
|
||||||
// route uses it to retain the full agent/history workspace sidebar.
|
// route uses it to retain the full agent/history workspace sidebar.
|
||||||
if (
|
if (
|
||||||
@@ -204,7 +258,7 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
const qs = target.toString();
|
const qs = target.toString();
|
||||||
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
||||||
}, [fullChatRequested, routeKeyTarget, searchParams]);
|
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
|
||||||
const externalParams = useMemo(
|
const externalParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
theme: searchParams.get('theme') ?? '',
|
theme: searchParams.get('theme') ?? '',
|
||||||
@@ -243,10 +297,20 @@ export default function LoginPage() {
|
|||||||
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
|
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEmbed || isIframeLogin) return;
|
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;
|
if (!themeValue) return;
|
||||||
setTheme(resolveThemeFromQuery(themeValue));
|
setTheme(resolveThemeFromQuery(themeValue));
|
||||||
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
|
}, [
|
||||||
|
isEmbed,
|
||||||
|
isIframeLogin,
|
||||||
|
routeKeyMapping?.theme,
|
||||||
|
wujieProps.theme,
|
||||||
|
externalParams.theme,
|
||||||
|
setTheme,
|
||||||
|
]);
|
||||||
|
|
||||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||||
useEffect(() => {
|
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 SensitiveWordsPage from "./SensitiveWordsPage";
|
||||||
import SkillAddressesPage from "./SkillAddressesPage";
|
import SkillAddressesPage from "./SkillAddressesPage";
|
||||||
import TaskButtonsPage from "./TaskButtonsPage";
|
import TaskButtonsPage from "./TaskButtonsPage";
|
||||||
|
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||||
|
import PositionRolesPage from "./PositionRolesPage";
|
||||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
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
|
<Route
|
||||||
path="admin/login-whitelist"
|
path="admin/login-whitelist"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
|||||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { getStoredAuth } from '@/core/auth';
|
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 { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||||
@@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage';
|
|||||||
import AdminUserHubPage from './AdminUserHubPage';
|
import AdminUserHubPage from './AdminUserHubPage';
|
||||||
import TagsPage from './TagsPage';
|
import TagsPage from './TagsPage';
|
||||||
import PositionsPage from './PositionsPage';
|
import PositionsPage from './PositionsPage';
|
||||||
|
import PositionRolesPage from './PositionRolesPage';
|
||||||
import PluginManagementPage from './PluginManagementPage';
|
import PluginManagementPage from './PluginManagementPage';
|
||||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||||
@@ -240,13 +245,20 @@ function NewChatEntryRoute({
|
|||||||
forceWorkspaceSidebarCollapsed: boolean;
|
forceWorkspaceSidebarCollapsed: boolean;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
// Do not infer the chat experience from window.self !== window.top here.
|
const location = useLocation();
|
||||||
// A generic `/chats/new` deep link must render the same standard chat both
|
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||||
// top-level and inside an iframe. The dedicated iframe protocol page remains
|
location.pathname,
|
||||||
// available only through its explicit `/chats_iframe/...` route (LoginPage
|
location.search,
|
||||||
// selects it for `from=iframe`). Otherwise a normal deep link was silently
|
isEmbeddedHost(),
|
||||||
// redirected to IframeChatPage, whose new-thread state intentionally has no
|
);
|
||||||
// manually selected agent and therefore stopped at "选择 Agent".
|
|
||||||
|
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 (
|
return (
|
||||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||||
<ChatRuntime>
|
<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
|
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
||||||
* Legacy links may still target it (or carry `from=iframe` during login), but
|
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||||
* ordinary iframe/Wujie embedding needs the normal full chat workspace.
|
* remains on the normal full chat workspace.
|
||||||
* `chatMode=iframe` is the only explicit opt-in for integrations that really
|
|
||||||
* implement the Brainy protocol.
|
|
||||||
*/
|
*/
|
||||||
function IframeChatEntryRoute({
|
function IframeChatEntryRoute({
|
||||||
layoutProps,
|
layoutProps,
|
||||||
@@ -341,9 +419,10 @@ export default function WorkspaceRoutes({
|
|||||||
<Route
|
<Route
|
||||||
path="chats"
|
path="chats"
|
||||||
element={
|
element={
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<ChatsIndexEntryRoute
|
||||||
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} />
|
layoutProps={layoutProps}
|
||||||
</WorkspaceLayout>
|
compactChrome={headerHidden && !compactActionsHidden}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@@ -352,13 +431,7 @@ export default function WorkspaceRoutes({
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="chats/:thread_id"
|
path="chats/:thread_id"
|
||||||
element={
|
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
|
||||||
<ChatRuntime>
|
|
||||||
<ChatPage />
|
|
||||||
</ChatRuntime>
|
|
||||||
</WorkspaceLayout>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="chats_iframe/new"
|
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
|
<Route
|
||||||
path="settings/recommended-questions"
|
path="settings/recommended-questions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ src/position-roundtable/demo/position-roundtable-full.json
|
|||||||
|
|
||||||
## 9. 开发与排查清单
|
## 9. 开发与排查清单
|
||||||
|
|
||||||
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。
|
1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。
|
||||||
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
|
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
|
||||||
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
|
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
|
||||||
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
|
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
|
||||||
@@ -226,3 +226,11 @@ pnpm build
|
|||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 11. 岗位角色配置
|
||||||
|
|
||||||
|
岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐
|
||||||
|
`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、
|
||||||
|
启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并
|
||||||
|
显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用;
|
||||||
|
自定义岗位可以新增、排序和删除。
|
||||||
@@ -130,10 +130,86 @@ interface ServerSession {
|
|||||||
nodes?: ServerNode[];
|
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> {
|
async function readJson<T>(response: Response, label: string): Promise<T> {
|
||||||
if (!response.ok) {
|
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(() => "");
|
const detail = await response.text().catch(() => "");
|
||||||
throw new Error(`${label} failed (${response.status}): ${detail}`);
|
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[]> {
|
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
|
||||||
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
|
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
|
||||||
const result = await readJson<ServerSession[]>(
|
const result = await readJson<ServerSession[]>(
|
||||||
await apiFetch(`${BASE()}/sessions${params}`),
|
await positionApiFetch(`${BASE()}/sessions${params}`),
|
||||||
"list position sessions",
|
"list position sessions",
|
||||||
);
|
);
|
||||||
return result.map(toSession);
|
return result.map(toSession);
|
||||||
@@ -230,7 +306,7 @@ export async function createPositionSession(input: {
|
|||||||
taskSnapshot: Record<string, unknown>;
|
taskSnapshot: Record<string, unknown>;
|
||||||
}): Promise<PositionRoundtableSession> {
|
}): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions`, {
|
await positionApiFetch(`${BASE()}/sessions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -244,8 +320,8 @@ export async function createPositionSession(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||||
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
|
const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
if (response.status === 404) {
|
if (response.status === 404 && !(await isMissingRoute(response))) {
|
||||||
// Consume the body so the connection can be reused, then expose a typed
|
// Consume the body so the connection can be reused, then expose a typed
|
||||||
// error that the page can resolve by selecting another history record.
|
// error that the page can resolve by selecting another history record.
|
||||||
await response.text().catch(() => "");
|
await response.text().catch(() => "");
|
||||||
@@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot(
|
|||||||
taskSnapshot: Record<string, unknown>,
|
taskSnapshot: Record<string, unknown>,
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ task_snapshot: taskSnapshot }),
|
body: JSON.stringify({ task_snapshot: taskSnapshot }),
|
||||||
@@ -279,7 +355,7 @@ export async function savePositionIntent(
|
|||||||
},
|
},
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -299,7 +375,7 @@ export async function activatePositionSession(
|
|||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
|
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chain_id: chainId }),
|
body: JSON.stringify({ chain_id: chainId }),
|
||||||
@@ -311,7 +387,7 @@ export async function activatePositionSession(
|
|||||||
|
|
||||||
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
"archive position session",
|
"archive position session",
|
||||||
@@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePositionSession(sessionId: string): Promise<void> {
|
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",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
// DELETE is deliberately idempotent from the UI's perspective. A stale
|
// DELETE is deliberately idempotent from the UI's perspective. A stale
|
||||||
// history row that is already gone still needs to be removed locally.
|
// 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(() => "");
|
await response.text().catch(() => "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -338,7 +414,7 @@ export async function bindPositionNodeThread(
|
|||||||
threadId: string,
|
threadId: string,
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
await apiFetch(
|
await positionApiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -360,7 +436,7 @@ export async function savePositionNodeConversation(
|
|||||||
},
|
},
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
await apiFetch(
|
await positionApiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation(
|
|||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const path = kind === "summary" ? "summary/state" : "action-plan/state";
|
const path = kind === "summary" ? "summary/state" : "action-plan/state";
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -416,7 +492,7 @@ export async function seedPositionDemoSession(
|
|||||||
payload: PositionDemoSeedPayload,
|
payload: PositionDemoSeedPayload,
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/demo/seed`, {
|
await positionApiFetch(`${BASE()}/demo/seed`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -437,7 +513,7 @@ export async function getPositionNodeContext(
|
|||||||
nodeKey: string,
|
nodeKey: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return readJson<Record<string, unknown>>(
|
return readJson<Record<string, unknown>>(
|
||||||
await apiFetch(
|
await positionApiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
@@ -456,7 +532,7 @@ export async function completePositionNodeTurn(
|
|||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
await apiFetch(
|
await positionApiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -479,7 +555,7 @@ export async function rejectPositionNode(
|
|||||||
input: { reason?: string; title?: string },
|
input: { reason?: string; title?: string },
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(
|
await positionApiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -533,7 +609,7 @@ async function prepareBuiltinRun(
|
|||||||
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
|
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
|
||||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ message }),
|
||||||
@@ -557,7 +633,7 @@ async function completeBuiltinRun(
|
|||||||
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
|
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
|
||||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
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 ?? ""),
|
direction: String(task.direction ?? ""),
|
||||||
source: String(task.source ?? "演示数据"),
|
source: String(task.source ?? "演示数据"),
|
||||||
issuedAt: String(task.issuedAt ?? ""),
|
issuedAt: String(task.issuedAt ?? ""),
|
||||||
|
priority: String(task.priority ?? "紧急"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface PositionTask {
|
|||||||
direction: string;
|
direction: string;
|
||||||
source: string;
|
source: string;
|
||||||
issuedAt: string;
|
issuedAt: string;
|
||||||
|
priority?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
|
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
|
||||||
@@ -53,6 +54,7 @@ export function taskFromPositionSnapshot(
|
|||||||
title: value(["title", "taskName", "name"], fallback.title),
|
title: value(["title", "taskName", "name"], fallback.title),
|
||||||
description: value(["description", "taskContent", "overview", "content"], fallback.description),
|
description: value(["description", "taskContent", "overview", "content"], fallback.description),
|
||||||
direction: value(["direction", "taskDirection", "category"], fallback.direction),
|
direction: value(["direction", "taskDirection", "category"], fallback.direction),
|
||||||
|
priority: value(["priority", "level", "urgency"], fallback.priority ?? "紧急"),
|
||||||
source: (() => {
|
source: (() => {
|
||||||
const source = value(["source", "taskSource", "sourceName"], fallback.source);
|
const source = value(["source", "taskSource", "sourceName"], fallback.source);
|
||||||
return source === "mock" ? "本系统创建" : source;
|
return source === "mock" ? "本系统创建" : source;
|
||||||
|
|||||||
@@ -1,39 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* 岗位协同的第一版岗位目录。
|
* 岗位会商的工作视角目录。
|
||||||
*
|
*
|
||||||
* 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id,
|
* 这不是组织/RBAC 岗位。稳定 id 会写入业务链节点,因此服务端的
|
||||||
* 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 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",
|
id: "intelligence",
|
||||||
name: "测试分析岗",
|
name: "测试分析岗",
|
||||||
description: "识别任务意图、补齐约束并确认业务链条",
|
description: "识别任务意图、补齐约束并确认业务链条",
|
||||||
isIntentPosition: true,
|
isIntentPosition: true,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltin: true,
|
||||||
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "planning",
|
id: "planning",
|
||||||
name: "组织测试岗",
|
name: "组织测试岗",
|
||||||
description: "承接上游信息并进行组织测试",
|
description: "承接上游信息并进行组织测试",
|
||||||
isIntentPosition: false,
|
isIntentPosition: false,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltin: true,
|
||||||
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "solution",
|
id: "solution",
|
||||||
name: "方案设计岗",
|
name: "方案设计岗",
|
||||||
description: "承接上游结果并形成方案产物",
|
description: "承接上游结果并形成方案产物",
|
||||||
isIntentPosition: false,
|
isIntentPosition: false,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltin: true,
|
||||||
|
sortOrder: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "evaluation",
|
id: "evaluation",
|
||||||
name: "综合测试岗",
|
name: "综合测试岗",
|
||||||
description: "测试链条产物并提出综合建议",
|
description: "测试链条产物并提出综合建议",
|
||||||
isIntentPosition: false,
|
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";
|
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 =>
|
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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
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 { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||||
import { getAPIClient } from "@/core/api/api-client";
|
import { getAPIClient } from "@/core/api/api-client";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
@@ -72,6 +74,7 @@ import { textOfMessage } from "@/core/threads/utils";
|
|||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import expandLogo from "@/assets/position-roundtable/expand-logo.svg";
|
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 { listChains } from "@/roundtable-planning/api/chains";
|
||||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||||
@@ -95,11 +98,15 @@ import {
|
|||||||
} from "@/position-roundtable/components/PositionBuiltinAgentChat";
|
} from "@/position-roundtable/components/PositionBuiltinAgentChat";
|
||||||
import type { PositionBuiltinAgentSwitchOption } from "@/position-roundtable/components/PositionAgentSwitcher";
|
import type { PositionBuiltinAgentSwitchOption } from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||||
import {
|
import {
|
||||||
DEFAULT_POSITION_ID,
|
|
||||||
POSITION_OPTIONS,
|
POSITION_OPTIONS,
|
||||||
|
intentPositionIdFor,
|
||||||
positionNameById,
|
positionNameById,
|
||||||
|
resolvePositionId,
|
||||||
|
setRuntimePositionOptions,
|
||||||
|
type PositionOption,
|
||||||
type PositionId,
|
type PositionId,
|
||||||
} from "@/position-roundtable/lib/positions";
|
} from "@/position-roundtable/lib/positions";
|
||||||
|
import { usePositionRoles } from "@/strategy-components/api/position-roles";
|
||||||
import {
|
import {
|
||||||
activatePositionSession,
|
activatePositionSession,
|
||||||
completePositionActionPlan,
|
completePositionActionPlan,
|
||||||
@@ -139,11 +146,7 @@ import {
|
|||||||
isPositionRoundtableStandaloneMode,
|
isPositionRoundtableStandaloneMode,
|
||||||
} from "@/position-roundtable/demo/position-roundtable-demo";
|
} from "@/position-roundtable/demo/position-roundtable-demo";
|
||||||
|
|
||||||
/**
|
/** 岗位目录来自全局角色配置;意图识别岗复用原会商 Step 1。 */
|
||||||
* 第一版先固定岗位,等岗位接口与权限范围确认后只替换常量数据源。
|
|
||||||
* 情报分析岗复用原会商 Step 1,承担意图识别。
|
|
||||||
*/
|
|
||||||
const POSITIONS = POSITION_OPTIONS;
|
|
||||||
type WorkspaceTab = "agents" | "results";
|
type WorkspaceTab = "agents" | "results";
|
||||||
type NodeVisualStatus = PositionRoundtableNode["status"] | undefined;
|
type NodeVisualStatus = PositionRoundtableNode["status"] | undefined;
|
||||||
type PositionArtifactPreview = {
|
type PositionArtifactPreview = {
|
||||||
@@ -167,16 +170,135 @@ type SandboxThreadSnapshot = {
|
|||||||
signature: string;
|
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
|
* URL parameters are externally supplied, so never cast the raw value into
|
||||||
* page state without checking it against the position directory first.
|
* 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 {
|
function normalizePreviewArtifactPath(path: string): string {
|
||||||
let normalized = path.trim();
|
let normalized = path.trim();
|
||||||
if (normalized.startsWith("write-file:")) {
|
if (normalized.startsWith("write-file:")) {
|
||||||
@@ -319,7 +441,7 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isIntentPositionNode(node: PositionRoundtableNode): boolean {
|
function isIntentPositionNode(node: PositionRoundtableNode): boolean {
|
||||||
return node.positionId === DEFAULT_POSITION_ID;
|
return node.positionId === intentPositionIdFor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function artifactIsMarkdown(artifact: Record<string, unknown>): boolean {
|
function artifactIsMarkdown(artifact: Record<string, unknown>): boolean {
|
||||||
@@ -558,9 +680,40 @@ export default function PositionRoundtablePage({
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const positionIdFromUrl = resolvePositionId(searchParams.get("positionId"));
|
|
||||||
const [positionId, setPositionId] = useState<PositionId>(() => positionIdFromUrl);
|
|
||||||
const standaloneDemoMode = isPositionRoundtableStandaloneMode();
|
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 demoModeEnabled = isPositionRoundtableDemoEnabled();
|
||||||
const demoTask = useMemo(() => getPositionDemoTask(), []);
|
const demoTask = useMemo(() => getPositionDemoTask(), []);
|
||||||
const taskIdFromUrl =
|
const taskIdFromUrl =
|
||||||
@@ -584,6 +737,50 @@ export default function PositionRoundtablePage({
|
|||||||
: rawSessionIdFromUrl;
|
: rawSessionIdFromUrl;
|
||||||
const forceNewSession =
|
const forceNewSession =
|
||||||
!standaloneDemoMode && searchParams.get("new") === "1";
|
!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 [positionSession, setPositionSession] = useState<PositionRoundtableSession | null>(null);
|
||||||
const positionSessionRef = useRef<PositionRoundtableSession | null>(null);
|
const positionSessionRef = useRef<PositionRoundtableSession | null>(null);
|
||||||
const sessionCreationRef = useRef<Promise<PositionRoundtableSession> | null>(null);
|
const sessionCreationRef = useRef<Promise<PositionRoundtableSession> | null>(null);
|
||||||
@@ -648,14 +845,14 @@ export default function PositionRoundtablePage({
|
|||||||
});
|
});
|
||||||
const [intentSaving, setIntentSaving] = useState(false);
|
const [intentSaving, setIntentSaving] = useState(false);
|
||||||
const [intentCompletedAt, setIntentCompletedAt] = useState("");
|
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(
|
const currentPositionOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
POSITIONS.map((position) => ({
|
positions.map((position) => ({
|
||||||
label: position.name,
|
label: position.name,
|
||||||
value: position.id,
|
value: position.id,
|
||||||
})),
|
})),
|
||||||
[],
|
[positions],
|
||||||
);
|
);
|
||||||
const currentPositionSelectStyle = useMemo(
|
const currentPositionSelectStyle = useMemo(
|
||||||
() => ({ width: 210, minWidth: 210, flex: "0 0 210px" }),
|
() => ({ width: 210, minWidth: 210, flex: "0 0 210px" }),
|
||||||
@@ -875,6 +1072,7 @@ export default function PositionRoundtablePage({
|
|||||||
const nextTask: PositionTask = {
|
const nextTask: PositionTask = {
|
||||||
...task,
|
...task,
|
||||||
title,
|
title,
|
||||||
|
priority: taskDraft.priority?.trim() || task.priority || "紧急",
|
||||||
description: taskDraft.description.trim(),
|
description: taskDraft.description.trim(),
|
||||||
direction: taskDraft.direction.trim() || task.direction,
|
direction: taskDraft.direction.trim() || task.direction,
|
||||||
source: taskDraft.source.trim() || task.source,
|
source: taskDraft.source.trim() || task.source,
|
||||||
@@ -1026,7 +1224,7 @@ export default function PositionRoundtablePage({
|
|||||||
);
|
);
|
||||||
const summaryDone = snapshotStatus(positionSession?.summarySnapshot) === "done";
|
const summaryDone = snapshotStatus(positionSession?.summarySnapshot) === "done";
|
||||||
const actionPlanDone = snapshotStatus(positionSession?.actionPlanSnapshot) === "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 [activeBuiltinKind, setActiveBuiltinKind] = useState<PositionBuiltinAgentKind | null>(null);
|
||||||
const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0);
|
const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0);
|
||||||
// Stage selection only arms the automatic first question. The key is
|
// Stage selection only arms the automatic first question. The key is
|
||||||
@@ -2049,8 +2247,8 @@ export default function PositionRoundtablePage({
|
|||||||
(kind: ClosureStageKind) => {
|
(kind: ClosureStageKind) => {
|
||||||
// The closing agents are part of the testing-analysis workspace only;
|
// The closing agents are part of the testing-analysis workspace only;
|
||||||
// ordinary positions keep a single business-chain node conversation.
|
// ordinary positions keep a single business-chain node conversation.
|
||||||
if (positionId !== DEFAULT_POSITION_ID) {
|
if (positionId !== intentPositionId) {
|
||||||
toast.info("收口智能体仅在测试分析岗中开放使用。");
|
toast.info("收口智能体仅在意图识别岗中开放使用。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allPositionDeliveriesDone) {
|
if (!allPositionDeliveriesDone) {
|
||||||
@@ -2068,7 +2266,7 @@ export default function PositionRoundtablePage({
|
|||||||
setActiveBuiltinKind(kind);
|
setActiveBuiltinKind(kind);
|
||||||
setPendingBuiltinAutoStartKind(kind);
|
setPendingBuiltinAutoStartKind(kind);
|
||||||
},
|
},
|
||||||
[allPositionDeliveriesDone, closeSandbox, positionId, summaryDone],
|
[allPositionDeliveriesDone, closeSandbox, intentPositionId, positionId, summaryDone],
|
||||||
);
|
);
|
||||||
const handleSelectPositionNode = useCallback(
|
const handleSelectPositionNode = useCallback(
|
||||||
(nextNode: PositionRoundtableNode) => {
|
(nextNode: PositionRoundtableNode) => {
|
||||||
@@ -2088,9 +2286,9 @@ export default function PositionRoundtablePage({
|
|||||||
setActiveBuiltinKind(null);
|
setActiveBuiltinKind(null);
|
||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
setActiveNodeKey(null);
|
setActiveNodeKey(null);
|
||||||
setRightPanelOpen(positionIdFromUrl === DEFAULT_POSITION_ID);
|
setRightPanelOpen(positionIdFromUrl === intentPositionId);
|
||||||
setLeftPanelCollapsed(false);
|
setLeftPanelCollapsed(false);
|
||||||
}, [closeSandbox, positionId, positionIdFromUrl]);
|
}, [closeSandbox, intentPositionId, positionId, positionIdFromUrl]);
|
||||||
const handlePositionChange = useCallback(
|
const handlePositionChange = useCallback(
|
||||||
(value: unknown) => {
|
(value: unknown) => {
|
||||||
// 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表,
|
// 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表,
|
||||||
@@ -2102,13 +2300,13 @@ export default function PositionRoundtablePage({
|
|||||||
setActiveBuiltinKind(null);
|
setActiveBuiltinKind(null);
|
||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
setActiveNodeKey(null);
|
setActiveNodeKey(null);
|
||||||
setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID);
|
setRightPanelOpen(nextPositionId === intentPositionId);
|
||||||
setLeftPanelCollapsed(false);
|
setLeftPanelCollapsed(false);
|
||||||
const next = new URLSearchParams(searchParamsString);
|
const next = new URLSearchParams(searchParamsString);
|
||||||
next.set("positionId", nextPositionId);
|
next.set("positionId", nextPositionId);
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
},
|
},
|
||||||
[closeSandbox, searchParamsString, setSearchParams],
|
[closeSandbox, intentPositionId, searchParamsString, setSearchParams],
|
||||||
);
|
);
|
||||||
const renderIntentChatPanel = (chainStarted: boolean) => (
|
const renderIntentChatPanel = (chainStarted: boolean) => (
|
||||||
<div className="position-roundtable-intent-page contents">
|
<div className="position-roundtable-intent-page contents">
|
||||||
@@ -2248,6 +2446,17 @@ export default function PositionRoundtablePage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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
|
<TButton
|
||||||
size="small"
|
size="small"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -2387,49 +2596,43 @@ export default function PositionRoundtablePage({
|
|||||||
<aside
|
<aside
|
||||||
aria-hidden={leftSidebarHidden || undefined}
|
aria-hidden={leftSidebarHidden || undefined}
|
||||||
className={cn(
|
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
|
leftSidebarHidden
|
||||||
? "w-0 translate-x-4 !border-0 !p-0 opacity-0 pointer-events-none"
|
? "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",
|
: "w-[360px] translate-x-0 p-3 opacity-100 dark:p-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TaskAnalysisPanel
|
{activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && (
|
||||||
task={task}
|
<PositionRoundtableEntrySidebar
|
||||||
intent={frozenIntent ?? intent.intentReady}
|
task={task}
|
||||||
taskEditable={!isArchivedSession}
|
editable={!isArchivedSession}
|
||||||
intentEditable={!isArchivedSession}
|
onEditTask={openTaskEditor}
|
||||||
onEditTask={openTaskEditor}
|
chains={chains}
|
||||||
onEditIntent={openIntentEditor}
|
loading={chainsLoading}
|
||||||
collapsed={false}
|
value={selectedChainId}
|
||||||
onCollapsedChange={setLeftPanelCollapsed}
|
onChange={setSelectedChainId}
|
||||||
className="min-h-full"
|
onConfigure={openPositionConfig}
|
||||||
>
|
/>
|
||||||
{activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && (
|
)}
|
||||||
<BusinessChainSelector
|
{(!activePosition.isIntentPosition || isIntelligenceDeliveryOverview) && (
|
||||||
chains={chains}
|
<>
|
||||||
loading={chainsLoading}
|
<TaskInformationCard
|
||||||
value={selectedChainId}
|
task={task}
|
||||||
onChange={setSelectedChainId}
|
editable={!isArchivedSession}
|
||||||
onConfigure={openPositionConfig}
|
onEdit={openTaskEditor}
|
||||||
className="min-h-0 flex-1"
|
collapsed={false}
|
||||||
|
onCollapsedChange={() => undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{(!activePosition.isIntentPosition || isIntelligenceDeliveryOverview) && (
|
|
||||||
<BusinessChainFlow
|
<BusinessChainFlow
|
||||||
chain={activeChain}
|
chain={activeChain}
|
||||||
nodes={positionSession?.nodes ?? []}
|
nodes={positionSession?.nodes ?? []}
|
||||||
session={positionSession}
|
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectIntent={
|
|
||||||
activePosition.isIntentPosition ? openIntentFlowDetail : undefined
|
|
||||||
}
|
|
||||||
onSelectNode={openNodeFlowDetail}
|
onSelectNode={openNodeFlowDetail}
|
||||||
onSelectSeat={openSeatFlowDetail}
|
onSelectSeat={openSeatFlowDetail}
|
||||||
onSelectBuiltin={handleSelectBuiltinStage}
|
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
</TaskAnalysisPanel>
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{leftSidebarHidden && (
|
{leftSidebarHidden && (
|
||||||
@@ -2517,6 +2720,7 @@ export default function PositionRoundtablePage({
|
|||||||
{activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && (
|
{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">
|
<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
|
<RightWorkspace
|
||||||
|
positions={configuredPositionOptions}
|
||||||
selectedAgent={selectedAgent}
|
selectedAgent={selectedAgent}
|
||||||
selectedAgentId={selectedAgentId}
|
selectedAgentId={selectedAgentId}
|
||||||
onSelectAgent={handleSelectAgent}
|
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]">
|
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[#032650] dark:text-[#c5cfe0]">
|
||||||
<RightWorkspace
|
<RightWorkspace
|
||||||
|
positions={configuredPositionOptions}
|
||||||
selectedAgent={selectedAgent}
|
selectedAgent={selectedAgent}
|
||||||
selectedAgentId={selectedAgentId}
|
selectedAgentId={selectedAgentId}
|
||||||
onSelectAgent={handleSelectAgent}
|
onSelectAgent={handleSelectAgent}
|
||||||
@@ -3348,7 +3553,7 @@ function PositionDeliveryOverview({
|
|||||||
agentName: "情报分析智能体",
|
agentName: "情报分析智能体",
|
||||||
productName: "任务意图理解",
|
productName: "任务意图理解",
|
||||||
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
|
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
|
||||||
positionId: DEFAULT_POSITION_ID,
|
positionId: intentPositionIdFor(),
|
||||||
updatedAt: intentCompletedAt || session?.updatedAt,
|
updatedAt: intentCompletedAt || session?.updatedAt,
|
||||||
status: intent ? "done" : "ready",
|
status: intent ? "done" : "ready",
|
||||||
accent: "#8b5cf6",
|
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({
|
function TaskInformationCard({
|
||||||
task,
|
task,
|
||||||
editable,
|
editable,
|
||||||
@@ -3688,8 +3969,34 @@ function TaskInformationCard({
|
|||||||
onCollapsedChange: (collapsed: boolean) => void;
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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="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-['']">
|
<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
|
<span
|
||||||
aria-hidden="true"
|
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%)]"
|
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>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
style={{ display: "none" }}
|
||||||
className={cn(
|
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",
|
collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -3769,6 +4077,20 @@ function TaskInformationEditDialog({
|
|||||||
<DialogTitle>编辑任务信息</DialogTitle>
|
<DialogTitle>编辑任务信息</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-1">
|
<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">
|
<label className="block space-y-1.5">
|
||||||
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务名称</span>
|
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务名称</span>
|
||||||
<TInput
|
<TInput
|
||||||
@@ -3844,19 +4166,25 @@ function BusinessChainSelector({
|
|||||||
const [relationOpenId, setRelationOpenId] = useState<string | null>(null);
|
const [relationOpenId, setRelationOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={cn("group/business-chain flex min-h-0 flex-col", className)}>
|
<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-10 items-center border-b border-slate-200 dark:border-[#2b8ffe]/60">
|
<div className="relative flex h-8 shrink-0 items-center">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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%)]"
|
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-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]">
|
<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}
|
{chains.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="flex h-20 items-center justify-center gap-2 text-xs text-slate-400 dark:text-[#8aa0c4]">
|
<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" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -4188,23 +4516,17 @@ const FLOW_NODE_GAP = 10;
|
|||||||
function BusinessChainFlow({
|
function BusinessChainFlow({
|
||||||
chain,
|
chain,
|
||||||
nodes,
|
nodes,
|
||||||
session,
|
|
||||||
activeNodeKey,
|
activeNodeKey,
|
||||||
onSelectIntent,
|
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
onSelectSeat,
|
onSelectSeat,
|
||||||
onSelectBuiltin,
|
|
||||||
className,
|
className,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
session: PositionRoundtableSession | null;
|
|
||||||
activeNodeKey: string | null;
|
activeNodeKey: string | null;
|
||||||
onSelectIntent?: () => void;
|
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
||||||
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -4260,12 +4582,9 @@ function BusinessChainFlow({
|
|||||||
<ChainFlowCanvas
|
<ChainFlowCanvas
|
||||||
chain={chain}
|
chain={chain}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
session={session}
|
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectIntent={onSelectIntent}
|
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
onSelectSeat={onSelectSeat}
|
onSelectSeat={onSelectSeat}
|
||||||
onSelectBuiltin={onSelectBuiltin}
|
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
|
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
|
||||||
@@ -4280,16 +4599,7 @@ function BusinessChainFlow({
|
|||||||
<ChainFlowCanvas
|
<ChainFlowCanvas
|
||||||
chain={chain}
|
chain={chain}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
session={session}
|
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectIntent={
|
|
||||||
onSelectIntent
|
|
||||||
? () => {
|
|
||||||
onSelectIntent();
|
|
||||||
setFlowDialogOpen(false);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onSelectNode={(node) => {
|
onSelectNode={(node) => {
|
||||||
onSelectNode(node);
|
onSelectNode(node);
|
||||||
setFlowDialogOpen(false);
|
setFlowDialogOpen(false);
|
||||||
@@ -4298,10 +4608,6 @@ function BusinessChainFlow({
|
|||||||
onSelectSeat?.(seat, stageIndex, seatIndex);
|
onSelectSeat?.(seat, stageIndex, seatIndex);
|
||||||
setFlowDialogOpen(false);
|
setFlowDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
onSelectBuiltin={(kind) => {
|
|
||||||
onSelectBuiltin(kind);
|
|
||||||
setFlowDialogOpen(false);
|
|
||||||
}}
|
|
||||||
className="h-full"
|
className="h-full"
|
||||||
expanded
|
expanded
|
||||||
/>
|
/>
|
||||||
@@ -4317,23 +4623,17 @@ function BusinessChainFlow({
|
|||||||
function ChainFlowCanvas({
|
function ChainFlowCanvas({
|
||||||
chain,
|
chain,
|
||||||
nodes,
|
nodes,
|
||||||
session,
|
|
||||||
activeNodeKey,
|
activeNodeKey,
|
||||||
onSelectIntent,
|
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
onSelectSeat,
|
onSelectSeat,
|
||||||
onSelectBuiltin,
|
|
||||||
className,
|
className,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain;
|
chain: BusinessChain;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
session: PositionRoundtableSession | null;
|
|
||||||
activeNodeKey: string | null;
|
activeNodeKey: string | null;
|
||||||
onSelectIntent?: () => void;
|
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
|
||||||
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -4349,12 +4649,6 @@ function ChainFlowCanvas({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
|
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
|
||||||
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
|
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 =
|
const connectorColor =
|
||||||
theme === "dark" ? "rgba(143, 196, 255, 0.95)" : "rgba(148, 163, 184, 0.88)";
|
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"
|
className="flex min-h-full min-w-max flex-col items-center p-5"
|
||||||
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
|
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) => {
|
{stages.map((stage, stageIndex) => {
|
||||||
const stageWidth = Math.max(
|
const stageWidth = Math.max(
|
||||||
nodeWidth,
|
nodeWidth,
|
||||||
@@ -4430,13 +4716,15 @@ function ChainFlowCanvas({
|
|||||||
key={`${stage.goal ?? "stage"}-${stageIndex}`}
|
key={`${stage.goal ?? "stage"}-${stageIndex}`}
|
||||||
className="flex flex-col items-center"
|
className="flex flex-col items-center"
|
||||||
>
|
>
|
||||||
<VerticalFlowConnector
|
{stageIndex > 0 && (
|
||||||
seatCount={stage.seats.length}
|
<VerticalFlowConnector
|
||||||
width={stageWidth}
|
seatCount={stage.seats.length}
|
||||||
nodeWidth={nodeWidth}
|
width={stageWidth}
|
||||||
nodeGap={nodeGap}
|
nodeWidth={nodeWidth}
|
||||||
connectorColor={connectorColor}
|
nodeGap={nodeGap}
|
||||||
/>
|
connectorColor={connectorColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
|
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
|
||||||
{stage.seats.map((seat, seatIndex) => {
|
{stage.seats.map((seat, seatIndex) => {
|
||||||
const node = nodes.find(
|
const node = nodes.find(
|
||||||
@@ -4472,38 +4760,6 @@ function ChainFlowCanvas({
|
|||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -4644,6 +4900,7 @@ function CompactChainNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RightWorkspace({
|
function RightWorkspace({
|
||||||
|
positions,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
@@ -4665,6 +4922,7 @@ function RightWorkspace({
|
|||||||
onTabChange,
|
onTabChange,
|
||||||
standaloneDemo = false,
|
standaloneDemo = false,
|
||||||
}: {
|
}: {
|
||||||
|
positions: readonly PositionOption[];
|
||||||
selectedAgent: Agent | null;
|
selectedAgent: Agent | null;
|
||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
onSelectAgent: (agentId: string) => void;
|
onSelectAgent: (agentId: string) => void;
|
||||||
@@ -4782,6 +5040,7 @@ function RightWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
|
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
|
||||||
<AnalysisResults
|
<AnalysisResults
|
||||||
|
positions={positions}
|
||||||
intentObjective={intentObjective}
|
intentObjective={intentObjective}
|
||||||
intentCompletedAt={intentCompletedAt}
|
intentCompletedAt={intentCompletedAt}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -5098,7 +5357,7 @@ function buildIntentResultItem(
|
|||||||
agentName: "情报分析智能体",
|
agentName: "情报分析智能体",
|
||||||
productName: "任务意图理解",
|
productName: "任务意图理解",
|
||||||
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
|
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
|
||||||
positionId: DEFAULT_POSITION_ID,
|
positionId: intentPositionIdFor(),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
status: intent ? "done" : "ready",
|
status: intent ? "done" : "ready",
|
||||||
};
|
};
|
||||||
@@ -5207,6 +5466,7 @@ function buildBuiltinResultItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AnalysisResults({
|
function AnalysisResults({
|
||||||
|
positions,
|
||||||
intentObjective,
|
intentObjective,
|
||||||
intentCompletedAt,
|
intentCompletedAt,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -5217,6 +5477,7 @@ function AnalysisResults({
|
|||||||
onOpenBuiltinChat,
|
onOpenBuiltinChat,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
|
positions: readonly PositionOption[];
|
||||||
intentObjective?: string;
|
intentObjective?: string;
|
||||||
intentCompletedAt: string;
|
intentCompletedAt: string;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
@@ -5258,7 +5519,7 @@ function AnalysisResults({
|
|||||||
agentName: "情报分析智能体",
|
agentName: "情报分析智能体",
|
||||||
productName: "任务意图理解",
|
productName: "任务意图理解",
|
||||||
summary: intentObjective,
|
summary: intentObjective,
|
||||||
positionId: DEFAULT_POSITION_ID,
|
positionId: intentPositionIdFor(),
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
status: "done",
|
status: "done",
|
||||||
});
|
});
|
||||||
@@ -5410,11 +5671,11 @@ function AnalysisResults({
|
|||||||
const positionOptions = useMemo(
|
const positionOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "全部岗位", value: "all" },
|
{ 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 }),
|
(position) => ({ label: position.name, value: position.id }),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[resultItems],
|
[positions, resultItems],
|
||||||
);
|
);
|
||||||
const nodeOptions = useMemo(
|
const nodeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -69,3 +69,120 @@
|
|||||||
.position-roundtable-intent-model-switch {
|
.position-roundtable-intent-model-switch {
|
||||||
border-radius: var(--prt-intent-radius);
|
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;
|
gatherFirst?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
|
/** 业务链条席位数硬约束,必须与后端持久化校验保持一致。 */
|
||||||
export const CHAIN_MIN_SEATS = 2;
|
export const CHAIN_MIN_SEATS = 2;
|
||||||
export const CHAIN_MAX_SEATS = 8;
|
export const CHAIN_MAX_SEATS = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
|
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
|
||||||
|
|||||||
@@ -82,8 +82,11 @@ import { listAgents } from "@/core/agents";
|
|||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import {
|
import {
|
||||||
POSITION_OPTIONS,
|
POSITION_OPTIONS,
|
||||||
|
setRuntimePositionOptions,
|
||||||
|
type PositionOption,
|
||||||
type PositionId,
|
type PositionId,
|
||||||
} from "@/position-roundtable/lib/positions";
|
} from "@/position-roundtable/lib/positions";
|
||||||
|
import { usePositionRoles } from "@/strategy-components/api/position-roles";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
filterRecommendCandidates,
|
filterRecommendCandidates,
|
||||||
@@ -160,6 +163,33 @@ export default function BusinessChainEditorPage() {
|
|||||||
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
|
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
|
||||||
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
|
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
|
||||||
const positionConfigMode = searchParams.get("mode") === "position";
|
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 requestedReturnTo = searchParams.get("returnTo");
|
||||||
const returnPath =
|
const returnPath =
|
||||||
positionConfigMode && requestedReturnTo?.startsWith("/")
|
positionConfigMode && requestedReturnTo?.startsWith("/")
|
||||||
@@ -1244,6 +1274,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
|
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
|
||||||
onSeatModeChange={setSeatModeCb}
|
onSeatModeChange={setSeatModeCb}
|
||||||
positionConfigMode={positionConfigMode}
|
positionConfigMode={positionConfigMode}
|
||||||
|
positionOptions={positionOptions}
|
||||||
onSeatPositionChange={setSeatPositionCb}
|
onSeatPositionChange={setSeatPositionCb}
|
||||||
onStageGoalChange={setGoal}
|
onStageGoalChange={setGoal}
|
||||||
goalOptimizingIdx={goalOptimizingIdx}
|
goalOptimizingIdx={goalOptimizingIdx}
|
||||||
@@ -1426,6 +1457,7 @@ function StagesColumn({
|
|||||||
onSeatSkillDirectiveChange,
|
onSeatSkillDirectiveChange,
|
||||||
onSeatModeChange,
|
onSeatModeChange,
|
||||||
positionConfigMode,
|
positionConfigMode,
|
||||||
|
positionOptions,
|
||||||
onSeatPositionChange,
|
onSeatPositionChange,
|
||||||
onStageGoalChange,
|
onStageGoalChange,
|
||||||
goalOptimizingIdx,
|
goalOptimizingIdx,
|
||||||
@@ -1447,6 +1479,8 @@ function StagesColumn({
|
|||||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||||
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
|
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
|
||||||
positionConfigMode: boolean;
|
positionConfigMode: boolean;
|
||||||
|
/** Live directory from the administrator's position-role configuration. */
|
||||||
|
positionOptions: readonly PositionOption[];
|
||||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||||
@@ -1554,6 +1588,7 @@ function StagesColumn({
|
|||||||
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
|
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
|
||||||
onSeatModeChange={onSeatModeChange}
|
onSeatModeChange={onSeatModeChange}
|
||||||
positionConfigMode={positionConfigMode}
|
positionConfigMode={positionConfigMode}
|
||||||
|
positionOptions={positionOptions}
|
||||||
onSeatPositionChange={onSeatPositionChange}
|
onSeatPositionChange={onSeatPositionChange}
|
||||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||||
@@ -1587,6 +1622,7 @@ function StageSection({
|
|||||||
onSeatSkillDirectiveChange,
|
onSeatSkillDirectiveChange,
|
||||||
onSeatModeChange,
|
onSeatModeChange,
|
||||||
positionConfigMode,
|
positionConfigMode,
|
||||||
|
positionOptions,
|
||||||
onSeatPositionChange,
|
onSeatPositionChange,
|
||||||
onGoalChange,
|
onGoalChange,
|
||||||
goalOptimizing,
|
goalOptimizing,
|
||||||
@@ -1609,6 +1645,7 @@ function StageSection({
|
|||||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||||
positionConfigMode: boolean;
|
positionConfigMode: boolean;
|
||||||
|
positionOptions: readonly PositionOption[];
|
||||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||||
onGoalChange: (goal: string) => void;
|
onGoalChange: (goal: string) => void;
|
||||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||||
@@ -1774,6 +1811,7 @@ function StageSection({
|
|||||||
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
|
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
|
||||||
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
|
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
|
||||||
showPositionConfig={positionConfigMode}
|
showPositionConfig={positionConfigMode}
|
||||||
|
positionOptions={positionOptions}
|
||||||
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
||||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||||
/>
|
/>
|
||||||
@@ -1801,6 +1839,7 @@ function SeatStrip({
|
|||||||
onSkillDirectiveChange,
|
onSkillDirectiveChange,
|
||||||
onSeatModeChange,
|
onSeatModeChange,
|
||||||
showPositionConfig,
|
showPositionConfig,
|
||||||
|
positionOptions,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onDetail,
|
onDetail,
|
||||||
}: {
|
}: {
|
||||||
@@ -1822,12 +1861,41 @@ function SeatStrip({
|
|||||||
onSeatModeChange: (mode: SeatMode | null) => void;
|
onSeatModeChange: (mode: SeatMode | null) => void;
|
||||||
/** 仅岗位协同入口展示的岗位配置。 */
|
/** 仅岗位协同入口展示的岗位配置。 */
|
||||||
showPositionConfig: boolean;
|
showPositionConfig: boolean;
|
||||||
|
/** Current position directory, injected by the business-chain editor. */
|
||||||
|
positionOptions: readonly PositionOption[];
|
||||||
onPositionChange: (positionId: PositionId | null) => void;
|
onPositionChange: (positionId: PositionId | null) => void;
|
||||||
onDetail: () => void;
|
onDetail: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||||
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -1880,13 +1948,7 @@ function SeatStrip({
|
|||||||
<TSelect
|
<TSelect
|
||||||
size="small"
|
size="small"
|
||||||
value={seat.position_id ?? ""}
|
value={seat.position_id ?? ""}
|
||||||
options={[
|
options={selectablePositionOptions}
|
||||||
{ label: "未分配", value: "" },
|
|
||||||
...POSITION_OPTIONS.map((position) => ({
|
|
||||||
label: position.name,
|
|
||||||
value: position.id,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onPositionChange(value ? (String(value) as PositionId) : null)
|
onPositionChange(value ? (String(value) as PositionId) : null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ export default function RoundtablePlanningPage({
|
|||||||
storageTaskId,
|
storageTaskId,
|
||||||
/** 供独立业务页在原页头部插入自己的操作入口。 */
|
/** 供独立业务页在原页头部插入自己的操作入口。 */
|
||||||
toolbarSlot,
|
toolbarSlot,
|
||||||
|
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
|
||||||
|
entryRailBusinessChainSlot,
|
||||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||||
rootClassName,
|
rootClassName,
|
||||||
}: {
|
}: {
|
||||||
@@ -304,6 +306,7 @@ export default function RoundtablePlanningPage({
|
|||||||
lockBusinessChain?: boolean;
|
lockBusinessChain?: boolean;
|
||||||
storageTaskId?: string | null;
|
storageTaskId?: string | null;
|
||||||
toolbarSlot?: ReactNode;
|
toolbarSlot?: ReactNode;
|
||||||
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
rootClassName?: string;
|
rootClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -2170,6 +2173,7 @@ export default function RoundtablePlanningPage({
|
|||||||
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
||||||
lockedChainId={lockedChainId}
|
lockedChainId={lockedChainId}
|
||||||
lockBusinessChain={lockBusinessChain}
|
lockBusinessChain={lockBusinessChain}
|
||||||
|
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -2729,6 +2733,8 @@ interface LeftSidebarProps {
|
|||||||
lockBusinessChain?: boolean;
|
lockBusinessChain?: boolean;
|
||||||
/** 独立页面锁定显示的业务链。 */
|
/** 独立页面锁定显示的业务链。 */
|
||||||
lockedChainId?: string | null;
|
lockedChainId?: string | null;
|
||||||
|
/** 第一阶段替换默认业务链选择区的自定义内容。 */
|
||||||
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2SandboxSidebarSync({
|
function Step2SandboxSidebarSync({
|
||||||
@@ -2779,6 +2785,7 @@ function LeftSidebar({
|
|||||||
entryRailBusinessCode,
|
entryRailBusinessCode,
|
||||||
lockBusinessChain,
|
lockBusinessChain,
|
||||||
lockedChainId,
|
lockedChainId,
|
||||||
|
entryRailBusinessChainSlot,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||||
@@ -2921,6 +2928,7 @@ function LeftSidebar({
|
|||||||
businessCode={entryRailBusinessCode ?? null}
|
businessCode={entryRailBusinessCode ?? null}
|
||||||
lockBusinessChain={lockBusinessChain}
|
lockBusinessChain={lockBusinessChain}
|
||||||
lockedChainId={lockedChainId ?? null}
|
lockedChainId={lockedChainId ?? null}
|
||||||
|
businessChainSlot={entryRailBusinessChainSlot}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -2975,6 +2983,7 @@ function RoundtableEntryRail({
|
|||||||
businessCode,
|
businessCode,
|
||||||
lockedChainId,
|
lockedChainId,
|
||||||
lockBusinessChain = false,
|
lockBusinessChain = false,
|
||||||
|
businessChainSlot,
|
||||||
}: {
|
}: {
|
||||||
selectedChainId: string | null;
|
selectedChainId: string | null;
|
||||||
onSelectChain: (chain: BusinessChain) => void;
|
onSelectChain: (chain: BusinessChain) => void;
|
||||||
@@ -2989,6 +2998,7 @@ function RoundtableEntryRail({
|
|||||||
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
|
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
|
||||||
lockedChainId?: string | null;
|
lockedChainId?: string | null;
|
||||||
lockBusinessChain?: boolean;
|
lockBusinessChain?: boolean;
|
||||||
|
businessChainSlot?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||||
@@ -3035,7 +3045,7 @@ function RoundtableEntryRail({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 深链接模式不展示业务链选择 → 不必拉公共链条。
|
// 深链接模式不展示业务链选择 → 不必拉公共链条。
|
||||||
if (deeplink) {
|
if (deeplink || lockBusinessChain) {
|
||||||
void refresh();
|
void refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3068,7 +3078,12 @@ function RoundtableEntryRail({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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),替代业务链选择。 */}
|
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
||||||
{deeplink ? (
|
{deeplink ? (
|
||||||
isXdfx ? (
|
isXdfx ? (
|
||||||
@@ -3110,6 +3125,8 @@ function RoundtableEntryRail({
|
|||||||
</div>
|
</div>
|
||||||
</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)]">
|
<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";
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
|
|
||||||
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean) =>
|
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
|
||||||
cn(
|
cn(
|
||||||
"size-8 shrink-0 rounded-sm",
|
"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"
|
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||||
: isPaleLightTheme
|
: isPaleLightTheme
|
||||||
? "text-[var(--foreground)] hover:bg-white/55 hover:text-[var(--foreground)] active:bg-white/75"
|
? "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 Header = ({ collapsed }: HeaderProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme, baseMode } = useTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = baseMode === "dark";
|
||||||
|
const isCustomTheme = theme.startsWith("custom-theme-");
|
||||||
const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow";
|
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 =
|
const darkLogoPosition =
|
||||||
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
||||||
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
||||||
@@ -152,11 +167,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||||
const actionCluster = (
|
const actionCluster = (
|
||||||
<>
|
<>
|
||||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
|
<CompactShortcutButtons tone={headerTone} />
|
||||||
<ThemeSwitcher tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
|
<ThemeSwitcher tone={headerTone} />
|
||||||
|
|
||||||
<LayoutModeSwitcher
|
<LayoutModeSwitcher
|
||||||
tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"}
|
tone={headerTone}
|
||||||
fallback="full"
|
fallback="full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setNotificationOpen(true)}
|
onClick={() => setNotificationOpen(true)}
|
||||||
className={cn(iconButtonClass(isDark, isPaleLightTheme), "relative")}
|
className={cn(iconButtonClass(isDark, isPaleLightTheme, isCustomTheme), "relative")}
|
||||||
title="通知"
|
title="通知"
|
||||||
>
|
>
|
||||||
<Bell className="size-4" />
|
<Bell className="size-4" />
|
||||||
@@ -186,7 +201,13 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-1 h-8 w-px",
|
"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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
@@ -196,7 +217,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 gap-2 rounded-sm px-2",
|
"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"
|
? "text-slate-200 hover:bg-slate-800/50"
|
||||||
: isPaleLightTheme
|
: isPaleLightTheme
|
||||||
? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75"
|
? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75"
|
||||||
@@ -206,7 +229,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
"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-gradient-to-br from-blue-500 to-cyan-400"
|
||||||
: "bg-white",
|
: "bg-white",
|
||||||
)}
|
)}
|
||||||
@@ -214,16 +239,16 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
<User
|
<User
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4",
|
"size-4",
|
||||||
isDark ? "text-white" : "text-[var(--brand-900)]",
|
isDark && !isCustomTheme ? "text-white" : "text-[var(--brand-900)]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-left sm:block">
|
<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}
|
{displayName}
|
||||||
</p>
|
</p>
|
||||||
{positionName && (
|
{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}
|
{positionName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -231,7 +256,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 shrink-0",
|
"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>
|
</Button>
|
||||||
@@ -323,7 +348,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
onError={handleBrandLogoError}
|
onError={handleBrandLogoError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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业务中心")}
|
{desensitizeText("CM业务中心")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,7 +367,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
"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"
|
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||||
: isPaleLightTheme
|
: isPaleLightTheme
|
||||||
? "bg-white/55"
|
? "bg-white/55"
|
||||||
@@ -358,7 +385,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className={cn("text-sm font-semibold", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
|
<span className={cn("text-sm font-semibold", headerTextClass)}>
|
||||||
{desensitizeText("CM业务中心")}
|
{desensitizeText("CM业务中心")}
|
||||||
</span>
|
</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
|
/* Runtime-created themes deliberately use semantic variables rather than a
|
||||||
generated class per colour. These compatibility rules keep the selected,
|
generated class per colour. These compatibility rules keep the selected,
|
||||||
hovered and compact-navigation states readable on Chrome 109 as well. */
|
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 {
|
html[data-custom-theme="true"] .agent-avatar {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--accent-foreground);
|
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: {
|
build: {
|
||||||
cssMinify: "lightningcss",
|
cssMinify: "lightningcss",
|
||||||
target: "chrome100",
|
target: "chrome100",
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ CI runs these regression tests for every pull request via [.github/workflows/bac
|
|||||||
|
|
||||||
### Position roundtable recovery persistence
|
### Position roundtable recovery persistence
|
||||||
|
|
||||||
`/api/position-roundtable` uses LangGraph threads as the live execution source
|
`/api/position-roundtable` uses the shared `/api/intent/*` LangGraph flow as
|
||||||
and stores an additional SQL recovery copy in
|
the live Step-1 execution source and stores an additional SQL recovery copy in
|
||||||
`position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy
|
`position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy
|
||||||
contains complete intent/node/summary/action-plan conversations, unsent
|
contains complete intent/node/summary/action-plan conversations, unsent
|
||||||
composer drafts, workflow snapshots, and bounded full text for generated
|
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.
|
working when container-local checkpoint or sandbox volumes are replaced.
|
||||||
Revision `20260723_05` adds the conversation columns. Session deletion
|
Revision `20260723_05` adds the conversation columns. Session deletion
|
||||||
explicitly removes child nodes so SQLite deployments do not depend on FK
|
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
|
### 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).
|
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
|
### Position-collaboration foundation
|
||||||
|
|
||||||
The position-collaboration workspace is a separate frontend route and must not
|
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
|
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
|
||||||
migration). The original roundtable only reads its established seat fields and
|
migration). The original roundtable only reads its established seat fields and
|
||||||
therefore remains unchanged. The separate `/api/position-roundtable` router
|
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`
|
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
|
||||||
and direct-agent node indexes in `position_roundtable_nodes` (migration
|
and direct-agent node indexes in `position_roundtable_nodes` (migration
|
||||||
`20260721_01`). Message history remains in the established LangGraph thread
|
`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`,
|
the built-in default theme. A row is `CustomThemePreset` (`id`, `name`,
|
||||||
six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`,
|
six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`,
|
||||||
`sort_order`). `overrides` is intentionally a short interaction-only list:
|
`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
|
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.
|
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
|
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
|
## 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:
|
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.
|
- [../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
|
- [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/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/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/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 |
|
| `GET /api/memory` | Retrieve memory data |
|
||||||
| `POST /api/memory/reload` | Force memory reload |
|
| `POST /api/memory/reload` | Force memory reload |
|
||||||
| `GET /api/memory/config` | Memory configuration |
|
| `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`,
|
safe six-digit `primary` colour, an optional primary-button `text_color`,
|
||||||
`light`/`dark` mode, name, enabled flag and sort order. The frontend derives
|
`light`/`dark` mode, name, enabled flag and sort order. The frontend derives
|
||||||
the rest of the visual tokens; the optional `overrides` object only exposes
|
the rest of the visual tokens; the optional `overrides` object only exposes
|
||||||
eight interaction colours (`button_hover`, `selected_background`, `border`,
|
ten interaction colours (`button_hover`, `navigation_gradient_start`,
|
||||||
`muted`, `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for exceptional brand requirements. A custom theme id
|
`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
|
must use the `custom-theme-` prefix. Disabled or removed custom themes cannot
|
||||||
remain the system default and automatically fall back to `light`.
|
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
|
remain the execution source, but SQL history continues to render after a
|
||||||
checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
||||||
`20260723_05` (or simply upgrade to `head`) before deploying this version.
|
`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,
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notifications,
|
||||||
|
notification_templates,
|
||||||
open_3qfx,
|
open_3qfx,
|
||||||
open_chat,
|
open_chat,
|
||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
parallel_agents,
|
parallel_agents,
|
||||||
position_roundtable,
|
position_roundtable,
|
||||||
positions,
|
positions,
|
||||||
|
position_roles,
|
||||||
public_config,
|
public_config,
|
||||||
public_embed,
|
public_embed,
|
||||||
public_leaderboard,
|
public_leaderboard,
|
||||||
@@ -857,9 +859,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
|||||||
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
|
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
|
||||||
return
|
return
|
||||||
|
|
||||||
store = getattr(app.state, "concurrency_sample_store", None)
|
store = getattr(app.state, "concurrency_sample_store", None)
|
||||||
run_mgr = getattr(app.state, "run_manager", None)
|
run_mgr = getattr(app.state, "run_manager", None)
|
||||||
concurrency_gate = getattr(app.state, "concurrency_gate", None)
|
concurrency_gate = getattr(app.state, "concurrency_gate", None)
|
||||||
if store is None or run_mgr is None:
|
if store is None or run_mgr is None:
|
||||||
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
|
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
|
||||||
return
|
return
|
||||||
@@ -880,16 +882,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
|
|||||||
try:
|
try:
|
||||||
# Runtime toggle (admin can turn sampling off without a restart).
|
# Runtime toggle (admin can turn sampling off without a restart).
|
||||||
# mtime-cached read, so this is cheap every tick.
|
# mtime-cached read, so this is cheap every tick.
|
||||||
from deerflow.config.system_settings import get_concurrency_monitor_settings
|
from deerflow.config.system_settings import get_concurrency_monitor_settings
|
||||||
|
|
||||||
if get_concurrency_monitor_settings().enabled:
|
if get_concurrency_monitor_settings().enabled:
|
||||||
active_count = None
|
active_count = None
|
||||||
if concurrency_gate is not None:
|
if concurrency_gate is not None:
|
||||||
active_count = await concurrency_gate.count_active()
|
active_count = await concurrency_gate.count_active()
|
||||||
if active_count is None:
|
if active_count is None:
|
||||||
active = await run_mgr.list_active()
|
active = await run_mgr.list_active()
|
||||||
active_count = len(active)
|
active_count = len(active)
|
||||||
await store.record(active_count)
|
await store.record(active_count)
|
||||||
ticks += 1
|
ticks += 1
|
||||||
if ticks % purge_every_ticks == 0:
|
if ticks % purge_every_ticks == 0:
|
||||||
from datetime import UTC, datetime, timedelta
|
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()
|
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
|
||||||
return v in ("1", "true", "yes", "on") or is_auth_disabled()
|
return v in ("1", "true", "yes", "on") or is_auth_disabled()
|
||||||
|
|
||||||
# The legacy TaskCOP Vue application uses Vite's default development port.
|
# The legacy TaskCOP Vue application uses Vite's default development port.
|
||||||
# Keep these origins available even when deployments configure additional
|
# Keep these origins available even when deployments configure additional
|
||||||
# origins through GATEWAY_CORS_ORIGINS.
|
# origins through GATEWAY_CORS_ORIGINS.
|
||||||
taskcop_vite_origins = [
|
taskcop_vite_origins = [
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
]
|
]
|
||||||
|
|
||||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
|
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 []
|
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
|
||||||
if cors_origins:
|
if cors_origins:
|
||||||
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
|
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
|
||||||
|
|
||||||
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
||||||
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
|
# 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://127.0.0.1:5173",
|
||||||
"http://localhost:5174",
|
"http://localhost:5174",
|
||||||
"http://127.0.0.1:5174",
|
"http://127.0.0.1:5174",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
*taskcop_vite_origins,
|
*taskcop_vite_origins,
|
||||||
]
|
]
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No CORS configuration found; enabling local dev origins only: %s. "
|
"No CORS configuration found; enabling local dev origins only: %s. "
|
||||||
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
|
"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
|
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||||
app.include_router(flow_extract.router)
|
app.include_router(flow_extract.router)
|
||||||
|
|
||||||
# Roundtable-planning business chains at /api/roundtable-chains
|
# Roundtable-planning business chains at /api/roundtable-chains
|
||||||
app.include_router(roundtable_chains.router)
|
app.include_router(roundtable_chains.router)
|
||||||
|
|
||||||
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
||||||
app.include_router(position_roundtable.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
|
# Global business ? agent / chain mapping at /api/business-mapping
|
||||||
app.include_router(business_mapping.router)
|
app.include_router(business_mapping.router)
|
||||||
|
|
||||||
|
# Position-roundtable work-view role configuration at /api/position-roles.
|
||||||
|
app.include_router(position_roles.router)
|
||||||
|
|
||||||
# Light-app registry (?????) at /api/light-apps
|
# Light-app registry (?????) at /api/light-apps
|
||||||
app.include_router(light_apps.router)
|
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
|
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||||
app.include_router(task_buttons.router)
|
app.include_router(task_buttons.router)
|
||||||
|
app.include_router(notification_templates.router)
|
||||||
|
|
||||||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||||||
app.include_router(taskcop_tasks.router)
|
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)
|
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||||
|
|
||||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||||
|
|
||||||
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
||||||
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
||||||
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
||||||
|
|
||||||
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
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
|
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
|
from deerflow.persistence.task_buttons import make_task_button_store
|
||||||
|
|
||||||
app.state.task_button_store = make_task_button_store(sf)
|
app.state.task_button_store = make_task_button_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||||
|
|
||||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
app.state.notification_template_store = make_notification_template_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.task_reports import make_task_report_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
app.state.task_report_store = make_task_report_store(sf)
|
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
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)
|
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)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|
||||||
# RunManager with store backing for persistence
|
# RunManager with store backing for persistence
|
||||||
app.state.run_manager = RunManager(store=app.state.run_store)
|
app.state.run_manager = RunManager(store=app.state.run_store)
|
||||||
from deerflow.runtime import make_redis_concurrency_gate
|
from deerflow.runtime import make_redis_concurrency_gate
|
||||||
|
|
||||||
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -399,10 +411,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
await queue.stop()
|
await queue.stop()
|
||||||
set_auto_ingest_queue(None)
|
set_auto_ingest_queue(None)
|
||||||
await close_engine()
|
await close_engine()
|
||||||
gate = getattr(app.state, "concurrency_gate", None)
|
gate = getattr(app.state, "concurrency_gate", None)
|
||||||
if gate is not None:
|
if gate is not None:
|
||||||
await gate.aclose()
|
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_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
||||||
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
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_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
||||||
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
||||||
|
|
||||||
|
|
||||||
def get_concurrency_gate(request: Request):
|
def get_concurrency_gate(request: Request):
|
||||||
"""Return the optional cross-worker concurrency gate."""
|
"""Return the optional cross-worker concurrency gate."""
|
||||||
return getattr(request.app.state, "concurrency_gate", None)
|
return getattr(request.app.state, "concurrency_gate", None)
|
||||||
|
|
||||||
|
|
||||||
def get_store(request: Request):
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
|
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
|
||||||
|
|
||||||
|
_EIGHTBF_BUSINESS_CODE = "8BF"
|
||||||
|
_EIGHTBF_MANAGER_USERNAME = "lqq"
|
||||||
|
|
||||||
|
|
||||||
# ── schemas ────────────────────────────────────────────────────────────────
|
# ── schemas ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,6 +73,20 @@ def _is_admin(request: Request) -> bool:
|
|||||||
return user is None or getattr(user, "system_role", None) == "admin"
|
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:
|
def _current_user_id(request: Request) -> str:
|
||||||
user = getattr(request.state, "user", None)
|
user = getattr(request.state, "user", None)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
@@ -119,17 +136,14 @@ async def _validate_public_chain_mapping(
|
|||||||
async def list_mappings(request: Request) -> BusinessMappingListResponse:
|
async def list_mappings(request: Request) -> BusinessMappingListResponse:
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
rows = await store.list_mappings()
|
rows = await store.list_mappings()
|
||||||
return BusinessMappingListResponse(
|
return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows])
|
||||||
mappings=[BusinessMappingResponse(**r) for r in rows]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{business_code}", response_model=BusinessMappingResponse)
|
@router.put("/{business_code}", response_model=BusinessMappingResponse)
|
||||||
async def update_mapping(
|
async def update_mapping(request: Request, business_code: str, body: BusinessMappingUpdateRequest) -> BusinessMappingResponse:
|
||||||
request: Request, business_code: str, body: BusinessMappingUpdateRequest
|
if not _can_update_mapping(request, business_code):
|
||||||
) -> BusinessMappingResponse:
|
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"
|
||||||
if not _is_admin(request):
|
raise HTTPException(status_code=403, detail=detail)
|
||||||
raise HTTPException(status_code=403, detail="Only administrators can update business mappings")
|
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
|
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
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 (
|
from app.gateway.routers.multi_agent import (
|
||||||
_ROUNDTABLE_THREAD_METADATA,
|
_ROUNDTABLE_THREAD_METADATA,
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
_create_thread,
|
_create_thread_direct,
|
||||||
_diagnose_exception,
|
_diagnose_exception,
|
||||||
_find_dispatch_message,
|
_find_dispatch_message,
|
||||||
_find_trailing_ai_text,
|
_find_trailing_ai_text,
|
||||||
@@ -311,21 +311,18 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
|||||||
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
|
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
|
||||||
ensure_roundtable_functional_agents()
|
ensure_roundtable_functional_agents()
|
||||||
|
|
||||||
base = _loopback_base(request)
|
# Keep Step-1 initialization aligned with multi-agent initialization:
|
||||||
headers = _auth_headers(request)
|
# create the empty thread in-process rather than looping back through
|
||||||
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
|
# 127.0.0.1:<gateway-port>. Intranet proxies and port mappings can make
|
||||||
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
|
# that loopback fail only for automatic page initialization. Streaming
|
||||||
# 看不到逐 token 增量。
|
# below still uses the TCP loopback so SSE remains incrementally flushed.
|
||||||
raw_task_context = payload.get("task_context")
|
raw_task_context = payload.get("task_context")
|
||||||
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
||||||
try:
|
try:
|
||||||
async with _make_loopback_client(request) as client:
|
thread_id = await _create_thread_direct(
|
||||||
thread_id = await _create_thread(
|
request,
|
||||||
client,
|
metadata=_intent_thread_metadata(task_context),
|
||||||
base,
|
)
|
||||||
headers,
|
|
||||||
metadata=_intent_thread_metadata(task_context),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -442,10 +442,30 @@ async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] |
|
|||||||
"parents": {},
|
"parents": {},
|
||||||
"created_at": now,
|
"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:
|
try:
|
||||||
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
|
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:
|
except Exception:
|
||||||
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
|
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")
|
raise HTTPException(status_code=500, detail="Failed to create thread")
|
||||||
return thread_id
|
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 app.gateway.path_utils import aresolve_thread_virtual_path
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
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"}
|
NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"}
|
||||||
_HANDOFF_TEXT_BUDGET = 12_000
|
_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"]
|
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(
|
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
|
for node in nodes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_intent_position_node(node: dict[str, Any]) -> bool:
|
def _is_intent_position_node(
|
||||||
return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"}
|
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:
|
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))
|
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)
|
now_answer = _intent_node_answer(intent)
|
||||||
primed: list[dict[str, Any]] = []
|
primed: list[dict[str, Any]] = []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
next_node = dict(node)
|
next_node = dict(node)
|
||||||
if _is_intent_position_node(next_node):
|
if _is_intent_position_node(next_node, intent_position_id):
|
||||||
next_node.update(
|
next_node.update(
|
||||||
{
|
{
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -459,7 +508,7 @@ def _build_node_context_text(
|
|||||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||||
agent_name, position_id = _seat_meta(session, current)
|
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 = [
|
lines = [
|
||||||
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
|
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
|
||||||
"",
|
"",
|
||||||
@@ -714,7 +763,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
|
|||||||
),
|
),
|
||||||
"delivery_requirement": {
|
"delivery_requirement": {
|
||||||
"type": "markdown_report",
|
"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 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
|
"completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
|
||||||
},
|
},
|
||||||
"task": session.get("task_snapshot") or {},
|
"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 [])
|
for seat in (chain.get("seats") if isinstance(chain.get("seats"), list) else [])
|
||||||
if seat.get("agent_id")
|
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(
|
nodes = _chain_nodes(
|
||||||
chain,
|
chain_snapshot,
|
||||||
available_agent_ids=await _available_agent_ids(request, seat_ids),
|
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(
|
activated = await store.activate_session(
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
chain_id=body.chain_id,
|
chain_id=body.chain_id,
|
||||||
chain_snapshot=chain,
|
chain_snapshot=chain_snapshot,
|
||||||
nodes=nodes,
|
nodes=nodes,
|
||||||
)
|
)
|
||||||
if activated is None:
|
if activated is None:
|
||||||
@@ -1213,7 +1272,7 @@ async def update_node_conversation(
|
|||||||
node_key: str,
|
node_key: str,
|
||||||
body: ConversationStateUpdateRequest,
|
body: ConversationStateUpdateRequest,
|
||||||
) -> NodeResponse:
|
) -> 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)
|
current = await store.get_node(session_id, node_key, user_id)
|
||||||
if current is None:
|
if current is None:
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
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(
|
async def complete_node_turn(
|
||||||
request: Request, session_id: str, node_key: str, body: CompleteTurnRequest
|
request: Request, session_id: str, node_key: str, body: CompleteTurnRequest
|
||||||
) -> NodeResponse:
|
) -> 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)
|
node = await store.get_node(session_id, node_key, user_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
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"}
|
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
|
||||||
next_status = (
|
next_status = (
|
||||||
"done"
|
"done"
|
||||||
if _is_intent_position_node(node)
|
if _is_intent_position_node(node, _session_intent_position_id(session))
|
||||||
else "done"
|
else "done"
|
||||||
if has_markdown and (not requires_fresh_delivery or markdown_changed)
|
if has_markdown and (not requires_fresh_delivery or markdown_changed)
|
||||||
else "running"
|
else "running"
|
||||||
@@ -1278,7 +1337,7 @@ async def complete_node_turn(
|
|||||||
new_effective_delivery = (
|
new_effective_delivery = (
|
||||||
next_status == "done"
|
next_status == "done"
|
||||||
and (
|
and (
|
||||||
_is_intent_position_node(node)
|
_is_intent_position_node(node, _session_intent_position_id(session))
|
||||||
or node.get("status") != "done"
|
or node.get("status") != "done"
|
||||||
or markdown_changed
|
or markdown_changed
|
||||||
)
|
)
|
||||||
@@ -1382,7 +1441,7 @@ async def _prepare_builtin_run(
|
|||||||
nodes = await store.list_nodes(session_id, user_id)
|
nodes = await store.list_nodes(session_id, user_id)
|
||||||
if nodes is None:
|
if nodes is None:
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
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="所有岗位产物完成后才能生成或更新内置交付")
|
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付")
|
||||||
|
|
||||||
if kind == "action_plan":
|
if kind == "action_plan":
|
||||||
@@ -1426,7 +1485,7 @@ async def _complete_builtin_run(
|
|||||||
nodes = await store.list_nodes(session_id, user_id)
|
nodes = await store.list_nodes(session_id, user_id)
|
||||||
if nodes is None:
|
if nodes is None:
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
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="上游岗位产物已变更或未完成,不能写入旧的内置交付")
|
raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付")
|
||||||
current_revisions = _source_revisions(nodes)
|
current_revisions = _source_revisions(nodes)
|
||||||
if body.source_revisions != current_revisions:
|
if body.source_revisions != current_revisions:
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ from deerflow.runtime.user_context import get_effective_user_id
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
|
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
|
MIN_SEATS = 2
|
||||||
MAX_SEATS = 8
|
MAX_SEATS = 20
|
||||||
|
|
||||||
|
|
||||||
# ── schemas ────────────────────────────────────────────────────────────────
|
# ── schemas ────────────────────────────────────────────────────────────────
|
||||||
@@ -183,7 +184,7 @@ def _get_store(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
|
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:
|
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ class CustomThemeColorOverrides(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
button_hover: str | None = Field(default=None, description="Primary button hover colour")
|
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")
|
selected_background: str | None = Field(default=None, description="Selected/active item background")
|
||||||
border: str | None = Field(default=None, description="Card and input border colour")
|
border: str | None = Field(default=None, description="Card and input border colour")
|
||||||
muted: str | None = Field(default=None, description="Muted/secondary surface colour")
|
muted: str | None = Field(default=None, description="Muted/secondary surface colour")
|
||||||
@@ -71,6 +79,8 @@ class CustomThemeColorOverrides(BaseModel):
|
|||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"button_hover",
|
"button_hover",
|
||||||
|
"navigation_gradient_start",
|
||||||
|
"navigation_gradient_end",
|
||||||
"selected_background",
|
"selected_background",
|
||||||
"border",
|
"border",
|
||||||
"muted",
|
"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.
|
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 (
|
from deerflow.knowledge.models import (
|
||||||
KnowledgeEmbeddingRow,
|
KnowledgeEmbeddingRow,
|
||||||
KnowledgeEntityRow,
|
KnowledgeEntityRow,
|
||||||
@@ -40,12 +28,27 @@ from deerflow.knowledge.models import (
|
|||||||
KnowledgeSourceRow,
|
KnowledgeSourceRow,
|
||||||
KnowledgeTagRow,
|
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.light_apps.model import LightAppRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
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.menu_overrides.model import MenuOverrideRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
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.notifications.model import NotificationRow
|
||||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||||
|
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||||
from deerflow.persistence.position_roundtable.model import (
|
from deerflow.persistence.position_roundtable.model import (
|
||||||
PositionRoundtableNodeRow,
|
PositionRoundtableNodeRow,
|
||||||
PositionRoundtableSessionRow,
|
PositionRoundtableSessionRow,
|
||||||
@@ -54,16 +57,15 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding
|
|||||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
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_draft_shares.model import RoundtableDraftShareRow
|
||||||
from deerflow.persistence.roundtable_drafts.model import (
|
from deerflow.persistence.roundtable_drafts.model import (
|
||||||
RoundtableDraftRow,
|
RoundtableDraftRow,
|
||||||
RoundtableRecommendHistoryRow,
|
RoundtableRecommendHistoryRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
|
|
||||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
from deerflow.persistence.run.model import RunRow
|
||||||
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import (
|
||||||
ScheduledTaskDeliveryProfileRow,
|
ScheduledTaskDeliveryProfileRow,
|
||||||
ScheduledTaskRow,
|
ScheduledTaskRow,
|
||||||
@@ -71,13 +73,13 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
ScheduledTaskSubscriptionRow,
|
ScheduledTaskSubscriptionRow,
|
||||||
SchedulerThreadRow,
|
SchedulerThreadRow,
|
||||||
)
|
)
|
||||||
|
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
||||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
|
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.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.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
|
||||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
@@ -109,6 +111,7 @@ __all__ = [
|
|||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"NotificationTemplateRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
"PositionRoundtableNodeRow",
|
"PositionRoundtableNodeRow",
|
||||||
"PositionRoundtableSessionRow",
|
"PositionRoundtableSessionRow",
|
||||||
@@ -123,6 +126,7 @@ __all__ = [
|
|||||||
"RoundtableJobRow",
|
"RoundtableJobRow",
|
||||||
"RoundtableRecommendHistoryRow",
|
"RoundtableRecommendHistoryRow",
|
||||||
"RoundtableTaskDraftRow",
|
"RoundtableTaskDraftRow",
|
||||||
|
"PositionRoleRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
"ScheduledTaskDeliveryProfileRow",
|
"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,
|
display_name: str | None = None,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
) -> dict:
|
) -> 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
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|||||||
+9
-1
@@ -45,6 +45,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
assistant_id=assistant_id,
|
assistant_id=assistant_id,
|
||||||
user_id=resolved_user_id,
|
user_id=resolved_user_id,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
status="idle",
|
||||||
metadata_json=metadata or {},
|
metadata_json=metadata or {},
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -52,7 +53,14 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
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)
|
return self._row_to_dict(row)
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ class _ChainStore:
|
|||||||
return self.chain
|
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(
|
return SimpleNamespace(
|
||||||
app=SimpleNamespace(
|
app=SimpleNamespace(
|
||||||
state=SimpleNamespace(
|
state=SimpleNamespace(
|
||||||
@@ -46,14 +51,29 @@ def _request(role: str, chain: dict[str, object] | None = None):
|
|||||||
roundtable_chain_store=_ChainStore(chain),
|
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
|
@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")
|
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:
|
with pytest.raises(HTTPException) as error:
|
||||||
await business_mapping_router.update_mapping(
|
await business_mapping_router.update_mapping(
|
||||||
request,
|
request,
|
||||||
@@ -65,6 +85,20 @@ async def test_only_administrators_can_update_global_business_mappings() -> None
|
|||||||
assert request.app.state.business_mapping_store.calls == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
|
async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
|
||||||
request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True})
|
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_id == "chain-8bf"
|
||||||
assert response.chain_title == "岗位协同链"
|
assert response.chain_title == "岗位协同链"
|
||||||
assert request.app.state.business_mapping_store.calls == [
|
assert request.app.state.business_mapping_store.calls == [("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})]
|
||||||
("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -2,18 +2,24 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
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 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 intent as intent_router
|
||||||
|
from app.gateway.routers import multi_agent
|
||||||
from app.gateway.routers import position_roundtable as position_roundtable_router
|
from app.gateway.routers import position_roundtable as position_roundtable_router
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
||||||
|
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -35,6 +41,56 @@ def test_reject_node_request_allows_an_optional_reason():
|
|||||||
assert request.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
|
@pytest.mark.asyncio
|
||||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||||
created = await store.create_session(
|
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 error.value.status_code == 409
|
||||||
assert "未解锁" in str(error.value.detail)
|
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