提交代码

This commit is contained in:
fengxiangdebaozi committed 2026-06-09 17:18:40 +08:00
1 parent dddd3c1670
commit cba165933c
16 files changed
+1045 -61

No files matched your search

@@ -207,6 +207,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "业务链条管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
@@ -0,0 +1,273 @@
/**
* 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { listAgents } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
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";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const [mappingRes, agentRes, chainRes] = await Promise.allSettled([
listBusinessMappings(),
listAgents(),
listChains("public"),
]);
if (cancelled) return;
if (mappingRes.status === "fulfilled") setRows(mappingRes.value);
else toast.error(`加载业务映射失败:${mappingRes.reason}`);
if (agentRes.status === "fulfilled") setAgents(agentRes.value);
if (chainRes.status === "fulfilled") setChains(chainRes.value);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const agentOptions = useMemo(
() => agents.map((a) => ({ label: a.name || a.id, value: a.id })),
[agents],
);
const chainOptions = useMemo(
() => chains.map((c) => ({ label: c.title || c.id, value: c.id })),
[chains],
);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
};
const cancelEdit = () => {
setEditingCode(null);
setDraft({ agentId: null, chainId: null });
};
const handleSave = async (row: BusinessMapping) => {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
agentName,
chainId: draft.chainId,
chainTitle,
});
if (!updated) {
toast.error(`业务「${row.label}」不存在,保存失败`);
return;
}
setRows((prev) => prev.map((r) => (r.businessCode === row.businessCode ? updated : r)));
toast.success(`已保存「${row.label}」的配置`);
cancelEdit();
} catch (err) {
toast.error(`保存失败:${(err as Error).message}`);
} finally {
setSaving(false);
}
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
业务链条管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Loading>
</div>
</div>
);
}
@@ -20,6 +20,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
@@ -172,6 +173,7 @@ 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="scheduled"
@@ -5,9 +5,12 @@
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
*
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
* 这里放纯类型 / 常量,以及不依赖 React 的纯函数(如 `buildChainSeatSelection`);
* 不含状态或请求逻辑(CRUD 在 `api/chains.ts`)。
*/
import { buildPlanFromStages } from "./dag-types";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
agent_id: string;
@@ -86,6 +89,55 @@ export interface SeatSelectionResult {
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
* 与 `BusinessChainPicker.handleUse` 同源逻辑,抽成纯函数供「自动选链」复用
* (会商自动化串联:从任务页带 chainId 进圆桌时跳过选链弹窗,直接据此进 Step 2):
* - 仅保留候选池里仍存在的存活席位;存活不足 `CHAIN_MIN_SEATS` 返回 null(调用方回退弹窗);
* - 链条带分层、过滤存活后仍含并行 stage / 任一阶段目标 / 总控提示 → 走 `dag`(默认 fast);
* - 否则走线性 `chain`,席位顺序 = 链条顺序。
*
* `candidates` 为 `{ agent_id, name }` 列表(一般来自 `filterRecommendCandidates(listAgents())`)。
*/
export function buildChainSeatSelection(
chain: BusinessChain,
candidates: ReadonlyArray<{ agent_id: string; name: string }>,
): SeatSelectionResult | null {
const existingIds = new Set(candidates.map((c) => c.agent_id));
const nameById = new Map(candidates.map((c) => [c.agent_id, c.name]));
const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name]));
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id));
if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
const chainMeta = { id: chain.id, title: chain.title };
if (chain.stages && chain.stages.length > 0) {
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(
chain.stages,
surviveIds,
true,
chain.stageGoals,
chain.coordinatorPrompt,
);
const hasParallel = plan.stages.some((st) => st.agentIds.length > 1);
const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== "");
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta };
}
/**
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
@@ -23,7 +23,7 @@
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
Check,
@@ -84,14 +84,44 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import {
buildChainSeatSelection,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
taskTitle?: string;
/** 任务描述(注入 Step 1 首条用户消息)。 */
taskGoal?: string;
/** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */
businessCode?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean;
}
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
// ─── 会商自动化串联(模块 C):任务页带进来的预填 ─────────────────
// 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null,
);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
const autoStartedRef = useRef(false);
const prefillClearedRef = useRef(false);
const { models: availableModels, isLoading: modelsLoading } = useModels();
@@ -604,6 +634,69 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => {
if (prefillClearedRef.current || !prefillRef.current) return;
prefillClearedRef.current = true;
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
/* noop */
}
}, []);
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.autoStart || autoStartedRef.current) return;
if (currentStep !== 1) return;
if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.trim();
const goal = prefill.taskGoal?.trim();
const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""]
.filter(Boolean)
.join("\n");
if (!text) return;
autoStartedRef.current = true;
void step1.sendIntentMessage(text);
}, [currentStep, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
// C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。
// 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足)
// 静默回退到弹窗手选。
// 不加「已启动」永久闸门 —— StrictMode 的 mount→unmount→remount 会让首次 run 在 cleanup
// 里被 cancelled,闸门又让 remount 跳过重启,导致 autoChainResult 永远设不上(与
// useStep1Intent 同款坑)。改为:每次 setup 都重跑,靠 cancelled 丢弃过期的首次结果,
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.businessCode) return;
let cancelled = false;
void (async () => {
try {
const mappings = await listBusinessMappings();
const row = mappings.find((m) => m.businessCode === prefill.businessCode);
const chainId = row?.chainId;
if (!chainId) return;
const [chain, agents] = await Promise.all([
getChain(chainId),
listAgents().catch(() => []),
]);
if (cancelled || !chain) return;
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled && result) setAutoChainResult(result);
} catch {
/* 解析失败 → 保持 null,回退弹窗手选 */
}
})();
return () => {
cancelled = true;
};
}, []);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
@@ -903,6 +996,13 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
@@ -0,0 +1,87 @@
/**
* 全局业务映射(业务码 → 智能体 / 业务链)API 客户端。
*
* 后端:`/api/business-mapping`(见 app/gateway/routers/business_mapping.py)。
* 全局共享、无用户隔离;只读列表 + 更新 agent / chain 两列(业务行由后端 seed 固定)。
* snake_case ⇄ camelCase 在此边界收敛,调用方保持干净。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface BusinessMapping {
businessCode: string;
label: string;
agentId: string | null;
agentName: string | null;
chainId: string | null;
chainTitle: string | null;
sortOrder: number;
}
/** 部分更新 —— 只发改动的字段;显式传 null 清空该字段。 */
export interface BusinessMappingPatch {
agentId?: string | null;
agentName?: string | null;
chainId?: string | null;
chainTitle?: string | null;
}
const BASE = () => `${getBackendBaseURL()}/api/business-mapping`;
interface ServerMapping {
business_code: string;
label: string;
agent_id: string | null;
agent_name: string | null;
chain_id: string | null;
chain_title: string | null;
sort_order: number;
}
function toMapping(s: ServerMapping): BusinessMapping {
return {
businessCode: s.business_code,
label: s.label ?? "",
agentId: s.agent_id ?? null,
agentName: s.agent_name ?? null,
chainId: s.chain_id ?? null,
chainTitle: s.chain_title ?? null,
sortOrder: s.sort_order ?? 0,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** 列出全部业务映射(按展示顺序)。 */
export async function listBusinessMappings(): Promise<BusinessMapping[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ mappings: ServerMapping[] }>(res, "list business mappings");
return (data.mappings ?? []).map(toMapping);
}
/** 更新某业务行的智能体 / 业务链选择。404 返回 null。 */
export async function updateBusinessMapping(
businessCode: string,
patch: BusinessMappingPatch,
): Promise<BusinessMapping | null> {
const body: Record<string, unknown> = {};
if (patch.agentId !== undefined) body.agent_id = patch.agentId;
if (patch.agentName !== undefined) body.agent_name = patch.agentName;
if (patch.chainId !== undefined) body.chain_id = patch.chainId;
if (patch.chainTitle !== undefined) body.chain_title = patch.chainTitle;
const res = await apiFetch(`${BASE()}/${encodeURIComponent(businessCode)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.status === 404) return null;
const data = await readJson<ServerMapping>(res, "update business mapping");
return toMapping(data);
}
@@ -4,10 +4,10 @@ import dayjs from 'dayjs';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { sentimentAnalyzeMock } from './kanbanMock';
interface TaskListPageProps {
title: string;
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
@@ -80,6 +79,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
await taskDataChange();
};
const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => {
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(list);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(list);
} else {
setCompletedTotal(total);
setCompletedList(list);
}
};
const taskListSearch = async (
taskType: ColumnType,
pageNum = 1,
@@ -107,18 +119,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
column: taskType,
taskLevel: item.taskLevel || 1,
}));
const total = Number(result.total ?? normalized.length);
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(normalized);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(normalized);
} else {
setCompletedTotal(total);
setCompletedList(normalized);
if (normalized.length === 0) {
// 接口无数据时回落到本地预览数据,方便联调看效果
const mock = sentimentAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
return;
}
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
} catch (err) {
console.warn('三情任务查询失败,使用本地预览数据', err);
const mock = sentimentAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
} finally {
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
if (taskType === 'running') setRunningLoading(false);
@@ -153,23 +166,19 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
const handleOpenConsultation = () => {
const task = stateRef.current.task;
if (!task) return;
try {
const sceneItem = JSON.parse(task.scene || '{}');
if (sceneItem.taskJson) {
const taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: task.overview,
});
navigate('/qa-center/mope-chat');
}
} finally {
setShowAnalysisModal(false);
}
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。
navigate('/page/strategy/qa/multi-agent-roundtable', {
state: {
roundtablePrefill: {
taskTitle: task.taskName,
taskGoal: task.overview,
businessCode: '3Q',
autoStart: true,
},
},
});
setShowAnalysisModal(false);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -4,10 +4,10 @@ import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from '
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { taskAnalyzeMock } from './kanbanMock';
interface TaskListPageProps {
title: string;
@@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
@@ -80,6 +79,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
await taskDataChange();
};
const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => {
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(list);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(list);
} else {
setCompletedTotal(total);
setCompletedList(list);
}
};
const taskListSearch = async (
taskType: ColumnType,
pageNum = 1,
@@ -107,18 +119,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
column: taskType,
taskLevel: item.taskLevel || 1,
}));
const total = Number(result.total ?? normalized.length);
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
setUnanalyzedList(normalized);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(normalized);
} else {
setCompletedTotal(total);
setCompletedList(normalized);
if (normalized.length === 0) {
// 接口无数据时回落到本地预览数据,方便联调看效果
const mock = taskAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
return;
}
applyColumnData(taskType, normalized, Number(result.total ?? normalized.length));
} catch (err) {
console.warn('任务查询失败,使用本地预览数据', err);
const mock = taskAnalyzeMock[taskType];
applyColumnData(taskType, mock, mock.length);
} finally {
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
if (taskType === 'running') setRunningLoading(false);
@@ -153,23 +166,19 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
const handleOpenConsultation = () => {
const task = stateRef.current.task;
if (!task) return;
try {
const sceneItem = JSON.parse(task.scene || '{}');
if (sceneItem.taskJson) {
const taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: task.overview,
});
navigate('/qa-center/mope-chat');
}
} finally {
setShowAnalysisModal(false);
}
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(6BF=任务FX)。
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 6BF 配置的业务链(跳过选链弹窗)。
navigate('/page/strategy/qa/multi-agent-roundtable', {
state: {
roundtablePrefill: {
taskTitle: task.taskName,
taskGoal: task.overview,
businessCode: '6BF',
autoStart: true,
},
},
});
setShowAnalysisModal(false);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -0,0 +1,148 @@
/**
* 看板(任务分析 / 三情分析)的本地预览数据。
*
* 仅在接口不可用 / 返回为空时回落使用(见各页 taskListSearch 的空数据 / catch 分支),
* 后端正常返回数据时不会用到。纯前端联调 / 演示效果用。
*/
import { TaskListItem } from "./TaskManagement";
export type KanbanColumnType = "unanalyzed" | "running" | "completed";
interface Seed {
taskName: string;
overview: string;
taskDirection: string;
taskType?: number; // 1单任务 2场景 3会商 4多智能体 5检索
taskLevel?: number; // 1一般 2紧急 3加急
createTime?: string;
updateTime?: string;
}
function buildTask(seed: Seed, column: KanbanColumnType, id: number): TaskListItem {
return {
id,
column,
taskName: seed.taskName,
overview: seed.overview,
taskDirection: seed.taskDirection,
taskType: seed.taskType ?? 1,
taskLevel: seed.taskLevel ?? 1,
createTime: seed.createTime ?? "2026-06-08 09:30:00",
updateTime: seed.updateTime ?? "2026-06-09 10:15:00",
scene: "",
} as unknown as TaskListItem;
}
function makeMock(
baseId: number,
seeds: Record<KanbanColumnType, Seed[]>,
): Record<KanbanColumnType, TaskListItem[]> {
let id = baseId;
const build = (col: KanbanColumnType) => seeds[col].map((s) => buildTask(s, col, (id += 1)));
return {
unanalyzed: build("unanalyzed"),
running: build("running"),
completed: build("completed"),
};
}
/** 任务分析(plan-task-fx)预览数据。 */
export const taskAnalyzeMock = makeMock(7100, {
unanalyzed: [
{
taskName: "区域目标人群拆解专项",
overview: "对重点区域目标人群进行分层拆解,明确触达优先级与切入口。",
taskDirection: "策略策划处",
taskType: 4,
taskLevel: 2,
createTime: "2026-06-09 08:20:00",
updateTime: "2026-06-09 08:20:00",
},
{
taskName: "竞品动向监测立项",
overview: "梳理同类项目近期动向,输出可对照的策略阻力清单。",
taskDirection: "情报研判处",
taskType: 5,
taskLevel: 1,
createTime: "2026-06-08 16:45:00",
updateTime: "2026-06-08 17:02:00",
},
],
running: [
{
taskName: "阶段目标可行性会商",
overview: "多智能体围绕阶段目标的可达性与风险点开展讨论并形成初步结论。",
taskDirection: "指挥协调中心",
taskType: 3,
taskLevel: 3,
createTime: "2026-06-07 10:10:00",
updateTime: "2026-06-09 09:40:00",
},
],
completed: [
{
taskName: "重点议题策略路径分析",
overview: "完成重点议题的策略路径推演,已形成可执行的行动建议。",
taskDirection: "策略策划处",
taskType: 2,
taskLevel: 1,
createTime: "2026-06-04 14:00:00",
updateTime: "2026-06-06 11:25:00",
},
{
taskName: "受众态度归因复盘",
overview: "对上一阶段受众态度变化进行归因复盘,沉淀经验供后续复用。",
taskDirection: "综合保障组",
taskType: 1,
taskLevel: 2,
createTime: "2026-06-03 09:15:00",
updateTime: "2026-06-05 18:30:00",
},
],
});
/** 三情分析(plan-task-sentiment)预览数据。 */
export const sentimentAnalyzeMock = makeMock(7200, {
unanalyzed: [
{
taskName: "重点平台情绪监测专项",
overview: "采集重点平台舆论样本,识别情绪倾向与潜在风险信号。",
taskDirection: "情报研判处",
taskType: 5,
taskLevel: 2,
createTime: "2026-06-09 07:50:00",
updateTime: "2026-06-09 07:50:00",
},
{
taskName: "舆情走势预研立项",
overview: "围绕热点事件开展舆情走势预研,输出三情研判初判结论。",
taskDirection: "策略策划处",
taskType: 1,
taskLevel: 1,
createTime: "2026-06-08 13:20:00",
updateTime: "2026-06-08 13:48:00",
},
],
running: [
{
taskName: "群体情绪共识会商",
overview: "多智能体从社情、舆情、敌情多视角讨论群体情绪态势并形成共识。",
taskDirection: "指挥协调中心",
taskType: 3,
taskLevel: 3,
createTime: "2026-06-07 11:30:00",
updateTime: "2026-06-09 10:05:00",
},
],
completed: [
{
taskName: "阶段性情绪研判归档",
overview: "完成阶段性三情研判,结论已通过审核并归档同步相关单位。",
taskDirection: "综合保障组",
taskType: 2,
taskLevel: 1,
createTime: "2026-06-02 15:10:00",
updateTime: "2026-06-05 16:40:00",
},
],
});
@@ -30,6 +30,7 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
business_mapping,
channels,
checkpoint_migration,
feedback,
@@ -766,6 +767,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
if business_mapping_store is not None:
inserted = await business_mapping_store.ensure_seed_defaults()
if inserted:
logger.info("Seeded %d business mapping rows", inserted)
except Exception:
logger.exception("Business mapping seeding failed at startup (non-fatal)")
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -1124,6 +1135,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Global business → agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
@@ -179,6 +179,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.business_mapping import make_business_mapping_store
app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -0,0 +1,87 @@
"""CRUD API for the global business → agent / chain mapping (业务链条管理).
Binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an optional 智能体 and an
optional 业务链条 (a **public** ``roundtable_chains`` row). Used to auto-pick the
business chain when a task page launches the multi-agent roundtable.
Global / shared — there is no user scoping; all accounts see and edit the same
rows. The four business rows are seeded on startup; this API only lists them and
updates the agent / chain columns (no create/delete).
Routes (prefix ``/api/business-mapping``):
GET "" list all business mappings (display order)
PUT "/{business_code}" update one row's agent / chain selection
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
# ── schemas ────────────────────────────────────────────────────────────────
class BusinessMappingResponse(BaseModel):
business_code: str
label: str = ""
agent_id: str | None = None
agent_name: str | None = None
chain_id: str | None = None
chain_title: str | None = None
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class BusinessMappingListResponse(BaseModel):
mappings: list[BusinessMappingResponse]
class BusinessMappingUpdateRequest(BaseModel):
# All optional → callers send only what changed; explicit null clears a field.
agent_id: str | None = Field(default=None, max_length=64)
agent_name: str | None = Field(default=None, max_length=191)
chain_id: str | None = Field(default=None, max_length=64)
chain_title: str | None = Field(default=None, max_length=512)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request):
store = getattr(request.app.state, "business_mapping_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Business mapping store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=BusinessMappingListResponse)
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]
)
@router.put("/{business_code}", response_model=BusinessMappingResponse)
async def update_mapping(
request: Request, business_code: str, body: BusinessMappingUpdateRequest
) -> BusinessMappingResponse:
store = _get_store(request)
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
fields = body.model_dump(exclude_unset=True)
row = await store.update_mapping(business_code, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Business mapping not found")
return BusinessMappingResponse(**row)
@@ -0,0 +1,18 @@
"""Global business → agent / chain mapping persistence (业务链条管理)."""
from __future__ import annotations
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.business_mapping.sql import BusinessMappingRepository
__all__ = [
"BusinessMappingRepository",
"BusinessMappingRow",
"make_business_mapping_store",
]
def make_business_mapping_store(session_factory):
if session_factory is None:
return None
return BusinessMappingRepository(session_factory)
@@ -0,0 +1,53 @@
"""ORM model for the global business → agent / chain mapping (业务链条管理).
A "business mapping" row binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an
optional 智能体 (``agent_id``) and an optional 业务链条 (``chain_id`` → a
``roundtable_chains`` row, which must be **public** so every account can resolve
it). Used to auto-pick the business chain when a task page launches the
multi-agent roundtable.
Unlike ``roundtable_chains`` (per-user), this mapping is **global / shared** —
there is no ``user_id`` column; all accounts see and use the same configuration.
The fixed business rows are seeded on startup; users only fill in the
agent / chain columns from the management page.
``agent_name`` / ``chain_title`` are denormalized display snapshots so the table
renders without cross-referencing and survives a later agent/chain rename or
delete (the picked id still drives the auto-flow).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class BusinessMappingRow(Base):
__tablename__ = "business_mapping"
# Business code is the natural primary key (e.g. "3Q", "6BF"). Global, no user scope.
business_code: Mapped[str] = mapped_column(String(32), primary_key=True)
# Display label shown in the 业务 column (e.g. "任务FX").
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
# Picked 智能体 (暂存不用,自动流程当前不消费此字段). NULL = 未配置.
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_name: Mapped[str | None] = mapped_column(String(191), nullable=True)
# Picked 业务链条 → roundtable_chains.id (须为 public). NULL = 未配置.
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
chain_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Fixed display order for the management table.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,120 @@
"""SQLAlchemy-backed global business → agent / chain mapping store.
Global / shared (no ``user_id`` scoping): every account reads and writes the same
rows. The four business rows are seeded once on startup (``ensure_seed_defaults``)
with empty agent/chain; users fill those in from the management page via
``update_mapping``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.business_mapping.model import BusinessMappingRow
_UNSET = object()
# Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty.
# 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。
DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [
{"business_code": "3Q", "label": "3QFX"},
{"business_code": "6BF", "label": "任务FX"},
{"business_code": "7BF", "label": "XDFX"},
{"business_code": "8BF", "label": "8BF"},
]
class BusinessMappingRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: BusinessMappingRow) -> dict[str, Any]:
return {
"business_code": row.business_code,
"label": row.label,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"chain_id": row.chain_id,
"chain_title": row.chain_title,
"sort_order": row.sort_order,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_mappings(self) -> list[dict[str, Any]]:
stmt = select(BusinessMappingRow).order_by(
BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
async def get_mapping(self, business_code: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
return self._to_dict(row) if row is not None else None
async def update_mapping(
self,
business_code: str,
*,
agent_id=_UNSET,
agent_name=_UNSET,
chain_id=_UNSET,
chain_title=_UNSET,
) -> dict[str, Any] | None:
"""Update an existing (seeded) business row. Returns None if the code is unknown.
Only forwarded fields are touched; explicit ``None`` clears that field.
"""
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
if row is None:
return None
if agent_id is not _UNSET:
row.agent_id = agent_id or None
if agent_name is not _UNSET:
row.agent_name = agent_name or None
if chain_id is not _UNSET:
row.chain_id = chain_id or None
if chain_title is not _UNSET:
row.chain_title = chain_title or None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def ensure_seed_defaults(self) -> int:
"""Idempotently insert the fixed business rows if missing. Returns inserted count.
Never overwrites an existing row (respects user-configured agent/chain).
"""
inserted = 0
now = datetime.now(UTC)
async with self._sf() as session:
for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS):
existing = await session.get(BusinessMappingRow, seed["business_code"])
if existing is not None:
continue
session.add(
BusinessMappingRow(
business_code=seed["business_code"],
label=seed.get("label", ""),
agent_id=None,
agent_name=None,
chain_id=None,
chain_title=None,
sort_order=idx,
created_at=now,
updated_at=now,
)
)
inserted += 1
if inserted:
await session.commit()
return inserted
@@ -22,6 +22,7 @@ from deerflow.persistence.agents.model import (
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -61,6 +62,7 @@ __all__ = [
"AgentRow",
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
"LlmCallMetricRow",
"NotificationRow",
"ParallelAgentTaskRow",