feat: 新增8BF多智能体会商页面

This commit is contained in:
董宏凯 committed 2026-07-27 19:00:14 +08:00
1 parent 239b798c5d
commit 1ab3329f6d
10 files changed
+552 -13

No files matched your search

@@ -0,0 +1,33 @@
import { readRuntimeEnv } from "@/env";
/**
* Short-link route map maintained by the frontend.
*
* 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.
*/
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = {
a: "/page/canvas/ai-writing",
"8bf": "/page/strategy/qa/8bf-roundtable",
};
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
export const ROUTE_KEY_PARAM = "routeKey";
/**
* Optional frontend-configured username-login password.
*
* This is intentionally an environment/runtime value rather than a literal in
* source code. It is used only with a mapped ``routeKey`` link. When it is
* empty, LoginPage falls back to the URL's existing ``password`` parameter.
*/
export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const key = routeKey?.trim();
if (!key) return "";
return ROUTE_KEY_MAPPINGS[key] ?? "";
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Loader2, Save, Settings2, X } from "lucide-react";
import { getStoredAuth } from "@/core/auth";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import {
listBusinessMappings,
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
import "../styles/step1.css";
import "../styles/step2.css";
import "../styles/step3.css";
const BUSINESS_CODE = "8BF";
/**
* 8BF 独立多智能体会商页。
*
* 页面复用圆桌会商的三步能力,但业务链选择、草稿存储键和 CSS 根节点完全独立;
* 不引用岗位会商的页面、会话或接口。
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [mappings, availableChains] = await Promise.all([
listBusinessMappings(),
listChains(isAdmin ? "all" : "public"),
]);
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
const nextChains = availableChains.filter((chain) => chain.isPublic);
setMapping(nextMapping);
setChains(nextChains);
setSelectedChainId(nextMapping?.chainId ?? "");
} catch {
setError("业务链配置加载失败,请刷新后重试");
} finally {
setIsLoading(false);
}
}, [isAdmin]);
useEffect(() => {
void loadConfiguration();
}, [loadConfiguration]);
const handleSave = useCallback(async () => {
const selected = chains.find((chain) => chain.id === selectedChainId);
if (!selected) {
setError("请选择一条已发布的业务链");
return;
}
setIsSaving(true);
setError(null);
try {
const next = await updateBusinessMapping(BUSINESS_CODE, {
chainId: selected.id,
chainTitle: selected.title,
});
if (!next) throw new Error("8BF 映射不存在");
setMapping(next);
setIsSettingsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "保存业务链失败");
} finally {
setIsSaving(false);
}
}, [chains, selectedChainId]);
const toolbarSlot = (
<>
<div className="eightbf-roundtable__chain-status" aria-live="polite">
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
<span className="eightbf-roundtable__chain-status-value">
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
</span>
</div>
{isAdmin && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
onClick={() => setIsSettingsOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
配置业务链
</button>
)}
{isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
className="eightbf-roundtable__settings-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="eightbf-chain-settings-title"
>
<div className="eightbf-roundtable__settings-heading">
<div>
<p className="eightbf-roundtable__settings-kicker">管理员配置</p>
<h2 id="eightbf-chain-settings-title">设置 8BF 公共业务链</h2>
</div>
<button
type="button"
className="eightbf-roundtable__settings-close"
onClick={() => setIsSettingsOpen(false)}
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="eightbf-roundtable__settings-tip">
保存后,所有 8BF 用户都会使用这条已发布业务链;会商记录仍按用户独立保存。
</p>
<label className="eightbf-roundtable__settings-field">
<span>已发布业务链</span>
<select value={selectedChainId} onChange={(event) => setSelectedChainId(event.target.value)}>
<option value="">请选择业务链</option>
{chains.map((chain) => (
<option key={chain.id} value={chain.id}>
{chain.title}
</option>
))}
</select>
</label>
{error && <p className="eightbf-roundtable__settings-error">{error}</p>}
<div className="eightbf-roundtable__settings-actions">
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
<button type="button" onClick={() => void handleSave()} disabled={isSaving}>
{isSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
保存并应用
</button>
</div>
</section>
</div>
)}
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
</>
);
return (
<RoundtablePlanningPage
compactChrome={compactChrome}
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
toolbarSlot={toolbarSlot}
rootClassName="eightbf-roundtable"
/>
);
}
@@ -0,0 +1,24 @@
/* 8BF 第 1 步(任务理解)独立样式入口。所有规则限定在 .eightbf-roundtable 内,不影响原多智能体会商。 */
.eightbf-roundtable {
--eightbf-accent: #0f766e;
--eightbf-accent-soft: #ecfdf5;
--eightbf-border: #b7ded8;
}
.eightbf-roundtable .eightbf-roundtable__chain-status {
display: inline-flex;
min-width: 184px;
align-items: center;
gap: 8px;
border: 1px solid var(--eightbf-border);
border-radius: 10px;
background: var(--eightbf-accent-soft);
padding: 6px 10px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
@@ -0,0 +1,17 @@
/* 8BF 第 2 步(多智能体会商)独立样式入口。 */
.eightbf-roundtable .eightbf-roundtable__chain-settings-button {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 9px;
background: #0f766e;
color: #fff;
padding: 7px 10px;
font-size: 12px;
font-weight: 700;
transition: background-color .15s ease;
}
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
@@ -0,0 +1,34 @@
/* 8BF 第 3 步(结果绘制)独立样式入口。 */
.eightbf-roundtable .eightbf-roundtable__settings-mask {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
background: rgb(15 23 42 / 42%);
padding: 20px;
}
.eightbf-roundtable .eightbf-roundtable__settings-dialog {
width: min(460px, 100%);
border-radius: 16px;
background: #fff;
padding: 20px;
box-shadow: 0 24px 56px rgb(15 23 42 / 22%);
}
.eightbf-roundtable .eightbf-roundtable__settings-heading,
.eightbf-roundtable .eightbf-roundtable__settings-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #0f766e; font-size: 12px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__settings-heading h2 { margin-top: 2px; color: #0f172a; font-size: 16px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__settings-close { color: #64748b; }
.eightbf-roundtable .eightbf-roundtable__settings-tip { margin-top: 14px; color: #64748b; font-size: 13px; line-height: 1.6; }
.eightbf-roundtable .eightbf-roundtable__settings-field { display: grid; gap: 7px; margin-top: 16px; color: #334155; font-size: 13px; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__settings-field select { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 9px 10px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__settings-error { margin-top: 10px; color: #b91c1c; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__settings-actions { margin-top: 20px; justify-content: flex-end; }
.eightbf-roundtable .eightbf-roundtable__settings-actions button { border-radius: 8px; padding: 8px 12px; font-size: 13px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; }
.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; }
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
+22 -1
View File
@@ -22,6 +22,7 @@ import KnowledgeBaseResourceManagement from "@/strategy-components/components/re
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
@@ -67,6 +68,9 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
export default function StrategyRoutes() {
const storedAuth = getStoredAuth();
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
@@ -92,6 +96,14 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route
path="qa/8bf-roundtable"
element={
<ChatRuntime>
<EightBfRoundtablePage />
</ChatRuntime>
}
/>
<Route
path="qa/position-roundtable"
element={
@@ -312,7 +324,16 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
<Route
path="business-chain-mapping"
element={
canManageBusinessMappings ? (
<BusinessChainMappingPage />
) : (
<Navigate to="/page/strategy/qa/general" replace />
)
}
/>
<Route path="business-chains" element={<BusinessChainListPage />} />
<Route path="roundtable/chains" element={<BusinessChainEditorPage />} />
@@ -56,6 +56,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import EightBfRoundtablePage from '@/eightbf-roundtable/pages/EightBfRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
@@ -493,6 +494,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="roundtable/8bf"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<EightBfRoundtablePage compactChrome={headerHidden && !compactActionsHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable/position"
element={
@@ -22,7 +22,7 @@
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { getStoredAuth } from "@/core/auth";
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
@@ -288,8 +288,23 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
export default function RoundtablePlanningPage({
compactChrome = false,
/** 独立业务页锁定的公共业务链;不影响原多智能体会商入口。 */
lockedChainId = null,
/** 即使尚未配置业务链,也禁止用户改为推荐/手选链路。 */
lockBusinessChain = false,
/** 独立业务页的草稿/后台作业存储键。 */
storageTaskId,
/** 供独立业务页在原页头部插入自己的操作入口。 */
toolbarSlot,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
lockBusinessChain?: boolean;
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
rootClassName?: string;
}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -309,6 +324,8 @@ export default function RoundtablePlanningPage({
readWujiePrefill() ??
readPersistedDeeplinkPrefill(location.pathname),
);
// 8BF 等独立页面可提供专属存储键;原会商仍沿用深链 taskId / 个人草稿语义。
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
@@ -317,6 +334,31 @@ export default function RoundtablePlanningPage({
inWujieEmbed &&
!!(prefillRef.current?.chainId || prefillRef.current?.businessCode),
);
// 独立业务页使用管理员配置的固定业务链。它只负责预选,不触发深链的自动开跑逻辑。
useEffect(() => {
if (!lockedChainId) {
setAutoChainResult(null);
return;
}
let cancelled = false;
setAutoChainLoading(true);
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
.then(([chain, agents]) => {
if (cancelled || !chain) return;
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
if (!cancelled) setAutoChainResult(result);
})
.catch(() => {
if (!cancelled) setAutoChainResult(null);
})
.finally(() => {
if (!cancelled) setAutoChainLoading(false);
});
return () => {
cancelled = true;
};
}, [lockedChainId]);
const autoStartedRef = useRef(false);
const prefillClearedRef = useRef(false);
// 共识达成后只向父页面(无界)回传一次交付结果。
@@ -756,7 +798,7 @@ export default function RoundtablePlanningPage({
setCurrentStep,
triggerToast,
// 聊天记录存储键:xdfx-A 按行动id 隔离,其它业务按真实 taskId(见 recordTaskIdOf)。
taskId: recordTaskIdOf(prefillRef.current),
taskId: draftStorageTaskId,
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
@@ -1042,6 +1084,10 @@ export default function RoundtablePlanningPage({
// 再次点击同一条链 = 取消勾选。
const handleSelectChainFromSidebar = useCallback(
async (chain: BusinessChain) => {
if (lockBusinessChain) {
triggerToast("当前页面的业务链由管理员统一配置");
return;
}
if (autoChainResult?.chain?.id === chain.id) {
setAutoChainResult(null);
triggerToast(`已取消业务链「${chain.title}」`);
@@ -1061,7 +1107,7 @@ export default function RoundtablePlanningPage({
triggerToast("选择业务链失败,请重试");
}
},
[autoChainResult, triggerToast],
[autoChainResult, lockBusinessChain, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
@@ -1524,7 +1570,7 @@ export default function RoundtablePlanningPage({
step2.stopOrchestration();
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
await cancelJob(jobId, recordTaskIdOf(prefillRef.current)).catch(() => null);
await cancelJob(jobId, draftStorageTaskId).catch(() => null);
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
await drafts.handleLoadDraft(draftId);
@@ -1629,7 +1675,7 @@ export default function RoundtablePlanningPage({
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
// 后台作业按聊天记录存储键归属(xdfx-A=行动id):报告写回对应 task-draft 存储、去重一致。
const tid = recordTaskIdOf(prefillRef.current);
const tid = draftStorageTaskId;
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId, tid).catch(() => null) : null;
const alreadyRunning =
@@ -1701,7 +1747,7 @@ export default function RoundtablePlanningPage({
const planSnap = mode === "dag" ? orchestrationPlan : null;
const draftId = drafts.currentDraftId;
// 后台作业按聊天记录存储键归属(xdfx-A=行动id),与草稿存储一致。
const tid = recordTaskIdOf(prefillRef.current);
const tid = draftStorageTaskId;
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
if (draftId) markPendingSuspend(draftId);
@@ -1796,7 +1842,7 @@ export default function RoundtablePlanningPage({
if (agents.length === 0) return;
startJobKeepalive({
draftId: drafts.currentDraftId ?? undefined,
taskId: recordTaskIdOf(prefillRef.current) ?? undefined,
taskId: draftStorageTaskId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
@@ -1866,6 +1912,7 @@ export default function RoundtablePlanningPage({
className={cn(
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
rootClassName,
)}
>
{/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿)
@@ -1936,7 +1983,7 @@ export default function RoundtablePlanningPage({
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
taskId={prefillRef.current?.taskId ?? null}
taskId={draftStorageTaskId ?? null}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
@@ -1973,6 +2020,7 @@ export default function RoundtablePlanningPage({
/>
<div className="flex items-center gap-2">
{toolbarSlot}
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
{prefillRef.current?.taskId && (
<button
@@ -1987,7 +2035,7 @@ export default function RoundtablePlanningPage({
)}
{/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
{!inWujieEmbed && !prefillRef.current?.taskId && (
{!lockBusinessChain && !inWujieEmbed && !prefillRef.current?.taskId && (
<button
id="business-chain-btn"
onClick={() => setIsChainListOpen(true)}
@@ -2118,7 +2166,10 @@ export default function RoundtablePlanningPage({
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
/>
{/* MIDDLE VIEWPORT */}
@@ -2163,6 +2214,14 @@ export default function RoundtablePlanningPage({
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading
? "正在加载管理员配置的业务链,请稍候…"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
@@ -2662,8 +2721,14 @@ interface LeftSidebarProps {
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
/** 独立页面的历史草稿存储键。 */
historyTaskId?: string | null;
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
entryRailBusinessCode?: string | null;
/** 独立页面禁止在左栏改选业务链。 */
lockBusinessChain?: boolean;
/** 独立页面锁定显示的业务链。 */
lockedChainId?: string | null;
}
function Step2SandboxSidebarSync({
@@ -2710,7 +2775,10 @@ function LeftSidebar({
onOpenDraft,
activeDraftId,
entryRailTaskId,
historyTaskId,
entryRailBusinessCode,
lockBusinessChain,
lockedChainId,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2849,7 +2917,10 @@ function LeftSidebar({
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
/>
)}
</section>
@@ -2900,7 +2971,10 @@ function RoundtableEntryRail({
onOpenDraft,
activeDraftId,
taskId,
historyTaskId,
businessCode,
lockedChainId,
lockBusinessChain = false,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -2908,13 +2982,18 @@ function RoundtableEntryRail({
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
/** 独立页面的草稿存储键;不改变当前任务卡片/业务链区域的展示语义。 */
historyTaskId?: string | null;
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
businessCode?: string | null;
/** 固定业务链时,左侧只展示这条链的流程图及放大入口。 */
lockedChainId?: string | null;
lockBusinessChain?: boolean;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
const { drafts: records, refresh } = useRoundtableDrafts(historyTaskId ?? taskId);
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
@@ -2963,7 +3042,9 @@ function RoundtableEntryRail({
let cancelled = false;
void listChains("public")
.then((all) => {
if (!cancelled) setChains(all);
if (!cancelled) {
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
}
})
.catch(() => {
/* 业务链非关键,静默忽略 */
@@ -2972,7 +3053,7 @@ function RoundtableEntryRail({
return () => {
cancelled = true;
};
}, [refresh, deeplink]);
}, [refresh, deeplink, lockedChainId, lockBusinessChain]);
const formatTime = (value: string) => {
const date = new Date(value);
@@ -21,6 +21,8 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
@@ -62,6 +64,54 @@ def _get_store(request: Request):
return store
def _is_admin(request: Request) -> bool:
"""Treat no-auth deployments as the single administrative workspace."""
user = getattr(request.state, "user", None)
return user is None or getattr(user, "system_role", None) == "admin"
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
async def _validate_public_chain_mapping(
request: Request,
fields: dict[str, object],
) -> None:
"""Require globally mapped chains to remain readable by every account."""
if "chain_id" not in fields:
return
raw_chain_id = fields["chain_id"]
chain_id = raw_chain_id.strip() if isinstance(raw_chain_id, str) else ""
if not chain_id:
fields["chain_id"] = None
fields["chain_title"] = None
return
chain_store = getattr(request.app.state, "roundtable_chain_store", None)
if chain_store is None:
raise HTTPException(status_code=503, detail="Business chain store not available")
chain = await chain_store.get_chain(
chain_id,
_current_user_id(request),
is_admin=True,
)
if chain is None:
raise HTTPException(status_code=400, detail="Business chain not found")
if not chain.get("is_public"):
raise HTTPException(
status_code=400,
detail="Global business mappings require a public business chain",
)
fields["chain_id"] = chain_id
fields["chain_title"] = str(chain.get("title") or "") or None
# ── routes ───────────────────────────────────────────────────────────────
@@ -78,9 +128,12 @@ async def list_mappings(request: Request) -> BusinessMappingListResponse:
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")
store = _get_store(request)
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
fields = body.model_dump(exclude_unset=True)
await _validate_public_chain_mapping(request, fields)
row = await store.update_mapping(business_code, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Business mapping not found")
@@ -0,0 +1,100 @@
"""Authorization and shared-chain invariants for global business mappings."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.gateway.routers import business_mapping as business_mapping_router
class _MappingStore:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, object]]] = []
async def update_mapping(self, business_code: str, **fields: object) -> dict[str, object]:
self.calls.append((business_code, fields))
return {
"business_code": business_code,
"label": "岗位多智能体会商",
"agent_id": None,
"agent_name": None,
"chain_id": fields.get("chain_id"),
"chain_title": fields.get("chain_title"),
"sort_order": 3,
}
class _ChainStore:
def __init__(self, chain: dict[str, object] | None) -> None:
self.chain = chain
async def get_chain(self, chain_id: str, user_id: str, *, is_admin: bool) -> dict[str, object] | None:
assert chain_id == "chain-8bf"
assert user_id == "admin-id"
assert is_admin is True
return self.chain
def _request(role: str, chain: dict[str, object] | None = None):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
business_mapping_store=_MappingStore(),
roundtable_chain_store=_ChainStore(chain),
)
),
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", system_role=role)),
)
@pytest.mark.asyncio
async def test_only_administrators_can_update_global_business_mappings() -> None:
request = _request("user")
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
"8BF",
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_global_mapping_requires_a_public_chain_and_canonicalizes_its_title() -> None:
request = _request("admin", {"id": "chain-8bf", "title": "岗位协同链", "is_public": True})
response = await business_mapping_router.update_mapping(
request,
"8BF",
business_mapping_router.BusinessMappingUpdateRequest(
chain_id="chain-8bf",
chain_title="客户端伪造的标题",
),
)
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": "岗位协同链"})
]
@pytest.mark.asyncio
async def test_global_mapping_rejects_a_private_chain() -> None:
request = _request("admin", {"id": "chain-8bf", "title": "私有链", "is_public": False})
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
"8BF",
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
)
assert error.value.status_code == 400
assert request.app.state.business_mapping_store.calls == []