From 1555d5344b15c14fcddaaebb9318bcbd3149d1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 27 Jul 2026 21:30:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=968BF=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=93=BE=E6=B5=81=E7=A8=8B=E5=9B=BE=E4=B8=8E=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E9=85=8D=E7=BD=AE=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/EightBfRoundtablePage.tsx | 31 +- .../src/eightbf-roundtable/styles/step1.css | 6 + .../src/eightbf-roundtable/styles/step3.css | 45 +++ .../components/PositionBusinessChainFlow.tsx | 305 ++++++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 21 +- 5 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index a1e2a1b3..3737d960 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Check, Loader2, Save, Settings2, X } from "lucide-react"; +import { Loader2, Save, Settings2, X } from "lucide-react"; import { getStoredAuth } from "@/core/auth"; +import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import { listChains } from "@/roundtable-planning/api/chains"; import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; @@ -34,6 +35,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa const [selectedChainId, setSelectedChainId] = useState(""); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const configuredChain = useMemo( + () => chains.find((chain) => chain.id === mapping?.chainId) ?? null, + [chains, mapping?.chainId], + ); const loadConfiguration = useCallback(async () => { setIsLoading(true); @@ -61,6 +66,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa }, [loadConfiguration]); const handleSave = useCallback(async () => { + if (!isAdmin) { + setError("仅管理员可以配置 8BF 业务链条"); + return; + } const selected = chains.find((chain) => chain.id === selectedChainId); if (!selected) { setError("请选择一条已发布的业务链"); @@ -81,16 +90,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } finally { setIsSaving(false); } - }, [chains, selectedChainId]); + }, [chains, isAdmin, selectedChainId]); const toolbarSlot = ( <> -
- 8BF 业务链 - - {isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"} - -
{isAdmin && ( + )} + + + {!chain ? ( +
+ {loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"} +
+ ) : ( + <> + + + + + {chain.title} · 业务链条流程图 + +
+ +
+
+
+ + )} + + ); +} + +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) => { + 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 ( +
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} + > +
+ + {stages.map((stage, stageIndex) => { + const stageWidth = Math.max( + nodeWidth, + stage.seats.length * (nodeWidth + nodeGap) - nodeGap, + ); + return ( +
+ +
+ {stage.seats.map((seat) => { + const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!; + return ( + + ); + })} +
+
+ ); + })} + + + + +
+
+ ); +} + +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 ( + + + + ); + } + + const nodeCenter = nodeWidth / 2; + return ( +
+ + + {Array.from({ length: branches }).map((_, index) => ( + + + + ))} +
+ ); +} + +function FlowNode({ + title, + description, + accent, + completed = false, + width, +}: { + title: string; + description: string; + accent: string; + completed?: boolean; + width: number; +}) { + const StatusIcon = completed ? CheckCircle2 : Circle; + return ( +
+ + + +

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index b0193548..95a00491 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -296,6 +296,8 @@ export default function RoundtablePlanningPage({ storageTaskId, /** 供独立业务页在原页头部插入自己的操作入口。 */ toolbarSlot, + /** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */ + entryRailBusinessChainSlot, /** 独立业务页的根节点样式类,避免修改原会商样式。 */ rootClassName, }: { @@ -304,6 +306,7 @@ export default function RoundtablePlanningPage({ lockBusinessChain?: boolean; storageTaskId?: string | null; toolbarSlot?: ReactNode; + entryRailBusinessChainSlot?: ReactNode; rootClassName?: string; }) { const { theme } = useTheme(); @@ -2170,6 +2173,7 @@ export default function RoundtablePlanningPage({ entryRailBusinessCode={prefillRef.current?.businessCode ?? null} lockedChainId={lockedChainId} lockBusinessChain={lockBusinessChain} + entryRailBusinessChainSlot={entryRailBusinessChainSlot} /> {/* MIDDLE VIEWPORT */} @@ -2729,6 +2733,8 @@ interface LeftSidebarProps { lockBusinessChain?: boolean; /** 独立页面锁定显示的业务链。 */ lockedChainId?: string | null; + /** 第一阶段替换默认业务链选择区的自定义内容。 */ + entryRailBusinessChainSlot?: ReactNode; } function Step2SandboxSidebarSync({ @@ -2779,6 +2785,7 @@ function LeftSidebar({ entryRailBusinessCode, lockBusinessChain, lockedChainId, + entryRailBusinessChainSlot, }: LeftSidebarProps) { // 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、 // 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。 @@ -2921,6 +2928,7 @@ function LeftSidebar({ businessCode={entryRailBusinessCode ?? null} lockBusinessChain={lockBusinessChain} lockedChainId={lockedChainId ?? null} + businessChainSlot={entryRailBusinessChainSlot} /> )} @@ -2975,6 +2983,7 @@ function RoundtableEntryRail({ businessCode, lockedChainId, lockBusinessChain = false, + businessChainSlot, }: { selectedChainId: string | null; onSelectChain: (chain: BusinessChain) => void; @@ -2989,6 +2998,7 @@ function RoundtableEntryRail({ /** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */ lockedChainId?: string | null; lockBusinessChain?: boolean; + businessChainSlot?: ReactNode; }) { const [chains, setChains] = useState([]); const [chainSectionExpanded, setChainSectionExpanded] = useState(true); @@ -3035,7 +3045,7 @@ function RoundtableEntryRail({ useEffect(() => { // 深链接模式不展示业务链选择 → 不必拉公共链条。 - if (deeplink) { + if (deeplink || lockBusinessChain) { void refresh(); return; } @@ -3068,7 +3078,12 @@ function RoundtableEntryRail({ }; return ( -
+
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */} {deeplink ? ( isXdfx ? ( @@ -3110,6 +3125,8 @@ function RoundtableEntryRail({
) + ) : lockBusinessChain && businessChainSlot ? ( + businessChainSlot ) : ( /* 业务链(公共列表,内部滚动) */
From 151ecceb852d2369dd45def97f55b773e051ccf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 27 Jul 2026 21:31:09 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=B3=BB=E7=BB=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/public/runtime-config.js | 2 +- frontend-web/src/components/page-sdebar.css | 21 +++++++ .../workspace/appearance-controls.tsx | 4 +- .../custom-theme-library-settings.tsx | 24 ++++++- frontend-web/src/contexts/ThemeContext.tsx | 5 +- frontend-web/src/core/system-settings/api.ts | 2 + frontend-web/src/core/theme/custom-theme.ts | 19 ++++++ .../src/strategy-components/Header.tsx | 63 +++++++++++++------ frontend-web/src/styles/globals.css | 10 +++ offline-backend-20260512/backend/CLAUDE.md | 5 +- offline-backend-20260512/backend/README.md | 7 ++- .../deerflow/config/system_settings.py | 10 +++ 12 files changed, 145 insertions(+), 27 deletions(-) diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 3a21c0aa..f18623c5 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -99,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes", // MaxKey 鉴权地址:onlineTicket 换 token 使用。 - VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn", + VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn", // PG 中任务 iframe 地址。 VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn", diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 0093a30a..73f6a7e2 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -607,6 +607,27 @@ html.lightyellow .light-topnav-item--active::after { } /* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */ +/* Runtime themes use their configurable header gradient rather than the + red/blue banner artwork. Navigation copy follows an automatically chosen + contrasting foreground so both pale and deep brand colours remain legible. */ +html[data-custom-theme="true"] .light-topnav-item, +html[data-custom-theme="true"] .top-submenu-item { + color: var(--navigation-foreground); +} + +html[data-custom-theme="true"] .light-topnav-item:hover, +html[data-custom-theme="true"] .light-topnav-item--active, +html[data-custom-theme="true"] .light-topnav-item--active:hover, +html[data-custom-theme="true"] .top-submenu-item:hover, +html[data-custom-theme="true"] .top-submenu-item--active { + color: var(--navigation-foreground); + text-shadow: none; +} + +html[data-custom-theme="true"] .light-topnav-item--active::after { + background: var(--navigation-foreground); +} + .light-sidebar { position: fixed; left: 0; diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 03f8f81d..742eeba2 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -26,7 +26,7 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; * - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。 * - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。 */ -export type AppearanceControlTone = "banner-dark" | "banner-light" | "content"; +export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content"; // 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark) export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ @@ -43,6 +43,8 @@ const triggerToneClass = (tone: AppearanceControlTone) => ? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400" : tone === "banner-light" ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" + : tone === "banner-custom" + ? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30" : "text-muted-foreground hover:bg-muted hover:text-foreground"; export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) { diff --git a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx index be2dbec0..e7f30e0d 100644 --- a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx +++ b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx @@ -70,6 +70,18 @@ const ADVANCED_COLOR_FIELDS: Array<{ description: "主按钮悬停时的背景色", token: "--theme-button-hover", }, + { + key: "navigation_gradient_start", + label: "导航渐变起点", + description: "全量模式顶部导航栏左侧的渐变色", + token: "--navigation-gradient-start", + }, + { + key: "navigation_gradient_end", + label: "导航渐变终点", + description: "全量模式顶部导航栏右侧的渐变色", + token: "--navigation-gradient-end", + }, { key: "selected_background", label: "选中背景", @@ -268,14 +280,20 @@ function CustomThemePreview({ theme }: { theme: CustomThemePreset }) { style={tokens} aria-label={`${theme.name} 的颜色预览`} > -
+
C - + 实时预览 void; setTheme: (theme: ThemeType) => void; @@ -309,6 +311,7 @@ export const ThemeProvider: React.FC = ({ children }) => { const theme: ThemeType = isBuiltinTheme(requestedTheme) ? requestedTheme : customTheme?.id ?? 'light'; + const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light'); useEffect(() => { if (typeof document === 'undefined') return; @@ -377,7 +380,7 @@ export const ThemeProvider: React.FC = ({ children }) => { return ( {children} diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 71f5036d..3b3e76b7 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -291,6 +291,8 @@ export type CustomThemeBaseMode = "light" | "dark"; export interface CustomThemeColorOverrides { button_hover?: string | null; + navigation_gradient_start?: string | null; + navigation_gradient_end?: string | null; selected_background?: string | null; border?: string | null; muted?: string | null; diff --git a/frontend-web/src/core/theme/custom-theme.ts b/frontend-web/src/core/theme/custom-theme.ts index e1dde872..161b02c6 100644 --- a/frontend-web/src/core/theme/custom-theme.ts +++ b/frontend-web/src/core/theme/custom-theme.ts @@ -13,6 +13,8 @@ const BLACK: Rgb = { r: 0, g: 0, b: 0 }; export const CUSTOM_THEME_ADVANCED_KEYS = [ "button_hover", + "navigation_gradient_start", + "navigation_gradient_end", "selected_background", "border", "muted", @@ -146,6 +148,18 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record +const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) => cn( "size-8 shrink-0 rounded-sm", - isDark + isCustomTheme + ? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30" + : isDark ? "text-slate-400 hover:bg-slate-800/50 hover:text-white" : isPaleLightTheme ? "text-[var(--foreground)] hover:bg-white/55 hover:text-[var(--foreground)] active:bg-white/75" @@ -48,9 +50,22 @@ type HeaderProps = { }; const Header = ({ collapsed }: HeaderProps) => { - const { theme } = useTheme(); - const isDark = theme === "dark"; + const { theme, baseMode } = useTheme(); + const isDark = baseMode === "dark"; + const isCustomTheme = theme.startsWith("custom-theme-"); const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow"; + const headerTone = isCustomTheme + ? "banner-custom" + : isDark + ? "banner-dark" + : isPaleLightTheme + ? "content" + : "banner-light"; + const headerTextClass = isCustomTheme + ? "text-[var(--navigation-foreground)]" + : isPaleLightTheme + ? "text-[var(--foreground)]" + : "text-white"; const darkLogoPosition = readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center"; const isDarkLogoOnLeft = darkLogoPosition === "left"; @@ -152,11 +167,11 @@ const Header = ({ collapsed }: HeaderProps) => { // 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用 const actionCluster = ( <> - - + + @@ -165,7 +180,7 @@ const Header = ({ collapsed }: HeaderProps) => { variant="ghost" size="icon-sm" onClick={() => setNotificationOpen(true)} - className={cn(iconButtonClass(isDark, isPaleLightTheme), "relative")} + className={cn(iconButtonClass(isDark, isPaleLightTheme, isCustomTheme), "relative")} title="通知" > @@ -186,7 +201,13 @@ const Header = ({ collapsed }: HeaderProps) => {
@@ -196,7 +217,9 @@ const Header = ({ collapsed }: HeaderProps) => { variant="ghost" className={cn( "h-8 gap-2 rounded-sm px-2", - isDark + isCustomTheme + ? "text-[var(--navigation-foreground)] hover:bg-white/20 active:bg-white/30" + : isDark ? "text-slate-200 hover:bg-slate-800/50" : isPaleLightTheme ? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75" @@ -206,7 +229,9 @@ const Header = ({ collapsed }: HeaderProps) => {
{
-

+

{displayName}

{positionName && ( -

+

{positionName}

)} @@ -231,7 +256,7 @@ const Header = ({ collapsed }: HeaderProps) => { @@ -323,7 +348,7 @@ const Header = ({ collapsed }: HeaderProps) => { onError={handleBrandLogoError} />
- + {desensitizeText("CM业务中心")}
@@ -342,7 +367,9 @@ const Header = ({ collapsed }: HeaderProps) => {
{ />
{!collapsed && ( - + {desensitizeText("CM业务中心")} )} diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index 0db166ff..d4e9e639 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -1849,6 +1849,16 @@ html.lightyellow .gq-history-item--active .bg-primary { /* Runtime-created themes deliberately use semantic variables rather than a generated class per colour. These compatibility rules keep the selected, hovered and compact-navigation states readable on Chrome 109 as well. */ +html[data-custom-theme="true"] .strategy-app-header { + background-color: var(--primary); + background-image: linear-gradient( + 105deg, + var(--navigation-gradient-start) 0%, + var(--primary) 52%, + var(--navigation-gradient-end) 100% + ); +} + html[data-custom-theme="true"] .agent-avatar { background-color: var(--accent); color: var(--accent-foreground); diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 42ed2b04..5f2a15f5 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -1009,7 +1009,10 @@ When using `make dev` from root, the frontend automatically connects through ngi the built-in default theme. A row is `CustomThemePreset` (`id`, `name`, six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`, `sort_order`). `overrides` is intentionally a short interaction-only list: -`button_hover`, `selected_background`, `border`, `muted`, `muted_text`, `destructive`, `brand_title`, and `primary_foreground`. +`button_hover`, `navigation_gradient_start`, `navigation_gradient_end`, +`selected_background`, `border`, `muted`, `muted_text`, `destructive`, +`brand_title`, and `primary_foreground`. The navigation gradient endpoints +default from `primary` and are only stored when an administrator overrides them. All values must be six-digit hex strings; the frontend derives every remaining semantic CSS/TDesign token so admins do not maintain a full raw token sheet. Ids must be `custom-theme-*`, names and ids are unique, and the router limits diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 795883e9..56b715ed 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -422,8 +422,11 @@ themes through `PUT /api/system-settings/appearance`. Each theme stores a safe six-digit `primary` colour, an optional primary-button `text_color`, `light`/`dark` mode, name, enabled flag and sort order. The frontend derives the rest of the visual tokens; the optional `overrides` object only exposes -eight interaction colours (`button_hover`, `selected_background`, `border`, -`muted`, `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for exceptional brand requirements. A custom theme id + ten interaction colours (`button_hover`, `navigation_gradient_start`, + `navigation_gradient_end`, `selected_background`, `border`, `muted`, + `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for + exceptional brand requirements. The two navigation values override the + automatically derived top-bar gradient. A custom theme id must use the `custom-theme-` prefix. Disabled or removed custom themes cannot remain the system default and automatically fall back to `light`. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index 10219c77..92e1d25e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -57,6 +57,14 @@ class CustomThemeColorOverrides(BaseModel): """ button_hover: str | None = Field(default=None, description="Primary button hover colour") + navigation_gradient_start: str | None = Field( + default=None, + description="Left colour for the configurable top-navigation gradient", + ) + navigation_gradient_end: str | None = Field( + default=None, + description="Right colour for the configurable top-navigation gradient", + ) selected_background: str | None = Field(default=None, description="Selected/active item background") border: str | None = Field(default=None, description="Card and input border colour") muted: str | None = Field(default=None, description="Muted/secondary surface colour") @@ -71,6 +79,8 @@ class CustomThemeColorOverrides(BaseModel): @field_validator( "button_hover", + "navigation_gradient_start", + "navigation_gradient_end", "selected_background", "border", "muted", From 816c46d447f872109ebaaa735a410d9f0988c847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 28 Jul 2026 19:17:40 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/position-roundtable/api/sessions.ts | 116 +++++++++++++++--- offline-backend-20260512/backend/CLAUDE.md | 12 +- offline-backend-20260512/backend/README.md | 9 +- .../backend/app/gateway/app.py | 10 +- .../gateway/routers/position_roundtable.py | 4 +- .../test_position_roundtable_sessions.py | 15 +++ 6 files changed, 140 insertions(+), 26 deletions(-) diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index ed5b7e9f..ac903dd6 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -130,10 +130,86 @@ interface ServerSession { nodes?: ServerNode[]; } -const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`; +const PRIMARY_API_PATH = "/api/position-roundtable"; +const COMPAT_API_PATH = "/api/multi-agent/position-roundtable"; +const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`; + +/** + * Some intranet gateways only forward the long-established `/api/multi-agent` + * namespace. The position workspace was added later under its own top-level + * prefix, so a frontend-only release can work locally but receive FastAPI's + * route-level `{"detail":"Not Found"}` after deployment. + * + * Retry only that exact "route is absent" response against the compatibility + * namespace. A domain 404 such as "Position roundtable session not found" + * remains a real 404 and is never hidden by this routing fallback. + */ +let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null; + +function replaceApiPath(url: string, apiPath: string): string { + return url.replace(PRIMARY_API_PATH, apiPath); +} + +async function isMissingRoute(response: Response): Promise { + 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" || + /\s*404 Not Found\s*<\/title>/i.test(normalized) + ); + } +} + +async function positionApiFetch( + input: string, + init?: RequestInit, +): Promise<Response> { + if (resolvedApiPath === COMPAT_API_PATH) { + const compatibilityResponse = await apiFetch( + replaceApiPath(input, COMPAT_API_PATH), + init, + ); + if (!(await isMissingRoute(compatibilityResponse))) { + return compatibilityResponse; + } + // A gateway may be reloaded while the SPA remains open. Re-probe the + // canonical route instead of pinning the tab to a now-stale route choice. + resolvedApiPath = null; + } + + const primaryResponse = await apiFetch(input, init); + if (!(await isMissingRoute(primaryResponse))) { + resolvedApiPath = PRIMARY_API_PATH; + return primaryResponse; + } + + const compatibilityResponse = await apiFetch( + replaceApiPath(input, COMPAT_API_PATH), + init, + ); + if (!(await isMissingRoute(compatibilityResponse))) { + resolvedApiPath = COMPAT_API_PATH; + return compatibilityResponse; + } + + // Preserve the canonical response/error when neither route exists. This + // keeps diagnostics stable and identifies an outdated backend release. + return primaryResponse; +} async function readJson<T>(response: Response, label: string): Promise<T> { if (!response.ok) { + if (await isMissingRoute(response)) { + throw new Error( + `${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` + + `已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`, + ); + } const detail = await response.text().catch(() => ""); throw new Error(`${label} failed (${response.status}): ${detail}`); } @@ -219,7 +295,7 @@ function toSession(session: ServerSession): PositionRoundtableSession { export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> { const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : ""; const result = await readJson<ServerSession[]>( - await apiFetch(`${BASE()}/sessions${params}`), + await positionApiFetch(`${BASE()}/sessions${params}`), "list position sessions", ); return result.map(toSession); @@ -230,7 +306,7 @@ export async function createPositionSession(input: { taskSnapshot: Record<string, unknown>; }): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions`, { + await positionApiFetch(`${BASE()}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -244,8 +320,8 @@ export async function createPositionSession(input: { } export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> { - const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); - if (response.status === 404) { + const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); + if (response.status === 404 && !(await isMissingRoute(response))) { // Consume the body so the connection can be reused, then expose a typed // error that the page can resolve by selecting another history record. await response.text().catch(() => ""); @@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot( taskSnapshot: Record<string, unknown>, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task_snapshot: taskSnapshot }), @@ -279,7 +355,7 @@ export async function savePositionIntent( }, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -299,7 +375,7 @@ export async function activatePositionSession( ): Promise<PositionRoundtableSession> { // 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。 const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chain_id: chainId }), @@ -311,7 +387,7 @@ export async function activatePositionSession( export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { method: "POST", }), "archive position session", @@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio } export async function deletePositionSession(sessionId: string): Promise<void> { - const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { + const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", }); // DELETE is deliberately idempotent from the UI's perspective. A stale // history row that is already gone still needs to be removed locally. - if (response.status === 404) { + if (response.status === 404 && !(await isMissingRoute(response))) { await response.text().catch(() => ""); return; } @@ -338,7 +414,7 @@ export async function bindPositionNodeThread( threadId: string, ): Promise<PositionRoundtableNode> { const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`, { method: "POST", @@ -360,7 +436,7 @@ export async function savePositionNodeConversation( }, ): Promise<PositionRoundtableNode> { const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`, { method: "PUT", @@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation( ): Promise<PositionRoundtableSession> { const path = kind === "summary" ? "summary/state" : "action-plan/state"; const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -416,7 +492,7 @@ export async function seedPositionDemoSession( payload: PositionDemoSeedPayload, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/demo/seed`, { + await positionApiFetch(`${BASE()}/demo/seed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -437,7 +513,7 @@ export async function getPositionNodeContext( nodeKey: string, ): Promise<Record<string, unknown>> { return readJson<Record<string, unknown>>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`, { method: "POST" }, ), @@ -456,7 +532,7 @@ export async function completePositionNodeTurn( ): Promise<PositionRoundtableNode> { // 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。 const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`, { method: "POST", @@ -479,7 +555,7 @@ export async function rejectPositionNode( input: { reason?: string; title?: string }, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`, { method: "POST", @@ -533,7 +609,7 @@ async function prepareBuiltinRun( // prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。 const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare"; const result = await readJson<ServerBuiltinRunPreparation>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }), @@ -557,7 +633,7 @@ async function completeBuiltinRun( // complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。 const path = kind === "summary" ? "summary/complete" : "action-plan/complete"; const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 5f2a15f5..727c4a65 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -109,8 +109,8 @@ CI runs these regression tests for every pull request via [.github/workflows/bac ### Position roundtable recovery persistence -`/api/position-roundtable` uses LangGraph threads as the live execution source -and stores an additional SQL recovery copy in +`/api/position-roundtable` uses the shared `/api/intent/*` LangGraph flow as +the live Step-1 execution source and stores an additional SQL recovery copy in `position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy contains complete intent/node/summary/action-plan conversations, unsent composer drafts, workflow snapshots, and bounded full text for generated @@ -118,7 +118,11 @@ Markdown/JSON artifacts. This lets history and downstream closing agents keep working when container-local checkpoint or sandbox volumes are replaced. Revision `20260723_05` adds the conversation columns. Session deletion explicitly removes child nodes so SQLite deployments do not depend on FK -cascade pragmas. +cascade pragmas. The router is mounted canonically below +`/api/position-roundtable` and, for intranet gateway allowlists, below the +schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`. +Clients may retry the compatibility route only for a framework/proxy-level +`404 Not Found`; domain-level missing-session responses must remain real 404s. ### Harness / App Split @@ -411,6 +415,8 @@ integration point is the existing `/api/roundtable-chains` store: each `seats` J may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema migration). The original roundtable only reads its established seat fields and therefore remains unchanged. The separate `/api/position-roundtable` router +(also mounted at `/api/multi-agent/position-roundtable` for intranet gateway +compatibility) persists task/intent/frozen-chain snapshots in `position_roundtable_sessions` and direct-agent node indexes in `position_roundtable_nodes` (migration `20260721_01`). Message history remains in the established LangGraph thread diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 56b715ed..f558cdff 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -124,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/POST /api/agents` | List visible agents or create a user-owned agent | | `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) | -| `GET/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/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. | | `GET /api/memory` | Retrieve memory data | | `POST /api/memory/reload` | Force memory reload | | `GET /api/memory/config` | Memory configuration | @@ -475,3 +475,10 @@ unsent composer drafts, and generated text deliverables. LangGraph checkpoints remain the execution source, but SQL history continues to render after a checkpoint or sandbox-output volume is replaced. Apply Alembic revision `20260723_05` (or simply upgrade to `head`) before deploying this version. + +Its live intent conversation still uses the shared `/api/intent/init` and +`/api/intent/stream` endpoints used by the original multi-agent roundtable. +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`. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 66a7e928..5f4aded9 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -1474,7 +1474,15 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an 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 app.include_router(business_mapping.router) diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index fee1a881..7ca43857 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -21,7 +21,9 @@ from pydantic import BaseModel, Field from app.gateway.path_utils import aresolve_thread_virtual_path from deerflow.runtime.user_context import get_effective_user_id -router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) +# The application mounts this router below both `/api` (canonical) and the +# long-established `/api/multi-agent` namespace (intranet compatibility). +router = APIRouter(prefix="/position-roundtable", tags=["position-roundtable"]) NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"} _HANDOFF_TEXT_BUDGET = 12_000 diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 797295c5..75b486db 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -9,6 +9,7 @@ import pytest_asyncio from fastapi import HTTPException from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.gateway.app import create_app from app.gateway import services as gateway_services from app.gateway.routers import intent as intent_router from app.gateway.routers import position_roundtable as position_roundtable_router @@ -35,6 +36,20 @@ def test_reject_node_request_allows_an_optional_reason(): assert request.reason == "" +def test_position_roundtable_router_has_intranet_compatibility_prefix(): + """The workspace must survive proxies that only expose /api/multi-agent.""" + app = create_app() + registrations = { + (route.include_context.prefix, route.include_context.include_in_schema) + for route in app.routes + if getattr(route, "original_router", None) is position_roundtable_router.router + } + + assert ("/api", True) in registrations + assert ("/api/multi-agent", False) in registrations + assert position_roundtable_router.router.prefix == "/position-roundtable" + + @pytest.mark.asyncio async def test_session_activation_freezes_chain_and_creates_nodes_once(store): created = await store.create_session( From 4450f342d8499ac6380f7f0affeb2fe468be5e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= <hkdong@kland.com.cn> Date: Wed, 29 Jul 2026 21:53:32 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/components/page-sidebar.tsx | 15 + .../workspace/workspace-nav-menu.tsx | 8 + frontend-web/src/core/auth/route-key-login.ts | 43 ++- frontend-web/src/core/embed/frame-context.ts | 24 ++ .../page-layout/chats-iframe-route.test.mjs | 147 +++++-- .../core/page-layout/chats-iframe-route.ts | 220 ++++++++++- .../src/core/page-layout/sidebar-menu.ts | 7 + .../pages/EightBfRoundtablePage.tsx | 20 +- frontend-web/src/env.ts | 7 + frontend-web/src/pages/IframeChatPage.tsx | 104 +---- frontend-web/src/pages/LoginPage.tsx | 98 ++++- frontend-web/src/pages/PositionRolesPage.tsx | 362 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + frontend-web/src/pages/WorkspaceRoutes.tsx | 131 +++++-- .../src/position-roundtable/README.md | 10 +- .../src/position-roundtable/lib/positions.ts | 67 +++- .../pages/PositionRoundtablePage.tsx | 94 +++-- .../roundtable-planning/lib/business-chain.ts | 4 +- .../strategy-components/api/position-roles.ts | 181 +++++++++ offline-backend-20260512/backend/CLAUDE.md | 21 + offline-backend-20260512/backend/README.md | 21 +- .../backend/app/gateway/app.py | 6 +- .../backend/app/gateway/deps.py | 8 + .../app/gateway/position_role_defaults.py | 47 +++ .../app/gateway/routers/business_mapping.py | 30 +- .../backend/app/gateway/routers/intent.py | 27 +- .../app/gateway/routers/position_roles.py | 148 +++++++ .../gateway/routers/position_roundtable.py | 91 ++++- .../app/gateway/routers/roundtable_chains.py | 7 +- .../versions/20260729_01_position_roles.py | 45 +++ .../deerflow/persistence/models/__init__.py | 38 +- .../persistence/position_roles/__init__.py | 27 ++ .../persistence/position_roles/base.py | 30 ++ .../persistence/position_roles/memory.py | 45 +++ .../persistence/position_roles/model.py | 37 ++ .../persistence/position_roles/sql.py | 66 ++++ .../backend/tests/test_business_mapping.py | 44 ++- .../test_position_roundtable_sessions.py | 38 +- 38 files changed, 2020 insertions(+), 309 deletions(-) create mode 100644 frontend-web/src/core/embed/frame-context.ts create mode 100644 frontend-web/src/pages/PositionRolesPage.tsx create mode 100644 frontend-web/src/strategy-components/api/position-roles.ts create mode 100644 offline-backend-20260512/backend/app/gateway/position_role_defaults.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/position_roles.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260729_01_position_roles.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/sql.py diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 2d5375ec..eb1f0bd1 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -10,6 +10,7 @@ import { Gauge, ListTree, MousePointerClick, + BriefcaseBusiness, Settings, ShieldAlert, ShieldCheck, @@ -235,6 +236,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 按钮管理 </DropdownMenuItem> )} + {isAdmin && ( + <DropdownMenuItem + onClick={() => navigate("/page/strategy/admin/position-roles")} + > + <BriefcaseBusiness className="size-4" /> + 岗位角色配置 + </DropdownMenuItem> + )} {isAdmin && ( <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")} @@ -540,6 +549,12 @@ function LegacyAccordionSidebar({ 按钮管理 </DropdownMenuItem> )} + {isAdmin && ( + <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}> + <BriefcaseBusiness className="size-4" /> + 岗位角色配置 + </DropdownMenuItem> + )} {isAdmin && ( <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}> <ShieldCheck className="size-4" /> diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 69f95724..8bc0e1a1 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -234,6 +234,14 @@ export function WorkspaceNavMenu() { 岗位管理 </DropdownMenuItem> )} + {isAdmin && ( + <DropdownMenuItem + onClick={() => navigate("/page/workspace/admin/position-roles")} + > + <BriefcaseIcon /> + 岗位角色配置 + </DropdownMenuItem> + )} {isAdmin && ( <DropdownMenuItem onClick={() => diff --git a/frontend-web/src/core/auth/route-key-login.ts b/frontend-web/src/core/auth/route-key-login.ts index 7c7601ff..0b8b157a 100644 --- a/frontend-web/src/core/auth/route-key-login.ts +++ b/frontend-web/src/core/auth/route-key-login.ts @@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env"; * * Add a new entry here when an external system needs a short ``routeKey``. * Targets must remain internal SPA routes; LoginPage validates them again - * before navigating. + * before navigating. The optional display settings are converted into the + * existing route parameters on the destination URL. */ -export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = { - a: "/page/canvas/ai-writing", - "8bf": "/page/strategy/qa/8bf-roundtable", +export interface RouteKeyMapping { + /** Internal SPA route to open after sign-in. */ + path: string; + /** Add `hideChrome=1` to hide the global header and page sidebar. */ + hideChrome?: boolean; + /** Add the existing `theme` route parameter and apply it during sign-in. */ + theme?: string; +} + +export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = { + a: { path: "/page/canvas/ai-writing" }, + "8bf": { + path: "/page/strategy/qa/8bf-roundtable", + hideChrome: true, + theme: "dark-blue", + }, }; /** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */ @@ -26,8 +40,23 @@ export function getRouteKeyLoginPassword(): string { return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? ""; } -export function resolveRouteKey(routeKey: string | null | undefined): string { +export function resolveRouteKeyMapping( + routeKey: string | null | undefined, +): RouteKeyMapping | undefined { const key = routeKey?.trim(); - if (!key) return ""; - return ROUTE_KEY_MAPPINGS[key] ?? ""; + if (!key) return undefined; + return ROUTE_KEY_MAPPINGS[key]; +} + +export function resolveRouteKey(routeKey: string | null | undefined): string { + const mapping = resolveRouteKeyMapping(routeKey); + if (!mapping) return ""; + + const [path, ownQuery = ""] = mapping.path.split("?"); + const query = new URLSearchParams(ownQuery); + if (mapping.hideChrome) query.set("hideChrome", "1"); + if (mapping.theme) query.set("theme", mapping.theme); + + const serializedQuery = query.toString(); + return serializedQuery ? `${path}?${serializedQuery}` : path; } diff --git a/frontend-web/src/core/embed/frame-context.ts b/frontend-web/src/core/embed/frame-context.ts new file mode 100644 index 00000000..3223cc2a --- /dev/null +++ b/frontend-web/src/core/embed/frame-context.ts @@ -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; + } +} diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs index 246c2d70..26692be0 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs +++ b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs @@ -2,13 +2,18 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildStandardChatRedirectPath, + buildEmbeddedIframeRedirectPath, buildChatsIframeRedirectPath, buildChatsIframeThreadPath, + hasIframePresentationOverride, isExplicitBrainyIframeMode, isChatsIframeWorkspacePath, + resolveChatsIframeEntryRedirect, resolvePostLoginRedirectPath, resolveForcedThemeForPath, resolveStandardChatFallback, + shouldUseChatsIframeProtocol, } from "./chats-iframe-route.ts"; test("matches only workspace chats_iframe routes", () => { @@ -37,7 +42,7 @@ test("forces light theme for landing and chats_iframe routes only", () => { assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined); }); -test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => { +test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => { assert.equal(isExplicitBrainyIframeMode(""), false); assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false); assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false); @@ -45,34 +50,72 @@ test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => { assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true); }); -test("upgrades legacy iframe-source routes to the iframe protocol", () => { +test("keeps bare from=iframe on the original dedicated iframe protocol", () => { + assert.equal( + resolveStandardChatFallback( + "/page/workspace/chats_iframe/new", + "?from=iframe", + ), + null, + ); + assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true); + assert.equal(hasIframePresentationOverride("?from=iframe"), false); +}); + +test("uses the dedicated AgentSelector route for a bare embedded host", () => { + assert.equal(shouldUseChatsIframeProtocol("", true), true); + assert.equal( + buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"), + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", + ); + assert.equal( + resolveChatsIframeEntryRedirect( + "/page/workspace/chats/new", + "", + true, + ), + "/page/workspace/chats_iframe/new?from=iframe", + ); + assert.equal( + resolveChatsIframeEntryRedirect( + "/page/workspace/chats/thread-123", + "", + true, + ), + "/page/workspace/chats_iframe/thread-123?from=iframe", + ); + assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false); + assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false); + assert.equal( + buildStandardChatRedirectPath("?hideChrome=1"), + "/page/workspace/chats/new?hideChrome=1&chatMode=full", + ); +}); + +test("routes legacy iframe links with standard presentation flags to full chat", () => { + assert.equal( + resolveStandardChatFallback( + "/page/workspace/chats_iframe/new", + "?from=iframe&hideHeader=1", + ), + "/page/workspace/chats/new?hideHeader=1&chatMode=full", + ); assert.equal( resolveStandardChatFallback( "/page/workspace/chats_iframe/new", "?from=iframe&hideChrome=1", ), - "/page/workspace/chats_iframe/new?from=iframe&hideChrome=1&chatMode=iframe", + "/page/workspace/chats/new?hideChrome=1&chatMode=full", ); -}); - -test("honors an explicit chat mode over the legacy iframe source", () => { assert.equal( resolveStandardChatFallback( "/page/workspace/chats_iframe/new", - "?from=iframe&chatMode=full", + "?from=iframe&embed=1&theme=dark-blue", ), - "/page/workspace/chats/new?chatMode=full", - ); -}); - -test("treats a blank chat mode as absent for the legacy iframe source", () => { - assert.equal( - resolveStandardChatFallback( - "/page/workspace/chats_iframe/new", - "?from=iframe&chatMode=%20%20", - ), - "/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe", + "/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full", ); + assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false); + assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true); }); test("redirects chats_iframe routes without the iframe source to standard full chat", () => { @@ -139,32 +182,53 @@ test("forces the iframe theme on the login route without changing normal login", ), undefined, ); + assert.equal( + resolveForcedThemeForPath( + "/login", + "?from=iframe&hideChrome=1&theme=dark-blue", + ), + undefined, + ); }); -test("preserves the iframe theme in the post-login redirect", () => { +test("does not add chatMode=iframe to the legacy post-login redirect", () => { assert.equal( buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"), - "/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", ); assert.equal( buildChatsIframeRedirectPath("?from=iframe"), - "/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe", + "/page/workspace/chats_iframe/new?from=iframe", ); assert.equal( buildChatsIframeRedirectPath( "?from=iframe&theme=dark-blue&ticket=secret&authToken=secret", ), - "/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", ); }); -test("keeps the iframe redirect ahead of login-method fallbacks", () => { +test("uses full chat after login when a legacy iframe link sets presentation", () => { assert.equal( resolvePostLoginRedirectPath( "?from=iframe&theme=dark-blue&yUserId=user-1", "/page/workspace/chats/new", ), - "/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", + ); + assert.equal( + resolvePostLoginRedirectPath( + "?from=iframe&hideChrome=1&theme=dark-blue", + "/page/workspace/chats/new", + ), + "/page/workspace/chats/new?hideChrome=1&chatMode=full", + ); + assert.equal( + resolvePostLoginRedirectPath( + "?from=iframe&embed=1&theme=dark-blue", + "/page/workspace/chats/new", + ), + "/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full", ); assert.equal( resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"), @@ -179,7 +243,40 @@ test("preserves iframe theme when a new chat becomes a thread", () => { "thread-123", "?from=iframe&theme=dark-blue&ticket=secret", ), - "/page/workspace/chats_iframe/thread-123?from=iframe&chatMode=iframe&theme=dark-blue", + "/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue", + ); +}); + +test("normalizes direct regular-chat legacy links according to the same split", () => { + assert.equal( + resolveChatsIframeEntryRedirect( + "/page/workspace/chats/new", + "?from=iframe", + ), + "/page/workspace/chats_iframe/new?from=iframe", + ); + assert.equal( + resolveChatsIframeEntryRedirect( + "/page/workspace/chats/thread-123", + "?from=iframe&hideChrome=1", + ), + "/page/workspace/chats/thread-123?hideChrome=1&chatMode=full", + ); + assert.equal( + resolveChatsIframeEntryRedirect( + "/page/workspace/chats/new", + "?from=iframe&embed=1&theme=dark-blue", + ), + "/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full", + ); +}); + +test("builds a standard full-chat URL without leaking the legacy marker", () => { + assert.equal( + buildStandardChatRedirectPath( + "?from=iframe&hideHeader=1&ticket=secret", + ), + "/page/workspace/chats/new?hideHeader=1&chatMode=full", ); }); diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.ts b/frontend-web/src/core/page-layout/chats-iframe-route.ts index aadfb64d..244c35cf 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.ts +++ b/frontend-web/src/core/page-layout/chats-iframe-route.ts @@ -2,15 +2,111 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs"; const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new"; const IFRAME_LOGIN_SOURCE = "iframe"; +const STANDARD_CHATS_PATH = "/page/workspace/chats"; function toSearchParams(search: string): URLSearchParams { return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); } +/** Keep the accepted URL booleans aligned with embed-session.ts. */ +function isEnabledPresentationParam(search: string, key: string): boolean { + const value = toSearchParams(search).get(key)?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + export function isChatsIframeLogin(search: string): boolean { return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE; } +function isExplicitFullChatMode(search: string): boolean { + return ( + toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full" + ); +} + +/** + * A bare login URL embedded by an external host has no query parameter to + * survive the login redirect. Add the legacy source marker before building + * the private chat URL so refreshes and subsequent thread navigation retain + * the selected dedicated iframe workspace. + */ +export function buildEmbeddedIframeRedirectPath(search: string): string { + const source = toSearchParams(search); + if (!source.has("from")) { + source.set("from", IFRAME_LOGIN_SOURCE); + } + return buildChatsIframeRedirectPath(source.toString()); +} + +/** + * `from=iframe` predates the regular embedded-chat presentation flags. Keep + * the two concerns separate: this only answers whether the caller explicitly + * asked PageLayout to alter global chrome, not whether it is an iframe page. + */ +export function hasIframePresentationOverride(search: string): boolean { + return ( + isEnabledPresentationParam(search, "hideHeader") || + isEnabledPresentationParam(search, "hideChrome") || + isEnabledPresentationParam(search, "embed") + ); +} + +/** + * The private `chats_iframe` transport is the legacy default for + * `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller + * that also supplies one of the standard presentation flags deliberately uses + * the ordinary full chat workspace instead. + */ +export function shouldUseChatsIframeProtocol( + search: string, + embeddedHost = false, +): boolean { + return ( + !hasIframePresentationOverride(search) && + (isExplicitBrainyIframeMode(search) || + isChatsIframeLogin(search) || + // The historical bare iframe integration enters `/login/admin` without + // any route parameters. Treat that embedded context as the legacy + // dedicated AgentSelector page, unless the caller explicitly selected + // the standard full-chat mode. + (embeddedHost && !isExplicitFullChatMode(search))) + ); +} + +/** + * Build the regular full-chat destination for a legacy iframe entry that has + * opted into a standard presentation mode. Keeping the display flags in the + * address bar makes direct links and refreshes deterministic; `from` is + * intentionally omitted so the legacy protocol cannot take over again. + */ +export function buildStandardChatRedirectPath( + search: string, + fallbackPath = `${STANDARD_CHATS_PATH}/new`, +): string { + const source = toSearchParams(search); + const [pathname, existingQuery = ""] = fallbackPath.split("?"); + const target = new URLSearchParams(existingQuery); + + for (const key of ["hideHeader", "hideChrome", "embed"] as const) { + const value = source.get(key)?.trim(); + if (value && !target.has(key)) { + target.set(key, value); + } + } + + // `theme` is meaningful only for embed mode, but preserving it here keeps a + // refreshed embedded page on the same forced theme before session storage is + // restored. + const theme = source.get("theme")?.trim(); + if (theme && target.get("embed")) { + target.set("theme", theme); + } + + target.set("chatMode", "full"); + const query = target.toString(); + return query ? `${pathname}?${query}` : pathname; +} + /** * Preserve only the presentation parameters consumed by the iframe workspace. * Login credentials must never be copied into the post-login address bar. @@ -24,13 +120,11 @@ export function buildChatsIframeRedirectPath(search: string): string { } const requestedMode = source.get("chatMode")?.trim(); - if (requestedMode) { + if ( + requestedMode?.toLowerCase() === "iframe" || + (!isChatsIframeLogin(search) && requestedMode) + ) { target.set("chatMode", requestedMode); - } else if (isChatsIframeLogin(search)) { - // Plugin links historically identify this protocol with `from=iframe`. - // Normalize that legacy marker into the explicit mode before handing the - // URL to the workspace route, so it does not fall back to the full chat. - target.set("chatMode", "iframe"); } const theme = source.get("theme")?.trim(); @@ -57,8 +151,12 @@ export function resolvePostLoginRedirectPath( search: string, fallbackPath: string, ): string { - return isChatsIframeLogin(search) - ? buildChatsIframeRedirectPath(search) + if (shouldUseChatsIframeProtocol(search)) { + return buildChatsIframeRedirectPath(search); + } + + return isChatsIframeLogin(search) && hasIframePresentationOverride(search) + ? buildStandardChatRedirectPath(search, fallbackPath) : fallbackPath; } @@ -69,13 +167,88 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean { ); } +/** + * A `from=iframe` link can arrive directly at a regular chat URL instead of + * passing through LoginPage. Normalize both entry paths to the same dedicated + * protocol workspace, including an existing thread id when one is supplied. + */ +export function resolveChatsIframeEntryRedirect( + pathname: string, + search: string, + embeddedHost = false, +): string | null { + const isStandardChatPath = + pathname === STANDARD_CHATS_PATH || + pathname === `${STANDARD_CHATS_PATH}/` || + pathname.startsWith(`${STANDARD_CHATS_PATH}/`); + + // Standard presentation flags deliberately bypass the private iframe + // protocol. Normalize direct legacy URLs once so the result stays on the + // normal full-chat workspace and retains its agent/history sidebar. + if ( + isChatsIframeLogin(search) && + hasIframePresentationOverride(search) && + !isExplicitBrainyIframeMode(search) + ) { + return isStandardChatPath + ? buildStandardChatRedirectPath(search, pathname) + : null; + } + + const inferredEmbeddedProtocol = + embeddedHost && + !hasIframePresentationOverride(search) && + !isExplicitFullChatMode(search) && + !isChatsIframeLogin(search) && + !isExplicitBrainyIframeMode(search); + const protocolSearch = inferredEmbeddedProtocol + ? `?${new URLSearchParams({ + ...Object.fromEntries(toSearchParams(search)), + from: IFRAME_LOGIN_SOURCE, + }).toString()}` + : search; + const requestsIframeProtocol = shouldUseChatsIframeProtocol( + protocolSearch, + embeddedHost, + ); + if (!requestsIframeProtocol) { + return null; + } + + if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) { + return buildChatsIframeRedirectPath(protocolSearch); + } + + if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) { + return null; + } + + const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length); + if (!routeSuffix || routeSuffix.includes("/")) { + return null; + } + + if (routeSuffix === "new") { + return buildChatsIframeRedirectPath(protocolSearch); + } + + try { + return buildChatsIframeThreadPath( + "/page/workspace", + decodeURIComponent(routeSuffix), + protocolSearch, + ); + } catch { + return null; + } +} + /** * The lightweight iframe page is a private transport for the Brainy * postMessage protocol, not a general-purpose embedded chat page. * - * Requiring an explicit mode prevents legacy `from=iframe` links, ordinary - * iframes and micro-frontend containers from accidentally landing on the - * protocol-only page. + * `from=iframe` is the legacy equivalent of the explicit mode. Ordinary + * iframe/Wujie embedding without either marker remains standard chat. */ export function isExplicitBrainyIframeMode(search: string): boolean { const normalizedSearch = search.startsWith("?") ? search.slice(1) : search; @@ -95,25 +268,32 @@ export function resolveStandardChatFallback( pathname: string, search: string, ): string | null { - if (!isChatsIframeWorkspacePath(pathname) || isExplicitBrainyIframeMode(search)) { + if (!isChatsIframeWorkspacePath(pathname)) { return null; } const normalizedSearch = search.startsWith("?") ? search.slice(1) : search; const params = new URLSearchParams(normalizedSearch); - const requestedMode = params.get("chatMode")?.trim(); - if (isChatsIframeLogin(search) && !requestedMode) { - params.set("chatMode", "iframe"); - const targetSearch = params.toString(); - return targetSearch ? `${pathname}?${targetSearch}` : pathname; - } - const routePrefix = "/page/workspace/chats_iframe/"; const routeSuffix = pathname.slice(routePrefix.length); const targetPath = routeSuffix && routeSuffix !== "new" ? `/page/workspace/chats/${routeSuffix}` : "/page/workspace/chats/new"; + + if ( + isChatsIframeLogin(search) && + hasIframePresentationOverride(search) && + !isExplicitBrainyIframeMode(search) + ) { + return buildStandardChatRedirectPath(search, targetPath); + } + + if (shouldUseChatsIframeProtocol(search)) { + // The legacy marker itself is enough. Do not add chatMode=iframe here: it + // is an implementation detail and adding it caused a self-redirect loop. + return null; + } params.delete("from"); params.set("chatMode", "full"); const targetSearch = params.toString(); @@ -131,7 +311,7 @@ export function resolveForcedThemeForPath( const isLoginPath = pathname === "/login" || pathname.startsWith("/login/"); if ( isChatsIframeWorkspacePath(pathname) || - (isLoginPath && isChatsIframeLogin(search)) + (isLoginPath && shouldUseChatsIframeProtocol(search)) ) { const rawTheme = toSearchParams(search).get("theme")?.trim(); return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light"; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 2000e93c..75949b46 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -270,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [ path: "/page/strategy/admin/positions", adminOnly: true, }, + { + id: "mgmt-position-roles", + label: "岗位角色配置", + icon: Briefcase, + path: "/page/strategy/admin/position-roles", + adminOnly: true, + }, { id: "mgmt-admin-scheduled-tasks", label: "定时任务管理", diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 3737d960..a5ba9789 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Loader2, Save, Settings2, X } from "lucide-react"; -import { getStoredAuth } from "@/core/auth"; +import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import { listChains } from "@/roundtable-planning/api/chains"; @@ -26,7 +26,9 @@ const BUSINESS_CODE = "8BF"; */ export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { const auth = getStoredAuth(); - const isAdmin = auth?.system_role === "admin"; + // 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。 + // 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。 + const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq"; const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); const [mapping, setMapping] = useState<BusinessMapping | null>(null); const [chains, setChains] = useState<BusinessChain[]>([]); @@ -46,7 +48,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa try { const [mappings, availableChains] = await Promise.all([ listBusinessMappings(), - listChains(isAdmin ? "all" : "public"), + listChains("public"), ]); const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null; // 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。 @@ -59,15 +61,15 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } finally { setIsLoading(false); } - }, [isAdmin]); + }, []); useEffect(() => { void loadConfiguration(); }, [loadConfiguration]); const handleSave = useCallback(async () => { - if (!isAdmin) { - setError("仅管理员可以配置 8BF 业务链条"); + if (!canConfigureChain) { + setError("仅 lqq 账号可以配置 8BF 业务链条"); return; } const selected = chains.find((chain) => chain.id === selectedChainId); @@ -90,11 +92,11 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } finally { setIsSaving(false); } - }, [chains, isAdmin, selectedChainId]); + }, [canConfigureChain, chains, selectedChainId]); const toolbarSlot = ( <> - {isAdmin && ( + {canConfigureChain && ( <button type="button" className="eightbf-roundtable__chain-settings-button" @@ -104,7 +106,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa 配置业务链 </button> )} - {isAdmin && isSettingsOpen && ( + {canConfigureChain && isSettingsOpen && ( <div className="eightbf-roundtable__settings-mask" role="presentation"> <section className="eightbf-roundtable__settings-dialog" diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 32d4844c..dafd3f5f 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean { return readRuntimeEnv(key) === "true"; } +/** + * Read an integer setting supplied by ``runtime-config.js``. + * + * Runtime configuration is editable after the frontend is built, so validate + * numeric values here instead of letting an invalid deployment value bypass a + * UI constraint that the backend cannot accept. + */ export const env = { NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"), NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"), diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index e01bcb44..cd329bd0 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -2,14 +2,12 @@ import { BookMarkedIcon, - BotIcon, BrainCircuitIcon, FileTextIcon, GraduationCapIcon, ShrinkIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { useArtifacts } from "@/components/workspace/artifacts"; @@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list"; import { Tooltip } from "@/components/workspace/tooltip"; import { Button } from "@/components/ui/button"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; +import { useAgentSelector } from "@/core/agents"; import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent"; import { getAccessToken } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; @@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks"; import { useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; -import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { type BrainyChatSubmitMessage, @@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) { export default function IframeChatPage() { const routeBase = useRouteBase(); - const location = useLocation(); - const navigate = useNavigate(); const { t } = useI18n(); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -147,34 +142,6 @@ export default function IframeChatPage() { useState<PendingIframeSubmit | null>(null); const { agents: selectorAgents, isLoading: selectorLoading } = useAgentSelector(); - // The protocol page must still be escapable when no curated selector list - // has been configured. Include the user's own and public agents in this - // page's native fallback picker. A native select also remains interactive - // in iframe/micro-frontend environments where a portal dialog can fail. - const { agents: mineAgents } = useAgentsPaginated({ - scope: "mine", - page: 1, - pageSize: 100, - }); - const { agents: squareAgents } = useAgentsPaginated({ - scope: "square", - page: 1, - pageSize: 100, - }); - const fallbackAgents = useMemo(() => { - const uniqueAgents = new Map< - string, - (typeof selectorAgents)[number] - >(); - for (const agent of [ - ...selectorAgents, - ...mineAgents, - ...squareAgents, - ]) { - uniqueAgents.set(agent.id, agent); - } - return [...uniqueAgents.values()]; - }, [mineAgents, selectorAgents, squareAgents]); const { data: pluginManagementSettings } = usePluginManagementSettings(); const pendingRequestIdRef = useRef<string | null>(null); const handleSubmitRef = useRef< @@ -383,30 +350,6 @@ export default function IframeChatPage() { saveLastAgentId(agentId); }, []); - /** - * Manual fallback for accidental protocol-page visits. - * - * A real Brainy integration drives this page with postMessage. A human who - * reaches it directly should not be trapped here: move them to the standard - * chat workspace, optionally opening the selected agent. - */ - const enterStandardChat = useCallback( - (agentId?: string) => { - const params = new URLSearchParams(location.search); - params.delete("from"); - params.set("chatMode", "full"); - params.delete("agent"); - if (agentId) { - params.set("agent", agentId); - saveLastAgentId(agentId); - } - navigate(`${routeBase}/chats/new?${params.toString()}`, { - replace: true, - }); - }, - [location.search, navigate, routeBase], - ); - useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); @@ -705,46 +648,15 @@ export default function IframeChatPage() { </div> </div> {isLandingState && mounted && ( - <div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center"> + <div className="mb-6 flex min-h-40 flex-col items-center justify-center"> <div className="text-2xl font-medium">你好</div> - <div className="mt-2 text-sm text-muted-foreground"> - 选择智能体进入问答 + <div className="mt-4"> + <AgentSelector + selectedAgentId={selectedAgentId} + onAgentChange={handleAgentChange} + disabled={isUploading} + /> </div> - <div className="mt-4 flex flex-wrap items-center justify-center gap-2"> - <label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white"> - <BotIcon className="mr-2 size-3.5 shrink-0" /> - <select - aria-label="选择智能体并进入问答" - defaultValue="" - disabled={isUploading || selectorLoading} - onChange={(event) => { - const agentId = event.target.value; - if (agentId) enterStandardChat(agentId); - }} - className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait" - > - <option value="" disabled> - {selectorLoading ? "正在加载智能体…" : "选择 Agent"} - </option> - {fallbackAgents.map((agent) => ( - <option key={agent.id} value={agent.id}> - {desensitize(agent.name)} - </option> - ))} - </select> - <span - aria-hidden - className="pointer-events-none absolute right-3 text-[10px]" - > - ▾ - </span> - </label> - </div> - {!selectorLoading && fallbackAgents.length === 0 && ( - <div className="mt-3 text-xs text-muted-foreground"> - 暂无可选智能体 - </div> - )} </div> )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 5acae55c..e6a92fcc 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth'; import { getRouteKeyLoginPassword, resolveRouteKey, + resolveRouteKeyMapping, ROUTE_KEY_PARAM, } from '@/core/auth/route-key-login'; import { @@ -25,6 +26,7 @@ import { } from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; +import { isEmbeddedHost } from '@/core/embed/frame-context'; import { readEmbedParam } from '@/core/embed/embed-session'; import { GENERAL_QA_ROUTE, @@ -33,9 +35,14 @@ import { persistToolsToken, } from '@/core/auth/yuser-login'; import { + buildStandardChatRedirectPath, + buildEmbeddedIframeRedirectPath, buildChatsIframeRedirectPath, + hasIframePresentationOverride, isChatsIframeLogin, + resolveChatsIframeEntryRedirect, resolvePostLoginRedirectPath, + shouldUseChatsIframeProtocol, } from '@/core/page-layout/chats-iframe-route'; import { isSidebarEnabledFromSearch, @@ -101,22 +108,42 @@ export default function LoginPage() { ); const requestedChatMode = searchParams.get('chatMode')?.trim().toLowerCase() ?? ''; - const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE; - // Wujie is an application container, not the Brainy iframe protocol. It - // must therefore land on the regular full ChatPage. Plugin links with - // `from=iframe` are normalized into the explicit iframe protocol mode. + const iframePresentationOverride = useMemo( + () => hasIframePresentationOverride(searchParams.toString()), + [searchParams], + ); + const embeddedHost = useMemo(() => isEmbeddedHost(), []); + // `from=iframe` is the established entry contract for the dedicated iframe + // page. A standard presentation flag is the one intentional exception: it + // uses the normal full chat workspace so Header/PageSidebar visibility is + // controlled by hideHeader/hideChrome/embed instead. + const brainyIframeRequested = shouldUseChatsIframeProtocol( + searchParams.toString(), + embeddedHost, + ); + // An explicit full-chat request and all standard presentation flags retain + // the regular workspace. Otherwise native iframe/Wujie hosts use the + // legacy dedicated AgentSelector entry, matching the established bare + // iframe login integration. const fullChatRequested = useMemo( () => !brainyIframeRequested && (isWujieEnv() || - requestedChatMode === FULL_CHAT_MODE), - [brainyIframeRequested, requestedChatMode, searchParams], + requestedChatMode === FULL_CHAT_MODE || + // hideHeader/hideChrome/embed are explicit presentation contracts. + // They must retain their query parameters and enter the standard + // workspace even when a native iframe has no legacy `from` marker. + iframePresentationOverride), + [ + brainyIframeRequested, + iframePresentationOverride, + requestedChatMode, + searchParams, + ], ); const isIframeLogin = useMemo( - () => - brainyIframeRequested || - (!fullChatRequested && isChatsIframeLogin(searchParams.toString())), - [brainyIframeRequested, fullChatRequested, searchParams], + () => brainyIframeRequested, + [brainyIframeRequested], ); const resolvedUsername = useMemo(() => { const raw = username?.trim(); @@ -127,22 +154,37 @@ export default function LoginPage() { const yUserId = searchParams.get('yUserId')?.trim() ?? ''; const redirectPath = useMemo( () => { - if (yUserId) return GENERAL_QA_ROUTE; // Keep the mode on the destination URL so PageRoutes can retain the // full chat workspace sidebar while hideChrome hides only the global // application chrome. if (brainyIframeRequested) { - return buildChatsIframeRedirectPath(searchParams.toString()); + return !isChatsIframeLogin(searchParams.toString()) && + requestedChatMode !== IFRAME_CHAT_MODE + ? buildEmbeddedIframeRedirectPath(searchParams.toString()) + : buildChatsIframeRedirectPath(searchParams.toString()); } if (fullChatRequested) { - return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`; + return buildStandardChatRedirectPath( + searchParams.toString(), + DEFAULT_REDIRECT_PATH, + ); } + // External iframe callers frequently also provide yUserId for login. + // That identity parameter must not steal a deliberate iframe entry and + // send it to the generic full-chat home before the route split above. + if (yUserId) return GENERAL_QA_ROUTE; return resolvePostLoginRedirectPath( searchParams.toString(), DEFAULT_REDIRECT_PATH, ); }, - [brainyIframeRequested, fullChatRequested, searchParams, yUserId], + [ + brainyIframeRequested, + fullChatRequested, + requestedChatMode, + searchParams, + yUserId, + ], ); const toolToken = searchParams.get('token')?.trim() ?? ''; // 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参 @@ -152,6 +194,10 @@ export default function LoginPage() { // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) const authToken = searchParams.get('authToken')?.trim() ?? ''; const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? ''; + const routeKeyMapping = useMemo( + () => resolveRouteKeyMapping(routeKey), + [routeKey], + ); const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]); const configuredRouteLoginPassword = useMemo( () => (routeKeyTarget ? getRouteKeyLoginPassword() : ''), @@ -183,6 +229,14 @@ export default function LoginPage() { const normalized = raw.startsWith('/') ? raw : `/${raw}`; const [pathOnly, ownQuery = ''] = normalized.split('?'); const target = new URLSearchParams(ownQuery); + const iframeEntryTarget = resolveChatsIframeEntryRedirect( + pathOnly, + searchParams.toString(), + embeddedHost, + ); + if (iframeEntryTarget) { + return iframeEntryTarget; + } // Keep the Wujie/full-chat decision on the target URL. The destination // route uses it to retain the full agent/history workspace sidebar. if ( @@ -204,7 +258,7 @@ export default function LoginPage() { }); const qs = target.toString(); return qs ? `${pathOnly}?${qs}` : pathOnly; - }, [fullChatRequested, routeKeyTarget, searchParams]); + }, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]); const externalParams = useMemo( () => ({ theme: searchParams.get('theme') ?? '', @@ -243,10 +297,20 @@ export default function LoginPage() { // 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。 useEffect(() => { if (isEmbed || isIframeLogin) return; - const themeValue = wujieProps.theme || externalParams.theme; + // routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼 + // `theme=dark-blue` 也能在登录完成前切换到指定主题。 + const themeValue = + routeKeyMapping?.theme || wujieProps.theme || externalParams.theme; if (!themeValue) return; setTheme(resolveThemeFromQuery(themeValue)); - }, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]); + }, [ + isEmbed, + isIframeLogin, + routeKeyMapping?.theme, + wujieProps.theme, + externalParams.theme, + setTheme, + ]); // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) useEffect(() => { diff --git a/frontend-web/src/pages/PositionRolesPage.tsx b/frontend-web/src/pages/PositionRolesPage.tsx new file mode 100644 index 00000000..55dce487 --- /dev/null +++ b/frontend-web/src/pages/PositionRolesPage.tsx @@ -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> + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 90d37b3c..ac61092d 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; +import PositionRolesPage from "./PositionRolesPage"; import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; @@ -437,6 +438,16 @@ export default function StrategyRoutes() { ) } /> + <Route + path="admin/position-roles" + element={ + getStoredAuth()?.system_role === "admin" ? ( + <PositionRolesPage /> + ) : ( + <Navigate to="/page/workspace/chats/new" replace /> + ) + } + /> <Route path="admin/login-whitelist" element={ diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 57e06f1c..ab1b01c3 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { getStoredAuth } from '@/core/auth'; -import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route'; +import { + resolveChatsIframeEntryRedirect, + resolveStandardChatFallback, +} from '@/core/page-layout/chats-iframe-route'; +import { isEmbeddedHost } from '@/core/embed/frame-context'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; @@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage'; import AdminUserHubPage from './AdminUserHubPage'; import TagsPage from './TagsPage'; import PositionsPage from './PositionsPage'; +import PositionRolesPage from './PositionRolesPage'; import PluginManagementPage from './PluginManagementPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; @@ -240,13 +245,20 @@ function NewChatEntryRoute({ forceWorkspaceSidebarCollapsed: boolean; }; }) { - // Do not infer the chat experience from window.self !== window.top here. - // A generic `/chats/new` deep link must render the same standard chat both - // top-level and inside an iframe. The dedicated iframe protocol page remains - // available only through its explicit `/chats_iframe/...` route (LoginPage - // selects it for `from=iframe`). Otherwise a normal deep link was silently - // redirected to IframeChatPage, whose new-thread state intentionally has no - // manually selected agent and therefore stopped at "选择 Agent". + const location = useLocation(); + const iframeEntryTarget = resolveChatsIframeEntryRedirect( + location.pathname, + location.search, + isEmbeddedHost(), + ); + + if (iframeEntryTarget) { + return <Navigate to={iframeEntryTarget} replace />; + } + + // A bare external iframe historically opens the dedicated AgentSelector + // page. `resolveChatsIframeEntryRedirect` records that inferred context as + // `from=iframe`; explicit full-chat/presentation parameters stay here. return ( <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <ChatRuntime> @@ -256,12 +268,78 @@ function NewChatEntryRoute({ ); } +function ChatsIndexEntryRoute({ + layoutProps, + compactChrome, +}: { + layoutProps: { + pageSidebarCollapsed: boolean; + pageSidebarHidden: boolean; + workspaceSidebarHidden: boolean; + headerHidden: boolean; + compactWorkspaceSidebar: boolean; + compactActionsHidden: boolean; + pageSidebarDark: boolean; + forceWorkspaceSidebarCollapsed: boolean; + }; + compactChrome: boolean; +}) { + const location = useLocation(); + const iframeEntryTarget = resolveChatsIframeEntryRedirect( + location.pathname, + location.search, + isEmbeddedHost(), + ); + + if (iframeEntryTarget) { + return <Navigate to={iframeEntryTarget} replace />; + } + + return ( + <WorkspaceLayout {...layoutProps}> + <ChatsPage compactChrome={compactChrome} /> + </WorkspaceLayout> + ); +} + +function ChatThreadEntryRoute({ + layoutProps, +}: { + layoutProps: { + pageSidebarCollapsed: boolean; + pageSidebarHidden: boolean; + workspaceSidebarHidden: boolean; + headerHidden: boolean; + compactWorkspaceSidebar: boolean; + compactActionsHidden: boolean; + pageSidebarDark: boolean; + forceWorkspaceSidebarCollapsed: boolean; + }; +}) { + const location = useLocation(); + const iframeEntryTarget = resolveChatsIframeEntryRedirect( + location.pathname, + location.search, + isEmbeddedHost(), + ); + + if (iframeEntryTarget) { + return <Navigate to={iframeEntryTarget} replace />; + } + + return ( + <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> + <ChatRuntime> + <ChatPage /> + </ChatRuntime> + </WorkspaceLayout> + ); +} + /** - * `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy - * Legacy links may still target it (or carry `from=iframe` during login), but - * ordinary iframe/Wujie embedding needs the normal full chat workspace. - * `chatMode=iframe` is the only explicit opt-in for integrations that really - * implement the Brainy protocol. + * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe` + * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding + * remains on the normal full chat workspace. */ function IframeChatEntryRoute({ layoutProps, @@ -341,9 +419,10 @@ export default function WorkspaceRoutes({ <Route path="chats" element={ - <WorkspaceLayout {...layoutProps}> - <ChatsPage compactChrome={headerHidden && !compactActionsHidden} /> - </WorkspaceLayout> + <ChatsIndexEntryRoute + layoutProps={layoutProps} + compactChrome={headerHidden && !compactActionsHidden} + /> } /> <Route @@ -352,13 +431,7 @@ export default function WorkspaceRoutes({ /> <Route path="chats/:thread_id" - element={ - <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> - <ChatRuntime> - <ChatPage /> - </ChatRuntime> - </WorkspaceLayout> - } + element={<ChatThreadEntryRoute layoutProps={layoutProps} />} /> <Route path="chats_iframe/new" @@ -665,6 +738,18 @@ export default function WorkspaceRoutes({ ) } /> + <Route + path="admin/position-roles" + element={ + getStoredAuth()?.system_role === 'admin' ? ( + <WorkspaceLayout {...layoutProps}> + <PositionRolesPage /> + </WorkspaceLayout> + ) : ( + <Navigate to="/page/workspace/chats/new" replace /> + ) + } + /> <Route path="settings/recommended-questions" element={ diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md index c66ab29f..6c2b7e6b 100644 --- a/frontend-web/src/position-roundtable/README.md +++ b/frontend-web/src/position-roundtable/README.md @@ -207,7 +207,7 @@ src/position-roundtable/demo/position-roundtable-full.json ## 9. 开发与排查清单 -1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。 +1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。 2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。 3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。 4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。 @@ -226,3 +226,11 @@ pnpm build ```powershell git diff --check ``` + +## 11. 岗位角色配置 + +岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐 +`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、 +启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并 +显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用; +自定义岗位可以新增、排序和删除。 diff --git a/frontend-web/src/position-roundtable/lib/positions.ts b/frontend-web/src/position-roundtable/lib/positions.ts index 68f5feca..1ecf55dc 100644 --- a/frontend-web/src/position-roundtable/lib/positions.ts +++ b/frontend-web/src/position-roundtable/lib/positions.ts @@ -1,39 +1,90 @@ /** - * 岗位协同的第一版岗位目录。 + * 岗位会商的工作视角目录。 * - * 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id, - * 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。 + * 这不是组织/RBAC 岗位。稳定 id 会写入业务链节点,因此服务端的 + * 「岗位角色配置」只允许变更展示属性;本模块保留一份内置回退,确保 + * 后端短暂不可用时历史会商仍能正常展示。 */ -export const POSITION_OPTIONS = [ + +export type PositionId = string; + +export interface PositionOption { + id: PositionId; + name: string; + description: string; + isIntentPosition: boolean; + enabled?: boolean; + isBuiltin?: boolean; + sortOrder?: number; +} + +export const POSITION_OPTIONS: readonly PositionOption[] = [ { id: "intelligence", name: "测试分析岗", description: "识别任务意图、补齐约束并确认业务链条", isIntentPosition: true, + enabled: true, + isBuiltin: true, + sortOrder: 0, }, { id: "planning", name: "组织测试岗", description: "承接上游信息并进行组织测试", isIntentPosition: false, + enabled: true, + isBuiltin: true, + sortOrder: 1, }, { id: "solution", name: "方案设计岗", description: "承接上游结果并形成方案产物", isIntentPosition: false, + enabled: true, + isBuiltin: true, + sortOrder: 2, }, { id: "evaluation", name: "综合测试岗", description: "测试链条产物并提出综合建议", isIntentPosition: false, + enabled: true, + isBuiltin: true, + sortOrder: 3, }, -] as const; - -export type PositionId = (typeof POSITION_OPTIONS)[number]["id"]; +]; export const DEFAULT_POSITION_ID: PositionId = "intelligence"; +let runtimePositionOptions: readonly PositionOption[] = POSITION_OPTIONS; + +/** + * 供独立的流程图、产物抽屉等非页面级组件读取当前展示名。 + * 调用方传入空数组时保留内置回退,避免接口暂时失败导致旧会话显示“未分配岗位”。 + */ +export function setRuntimePositionOptions(options: readonly PositionOption[]): void { + runtimePositionOptions = options.length ? options : POSITION_OPTIONS; +} + +export function getRuntimePositionOptions(): readonly PositionOption[] { + return runtimePositionOptions; +} + +export function intentPositionIdFor(options: readonly PositionOption[] = runtimePositionOptions): PositionId { + return options.find((position) => position.isIntentPosition)?.id ?? DEFAULT_POSITION_ID; +} + +export function resolvePositionId( + value: string | null | undefined, + options: readonly PositionOption[] = runtimePositionOptions, +): PositionId { + return options.some((position) => position.id === value) + ? String(value) + : intentPositionIdFor(options); +} + export const positionNameById = (positionId: string | null | undefined): string => - POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位"; + runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位"; diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 5c60105a..91c774de 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -95,11 +95,15 @@ import { } from "@/position-roundtable/components/PositionBuiltinAgentChat"; import type { PositionBuiltinAgentSwitchOption } from "@/position-roundtable/components/PositionAgentSwitcher"; import { - DEFAULT_POSITION_ID, POSITION_OPTIONS, + intentPositionIdFor, positionNameById, + resolvePositionId, + setRuntimePositionOptions, + type PositionOption, type PositionId, } from "@/position-roundtable/lib/positions"; +import { usePositionRoles } from "@/strategy-components/api/position-roles"; import { activatePositionSession, completePositionActionPlan, @@ -139,11 +143,7 @@ import { isPositionRoundtableStandaloneMode, } from "@/position-roundtable/demo/position-roundtable-demo"; -/** - * 第一版先固定岗位,等岗位接口与权限范围确认后只替换常量数据源。 - * 情报分析岗复用原会商 Step 1,承担意图识别。 - */ -const POSITIONS = POSITION_OPTIONS; +/** 岗位目录来自全局角色配置;意图识别岗复用原会商 Step 1。 */ type WorkspaceTab = "agents" | "results"; type NodeVisualStatus = PositionRoundtableNode["status"] | undefined; type PositionArtifactPreview = { @@ -171,12 +171,6 @@ type SandboxThreadSnapshot = { * URL parameters are externally supplied, so never cast the raw value into * page state without checking it against the position directory first. */ -function resolvePositionId(value: string | null): PositionId { - return POSITIONS.some((position) => position.id === value) - ? (value as PositionId) - : DEFAULT_POSITION_ID; -} - function normalizePreviewArtifactPath(path: string): string { let normalized = path.trim(); if (normalized.startsWith("write-file:")) { @@ -319,7 +313,7 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { } function isIntentPositionNode(node: PositionRoundtableNode): boolean { - return node.positionId === DEFAULT_POSITION_ID; + return node.positionId === intentPositionIdFor(); } function artifactIsMarkdown(artifact: Record<string, unknown>): boolean { @@ -558,9 +552,40 @@ export default function PositionRoundtablePage({ const [searchParams, setSearchParams] = useSearchParams(); const { theme } = useTheme(); const isDark = theme === "dark"; - const positionIdFromUrl = resolvePositionId(searchParams.get("positionId")); - const [positionId, setPositionId] = useState<PositionId>(() => positionIdFromUrl); const standaloneDemoMode = isPositionRoundtableStandaloneMode(); + const { data: configuredPositionRoles = [] } = usePositionRoles({ + enabled: !standaloneDemoMode, + }); + const configuredPositionOptions = useMemo<PositionOption[]>( + () => + configuredPositionRoles.length + ? configuredPositionRoles + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((role) => ({ + id: role.id, + name: role.name, + description: role.description, + isIntentPosition: role.roleType === "intent", + enabled: role.enabled, + isBuiltin: role.isBuiltin, + sortOrder: role.sortOrder, + })) + : [...POSITION_OPTIONS], + [configuredPositionRoles], + ); + // The switcher only exposes enabled roles. The complete directory stays in + // the runtime cache so archived chains can still resolve a disabled role's + // historical display name. + const positions = useMemo( + () => configuredPositionOptions.filter((position) => position.enabled !== false), + [configuredPositionOptions], + ); + // Flowchart, delivery dialog and helper functions outside this component use + // the same live directory to resolve position names / the special role. + setRuntimePositionOptions(configuredPositionOptions); + const intentPositionId = intentPositionIdFor(positions); + const positionIdFromUrl = resolvePositionId(searchParams.get("positionId"), positions); + const [positionId, setPositionId] = useState<PositionId>(() => positionIdFromUrl); const demoModeEnabled = isPositionRoundtableDemoEnabled(); const demoTask = useMemo(() => getPositionDemoTask(), []); const taskIdFromUrl = @@ -648,14 +673,14 @@ export default function PositionRoundtablePage({ }); const [intentSaving, setIntentSaving] = useState(false); const [intentCompletedAt, setIntentCompletedAt] = useState(""); - const activePosition = POSITIONS.find((item) => item.id === positionId)!; + const activePosition = positions.find((item) => item.id === positionId) ?? positions[0] ?? POSITION_OPTIONS[0]!; const currentPositionOptions = useMemo( () => - POSITIONS.map((position) => ({ + positions.map((position) => ({ label: position.name, value: position.id, })), - [], + [positions], ); const currentPositionSelectStyle = useMemo( () => ({ width: 210, minWidth: 210, flex: "0 0 210px" }), @@ -1026,7 +1051,7 @@ export default function PositionRoundtablePage({ ); const summaryDone = snapshotStatus(positionSession?.summarySnapshot) === "done"; const actionPlanDone = snapshotStatus(positionSession?.actionPlanSnapshot) === "done"; - const showClosureAgents = positionId === DEFAULT_POSITION_ID; + const showClosureAgents = positionId === intentPositionId; const [activeBuiltinKind, setActiveBuiltinKind] = useState<PositionBuiltinAgentKind | null>(null); const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0); // Stage selection only arms the automatic first question. The key is @@ -2049,8 +2074,8 @@ export default function PositionRoundtablePage({ (kind: ClosureStageKind) => { // The closing agents are part of the testing-analysis workspace only; // ordinary positions keep a single business-chain node conversation. - if (positionId !== DEFAULT_POSITION_ID) { - toast.info("收口智能体仅在测试分析岗中开放使用。"); + if (positionId !== intentPositionId) { + toast.info("收口智能体仅在意图识别岗中开放使用。"); return; } if (!allPositionDeliveriesDone) { @@ -2068,7 +2093,7 @@ export default function PositionRoundtablePage({ setActiveBuiltinKind(kind); setPendingBuiltinAutoStartKind(kind); }, - [allPositionDeliveriesDone, closeSandbox, positionId, summaryDone], + [allPositionDeliveriesDone, closeSandbox, intentPositionId, positionId, summaryDone], ); const handleSelectPositionNode = useCallback( (nextNode: PositionRoundtableNode) => { @@ -2088,9 +2113,9 @@ export default function PositionRoundtablePage({ setActiveBuiltinKind(null); setSelectedAgentId(null); setActiveNodeKey(null); - setRightPanelOpen(positionIdFromUrl === DEFAULT_POSITION_ID); + setRightPanelOpen(positionIdFromUrl === intentPositionId); setLeftPanelCollapsed(false); - }, [closeSandbox, positionId, positionIdFromUrl]); + }, [closeSandbox, intentPositionId, positionId, positionIdFromUrl]); const handlePositionChange = useCallback( (value: unknown) => { // 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表, @@ -2102,13 +2127,13 @@ export default function PositionRoundtablePage({ setActiveBuiltinKind(null); setSelectedAgentId(null); setActiveNodeKey(null); - setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID); + setRightPanelOpen(nextPositionId === intentPositionId); setLeftPanelCollapsed(false); const next = new URLSearchParams(searchParamsString); next.set("positionId", nextPositionId); setSearchParams(next, { replace: true }); }, - [closeSandbox, searchParamsString, setSearchParams], + [closeSandbox, intentPositionId, searchParamsString, setSearchParams], ); const renderIntentChatPanel = (chainStarted: boolean) => ( <div className="position-roundtable-intent-page contents"> @@ -2517,6 +2542,7 @@ export default function PositionRoundtablePage({ {activePosition.isIntentPosition && rightPanelOpen && !sandboxOpen && ( <aside className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md"> <RightWorkspace + positions={configuredPositionOptions} selectedAgent={selectedAgent} selectedAgentId={selectedAgentId} onSelectAgent={handleSelectAgent} @@ -2561,6 +2587,7 @@ export default function PositionRoundtablePage({ > <div className="flex h-full min-h-0 flex-col bg-white dark:bg-[#032650] dark:text-[#c5cfe0]"> <RightWorkspace + positions={configuredPositionOptions} selectedAgent={selectedAgent} selectedAgentId={selectedAgentId} onSelectAgent={handleSelectAgent} @@ -3348,7 +3375,7 @@ function PositionDeliveryOverview({ agentName: "情报分析智能体", productName: "任务意图理解", summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。", - positionId: DEFAULT_POSITION_ID, + positionId: intentPositionIdFor(), updatedAt: intentCompletedAt || session?.updatedAt, status: intent ? "done" : "ready", accent: "#8b5cf6", @@ -4644,6 +4671,7 @@ function CompactChainNode({ } function RightWorkspace({ + positions, selectedAgent, selectedAgentId, onSelectAgent, @@ -4665,6 +4693,7 @@ function RightWorkspace({ onTabChange, standaloneDemo = false, }: { + positions: readonly PositionOption[]; selectedAgent: Agent | null; selectedAgentId: string | null; onSelectAgent: (agentId: string) => void; @@ -4782,6 +4811,7 @@ function RightWorkspace({ ) : ( <div className="min-h-0 flex-1 overflow-y-auto pt-2"> <AnalysisResults + positions={positions} intentObjective={intentObjective} intentCompletedAt={intentCompletedAt} nodes={nodes} @@ -5098,7 +5128,7 @@ function buildIntentResultItem( agentName: "情报分析智能体", productName: "任务意图理解", summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。", - positionId: DEFAULT_POSITION_ID, + positionId: intentPositionIdFor(), updatedAt, status: intent ? "done" : "ready", }; @@ -5207,6 +5237,7 @@ function buildBuiltinResultItem( } function AnalysisResults({ + positions, intentObjective, intentCompletedAt, nodes, @@ -5217,6 +5248,7 @@ function AnalysisResults({ onOpenBuiltinChat, readOnly = false, }: { + positions: readonly PositionOption[]; intentObjective?: string; intentCompletedAt: string; nodes: PositionRoundtableNode[]; @@ -5258,7 +5290,7 @@ function AnalysisResults({ agentName: "情报分析智能体", productName: "任务意图理解", summary: intentObjective, - positionId: DEFAULT_POSITION_ID, + positionId: intentPositionIdFor(), updatedAt: undefined, status: "done", }); @@ -5410,11 +5442,11 @@ function AnalysisResults({ const positionOptions = useMemo( () => [ { label: "全部岗位", value: "all" }, - ...POSITIONS.filter((position) => resultItems.some((item) => item.positionId === position.id)).map( + ...positions.filter((position) => resultItems.some((item) => item.positionId === position.id)).map( (position) => ({ label: position.name, value: position.id }), ), ], - [resultItems], + [positions, resultItems], ); const nodeOptions = useMemo( () => [ diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index f6861ddf..b1474d2f 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -142,9 +142,9 @@ export interface SeatSelectionResult { gatherFirst?: boolean; } -/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */ +/** 业务链条席位数硬约束,必须与后端持久化校验保持一致。 */ export const CHAIN_MIN_SEATS = 2; -export const CHAIN_MAX_SEATS = 8; +export const CHAIN_MAX_SEATS = 20; /** * 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。 diff --git a/frontend-web/src/strategy-components/api/position-roles.ts b/frontend-web/src/strategy-components/api/position-roles.ts new file mode 100644 index 00000000..67a03aa6 --- /dev/null +++ b/frontend-web/src/strategy-components/api/position-roles.ts @@ -0,0 +1,181 @@ +/** 岗位角色配置 API:岗位会商工作视角,不等同于组织岗位/RBAC。 */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; + +export type PositionRoleType = "standard" | "intent"; + +export interface PositionRole { + id: string; + name: string; + description: string; + roleType: PositionRoleType; + enabled: boolean; + isBuiltin: boolean; + sortOrder: number; + updatedBy?: string | null; + updatedAt?: string | null; + /** True only when the API is unavailable and the UI is showing built-ins. */ + isFallback?: boolean; +} + +interface ServerPositionRole { + id: string; + name: string; + description: string; + role_type: PositionRoleType; + enabled: boolean; + is_builtin: boolean; + sort_order: number; + updated_by?: string | null; + updated_at?: string | null; +} + +const BASE = () => `${getBackendBaseURL()}/api/position-roles`; +export const POSITION_ROLES_QUERY_KEY = ["position-roles"] as const; + +/** + * Safe first-run directory for the position-roundtable workbench. + * + * The backend persists exactly the same values in ``position_roles``. Keeping + * this client copy means an old/offline gateway cannot turn the administrator + * page or the roundtable selector into an empty screen during deployment. + */ +export const DEFAULT_POSITION_ROLES: readonly PositionRole[] = [ + { + id: "intelligence", + name: "测试分析岗", + description: "识别任务意图、补齐约束并确认业务链条", + roleType: "intent", + enabled: true, + isBuiltin: true, + sortOrder: 0, + }, + { + id: "planning", + name: "组织测试岗", + description: "承接上游信息并进行组织测试", + roleType: "standard", + enabled: true, + isBuiltin: true, + sortOrder: 1, + }, + { + id: "solution", + name: "方案设计岗", + description: "承接上游结果并形成方案产物", + roleType: "standard", + enabled: true, + isBuiltin: true, + sortOrder: 2, + }, + { + id: "evaluation", + name: "综合测试岗", + description: "测试链条产物并提出综合建议", + roleType: "standard", + enabled: true, + isBuiltin: true, + sortOrder: 3, + }, +]; + +function defaultRoles(): PositionRole[] { + return DEFAULT_POSITION_ROLES.map((role) => ({ ...role, isFallback: true })); +} + +function fromServer(row: ServerPositionRole): PositionRole { + return { + id: row.id, + name: row.name ?? "", + description: row.description ?? "", + roleType: row.role_type ?? "standard", + enabled: row.enabled ?? true, + isBuiltin: row.is_builtin ?? false, + sortOrder: row.sort_order ?? 0, + updatedBy: row.updated_by ?? null, + updatedAt: row.updated_at ?? null, + }; +} + +function toServer(row: PositionRole): ServerPositionRole { + return { + id: row.id, + name: row.name, + description: row.description, + role_type: row.roleType, + enabled: row.enabled, + is_builtin: row.isBuiltin, + sort_order: row.sortOrder, + }; +} + +async function readJson<T>(response: Response, context: string): Promise<T> { + if (!response.ok) { + const payload = await response.json().catch(() => null as unknown); + const detail = + payload && typeof payload === "object" && "detail" in payload + ? String((payload as { detail: unknown }).detail) + : `${context} failed (${response.status})`; + throw new Error(detail); + } + return (await response.json()) as T; +} + +export async function listPositionRoles(): Promise<PositionRole[]> { + try { + const response = await apiFetch(BASE(), { method: "GET" }); + const payload = await readJson<{ roles: ServerPositionRole[] }>(response, "list position roles"); + const roles = (payload.roles ?? []).map(fromServer); + // An empty directory is never valid: it means an old gateway is running + // or the first-run seed has not completed yet. Keep the page usable. + return roles.length > 0 ? roles : defaultRoles(); + } catch { + // Do not leave administrators with an empty configuration screen when an + // older deployment has not received the new router/migration yet. + return defaultRoles(); + } +} + +export async function replacePositionRoles(roles: PositionRole[]): Promise<PositionRole[]> { + const response = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roles: roles.map(toServer) }), + }); + const payload = await readJson<{ roles: ServerPositionRole[] }>(response, "save position roles"); + const saved = (payload.roles ?? []).map(fromServer); + writeYLog({ + optType: OptType.edit, + optDetail: "CM助手-岗位角色配置-保存", + url: "/api/position-roles", + reqParam: { roles }, + returnParam: saved, + }); + return saved; +} + +export function usePositionRoles(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: POSITION_ROLES_QUERY_KEY, + queryFn: listPositionRoles, + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: options?.enabled ?? true, + }); +} + +export function usePositionRoleActions() { + const queryClient = useQueryClient(); + return { + save: useMutation({ + mutationFn: replacePositionRoles, + onSuccess: (roles) => { + queryClient.setQueryData(POSITION_ROLES_QUERY_KEY, roles); + }, + }), + }; +} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 727c4a65..09f9b087 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -123,6 +123,11 @@ cascade pragmas. The router is mounted canonically below schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`. Clients may retry the compatibility route only for a framework/proxy-level `404 Not Found`; domain-level missing-session responses must remain real 404s. +`/api/intent/init` must create the empty Step-1 thread through +`multi_agent._create_thread_direct`, exactly like multi-agent initialization; +do not restore the old HTTP loopback to `/api/threads`, because deployed +Gateway ports/proxies may make the automatic page initialization fail while +later manual turns still work. Streaming stays on the existing loopback path. ### Harness / App Split @@ -407,6 +412,10 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, ` Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header). +### Business-mapping authorization + +`/api/business-mapping` remains globally readable. Updates to 3Q, 6BF, and 7BF require `system_role == "admin"`; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is `lqq`. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (`app/gateway/routers/business_mapping.py`) remains the enforcement point. Tests: `tests/test_business_mapping.py`. + ### Position-collaboration foundation The position-collaboration workspace is a separate frontend route and must not @@ -1093,6 +1102,18 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi ## Documentation +### Position-roundtable role directory + +`position_roles` is the global shared directory for position-roundtable work +views. It is deliberately separate from organisation/RBAC `positions`. +`app.gateway.deps.langgraph_runtime()` seeds the four stable built-in ids from +`app/gateway/position_role_defaults.py` via `ensure_seed()` (missing rows only; +never overwrite operator edits). The API is `GET/PUT /api/position-roles`; +writes are admin-only. Exactly one enabled `intent` role is required and owns +the initial intent workspace plus the summary/action-plan closing agents. Do +not delete a built-in id because historical chain nodes persist those ids; +custom role ids may be added and removed. + See `docs/` directory for detailed documentation: - [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only. - [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index f558cdff..4c91b58c 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -124,6 +124,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/POST /api/agents` | List visible agents or create a user-owned agent | | `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) | +| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). | | `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. | | `GET /api/memory` | Retrieve memory data | | `POST /api/memory/reload` | Force memory reload | @@ -478,7 +479,25 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision Its live intent conversation still uses the shared `/api/intent/init` and `/api/intent/stream` endpoints used by the original multi-agent roundtable. -Only the recovery snapshot layer is position-specific. Deployments must expose +`/api/intent/init` creates its empty thread directly through the in-process +thread store/checkpointer, matching multi-agent initialization and avoiding a +deployment-sensitive `127.0.0.1:<gateway-port>` loopback. Only the recovery +snapshot layer is position-specific. Deployments must expose either `/api/position-roundtable/*` or the compatibility mirror `/api/multi-agent/position-roundtable/*`; updated clients probe both and only retry when the first response is the framework/proxy-level `404 Not Found`. + +## Position-roundtable role directory + +The position-roundtable work views are persisted separately from organisation +positions/RBAC in `position_roles`. Gateway startup idempotently inserts the +four built-in ids (testing analysis, organisation test, solution design and +comprehensive test), but only when an id is missing: administrator changes to +the name, order, enabled state or role type are never overwritten. + +`GET /api/position-roles` is consumed by the workbench and `PUT +/api/position-roles` is administrator-only. Exactly one enabled `intent` role +is required. It is the initial intent-identification workspace and owns the +summary/action-plan closing agents. Built-in rows retain stable ids so +historical chain nodes remain readable; they can be edited or disabled but not +deleted. Custom roles may be freely added or removed. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 5f4aded9..fc8255e7 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -57,6 +57,7 @@ from app.gateway.routers import ( parallel_agents, position_roundtable, positions, + position_roles, public_config, public_embed, public_leaderboard, @@ -1485,7 +1486,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an ) # Global business ? agent / chain mapping at /api/business-mapping - app.include_router(business_mapping.router) + app.include_router(business_mapping.router) + + # Position-roundtable work-view role configuration at /api/position-roles. + app.include_router(position_roles.router) # Light-app registry (?????) at /api/light-apps app.include_router(light_apps.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 9f9e70de..a2b4c5f2 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -264,6 +264,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: from deerflow.persistence.position_roundtable import make_position_roundtable_store app.state.position_roundtable_store = make_position_roundtable_store(sf) + + # 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC + # 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。 + from app.gateway.position_role_defaults import POSITION_ROLE_SEED + from deerflow.persistence.position_roles import make_position_role_store + + app.state.position_role_store = make_position_role_store(sf) + await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED) from deerflow.persistence.business_mapping import make_business_mapping_store diff --git a/offline-backend-20260512/backend/app/gateway/position_role_defaults.py b/offline-backend-20260512/backend/app/gateway/position_role_defaults.py new file mode 100644 index 00000000..240ac58f --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/position_role_defaults.py @@ -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, + }, +] diff --git a/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py index 13a42688..bf315af0 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py +++ b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py @@ -26,6 +26,9 @@ from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"]) +_EIGHTBF_BUSINESS_CODE = "8BF" +_EIGHTBF_MANAGER_USERNAME = "lqq" + # ── schemas ──────────────────────────────────────────────────────────────── @@ -70,6 +73,20 @@ def _is_admin(request: Request) -> bool: return user is None or getattr(user, "system_role", None) == "admin" +def _username_of(request: Request) -> str: + """Read the trusted login account name, not an externally supplied username.""" + user = getattr(request.state, "user", None) + email = str(getattr(user, "email", "") or "") + return email.split("@", 1)[0].strip().lower() + + +def _can_update_mapping(request: Request, business_code: str) -> bool: + """8BF's public chain is maintained exclusively by the lqq account.""" + if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE: + return _username_of(request) == _EIGHTBF_MANAGER_USERNAME + return _is_admin(request) + + def _current_user_id(request: Request) -> str: user = getattr(request.state, "user", None) if user is not None: @@ -119,17 +136,14 @@ async def _validate_public_chain_mapping( async def list_mappings(request: Request) -> BusinessMappingListResponse: store = _get_store(request) rows = await store.list_mappings() - return BusinessMappingListResponse( - mappings=[BusinessMappingResponse(**r) for r in rows] - ) + return BusinessMappingListResponse(mappings=[BusinessMappingResponse(**r) for r in rows]) @router.put("/{business_code}", response_model=BusinessMappingResponse) -async def update_mapping( - request: Request, business_code: str, body: BusinessMappingUpdateRequest -) -> BusinessMappingResponse: - if not _is_admin(request): - raise HTTPException(status_code=403, detail="Only administrators can update business mappings") +async def update_mapping(request: Request, business_code: str, body: BusinessMappingUpdateRequest) -> BusinessMappingResponse: + if not _can_update_mapping(request, business_code): + detail = "Only the lqq account can update the 8BF business mapping" if business_code.strip().upper() == _EIGHTBF_BUSINESS_CODE else "Only administrators can update business mappings" + raise HTTPException(status_code=403, detail=detail) store = _get_store(request) # Only forward fields the caller explicitly sent (distinguish omitted vs null). fields = body.model_dump(exclude_unset=True) diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 3fc06fcd..fdc7a558 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -51,11 +51,11 @@ SSE 协议(与前端 ``streamIntent`` 对齐) from __future__ import annotations -import asyncio import json import logging import re -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any import httpx from fastapi import APIRouter, Body, HTTPException, Request @@ -69,7 +69,7 @@ from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_ag from app.gateway.routers.multi_agent import ( _ROUNDTABLE_THREAD_METADATA, _auth_headers, - _create_thread, + _create_thread_direct, _diagnose_exception, _find_dispatch_message, _find_trailing_ai_text, @@ -311,21 +311,18 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, # 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。 ensure_roundtable_functional_agents() - base = _loopback_base(request) - headers = _auth_headers(request) - # TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。 - # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 - # 看不到逐 token 增量。 + # Keep Step-1 initialization aligned with multi-agent initialization: + # create the empty thread in-process rather than looping back through + # 127.0.0.1:<gateway-port>. Intranet proxies and port mappings can make + # that loopback fail only for automatic page initialization. Streaming + # below still uses the TCP loopback so SSE remains incrementally flushed. raw_task_context = payload.get("task_context") task_context = raw_task_context if isinstance(raw_task_context, dict) else None try: - async with _make_loopback_client(request) as client: - thread_id = await _create_thread( - client, - base, - headers, - metadata=_intent_thread_metadata(task_context), - ) + thread_id = await _create_thread_direct( + request, + metadata=_intent_thread_metadata(task_context), + ) except HTTPException: # 上游已用结构化方式失败,原样传给前端不需要二次包装。 raise diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roles.py b/offline-backend-20260512/backend/app/gateway/routers/position_roles.py new file mode 100644 index 00000000..971b3509 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roles.py @@ -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]) diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index 7ca43857..e604d63d 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -331,15 +331,57 @@ def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: return [node for node in nodes if node.get("status") == "done"] -def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool: +def _all_nodes_delivered( + nodes: list[dict[str, Any]], + *, + intent_position_id: str | None = None, +) -> bool: return bool(nodes) and all( - node.get("status") == "done" and (_is_intent_position_node(node) or _node_has_markdown_delivery(node)) + node.get("status") == "done" + and (_is_intent_position_node(node, intent_position_id) or _node_has_markdown_delivery(node)) for node in nodes ) -def _is_intent_position_node(node: dict[str, Any]) -> bool: - return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"} +def _is_intent_position_node( + node: dict[str, Any], + intent_position_id: str | None = None, +) -> bool: + position_id = str(node.get("position_id") or "").strip() + if intent_position_id: + return position_id == intent_position_id + # Existing persisted sessions predate the configurable directory. Retain + # their historical interpretation until they are archived/recreated. + return position_id.lower() in {"intelligence", "intel", "intent"} + + +def _session_intent_position_id(session: dict[str, Any]) -> str | None: + chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {} + value = chain.get("_intent_position_id") + return value.strip() if isinstance(value, str) and value.strip() else None + + +async def _configured_intent_position_id(request: Request) -> str: + """Get the one enabled special role for a newly activated session. + + The chosen id is frozen into ``chain_snapshot`` on activation. A later + configuration change therefore applies to future workspaces without + reinterpreting completion rules in an in-flight historical business chain. + """ + store = getattr(request.app.state, "position_role_store", None) + if store is not None: + rows = await store.list_roles() + selected = next( + ( + row.get("id") + for row in rows + if row.get("enabled") and row.get("role_type") == "intent" and isinstance(row.get("id"), str) + ), + None, + ) + if isinstance(selected, str) and selected.strip(): + return selected.strip() + return "intelligence" def _is_markdown_artifact(artifact: Any) -> bool: @@ -419,12 +461,17 @@ def _has_confirmed_intent(intent: Any) -> bool: return bool(objective and isinstance(constraints, list)) -def _prime_intent_nodes(nodes: list[dict[str, Any]], intent: Any) -> list[dict[str, Any]]: +def _prime_intent_nodes( + nodes: list[dict[str, Any]], + intent: Any, + *, + intent_position_id: str | None = None, +) -> list[dict[str, Any]]: now_answer = _intent_node_answer(intent) primed: list[dict[str, Any]] = [] for node in nodes: next_node = dict(node) - if _is_intent_position_node(next_node): + if _is_intent_position_node(next_node, intent_position_id): next_node.update( { "status": "done", @@ -461,7 +508,7 @@ def _build_node_context_text( objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论") constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else [] agent_name, position_id = _seat_meta(session, current) - md_required = not _is_intent_position_node(current) + md_required = not _is_intent_position_node(current, _session_intent_position_id(session)) lines = [ "岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):", "", @@ -716,7 +763,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) -> ), "delivery_requirement": { "type": "markdown_report", - "required": not _is_intent_position_node(current), + "required": not _is_intent_position_node(current, _session_intent_position_id(session)), "completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。", }, "task": session.get("task_snapshot") or {}, @@ -1137,16 +1184,26 @@ async def activate_session( for seat in (chain.get("seats") if isinstance(chain.get("seats"), list) else []) if seat.get("agent_id") } + # Freeze the currently configured special role with this session. This + # prevents a later global config edit from changing the completion rule of + # an already running historical chain. + chain_snapshot = dict(chain) + intent_position_id = await _configured_intent_position_id(request) + chain_snapshot["_intent_position_id"] = intent_position_id nodes = _chain_nodes( - chain, + chain_snapshot, available_agent_ids=await _available_agent_ids(request, seat_ids), ) - nodes = _prime_intent_nodes(nodes, current.get("intent_snapshot")) + nodes = _prime_intent_nodes( + nodes, + current.get("intent_snapshot"), + intent_position_id=intent_position_id, + ) activated = await store.activate_session( session_id, user_id, chain_id=body.chain_id, - chain_snapshot=chain, + chain_snapshot=chain_snapshot, nodes=nodes, ) if activated is None: @@ -1215,7 +1272,7 @@ async def update_node_conversation( node_key: str, body: ConversationStateUpdateRequest, ) -> NodeResponse: - store, user_id, _ = await _require_writable_session(request, session_id) + store, user_id, session = await _require_writable_session(request, session_id) current = await store.get_node(session_id, node_key, user_id) if current is None: raise HTTPException(status_code=404, detail="Position roundtable node not found") @@ -1247,7 +1304,7 @@ async def build_node_context(request: Request, session_id: str, node_key: str) - async def complete_node_turn( request: Request, session_id: str, node_key: str, body: CompleteTurnRequest ) -> NodeResponse: - store, user_id, _ = await _require_writable_session(request, session_id) + store, user_id, session = await _require_writable_session(request, session_id) node = await store.get_node(session_id, node_key, user_id) if node is None: raise HTTPException(status_code=404, detail="Position roundtable node not found") @@ -1268,7 +1325,7 @@ async def complete_node_turn( requires_fresh_delivery = node.get("status") in {"rejected", "stale"} next_status = ( "done" - if _is_intent_position_node(node) + if _is_intent_position_node(node, _session_intent_position_id(session)) else "done" if has_markdown and (not requires_fresh_delivery or markdown_changed) else "running" @@ -1280,7 +1337,7 @@ async def complete_node_turn( new_effective_delivery = ( next_status == "done" and ( - _is_intent_position_node(node) + _is_intent_position_node(node, _session_intent_position_id(session)) or node.get("status") != "done" or markdown_changed ) @@ -1384,7 +1441,7 @@ async def _prepare_builtin_run( nodes = await store.list_nodes(session_id, user_id) if nodes is None: raise HTTPException(status_code=404, detail="Position roundtable session not found") - if not _all_nodes_delivered(nodes): + if not _all_nodes_delivered(nodes, intent_position_id=_session_intent_position_id(session)): raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付") if kind == "action_plan": @@ -1428,7 +1485,7 @@ async def _complete_builtin_run( nodes = await store.list_nodes(session_id, user_id) if nodes is None: raise HTTPException(status_code=404, detail="Position roundtable session not found") - if not _all_nodes_delivered(nodes): + if not _all_nodes_delivered(nodes, intent_position_id=_session_intent_position_id(session)): raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付") current_revisions = _source_revisions(nodes) if body.source_revisions != current_revisions: diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py index 10244f38..54967e61 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -35,9 +35,10 @@ from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"]) -# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8). +# Stored business chains support up to 20 seats. The frontend may choose a +# lower deployment-specific cap, but must never exceed this server contract. MIN_SEATS = 2 -MAX_SEATS = 8 +MAX_SEATS = 20 # ── schemas ──────────────────────────────────────────────────────────────── @@ -183,7 +184,7 @@ def _get_store(request: Request): def _validate_seats(seats: list[ChainSeatSchema]) -> None: - """Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain.""" + """Enforce 2 ≤ N ≤ 20 and reject duplicate agent ids within a chain.""" if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS: raise HTTPException( status_code=400, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260729_01_position_roles.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260729_01_position_roles.py new file mode 100644 index 00000000..450213ad --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260729_01_position_roles.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 1d201f27..b32bede6 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -15,18 +15,6 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and there is no matching entity directory. """ -from deerflow.persistence.agents.model import ( - AgentExtrasRow, - AgentFavoriteRow, - AgentRow, - AgentSkillRow, -) -from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow -from deerflow.persistence.business_mapping.model import BusinessMappingRow -from deerflow.persistence.concurrency.model import ConcurrencySampleRow -from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow -from deerflow.persistence.memory_config.model import UserMemoryConfigRow -from deerflow.persistence.feedback.model import FeedbackRow from deerflow.knowledge.models import ( KnowledgeEmbeddingRow, KnowledgeEntityRow, @@ -40,12 +28,26 @@ from deerflow.knowledge.models import ( KnowledgeSourceRow, KnowledgeTagRow, ) +from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow +from deerflow.persistence.agents.model import ( + AgentExtrasRow, + AgentFavoriteRow, + AgentRow, + AgentSkillRow, +) +from deerflow.persistence.business_mapping.model import BusinessMappingRow +from deerflow.persistence.concurrency.model import ConcurrencySampleRow +from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow +from deerflow.persistence.embed_sessions.model import EmbedSessionRow +from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.light_apps.model import LightAppRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow +from deerflow.persistence.memory_config.model import UserMemoryConfigRow from deerflow.persistence.menu_overrides.model import MenuOverrideRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.notifications.model import NotificationRow from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow +from deerflow.persistence.position_roles.model import PositionRoleRow from deerflow.persistence.position_roundtable.model import ( PositionRoundtableNodeRow, PositionRoundtableSessionRow, @@ -54,16 +56,15 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow from deerflow.persistence.research_templates.model import ResearchTemplateRow from deerflow.persistence.roundtable_chains.model import RoundtableChainRow +from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow from deerflow.persistence.roundtable_drafts.model import ( RoundtableDraftRow, RoundtableRecommendHistoryRow, ) -from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow from deerflow.persistence.run.model import RunRow -from deerflow.persistence.sensitive_words.model import SensitiveWordRow from deerflow.persistence.scheduled_tasks.model import ( ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, @@ -71,13 +72,13 @@ from deerflow.persistence.scheduled_tasks.model import ( ScheduledTaskSubscriptionRow, SchedulerThreadRow, ) +from deerflow.persistence.sensitive_words.model import SensitiveWordRow from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow -from deerflow.persistence.task_buttons.model import TaskButtonRow -from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow -from deerflow.persistence.task_reports.model import TaskReportRow from deerflow.persistence.tags.model import TagAssignmentRow, TagRow +from deerflow.persistence.task_buttons.model import TaskButtonRow +from deerflow.persistence.task_reports.model import TaskReportRow +from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow from deerflow.persistence.thread_meta.model import ThreadMetaRow -from deerflow.persistence.embed_sessions.model import EmbedSessionRow from deerflow.persistence.thread_shares.model import ThreadShareRow from deerflow.persistence.tool_metrics.model import ToolCallMetricRow from deerflow.persistence.user.model import UserRow @@ -123,6 +124,7 @@ __all__ = [ "RoundtableJobRow", "RoundtableRecommendHistoryRow", "RoundtableTaskDraftRow", + "PositionRoleRow", "RunEventRow", "RunRow", "ScheduledTaskDeliveryProfileRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/__init__.py new file mode 100644 index 00000000..5ad9254e --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/base.py new file mode 100644 index 00000000..30090490 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/memory.py new file mode 100644 index 00000000..04756572 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/memory.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/model.py new file mode 100644 index 00000000..53a92064 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/model.py @@ -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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/sql.py new file mode 100644 index 00000000..701f3447 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roles/sql.py @@ -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() diff --git a/offline-backend-20260512/backend/tests/test_business_mapping.py b/offline-backend-20260512/backend/tests/test_business_mapping.py index 0ff030a3..d2524a3e 100644 --- a/offline-backend-20260512/backend/tests/test_business_mapping.py +++ b/offline-backend-20260512/backend/tests/test_business_mapping.py @@ -38,7 +38,12 @@ class _ChainStore: return self.chain -def _request(role: str, chain: dict[str, object] | None = None): +def _request( + role: str, + chain: dict[str, object] | None = None, + *, + email: str = "lqq@example.com", +): return SimpleNamespace( app=SimpleNamespace( state=SimpleNamespace( @@ -46,14 +51,29 @@ def _request(role: str, chain: dict[str, object] | None = None): roundtable_chain_store=_ChainStore(chain), ) ), - state=SimpleNamespace(user=SimpleNamespace(id="admin-id", system_role=role)), + state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)), ) @pytest.mark.asyncio -async def test_only_administrators_can_update_global_business_mappings() -> None: +async def test_only_administrators_can_update_non_8bf_business_mappings() -> None: request = _request("user") + with pytest.raises(HTTPException) as error: + await business_mapping_router.update_mapping( + request, + "6BF", + business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"), + ) + + assert error.value.status_code == 403 + assert request.app.state.business_mapping_store.calls == [] + + +@pytest.mark.asyncio +async def test_only_lqq_can_update_the_8bf_business_mapping() -> None: + request = _request("admin", email="admin@example.com") + with pytest.raises(HTTPException) as error: await business_mapping_router.update_mapping( request, @@ -65,6 +85,20 @@ async def test_only_administrators_can_update_global_business_mappings() -> None assert request.app.state.business_mapping_store.calls == [] +@pytest.mark.asyncio +async def test_lqq_can_update_the_8bf_business_mapping_without_admin_role() -> None: + request = _request("user", email="lqq@example.com") + + response = await business_mapping_router.update_mapping( + request, + "8BF", + business_mapping_router.BusinessMappingUpdateRequest(), + ) + + assert response.business_code == "8BF" + assert request.app.state.business_mapping_store.calls == [("8BF", {})] + + @pytest.mark.asyncio async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None: request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True}) @@ -80,9 +114,7 @@ async def test_global_mapping_requires_a_public_chain_and_canonicalizes_its_titl assert response.chain_id == "chain-8bf" assert response.chain_title == "岗位协同链" - assert request.app.state.business_mapping_store.calls == [ - ("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"}) - ] + assert request.app.state.business_mapping_store.calls == [("8BF", {"chain_id": "chain-8bf", "chain_title": "岗位协同链"})] @pytest.mark.asyncio diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 75b486db..f39bfc2d 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -9,8 +9,8 @@ import pytest_asyncio from fastapi import HTTPException from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from app.gateway.app import create_app from app.gateway import services as gateway_services +from app.gateway.app import create_app from app.gateway.routers import intent as intent_router from app.gateway.routers import position_roundtable as position_roundtable_router from deerflow.persistence.base import Base @@ -50,6 +50,42 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix(): assert position_roundtable_router.router.prefix == "/position-roundtable" +@pytest.mark.asyncio +async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch): + """Step 1 initialization must not depend on the deployed Gateway port.""" + + captured: dict[str, object] = {} + monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None) + + async def fake_create_thread_direct(request, *, metadata): + captured["request"] = request + captured["metadata"] = metadata + return "intent-thread-direct" + + monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct) + + request = SimpleNamespace() + result = await intent_router.init_intent( + request, + {"task_context": {"task": {"id": "task-001"}}}, + ) + + assert result == { + "status": "success", + "agent_id": intent_router.INTENT_AGENT_ID, + "thread_id": "intent-thread-direct", + } + assert captured["request"] is request + assert captured["metadata"] == { + **intent_router._ROUNDTABLE_THREAD_METADATA, + "taskId": "task-001", + "position_roundtable_task_id": "task-001", + "position_task_id": "task-001:intelligence", + "position_id": "intelligence", + "position_roundtable_phase": "intent", + } + + @pytest.mark.asyncio async def test_session_activation_freezes_chain_and_creates_nodes_once(store): created = await store.create_session( From e52261fc3da3452c4e691cf71d61213ab56da1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= <hkdong@kland.com.cn> Date: Wed, 29 Jul 2026 22:25:42 +0800 Subject: [PATCH 5/5] fix: preserve full chat embed mode navigation --- .../workspace/general-qa-sidebar.tsx | 38 ++++++++++++++----- .../page-layout/chats-iframe-route.test.mjs | 22 +++++++++++ .../core/page-layout/chats-iframe-route.ts | 20 ++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index 23bdfbab..51f47ad8 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -83,6 +83,7 @@ import { sortThreadsPinnedFirst, titleOfThread, } from "@/core/threads/utils"; +import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { isIMEComposing } from "@/lib/ime"; @@ -185,15 +186,24 @@ export function GeneralQaSidebar() { // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 const searchParams = useSearchParams(); const activeAgentId = searchParams.get("agent"); + const currentSearch = searchParams.toString(); + const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch); + const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch); + const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch); // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, // 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。 // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 const handleSelectAgent = useCallback( (agentId: string) => { - navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`); + navigate( + buildFullChatNavigationPath( + `${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`, + currentSearch, + ), + ); }, - [navigate], + [currentSearch, navigate], ); // ── 最近的对话取数 + 搜索 ── @@ -241,7 +251,7 @@ export function GeneralQaSidebar() { const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return; event.preventDefault(); - navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } }); + navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } }); }; const handleDelete = useCallback( @@ -257,10 +267,17 @@ export function GeneralQaSidebar() { nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } - void router.push(nextThreadPath); + void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch)); } }, - [agentIdFromPath, deleteThread, router, threadIdFromPath, threads], + [ + agentIdFromPath, + currentSearch, + deleteThread, + router, + threadIdFromPath, + threads, + ], ); const handleRenameClick = useCallback((threadId: string, currentTitle: string) => { @@ -314,7 +331,7 @@ export function GeneralQaSidebar() { 底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应 (不要混 white,否则深蓝主题下会变成浅色块)。 */} <Link - href={NEW_CHAT_PATH} + href={newChatHref} onClick={handleNewChatClick} style={{ borderColor: "var(--primary)", color: "var(--primary)" }} className={cn( @@ -415,7 +432,7 @@ export function GeneralQaSidebar() { ); })} <Link - href={MORE_AGENTS_HREF} + href={moreAgentsHref} className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground" > <span>{t.common.more}</span> @@ -454,7 +471,10 @@ export function GeneralQaSidebar() { </div> ) : ( filteredThreads.map((thread) => { - const href = listPathOfThread(thread); + const href = buildFullChatNavigationPath( + listPathOfThread(thread), + currentSearch, + ); const isActive = href === pathname; const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); @@ -576,7 +596,7 @@ export function GeneralQaSidebar() { )} {!query && ( <Link - href={MORE_CHATS_HREF} + href={moreChatsHref} className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground" > <span>更多最近对话</span> diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs index 26692be0..313d53b0 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs +++ b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildStandardChatRedirectPath, + buildFullChatNavigationPath, buildEmbeddedIframeRedirectPath, buildChatsIframeRedirectPath, buildChatsIframeThreadPath, @@ -280,6 +281,27 @@ test("builds a standard full-chat URL without leaking the legacy marker", () => ); }); +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", + ); +}); + test("preserves explicit Brainy iframe mode and theme across redirects", () => { assert.equal( buildChatsIframeRedirectPath("?chatMode=iframe&theme=dark-blue&ticket=secret"), diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.ts b/frontend-web/src/core/page-layout/chats-iframe-route.ts index 244c35cf..07cd0bb9 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.ts +++ b/frontend-web/src/core/page-layout/chats-iframe-route.ts @@ -107,6 +107,26 @@ export function buildStandardChatRedirectPath( return query ? `${pathname}?${query}` : pathname; } +/** + * Keep an explicitly selected standard-chat presentation while navigating + * between `/chats/new` and a concrete chat thread. Without this, a click in + * the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host + * then looks like the legacy bare integration and is incorrectly redirected + * to the private AgentSelector workspace. + * + * This deliberately whitelists presentation flags through + * `buildStandardChatRedirectPath` instead of copying arbitrary login/query + * values into user-facing chat URLs. + */ +export function buildFullChatNavigationPath( + destinationPath: string, + search: string, +): string { + return hasIframePresentationOverride(search) || isExplicitFullChatMode(search) + ? buildStandardChatRedirectPath(search, destinationPath) + : destinationPath; +} + /** * Preserve only the presentation parameters consumed by the iframe workspace. * Login credentials must never be copied into the post-login address bar.