diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 6aff6410..205eb9a1 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -119,7 +119,8 @@ border: 1px solid; border-color: rgba(43, 143, 254, 0.6); border-radius: 4px; - backdrop-filter: blur(2px); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); position: absolute; left: 108px; top: 0; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index b06ccd8b..b959d0b2 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -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" }, { diff --git a/frontend-web/src/pages/BusinessChainMappingPage.tsx b/frontend-web/src/pages/BusinessChainMappingPage.tsx new file mode 100644 index 00000000..a3d05703 --- /dev/null +++ b/frontend-web/src/pages/BusinessChainMappingPage.tsx @@ -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([]); + const [agents, setAgents] = useState([]); + const [chains, setChains] = useState([]); + const [loading, setLoading] = useState(true); + const [editingCode, setEditingCode] = useState(null); + const [draft, setDraft] = useState({ 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 ( +
+
+
+

+ 业务链条管理 +

+

+ 为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。 +

+
+ + +
+ + + + + + + + + + + {rows.length === 0 && !loading ? ( + + + + ) : ( + rows.map((row) => { + const editing = editingCode === row.businessCode; + return ( + + {/* 业务(只读) */} + + + {/* 智能体 */} + + + {/* 业务链 */} + + + {/* 操作 */} + + + ); + }) + )} + +
业务智能体业务链操作
+ 暂无业务数据 +
+
+ + {row.label} + + + {row.businessCode} + +
+
+ {editing ? ( + + {editing ? ( + + {editing ? ( +
+ + +
+ ) : ( + + )} +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 07979f95..5f0129ed 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -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() { } /> } /> } /> + } /> = ({ [artifactsBySeat], ); + // 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。 + // 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。 + const seatsWithFiles = useMemo( + () => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0), + [artifactsBySeat], + ); + const toggleSeat = useCallback((key: string) => { setExpandedSeats((prev) => { const next = new Set(prev); @@ -424,21 +431,20 @@ export const HighFidelityReport: React.FC = ({ )} - {/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */} -
-

- - 生成的报告文件 - - {totalFileCount} - -

+ {/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。 + 仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */} + {seatsWithFiles.length > 0 && ( +
+

+ + 生成的报告文件 + + {totalFileCount} + +

- {artifactsBySeat.length === 0 ? ( -

尚未初始化席位线程,无法列出文件。

- ) : (
- {artifactsBySeat.map((seat) => ( + {seatsWithFiles.map((seat) => (
{seat.displayName} @@ -501,8 +507,8 @@ export const HighFidelityReport: React.FC = ({
))}
- )} -
+
+ )} {/* Footer */}
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index eac12627..d550b4a6 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -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 类(背景 + 文字 + 边框,含深色变体)。 diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1ee2c3f7..e4d75d7f 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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( + ((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null, + ); + // 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。 + const [autoChainResult, setAutoChainResult] = useState(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)); diff --git a/frontend-web/src/strategy-components/api/business-mapping.ts b/frontend-web/src/strategy-components/api/business-mapping.ts new file mode 100644 index 00000000..858e0a1c --- /dev/null +++ b/frontend-web/src/strategy-components/api/business-mapping.ts @@ -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(res: Response, ctx: string): Promise { + 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 { + 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 { + const body: Record = {}; + 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(res, "update business mapping"); + return toMapping(data); +} diff --git a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx index d35e6690..25d61dad 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx @@ -4,9 +4,10 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta import React, { useEffect, useRef, useState } from 'react'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react'; +import { Col, Loading, Pagination, Progress, Row } from 'tdesign-react'; import { requestCrudService } from '@/service/request-axios-service'; import { ActionColumn } from './ActionColumn'; +import { resolveStatusIcon } from './icons'; interface ActionAnalyzesProps { TaskType?: string; @@ -78,7 +79,6 @@ export const ActionAnalyzes: React.FC = ({ title, category, setTaskLoading(true); const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param); setTaskLoading(false); - console.log('任务搜索', res); const result = res.data; result.forEach((item: TaskListItem) => { item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比 @@ -86,7 +86,6 @@ export const ActionAnalyzes: React.FC = ({ title, category, }) setTaskList(result || []); // 设置任务列表页 const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数 - console.log('totalRes', totalRes) setTotal(totalRes.data || 0); // 设置总数 if (result.length > 0) { setTaskId(result[0].id); @@ -101,7 +100,6 @@ export const ActionAnalyzes: React.FC = ({ title, category, } setActionLoading(true); const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param); - console.log('行动查询', res); setActionLoading(false); const result = res.data; setActionColumnList([ @@ -151,7 +149,7 @@ export const ActionAnalyzes: React.FC = ({ title, category,
{item.taskName}
{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}
- + {item?.taskStatusData?.label}
@@ -195,7 +193,11 @@ export const ActionAnalyzes: React.FC = ({ title, category, )) } { - actionColumnList.length === 0 && + actionColumnList.length === 0 && ( +
+ 暂无数据 +
+ ) }
diff --git a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx index f890c9ef..9bcfc62f 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx @@ -4,8 +4,9 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta import React, { useEffect, useRef, useState } from 'react'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react'; +import { Col, Pagination, Progress, Row } from 'tdesign-react'; import { requestCrudService } from '@/service/request-axios-service'; +import { resolveStatusIcon } from './icons'; interface ActionColumnProps { TaskType?: string; @@ -66,7 +67,7 @@ export const ActionColumn: React.FC = ({ title, category, sub
- + {columnName} {actionCount}
@@ -99,13 +100,11 @@ export const ActionColumn: React.FC = ({ title, category, sub )) } { - actionList.length === 0 && - } /> + actionList.length === 0 && ( +
+ 暂无数据 +
+ ) }
diff --git a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx index d9e34a20..d7aa1d58 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowRight, Layers, Plus, Search, Users, X } from 'lucide-react'; +import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react'; 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 { toast } from 'sonner'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; +import { listBusinessMappings } from '@/strategy-components/api/business-mapping'; interface TaskListPageProps { title: string; @@ -25,10 +26,11 @@ type ColumnType = 'unanalyzed' | 'running' | 'completed'; const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +/** 本页对应的业务码(3QFX = 3Q);用于查业务映射拿到要自动选用的业务链 / 单智能体。 */ +const BUSINESS_CODE = '3Q'; const SentimentAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); - const { setStartConfig } = useChatContext(); const navigate = useNavigate(); const isDark = theme === 'dark'; @@ -153,23 +155,54 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa const handleOpenConsultation = () => { const task = stateRef.current.task; if (!task) return; + // 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。 + // 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。 + navigate('/page/strategy/qa/multi-agent-roundtable', { + state: { + roundtablePrefill: { + taskTitle: task.taskName, + taskGoal: task.overview, + businessCode: BUSINESS_CODE, + autoStart: true, + }, + }, + }); + setShowAnalysisModal(false); + }; + // 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话), + // 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage + // 自动发出并开始问答。未配置智能体则提示去配置。 + const handleSingleAgentAnalysis = async () => { + const task = stateRef.current.task; + if (!task) return; + let agentId: string | null = null; 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); + const mappings = await listBusinessMappings(); + agentId = mappings.find((m) => m.businessCode === BUSINESS_CODE)?.agentId ?? null; + } catch { + /* ignore → 下面按未配置处理 */ } + if (!agentId) { + toast.error('该业务尚未在「业务链条管理」中配置单智能体'); + return; + } + const text = [ + task.taskName ? `任务名称:${task.taskName}` : '', + task.overview ? `任务简介:${task.overview}` : '', + ] + .filter(Boolean) + .join('\n'); + try { + sessionStorage.setItem( + 'pendingAgentMessage', + JSON.stringify({ agentId, text }), + ); + } catch { + /* noop */ + } + navigate(`/page/strategy/agents/${agentId}/chats/new`); + setShowAnalysisModal(false); }; const handleKeyDown = async (event: React.KeyboardEvent) => { @@ -390,6 +423,23 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa
+ + diff --git a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx index c4297e29..5b689846 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx @@ -4,10 +4,11 @@ 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 { toast } from 'sonner'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; +import { listBusinessMappings } from '@/strategy-components/api/business-mapping'; interface TaskListPageProps { title: string; @@ -25,10 +26,11 @@ type ColumnType = 'unanalyzed' | 'running' | 'completed'; const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +/** 本页对应的业务码(任务FX = 6BF);用于查业务映射拿到要自动选用的业务链 / 单智能体。 */ +const BUSINESS_CODE = '6BF'; const TaskAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); - const { setStartConfig } = useChatContext(); const navigate = useNavigate(); const isDark = theme === 'dark'; @@ -153,23 +155,54 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor const handleOpenConsultation = () => { const task = stateRef.current.task; if (!task) return; + // 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(6BF=任务FX)。 + // 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 6BF 配置的业务链(跳过选链弹窗)。 + navigate('/page/strategy/qa/multi-agent-roundtable', { + state: { + roundtablePrefill: { + taskTitle: task.taskName, + taskGoal: task.overview, + businessCode: BUSINESS_CODE, + autoStart: true, + }, + }, + }); + setShowAnalysisModal(false); + }; + // 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话), + // 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage + // 自动发出并开始问答。未配置智能体则提示去配置。 + const handleSingleAgentAnalysis = async () => { + const task = stateRef.current.task; + if (!task) return; + let agentId: string | null = null; 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); + const mappings = await listBusinessMappings(); + agentId = mappings.find((m) => m.businessCode === BUSINESS_CODE)?.agentId ?? null; + } catch { + /* ignore → 下面按未配置处理 */ } + if (!agentId) { + toast.error('该业务尚未在「业务链条管理」中配置单智能体'); + return; + } + const text = [ + task.taskName ? `任务名称:${task.taskName}` : '', + task.overview ? `任务简介:${task.overview}` : '', + ] + .filter(Boolean) + .join('\n'); + try { + sessionStorage.setItem( + 'pendingAgentMessage', + JSON.stringify({ agentId, text }), + ); + } catch { + /* noop */ + } + navigate(`/page/strategy/agents/${agentId}/chats/new`); + setShowAnalysisModal(false); }; const handleKeyDown = async (event: React.KeyboardEvent) => { @@ -236,9 +269,6 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor
-
- -

{title} @@ -413,6 +443,25 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor

+ +
diff --git a/frontend-web/src/strategy-components/components/task-components/icons/index.ts b/frontend-web/src/strategy-components/components/task-components/icons/index.ts new file mode 100644 index 00000000..a1adb766 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/index.ts @@ -0,0 +1,30 @@ +/** + * 行动 / 任务状态图标。 + * + * 用 `import` 引入 SVG,让 Vite 把它们打进产物并生成带 hash 的最终 URL, + * 这样无论部署到子路径(base)还是带指纹的静态资源服务都能正确访问 —— + * 不能再用 `/icon/xxx.svg` 这种运行时绝对路径(部署后会 404)。 + * + * key 沿用后端 / taskStatusList 里给的中文文件名,方便按名取用。 + */ +import daiQianShou from "./待签收.svg"; +import daiCEH from "./待CEH.svg"; +import jinXingZhong from "./进行中.svg"; +import wanCheng from "./完成.svg"; +import weiTongGuo from "./未通过.svg"; +import yiZhongZhi from "./已终止.svg"; + +export const statusIconMap: Record = { + "待签收.svg": daiQianShou, + "待CEH.svg": daiCEH, + "进行中.svg": jinXingZhong, + "完成.svg": wanCheng, + "未通过.svg": weiTongGuo, + "已终止.svg": yiZhongZhi, +}; + +/** 按文件名取打包后的图标 URL;取不到返回 undefined( 不渲染 broken icon)。 */ +export function resolveStatusIcon(name?: string | null): string | undefined { + if (!name) return undefined; + return statusIconMap[name]; +} diff --git a/frontend-web/src/strategy-components/components/task-components/icons/完成.svg b/frontend-web/src/strategy-components/components/task-components/icons/完成.svg new file mode 100644 index 00000000..a05e3a40 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/完成.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg b/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg new file mode 100644 index 00000000..6324baa5 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg b/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg new file mode 100644 index 00000000..d832a6cc --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg b/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg new file mode 100644 index 00000000..ce2b1dc6 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg b/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg new file mode 100644 index 00000000..0310b3a2 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg b/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg new file mode 100644 index 00000000..fdecefde --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg @@ -0,0 +1,4 @@ + + + + diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 06797fce..5eb1accc 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 22973c3b..cfc86437 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py new file mode 100644 index 00000000..8ff56055 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py @@ -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) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index c4128ea2..1c070de0 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI model: deepseek-chat - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-3fee562f834a4c08bb0d471ef44cb445 base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 @@ -41,7 +41,7 @@ models: display_name: DeepSeek V4 Flash use: langchain_openai:ChatOpenAI model: deepseek-v4-flash - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-3fee562f834a4c08bb0d471ef44cb445 base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py new file mode 100644 index 00000000..664c05ee --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py new file mode 100644 index 00000000..b8bad5c6 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py @@ -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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py new file mode 100644 index 00000000..ff14bb4d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py @@ -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 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 7cb7408b..26e5cfef 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 @@ -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",