From 03329387b32566ebf460a361299ac68bfa9996b4 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 5 Jun 2026 22:22:38 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/pages/StrategyRoutes.tsx | 6 +- .../task-components/KanbanColumn.tsx | 135 +- .../task-components/SentimentAnalyzes.tsx | 714 +++++----- .../task-components/TaskAnalyzes.tsx | 1154 +++++++++-------- .../components/task-components/TaskCard.tsx | 324 +++-- .../task-components/css/strategy-data.css | 82 +- 6 files changed, 1242 insertions(+), 1173 deletions(-) diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index ce98f16b..abb0b985 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -104,9 +104,9 @@ export default function StrategyRoutes() { path="plan-task/plan-task-fx" element={ } /> diff --git a/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx b/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx index 74522869..237c30d3 100644 --- a/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx +++ b/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { ChevronRight, FileText } from 'lucide-react'; +import { FileText } from 'lucide-react'; import { Pagination } from 'tdesign-react'; import { useTheme } from '@/contexts/ThemeContext'; import { TaskListItem } from './TaskManagement'; @@ -11,8 +11,8 @@ interface KanbanColumnProps { color: string; tasks: TaskListItem[]; taskType: string; - TaskDataType?: string; - pageIndexSearch: (taskType: string, pageIndex: number) => void; + taskDataType?: string; + pageIndexSearch: (taskType: string, pageIndex: number) => void | Promise; handleStartAnalysis: (task: TaskListItem) => void; } @@ -22,7 +22,7 @@ export const KanbanColumn: React.FC = ({ color, tasks, taskType, - TaskDataType = 'task', + taskDataType = 'task', pageIndexSearch, handleStartAnalysis, }) => { @@ -30,94 +30,79 @@ export const KanbanColumn: React.FC = ({ const isDark = theme === 'dark'; const [pageNum, setPageNum] = useState(1); - const pageIndexChange = async (value: number) => { + const handlePageChange = async (value: number) => { setPageNum(value); await pageIndexSearch(taskType, value); }; + const columnClass = isDark + ? 'border border-[rgba(67,89,124,0.46)] bg-[linear-gradient(180deg,rgba(18,36,67,0.96),rgba(12,28,54,0.96))] shadow-[0_18px_40px_rgba(3,10,22,0.18)]' + : 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_14px_28px_rgba(245,34,45,0.06)]'; + return ( -
-
-
-
-
-
-

{title}

- - {count} - -
-

- 按时间倒序展示,支持分页切换。 -

-
-
- -
-
- -
- pageIndexChange(Number(page))} - /> -
-
- -
- {tasks.length === 0 ? ( -
-
+
+
+
+ +

- -

-
暂无任务
-
- 可以尝试调整筛选条件或切换数据模式。 -
+ {title} + + + {count} + +
+
+ + handlePageChange(Number(page))} + /> +
+ +

+ 按时间倒序展示,支持分页切换。 +

+ +
+ {tasks.length === 0 ? ( +
+ +

暂无任务

) : ( tasks.map((task) => ( )) )}
-
+ ); }; 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 b3f3fb56..5608ff14 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowRight, Bot, Clock3, Layers, Search, Sparkles, Users, X } from 'lucide-react'; -import { DateRangePicker, DateRangeValue, Loading } from 'tdesign-react'; +import { ArrowRight, 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'; @@ -15,158 +15,156 @@ interface TaskListPageProps { subCategory: string; } -type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search'; -type ColumnType = 'unanalyzed' | 'analyzing' | 'completed'; +type ColumnType = 'unanalyzed' | 'running' | 'completed'; const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; const ENABLE_SENTIMENT_DEMO_BY_DEFAULT = import.meta.env.DEV; const buildMockTask = ( - id: string, - name: string, + id: number, + taskName: string, overview: string, column: ColumnType, - type: TaskType, - priority: 1 | 2 | 3, - progress: number, + taskType: number, + taskLevel: number, + taskDirection: string, createTime: string, updateTime: string, - owner: string, ): TaskListItem => ({ - id, - taskName: name, - overview, - column, - type, - priority, - progress, + createBy: 0, createTime, - updateTime, - owner, - creator: owner, - executor: owner, + deptId: null, + id, + isTop: 0, + issuingDept: '', + keywords: null, + overview, + planTaskTop: 0, + platform: null, + ptaskId: null, + recommendedBasis: null, scene: JSON.stringify({ taskJson: JSON.stringify({ - task_title: name, + task_title: taskName, task_goal: overview, }), }), + sourceType: 0, + step: null, + taskContent: overview, + taskDirection, + taskEndDate: '', + taskLevel, + taskName, + taskNo: `TASK-${id}`, + taskStartDate: '', + taskStatus: 0, + taskSuperiorFlag: 0, + taskTimeLimit: null, + taskType, + column, + updateBy: 0, + updateTime, + endCount: 0, + sumAll: 0, + progress: 0, + taskStatusData: null, }); const MOCK_TASKS: Record = { unanalyzed: [ buildMockTask( - 'demo-u-1', + 1001, '新能源价格舆情波动分析', '聚焦近 7 日新能源整车与动力电池价格讨论热度,识别社媒与媒体端异常扩散信号。', 'unanalyzed', - 'single', + 1, 2, - 0, - '2026-06-05 09:10:00', - '2026-06-05 09:10:00', '王晓楠', + '2026-06-05 09:10:00', + '2026-06-05 09:10:00', ), buildMockTask( - 'demo-u-2', + 1002, '重点项目负面反馈聚类', '从任务评论、审批意见和业务群消息中抽取高频情绪主题,辅助判断风险聚焦点。', 'unanalyzed', - 'consultation', 3, - 0, - '2026-06-05 08:40:00', - '2026-06-05 08:40:00', + 3, '刘志诚', + '2026-06-05 08:40:00', + '2026-06-05 08:40:00', ), buildMockTask( - 'demo-u-3', + 1003, '竞品发布会用户情绪追踪', '围绕发布会直播、短视频评论与论坛帖子,建立情绪转折时间线并识别主要触发事件。', 'unanalyzed', - 'scene', - 1, - 0, - '2026-06-04 18:20:00', - '2026-06-04 18:20:00', - '陈思雨', - ), - buildMockTask( - 'demo-u-4', - '区域渠道满意度扫描', - '基于周报、工单和售后回访文本,发现华东区域渠道服务满意度下行趋势。', - 'unanalyzed', - 'task-search', 2, - 0, - '2026-06-04 15:35:00', - '2026-06-04 15:35:00', - '赵明宇', + 1, + '陈思雨', + '2026-06-04 18:20:00', + '2026-06-04 18:20:00', ), ], - analyzing: [ + running: [ buildMockTask( - 'demo-a-1', + 2001, '新车上市口碑监测会商', '正在联合多个智能体汇总平台评价、媒体测评与用户问答,生成阶段性情绪画像。', - 'analyzing', - 'multi-agent', + 'running', + 4, 3, - 56, + '张雨桐', '2026-06-05 07:50:00', '2026-06-05 10:05:00', - '张雨桐', ), buildMockTask( - 'demo-a-2', + 2002, '投诉热点归因拆解', '拆分售后投诉中关于续航、交付和门店服务的负面占比,分析主要归因与蔓延路径。', - 'analyzing', - 'consultation', + 'running', + 3, 2, - 78, + '何嘉成', '2026-06-04 20:10:00', '2026-06-05 09:48:00', - '何嘉成', ), ], completed: [ buildMockTask( - 'demo-c-1', + 3001, '车展传播效果情绪复盘', '已输出传播节点热度变化、核心评论主题以及 KOL 内容对情绪曲线的影响分析。', 'completed', - 'single', 1, - 100, + 1, + '孙怡宁', '2026-06-03 14:20:00', '2026-06-04 17:30:00', - '孙怡宁', ), buildMockTask( - 'demo-c-2', + 3002, '智能驾驶争议议题扫描', '已完成争议议题聚类、情绪倾向分层和外部平台扩散链路分析,可直接查看结论。', 'completed', - 'scene', + 2, 3, - 100, + '李承泽', '2026-06-03 09:00:00', '2026-06-04 11:45:00', - '李承泽', ), buildMockTask( - 'demo-c-3', + 3003, '品牌联名活动舆情总结', '已完成用户态度分布统计与正负面声音对比,附带评论样本摘要和建议动作。', 'completed', - 'task-search', + 5, 2, - 100, + '周清越', '2026-06-02 16:50:00', '2026-06-03 19:12:00', - '周清越', ), ], }; @@ -176,28 +174,27 @@ const paginateTasks = (tasks: TaskListItem[], pageIndex: number, pageSize: numbe return tasks.slice(start, start + pageSize); }; +const normalizeRangeValue = (range: DateRangeValue) => { + if (!Array.isArray(range) || range.length < 2) { + return { startTime: '', endTime: '' }; + } + + return { + startTime: dayjs(range[0]).format('YYYY-MM-DD HH:mm:ss'), + endTime: dayjs(range[1]).format('YYYY-MM-DD HH:mm:ss'), + }; +}; + const SentimentAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); const { setStartConfig } = useChatContext(); const navigate = useNavigate(); const isDark = theme === 'dark'; - const shellClass = isDark - ? 'strategy-data min-h-full bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100' - : 'strategy-data min-h-full bg-[#f8fafc] text-slate-900'; - const metricCardClass = isDark - ? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(12,32,58,0.96),rgba(9,22,39,0.96))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]' - : 'border border-slate-200 bg-white shadow-sm'; - const inputClass = isDark - ? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500' - : 'border-slate-200 bg-white text-slate-900 placeholder:text-slate-400'; - const panelGlowClass = isDark - ? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]' - : ''; const [searchQuery, setSearchQuery] = useState(''); - const [showAnalysisModal, setShowAnalysisModal] = useState(false); - const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState(null); const [useDemoData, setUseDemoData] = useState(ENABLE_SENTIMENT_DEMO_BY_DEFAULT); + const [selectedTask, setSelectedTask] = useState(null); + const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); const [runningTotal, setRunningTotal] = useState(0); const [completedTotal, setCompletedTotal] = useState(0); @@ -207,378 +204,465 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa const [unanalyzedLoading, setUnanalyzedLoading] = useState(false); const [runningLoading, setRunningLoading] = useState(false); const [completedLoading, setCompletedLoading] = useState(false); - const [dateRange, setDateRange] = useState([ - dayjs().startOf('month').format('YYYY-MM-DD'), - dayjs().endOf('day').format('YYYY-MM-DD'), - ]); - + const [range, setRange] = useState([]); const pageSizeRef = useRef(10); + const presets = useMemo( + () => ({ + 最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()], + 最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()], + 今天: [dayjs().toDate(), dayjs().toDate()], + }), + [], + ); + const filteredMock = useMemo(() => { const keyword = searchQuery.trim().toLowerCase(); - const filterItems = (items: TaskListItem[]) => { - if (!keyword) return items; - return items.filter((item) => { - const text = `${item.taskName} ${item.overview} ${item.owner || ''}`.toLowerCase(); - return text.includes(keyword); - }); + const filterList = (list: TaskListItem[]) => { + if (!keyword) return list; + return list.filter((item) => + `${item.taskName} ${item.overview} ${item.taskDirection}`.toLowerCase().includes(keyword), + ); }; return { - unanalyzed: filterItems(MOCK_TASKS.unanalyzed), - analyzing: filterItems(MOCK_TASKS.analyzing), - completed: filterItems(MOCK_TASKS.completed), + unanalyzed: filterList(MOCK_TASKS.unanalyzed), + running: filterList(MOCK_TASKS.running), + completed: filterList(MOCK_TASKS.completed), }; }, [searchQuery]); const applyMockColumn = (column: ColumnType, pageIndex = 1) => { const source = filteredMock[column]; - const pageItems = paginateTasks(source, pageIndex, pageSizeRef.current); + const pageList = paginateTasks(source, pageIndex, pageSizeRef.current); if (column === 'unanalyzed') { setUnanalyzedTotal(source.length); - setUnanalyzedList(pageItems); + setUnanalyzedList(pageList); setUnanalyzedLoading(false); - } else if (column === 'analyzing') { - setRunningTotal(source.length); - setRunningList(pageItems); - setRunningLoading(false); - } else { - setCompletedTotal(source.length); - setCompletedList(pageItems); - setCompletedLoading(false); + return; } + + if (column === 'running') { + setRunningTotal(source.length); + setRunningList(pageList); + setRunningLoading(false); + return; + } + + setCompletedTotal(source.length); + setCompletedList(pageList); + setCompletedLoading(false); }; const taskListSearch = async ( - taskStatus: ColumnType, + column: ColumnType, pageIndex = 1, pageSize = pageSizeRef.current, ) => { pageSizeRef.current = pageSize; - const setLoadingByType = (loading: boolean) => { - if (taskStatus === 'unanalyzed') setUnanalyzedLoading(loading); - if (taskStatus === 'analyzing') setRunningLoading(loading); - if (taskStatus === 'completed') setCompletedLoading(loading); - }; - - setLoadingByType(true); + if (column === 'unanalyzed') setUnanalyzedLoading(true); + if (column === 'running') setRunningLoading(true); + if (column === 'completed') setCompletedLoading(true); if (useDemoData) { - applyMockColumn(taskStatus, pageIndex); + applyMockColumn(column, pageIndex); return; } + const taskStatusMap: Record = { + unanalyzed: [0, 10], + running: [20], + completed: [25, 30, 40, 60, 70, 80, 90, 100, 110], + }; + try { - const [startTime, endTime] = Array.isArray(dateRange) ? dateRange : ['', '']; - const payload = { + const { startTime, endTime } = normalizeRangeValue(range); + const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, { + content: searchQuery.trim(), pageNum: pageIndex, pageSize, - taskStatus, - taskCategoryName: category, - taskTypeName: subCategory, - searchKey: searchQuery.trim() || undefined, + taskStatus: taskStatusMap[column], startTime, endTime, - }; + taskDirection: '', + }); - const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, payload); - const rows = response?.rows || response?.data?.rows || []; - const total = response?.total || response?.data?.total || 0; - const mappedList = rows.map((item: TaskListItem) => ({ + const result = response?.data ?? response ?? {}; + const records = Array.isArray(result.records) ? result.records : []; + const total = Number(result.total ?? records.length); + const mapped = records.map((item: TaskListItem) => ({ ...item, - column: taskStatus === 'analyzing' ? 'running' : taskStatus, + column, + taskLevel: item.taskLevel || 1, })); - if (taskStatus === 'unanalyzed') { - setUnanalyzedList(mappedList); + if (column === 'unanalyzed') { + setUnanalyzedList(mapped); setUnanalyzedTotal(total); - } else if (taskStatus === 'analyzing') { - setRunningList(mappedList); + } else if (column === 'running') { + setRunningList(mapped); setRunningTotal(total); } else { - setCompletedList(mappedList); + setCompletedList(mapped); setCompletedTotal(total); } } catch (error) { console.error('sentiment task list fetch failed', error); - applyMockColumn(taskStatus, pageIndex); setUseDemoData(true); + applyMockColumn(column, pageIndex); } finally { - setLoadingByType(false); + if (column === 'unanalyzed') setUnanalyzedLoading(false); + if (column === 'running') setRunningLoading(false); + if (column === 'completed') setCompletedLoading(false); } }; - const refreshAllColumns = async () => { - await Promise.all([ - taskListSearch('unanalyzed', 1), - taskListSearch('analyzing', 1), - taskListSearch('completed', 1), - ]); + const refreshColumns = () => { + taskListSearch('unanalyzed', 1); + taskListSearch('running', 1); + taskListSearch('completed', 1); }; useEffect(() => { - refreshAllColumns(); + refreshColumns(); }, [useDemoData]); useEffect(() => { const timer = window.setTimeout(() => { - refreshAllColumns(); - }, useDemoData ? 120 : 260); + refreshColumns(); + }, 200); return () => window.clearTimeout(timer); - }, [searchQuery, dateRange, useDemoData]); - - const metrics = [ - { - label: '全部任务', - value: unanalyzedTotal + runningTotal + completedTotal, - icon: Layers, - accent: 'from-[#4D8DFF] to-[#76C2FF]', - }, - { - label: '未分析', - value: unanalyzedTotal, - icon: Clock3, - accent: 'from-[#38BDF8] to-[#7DD3FC]', - }, - { - label: '分析中', - value: runningTotal, - icon: Sparkles, - accent: 'from-[#2DD4BF] to-[#67E8F9]', - }, - { - label: '已完成', - value: completedTotal, - icon: Bot, - accent: 'from-[#60A5FA] to-[#A78BFA]', - }, - ]; + }, [searchQuery, range]); const handleStartAnalysis = (task: TaskListItem) => { - setSelectedTaskForAnalysis(task); + setSelectedTask(task); setShowAnalysisModal(true); }; - const handleViewTaskDetail = () => { - if (!selectedTaskForAnalysis) return; + const handleOpenTaskDetail = () => { + if (!selectedTask) return; + setStartConfig({ - task_title: selectedTaskForAnalysis.taskName, - task_goal: selectedTaskForAnalysis.overview, - task_context: selectedTaskForAnalysis.overview, + task_title: selectedTask.taskName, + task_goal: selectedTask.overview, + task_context: selectedTask.overview, llm_preset_id: 'BAILING', consistency_mode: 'soft', phase_config: { diverge_rounds: 3 }, }); + setShowAnalysisModal(false); navigate('/qa-center/mope-chat'); }; - const handleMultiAgentDiscussion = () => { - if (!selectedTaskForAnalysis) return; + const handleOpenRoundtable = () => { + if (!selectedTask) return; + setStartConfig({ - task_title: selectedTaskForAnalysis.taskName, - task_goal: selectedTaskForAnalysis.overview, - task_context: selectedTaskForAnalysis.overview, + task_title: selectedTask.taskName, + task_goal: selectedTask.overview, + task_context: selectedTask.overview, llm_preset_id: 'BAILING', consistency_mode: 'soft', phase_config: { diverge_rounds: 3 }, }); + setShowAnalysisModal(false); navigate('/page/workspace/roundtable/planning'); }; + const shellClass = isDark + ? 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)]' + : 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#FFFDFD_0%,#FFF7F7_46%,#FAFAFA_100%)]'; + + const toolbarClass = isDark + ? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.94)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]' + : 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_14px_30px_rgba(245,34,45,0.06)]'; + + const metricClass = isDark + ? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)]' + : 'border border-[#E8E8E8] bg-white'; + + const inputClass = isDark + ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(8,19,35,0.9)] text-white placeholder:text-slate-500' + : 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]'; + + const totalCount = unanalyzedTotal + runningTotal + completedTotal; + const metrics = [ + { label: '全部任务', value: totalCount }, + { label: '未分析', value: unanalyzedTotal }, + { label: '分析中', value: runningTotal }, + { label: '已完成', value: completedTotal }, + ]; + return (
-
-
-
-
-
- +
+
+
+
+
+ + {category} + + + + {subCategory} +
+
-
-

{title}

+
+

+ {title} +

{useDemoData ? ( - + 演示数据 ) : null}
-

+

面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。

-
- {category} - - {subCategory} -
-
-
-
-
-
+
-
- {metrics.map(({ label, value, icon: Icon, accent }) => ( -
+
+ {metrics.map((item, index) => ( +
-

{label}

-

{value}

+

{item.label}

+

+ {item.value} +

-
- +
+
))} -
+
-
-
- - taskListSearch(taskType as ColumnType, pageIndex)} - handleStartAnalysis={handleStartAnalysis} - /> +
+
+ +
+ +
-
- - taskListSearch(taskType as ColumnType, pageIndex)} - handleStartAnalysis={handleStartAnalysis} - /> + +
+ +
+ +
-
- - taskListSearch(taskType as ColumnType, pageIndex)} - handleStartAnalysis={handleStartAnalysis} - /> + +
+ +
+ +
-
+
- {showAnalysisModal && selectedTaskForAnalysis ? ( -
-
+ {showAnalysisModal && selectedTask ? ( +
+

选择分析模式

-

- 当前任务:{selectedTaskForAnalysis.taskName} +

+ 为任务“{selectedTask.taskName}”选择分析方式

-
+
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 0f394db2..5d1278ac 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx @@ -1,38 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Plus, - Search, - Eye, - Play, - Pause, - Clock, - User, - ChevronRight, - Filter, - FileText, - AlertTriangle, - CheckCircle, - XCircle, - MoreHorizontal, - Edit, - Download, - Bot, - Users, - Workflow, - X, - ArrowRight, - Target, - Layers, -} from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowRight, Bot, Clock3, Layers, Search, Sparkles, Users, X } from 'lucide-react'; +import { DateRangePicker, DateRangeValue, Loading } from 'tdesign-react'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; import { useTheme } from '@/contexts/ThemeContext'; -// 导入请求服务,用于与后端 API 交互 +import { useChatContext } from '@/contexts/ChatContext'; import { requestCrudService } from '@/service/request-axios-service'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; -import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react'; -import { useNavigate } from 'react-router-dom'; -import dayjs from 'dayjs'; -import { useChatContext } from '@/contexts/ChatContext'; interface TaskListPageProps { title: string; @@ -41,606 +16,637 @@ interface TaskListPageProps { } type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search'; -type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed'; type ColumnType = 'unanalyzed' | 'analyzing' | 'completed'; -interface Task { - id: string; - name: string; - type: TaskType; - status: TaskStatus; - agent?: string; - scene?: string; - participants?: string[]; - currentPhase?: string; - currentRound?: number; - summary?: string; - creator: string; - createTime: string; - updateTime: string; - column: ColumnType; -} - -interface stateItem { - startTime: string, - endTime: string, - task: TaskListItem, -} - const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +const ENABLE_TASK_ANALYSIS_DEMO_BY_DEFAULT = import.meta.env.DEV; -const TaskAnalyzes: React.FC = ({ title, category, subCategory}) => { +const buildMockTask = ( + id: string, + name: string, + overview: string, + column: ColumnType, + type: TaskType, + priority: 1 | 2 | 3, + progress: number, + createTime: string, + updateTime: string, + owner: string, +): TaskListItem => ({ + id, + taskName: name, + overview, + column, + type, + priority, + progress, + createTime, + updateTime, + owner, + creator: owner, + executor: owner, + scene: JSON.stringify({ + taskJson: JSON.stringify({ + task_title: name, + task_goal: overview, + }), + }), +}); + +const MOCK_TASKS: Record = { + unanalyzed: [ + buildMockTask( + 'plan-fx-u-1', + '重点区域受众画像分析', + '识别华东与华南区域的核心受众特征、关注议题和任务接受门槛,为后续执行策略提供输入。', + 'unanalyzed', + 'single', + 2, + 0, + '2026-06-05 10:20:00', + '2026-06-05 10:20:00', + '赵嘉宁', + ), + buildMockTask( + 'plan-fx-u-2', + '项目目标拆解与阻力识别', + '围绕项目推进目标,提炼关键里程碑、潜在阻力和依赖条件,形成可执行分析框架。', + 'unanalyzed', + 'scene', + 3, + 0, + '2026-06-05 09:45:00', + '2026-06-05 09:45:00', + '程浩宇', + ), + buildMockTask( + 'plan-fx-u-3', + '传播路径假设验证', + '针对既定传播路径做初步假设验证,识别关键触点与信息衰减节点。', + 'unanalyzed', + 'task-search', + 1, + 0, + '2026-06-04 19:10:00', + '2026-06-04 19:10:00', + '徐可欣', + ), + ], + analyzing: [ + buildMockTask( + 'plan-fx-a-1', + '目标受众识别会商', + '正在联合多个智能体对目标受众、关键诉求和阻力点进行交叉分析,输出阶段性判断。', + 'analyzing', + 'multi-agent', + 3, + 62, + '2026-06-05 08:10:00', + '2026-06-05 10:18:00', + '林知远', + ), + buildMockTask( + 'plan-fx-a-2', + '关切提炼与策略匹配', + '拆分用户关切、媒体关切与执行侧约束,评估不同策略路径的匹配度。', + 'analyzing', + 'consultation', + 2, + 74, + '2026-06-04 17:50:00', + '2026-06-05 09:36:00', + '沈语禾', + ), + ], + completed: [ + buildMockTask( + 'plan-fx-c-1', + '目标受众识别报告', + '已完成核心受众划分、关键关切提炼和阻力分析,可直接查看结论与建议。', + 'completed', + 'single', + 1, + 100, + '2026-06-03 10:30:00', + '2026-06-04 16:40:00', + '郑书言', + ), + buildMockTask( + 'plan-fx-c-2', + '宣传策略初稿生成', + '已完成传播重点、内容方向与节奏建议,附带策略版本对比与风险提示。', + 'completed', + 'scene', + 2, + 100, + '2026-06-03 14:10:00', + '2026-06-04 18:20:00', + '余思成', + ), + buildMockTask( + 'plan-fx-c-3', + '多智能体会商总结', + '已形成多角色会商纪要,收敛主要分歧并给出后续推进建议。', + 'completed', + 'consultation', + 3, + 100, + '2026-06-02 16:00:00', + '2026-06-03 20:10:00', + '周奕辰', + ), + ], +}; + +const paginateTasks = (tasks: TaskListItem[], pageIndex: number, pageSize: number) => { + const start = (pageIndex - 1) * pageSize; + return tasks.slice(start, start + pageSize); +}; + +const TaskAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); const { setStartConfig } = useChatContext(); - const router = useNavigate(); + const navigate = useNavigate(); const isDark = theme === 'dark'; - - // 主题颜色变量 - const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white'; - const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100'; - const textPrimary = isDark ? 'text-white' : 'text-gray-900'; - const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600'; - const textMuted = isDark ? 'text-slate-500' : 'text-gray-400'; - const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white'; - - // 生成模拟数据 - const [tasks] = useState([ - // 未分析列 - { - id: '2026-0320-001', - name: '端午节品牌传播方案', - type: 'scene', - scene: '宣传策略生成', - status: 'draft', - column: 'unanalyzed', - summary: '针对端午节品牌活动制定完整传播策略方案', - creator: '李策划', - createTime: '2026-03-20 10:00', - updateTime: '2026-03-20 10:00', - }, - { - id: '2026-0320-002', - name: '新品上市用户调研', - type: 'single', - agent: 'AudienceAgent', - status: 'pending', - column: 'unanalyzed', - summary: '对新品目标用户进行画像分析', - creator: '王运营', - createTime: '2026-03-20 09:30', - updateTime: '2026-03-20 09:30', - }, - // 分析中列 - { - id: '2026-0319-001', - name: '五四青年节宣传活动策略', - type: 'scene', - scene: '宣传策略生成', - status: 'running', - currentPhase: '行为分析', - column: 'analyzing', - summary: '正在按场景模板执行目标、受众、行为、叙事等分析...', - creator: '张策略', - createTime: '2026-03-19 10:30', - updateTime: '2026-03-20 09:45', - }, - { - id: '2026-0319-002', - name: '青年传播方向会商分析', - type: 'consultation', - participants: ['GoalAgent', 'AudienceAgent', 'NarrativeAgent'], - status: 'running', - currentRound: 2, - column: 'analyzing', - summary: '围绕宣传方向、叙事方式和受众切入点开展多视角会商...', - creator: '王策划', - createTime: '2026-03-19 14:00', - updateTime: '2026-03-20 09:00', - }, - { - id: '2026-0318-001', - name: '年度品牌升级方案推演', - type: 'scene', - scene: '宣传方案推演', - status: 'paused', - currentPhase: '叙事分析', - column: 'analyzing', - summary: '任务已暂停,等待人工介入确认叙事方向', - creator: '王策划', - createTime: '2026-03-18 14:00', - updateTime: '2026-03-20 08:30', - }, - // 已完成列 - { - id: '2026-0318-002', - name: '核心受众画像分析', - type: 'single', - agent: 'AudienceAgent', - status: 'completed', - column: 'completed', - summary: '已完成目标受众识别、关切提炼和阻力分析', - creator: '赵运营', - createTime: '2026-03-18 09:00', - updateTime: '2026-03-18 16:30', - }, - { - id: '2026-0319-003', - name: '五四青年节宣传策略', - type: 'scene', - scene: '宣传策略生成', - status: 'completed', - column: 'completed', - summary: '已完成宣传策略方案生成', - creator: '张策略', - createTime: '2026-03-19 10:00', - updateTime: '2026-03-19 18:00', - }, - { - id: '2026-0319-004', - name: '青年品牌传播路径会商', - type: 'consultation', - participants: ['GoalAgent', 'NarrativeAgent', 'RiskAgent'], - status: 'completed', - column: 'completed', - summary: '已完成多智能体会商,形成结论与分歧报告', - creator: '王策划', - createTime: '2026-03-19 14:30', - updateTime: '2026-03-19 17:00', - }, - ]); + const shellClass = isDark + ? 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100' + : 'strategy-data h-full overflow-y-auto bg-[radial-gradient(circle_at_top_left,rgba(245,34,45,0.08),transparent_26%),linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]'; + const metricCardClass = isDark + ? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(13,31,55,0.98),rgba(8,19,34,0.98))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]' + : 'border border-[#E8E8E8] bg-white shadow-[0_12px_30px_rgba(245,34,45,0.06)]'; + const inputClass = isDark + ? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500' + : 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6] shadow-[0_4px_14px_rgba(245,34,45,0.04)]'; + const panelGlowClass = isDark + ? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]' + : ''; const [searchQuery, setSearchQuery] = useState(''); - const [modeFilter, setModeFilter] = useState('all'); const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState(null); - const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); // 未分析 - const [runningTotal, setRunningTotal] = useState(0); // 分析中 - const [completedTotal, setCompletedTotal] = useState(0); // 已完成 - const [unanalyzedList, setUnanalyzedList] = useState([]); // 未分析 - const [runningList, setRunningList] = useState([]); // 分析中 - const [completedList, setCompletedList] = useState([]); // 已完成 - const [unanalyzedLoading, setUnanalyzedLoading] = useState(false); // 未分析 - const [runningLoading, setRunningLoading] = useState(false); // 分析中 - const [completedLoading, setCompletedLoading] = useState(false); // 已完成 - const [presets] = useState({ - 最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()], - 最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()], - 今天: [dayjs().toDate(), dayjs().toDate()], - }); - const [range, setRange] = useState([]); - const state = useRef({ - startTime: '', - endTime: '', - task: {} as TaskListItem, - }) + const [useDemoData, setUseDemoData] = useState(ENABLE_TASK_ANALYSIS_DEMO_BY_DEFAULT); + const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); + const [runningTotal, setRunningTotal] = useState(0); + const [completedTotal, setCompletedTotal] = useState(0); + const [unanalyzedList, setUnanalyzedList] = useState([]); + const [runningList, setRunningList] = useState([]); + const [completedList, setCompletedList] = useState([]); + const [unanalyzedLoading, setUnanalyzedLoading] = useState(false); + const [runningLoading, setRunningLoading] = useState(false); + const [completedLoading, setCompletedLoading] = useState(false); + const [dateRange, setDateRange] = useState([ + dayjs().startOf('month').format('YYYY-MM-DD'), + dayjs().endOf('day').format('YYYY-MM-DD'), + ]); - // 时间日期选择器查询 - const setRangeChange = async (val) => { - setRange(val); - console.log('时间日期', val); - state.current.startTime = ''; - state.current.endTime = ''; - if (val.length > 0) { - state.current.startTime = val[0]; - state.current.endTime = val[1]; - } + const pageSizeRef = useRef(10); - await taskDataChange(); - } + const filteredMock = useMemo(() => { + const keyword = searchQuery.trim().toLowerCase(); + const filterItems = (items: TaskListItem[]) => { + if (!keyword) return items; + return items.filter((item) => { + const text = `${item.taskName} ${item.overview} ${item.owner || ''}`.toLowerCase(); + return text.includes(keyword); + }); + }; - // 获取任务类型配置 - const getTypeConfig = (type: TaskType) => { - if (isDark) { - switch (type) { - case 'single': - return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot }; - case 'scene': - return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow }; - case 'consultation': - return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users }; - case 'multi-agent': - return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers }; - } - } else { - switch (type) { - case 'single': - return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot }; - case 'scene': - return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow }; - case 'consultation': - return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users }; - case 'multi-agent': - return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers }; - } - } - }; + return { + unanalyzed: filterItems(MOCK_TASKS.unanalyzed), + analyzing: filterItems(MOCK_TASKS.analyzing), + completed: filterItems(MOCK_TASKS.completed), + }; + }, [searchQuery]); + + const applyMockColumn = (column: ColumnType, pageIndex = 1) => { + const source = filteredMock[column]; + const pageItems = paginateTasks(source, pageIndex, pageSizeRef.current); - // 获取状态配置 - const getStatusConfig = (status: TaskStatus, column: ColumnType) => { if (column === 'unanalyzed') { - switch (status) { - case 'draft': - return { label: '草稿', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' }; - case 'pending': - return { label: '待启动', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' }; - default: - return { label: '待配置', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' }; - } - } else if (column === 'analyzing') { - switch (status) { - case 'running': - return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' }; - case 'paused': - return { label: '已暂停', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' }; - case 'blocked': - return { label: '阻塞中', color: isDark ? 'bg-red-500/20 text-red-400' : 'bg-orange-100 text-orange-600' }; - default: - return { label: '待处理', color: isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600' }; - } - } else { - return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' }; - } - }; - - // 筛选任务 - const filteredTasks = tasks.filter(task => { - const matchesSearch = task.name.toLowerCase().includes(searchQuery.toLowerCase()) || - task.id.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesMode = modeFilter === 'all' || task.type === modeFilter; - return matchesSearch && matchesMode; - }); - - const unanalyzedTasks = filteredTasks.filter(t => t.column === 'unanalyzed'); - const analyzingTasks = filteredTasks.filter(t => t.column === 'analyzing'); - const completedTasks = filteredTasks.filter(t => t.column === 'completed'); - - // 统计数据 - const stats = { - total: tasks.length, - unanalyzed: unanalyzedTasks.length, - analyzing: analyzingTasks.length, - completed: completedTasks.length, - }; - - // 处理开始分析按钮点击 - const handleStartAnalysis = (task: TaskListItem) => { - setSelectedTaskForAnalysis(task); - state.current.task = task; - setShowAnalysisModal(true); - }; - - // 处理分析模式选择 - const handleSelectAnalysisMode = (mode: TaskType) => { - setShowAnalysisModal(false); - console.log('mode', mode); - if (mode === 'consultation') { - console.log('state.current.task', state.current.task) - const sceneItem = JSON.parse(state.current.task.scene); - if (sceneItem.taskJson) { - sceneItem.taskJson = JSON.parse(sceneItem.taskJson); - setStartConfig({ - ...sceneItem.taskJson, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: {diverge_rounds: 3}, - task_context: state.current.task.overview - }); - router('/qa-center/mope-chat') - } - console.log('sceneItem', sceneItem); - } else if (mode === 'task-search') { - window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`) - } - }; - - const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => { - let taskStatus: number[] = [] - if (taskType === 'unanalyzed') { // 未分析 - taskStatus = [0, 10]; - setUnanalyzedLoading(true); - } else if (taskType === 'running') { // 分析中 - taskStatus = [20]; - setRunningLoading(true); - } else if (taskType === 'completed') { // 已完成 - taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110]; - setCompletedLoading(true); - } - const param = { - content: searchQuery, - pageNum: pageNum, - pageSize: pageSize, - taskStatus: taskStatus, - startTime: state.current.startTime, - endTime: state.current.endTime, - taskDirection: '' - } - const res = await service.post(`${zncmUrl}/task/cop-tasks`, param); - console.log('任务列表', res); - const result = res.data; - result.records.forEach((item: TaskListItem) => { - item.column = taskType; - if (!item.taskLevel) item.taskLevel = 1; - }) - if (taskType === 'unanalyzed') { + setUnanalyzedTotal(source.length); + setUnanalyzedList(pageItems); setUnanalyzedLoading(false); - setUnanalyzedTotal(result.total); - setUnanalyzedList(result.records); - } else if (taskType === 'running') { + } else if (column === 'analyzing') { + setRunningTotal(source.length); + setRunningList(pageItems); setRunningLoading(false); - setRunningTotal(result.total); - setRunningList(result.records); - } else if (taskType === 'completed') { + } else { + setCompletedTotal(source.length); + setCompletedList(pageItems); setCompletedLoading(false); - setCompletedTotal(result.total); - setCompletedList(result.records); } - } + }; - /** - * 页码变化时的回调函数 - * 当用户切换页码时触发,更新页码并重新获取数据 - * @param value - 新的页码值 - */ - const pageIndexChange = async (type: string, pageIndex: number) => { - // 更新 ref 中存储的页码值 - // 更新页码状态,触发组件重新渲染 - await taskListSearch(type, pageIndex); - } + const taskListSearch = async ( + taskStatus: ColumnType, + pageIndex = 1, + pageSize = pageSizeRef.current, + ) => { + pageSizeRef.current = pageSize; - const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => { - taskListSearch('unanalyzed', pageNum, pageSize); - taskListSearch('running', pageNum, pageSize); - taskListSearch('completed', pageNum, pageSize); - } + const setLoadingByType = (loading: boolean) => { + if (taskStatus === 'unanalyzed') setUnanalyzedLoading(loading); + if (taskStatus === 'analyzing') setRunningLoading(loading); + if (taskStatus === 'completed') setCompletedLoading(loading); + }; - // 回车处理 - const handleKeyDown = async (e) => { - if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") { - e.preventDefault(); - } else if (e.keyCode === 13) { - e.preventDefault(); - await taskDataChange(); - } else if (e.key === "/") { - e.preventDefault(); + setLoadingByType(true); + + if (useDemoData) { + applyMockColumn(taskStatus, pageIndex); + return; } + + try { + const [startTime, endTime] = Array.isArray(dateRange) ? dateRange : ['', '']; + const payload = { + pageNum: pageIndex, + pageSize, + taskStatus, + taskCategoryName: category, + taskTypeName: subCategory, + searchKey: searchQuery.trim() || undefined, + startTime, + endTime, + }; + + const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, payload); + const rows = response?.rows || response?.data?.rows || []; + const total = response?.total || response?.data?.total || 0; + const mappedList = rows.map((item: TaskListItem) => ({ + ...item, + column: taskStatus === 'analyzing' ? 'running' : taskStatus, + })); + + if (taskStatus === 'unanalyzed') { + setUnanalyzedList(mappedList); + setUnanalyzedTotal(total); + } else if (taskStatus === 'analyzing') { + setRunningList(mappedList); + setRunningTotal(total); + } else { + setCompletedList(mappedList); + setCompletedTotal(total); + } + } catch (error) { + console.error('task analysis list fetch failed', error); + applyMockColumn(taskStatus, pageIndex); + setUseDemoData(true); + } finally { + setLoadingByType(false); + } + }; + + const refreshAllColumns = async () => { + await Promise.all([ + taskListSearch('unanalyzed', 1), + taskListSearch('analyzing', 1), + taskListSearch('completed', 1), + ]); }; useEffect(() => { - taskDataChange(); - }, []); + refreshAllColumns(); + }, [useDemoData]); - // 渲染看板列 + useEffect(() => { + const timer = window.setTimeout(() => { + refreshAllColumns(); + }, useDemoData ? 120 : 260); + + return () => window.clearTimeout(timer); + }, [searchQuery, dateRange, useDemoData]); + + const metrics = [ + { + label: '全部任务', + value: unanalyzedTotal + runningTotal + completedTotal, + icon: Layers, + accent: 'from-[#4D8DFF] to-[#7CC9FF]', + }, + { + label: '未分析', + value: unanalyzedTotal, + icon: Clock3, + accent: 'from-[#7C9BFF] to-[#5EEAD4]', + }, + { + label: '分析中', + value: runningTotal, + icon: Sparkles, + accent: 'from-[#22C55E] to-[#67E8F9]', + }, + { + label: '已完成', + value: completedTotal, + icon: Bot, + accent: 'from-[#38BDF8] to-[#A78BFA]', + }, + ]; + + const handleStartAnalysis = (task: TaskListItem) => { + setSelectedTaskForAnalysis(task); + setShowAnalysisModal(true); + }; + + const handleViewTaskDetail = () => { + if (!selectedTaskForAnalysis) return; + setStartConfig({ + task_title: selectedTaskForAnalysis.taskName, + task_goal: selectedTaskForAnalysis.overview, + task_context: selectedTaskForAnalysis.overview, + llm_preset_id: 'BAILING', + consistency_mode: 'soft', + phase_config: { diverge_rounds: 3 }, + }); + setShowAnalysisModal(false); + navigate('/qa-center/mope-chat'); + }; + + const handleMultiAgentDiscussion = () => { + if (!selectedTaskForAnalysis) return; + setStartConfig({ + task_title: selectedTaskForAnalysis.taskName, + task_goal: selectedTaskForAnalysis.overview, + task_context: selectedTaskForAnalysis.overview, + llm_preset_id: 'BAILING', + consistency_mode: 'soft', + phase_config: { diverge_rounds: 3 }, + }); + setShowAnalysisModal(false); + navigate('/page/workspace/roundtable/planning'); + }; return ( -
- {/* 顶部工具栏 */} -
-
-
-
- - {category} - - / - - {subCategory} - -
-

任务中心

-
- -
- - {/* 搜索和筛选 */} -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => handleKeyDown(e)} - className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`} - /> -
- {/* */} - {/* */} - setRangeChange(val)} enableTimePicker={true} clearable /> -
-
- - {/* 统计概览栏 */} -
- - - - -
- - {/* 看板区 */} -
- - - - - - - - - -
- - {/* 选择分析模式弹窗 */} - {showAnalysisModal && selectedTaskForAnalysis && ( -
setShowAnalysisModal(false)}> -
e.stopPropagation()} - > -
+
+
+
+
+
+
+ +
-

选择分析模式

-

为任务「{selectedTaskForAnalysis.taskName}」选择分析方式

+
+

{title}

+ {useDemoData ? ( + + 演示数据 + + ) : null} +
+

+ 面向规划任务的任务分析看板,聚焦目标拆解、受众判断和策略阻力识别,支持快速检索、启动分析和查看会商结果。 +

+
+
+
+ {category} + + {subCategory} +
+
+ +
+
+ + setDateRange(value)} + /> +
+
+ + +
+
+
+ +
+ {metrics.map(({ label, value, icon: Icon, accent }) => ( +
+
+
+

{label}

+

{value}

+
+
+ +
+
+
+ ))} +
+ +
+
+
+
分析工作台
+
+ 当前为演示任务数据,便于预览深色模式下的多列分析工作流。 +
+
+
+ 目标拆解 / 受众判断 / 会商收敛 +
+
+
+ +
+
+ +
+ + taskListSearch(taskType as ColumnType, pageIndex) + } + handleStartAnalysis={handleStartAnalysis} + /> +
+
+
+ +
+ +
+ + taskListSearch(taskType as ColumnType, pageIndex) + } + handleStartAnalysis={handleStartAnalysis} + /> +
+
+
+ +
+ +
+ + taskListSearch(taskType as ColumnType, pageIndex) + } + handleStartAnalysis={handleStartAnalysis} + /> +
+
+
+
+
+ + {showAnalysisModal && selectedTaskForAnalysis ? ( +
+
+
+
+

选择分析模式

+

+ 当前任务:{selectedTaskForAnalysis.taskName} +

-
- {/* 单智能体分析 */} - {/* */} - - {/* 多智能体协同分析 */} - {/* */} +
- {/* 多智能体会商 */} - - {/* 场景执行 */} - {/* */}
- )} + ) : null}
); }; diff --git a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx index 62edd86b..2eabb746 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx @@ -1,20 +1,54 @@ import React from 'react'; import { format } from 'date-fns'; -import { useNavigate } from 'react-router-dom'; import { Bot, Eye, Layers, Play, User, Users, Workflow } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { useTheme } from '@/contexts/ThemeContext'; import { useChatContext } from '@/contexts/ChatContext'; import { TaskListItem } from './TaskManagement'; interface TaskCardProps { - TaskType?: string; task: TaskListItem; + taskDataType?: string; handleStartAnalysis: (task: TaskListItem) => void; } +const getTaskTypeConfig = (taskType?: number) => { + switch (taskType) { + case 2: + return { label: '场景', icon: Workflow }; + case 3: + return { label: '会商', icon: Users }; + case 4: + return { label: '多智能体', icon: Layers }; + case 5: + return { label: '检索', icon: Layers }; + case 1: + default: + return { label: '单任务', icon: Bot }; + } +}; + +const getTaskLevelLabel = (taskLevel?: number) => { + switch (taskLevel) { + case 2: + return '紧急'; + case 3: + return '加急'; + case 1: + default: + return '一般'; + } +}; + +const getColumnLabel = (column?: string) => { + if (column === 'running' || column === 'analyzing') return '分析中'; + if (column === 'completed') return '已完成'; + return '待分析'; +}; + export const TaskCard: React.FC = ({ task, - TaskType = 'task', + taskDataType = 'task', handleStartAnalysis, }) => { const { theme } = useTheme(); @@ -22,209 +56,159 @@ export const TaskCard: React.FC = ({ const navigate = useNavigate(); const isDark = theme === 'dark'; - const sceneDataChange = (currentTask: TaskListItem) => { - if (!currentTask.scene) return; + const typeConfig = getTaskTypeConfig(task.taskType); + const TypeIcon = typeConfig.icon; + const isCompleted = task.column === 'completed'; + const isRunning = task.column === 'running' || task.column === 'analyzing'; + + const openConversation = () => { + try { + const sceneItem = task.scene ? JSON.parse(task.scene) : {}; + const taskJson = sceneItem?.taskJson ? JSON.parse(sceneItem.taskJson) : {}; - const sceneItem = JSON.parse(currentTask.scene); - if (sceneItem.taskJson) { - sceneItem.taskJson = JSON.parse(sceneItem.taskJson); setStartConfig({ - ...sceneItem.taskJson, + ...taskJson, + task_title: task.taskName, + task_goal: task.overview, + task_context: task.overview, + llm_preset_id: 'BAILING', + consistency_mode: 'soft', + phase_config: { diverge_rounds: 3 }, + }); + } catch (error) { + setStartConfig({ + task_title: task.taskName, + task_goal: task.overview, + task_context: task.overview, llm_preset_id: 'BAILING', consistency_mode: 'soft', phase_config: { diverge_rounds: 3 }, - task_context: currentTask.overview, }); - navigate('/qa-center/mope-chat'); } + + navigate('/qa-center/mope-chat'); }; - const handleTaskClick = (currentTask: TaskListItem) => { - if (TaskType === 'sentiment') { - sceneDataChange(currentTask); + const handleCardClick = () => { + if (taskDataType === 'sentiment') { + openConversation(); return; } - if (currentTask.column === 'completed') { + if (isCompleted) { navigate('/strategy-result'); return; } - if (currentTask.column === 'running' || currentTask.column === 'analyzing') { - sceneDataChange(currentTask); + if (isRunning) { + openConversation(); } }; - const handleStartChange = (currentTask: TaskListItem) => { - handleStartAnalysis(currentTask); - }; + const handleActionClick = (event: React.MouseEvent) => { + event.stopPropagation(); - const getTypeConfig = (type: string) => { - const common = isDark ? 'border border-white/10 text-slate-200' : 'border border-slate-200 text-slate-700'; - switch (type) { - case 'single': - return { - label: '单任务', - icon: Layers, - className: `${common} ${isDark ? 'bg-[rgba(22,119,255,0.14)]' : 'bg-sky-50'}`, - }; - case 'consultation': - return { - label: '会商', - icon: Users, - className: `${common} ${isDark ? 'bg-[rgba(20,184,166,0.14)]' : 'bg-emerald-50'}`, - }; - case 'scene': - return { - label: '场景', - icon: Workflow, - className: `${common} ${isDark ? 'bg-[rgba(168,85,247,0.14)]' : 'bg-violet-50'}`, - }; - case 'multi-agent': - return { - label: '多智能体', - icon: Bot, - className: `${common} ${isDark ? 'bg-[rgba(99,102,241,0.16)]' : 'bg-indigo-50'}`, - }; - default: - return { - label: '检索', - icon: Layers, - className: `${common} ${isDark ? 'bg-[rgba(59,130,246,0.12)]' : 'bg-slate-100'}`, - }; + if (isCompleted) { + handleCardClick(); + return; } + + handleStartAnalysis(task); }; - const getLevel = (priority: number = 1) => { - if (priority >= 3) return '加急'; - if (priority === 2) return '紧急'; - return '一般'; - }; + const panelClass = isDark + ? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(19,39,72,0.9)] shadow-[0_14px_34px_rgba(3,10,22,0.2)] hover:border-[rgba(110,168,255,0.52)]' + : 'border border-[#E8E8E8] bg-white shadow-[0_10px_24px_rgba(245,34,45,0.06)] hover:border-[#F6B0B4] hover:shadow-[0_16px_32px_rgba(245,34,45,0.12)]'; - const getStatusConfig = (column: string) => { - switch (column) { - case 'completed': - return { - label: '已完成', - className: isDark - ? 'bg-[rgba(34,197,94,0.16)] text-emerald-300 border border-emerald-400/20' - : 'bg-emerald-50 text-emerald-600 border border-emerald-100', - }; - case 'running': - case 'analyzing': - return { - label: '分析中', - className: isDark - ? 'bg-[rgba(14,165,233,0.16)] text-sky-300 border border-sky-400/20' - : 'bg-sky-50 text-sky-600 border border-sky-100', - }; - default: - return { - label: '待分析', - className: isDark - ? 'bg-[rgba(96,165,250,0.14)] text-blue-200 border border-blue-300/15' - : 'bg-blue-50 text-blue-600 border border-blue-100', - }; - } - }; + const typeBadgeClass = isDark + ? 'bg-[rgba(77,141,255,0.14)] text-[#A9D2FF] border border-[rgba(108,161,255,0.24)]' + : 'bg-[#FFF1F0] text-[#CF1322] border border-[#FFD6D6]'; - const typeConfig = getTypeConfig(task.type || 'single'); - const statusConfig = getStatusConfig(task.column || 'unanalyzed'); - const TypeIcon = typeConfig.icon; - const progressValue = Math.max(0, Math.min(task.progress || 0, 100)); + const levelClass = isDark + ? 'text-slate-400' + : 'text-[#8C8C8C]'; + + const statusClass = + task.column === 'completed' + ? isDark + ? 'bg-[rgba(18,184,134,0.16)] text-[#59E2B1] border border-[rgba(89,226,177,0.2)]' + : 'bg-[#EEFCF7] text-[#0F9D6C] border border-[#B7F0D5]' + : task.column === 'running' || task.column === 'analyzing' + ? isDark + ? 'bg-[rgba(34,197,94,0.12)] text-[#67E8A5] border border-[rgba(103,232,165,0.2)]' + : 'bg-[#EAF4FF] text-[#1677FF] border border-[#CDE2FF]' + : isDark + ? 'bg-[rgba(96,165,250,0.12)] text-[#8FD1FF] border border-[rgba(143,209,255,0.2)]' + : 'bg-[#FFF1F0] text-[#F5222D] border border-[#FFD6D6]'; + + const actionClass = isCompleted + ? isDark + ? 'bg-[rgba(12,48,82,0.95)] text-white hover:bg-[rgba(15,60,104,0.98)]' + : 'bg-[#FFF1F0] text-[#CF1322] hover:bg-[#FFE4E6]' + : isDark + ? 'bg-[linear-gradient(90deg,#1677FF,#22A8F0)] text-white hover:brightness-110' + : 'bg-[linear-gradient(90deg,#F5222D,#FF6B73)] text-white hover:brightness-105'; return ( -
-
+ - {typeConfig.label} -
- - {statusConfig.label} + {typeConfig.label} + + {getColumnLabel(task.column)} + +
+ +

+ {task.taskName} +

+ +

+ {task.overview || '暂无任务摘要'} +

+ +
+
+ + {task.taskDirection || '未分配'} +
+
优先级:{getTaskLevelLabel(task.taskLevel)}
+
+ +
+
创建于 {format(new Date(task.createTime), 'MM-dd HH:mm')}
+
更新于 {format(new Date(task.updateTime), 'MM-dd HH:mm')}
- -
-
-
- - {task.owner || task.creator || '未分配'} -
-
优先级:{getLevel(task.priority)}
-
- -
-
- 分析进度 - {progressValue}% -
-
-
-
-
- -
- - 创建于 {task.createTime ? format(new Date(task.createTime), 'MM-dd HH:mm') : '--'} - - - 更新于 {task.updateTime ? format(new Date(task.updateTime), 'MM-dd HH:mm') : '--'} - -
-
- -
- {task.column === 'completed' ? ( - - ) : ( - - )} -
-
+ ); }; diff --git a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css index 7ef260bc..5d356922 100644 --- a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css +++ b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css @@ -77,28 +77,16 @@ border-radius: 3px; } -.strategy-data::-webkit-scrollbar, -.strategy-data *::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.strategy-data::-webkit-scrollbar-thumb, -.strategy-data *::-webkit-scrollbar-thumb { - background: rgba(203, 213, 225, 0.75); - border-radius: 999px; -} - -.strategy-data::-webkit-scrollbar-track, -.strategy-data *::-webkit-scrollbar-track { - background: rgba(241, 245, 249, 0.9); - border-radius: 999px; -} - .dark .strategy-data .scrollbar-thin { scrollbar-color: rgba(148, 163, 184, 0.45) transparent; } +.dark .strategy-data::-webkit-scrollbar, +.dark .strategy-data *::-webkit-scrollbar { + width: 8px; + height: 8px; +} + .dark .strategy-data, .dark .strategy-data * { scrollbar-width: thin; @@ -262,27 +250,39 @@ min-width: 320px; } -.sentiment-date-range-popup .t-date-picker__panel, -.sentiment-date-range-popup .t-date-range-picker__panel, -.sentiment-date-range-popup .t-date-picker__panel-body, -.sentiment-date-range-popup .t-date-picker__panel-content, -.sentiment-date-range-popup .t-date-picker__table, -.sentiment-date-range-popup .t-time-picker__panel { +html.dark .sentiment-date-range-popup .t-date-picker__panel, +html.dark .sentiment-date-range-popup .t-date-range-picker__panel, +html.dark .sentiment-date-range-popup .t-date-picker__panel-body, +html.dark .sentiment-date-range-popup .t-date-picker__panel-content, +html.dark .sentiment-date-range-popup .t-date-picker__table, +html.dark .sentiment-date-range-popup .t-time-picker__panel, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-range-picker__panel, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel-body, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel-content, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__table, +html[theme-mode='dark'] .sentiment-date-range-popup .t-time-picker__panel { background: #0d1d33 !important; color: #e6f4ff !important; } -.sentiment-date-range-popup .t-popup__content { +html.dark .sentiment-date-range-popup .t-popup__content, +html[theme-mode='dark'] .sentiment-date-range-popup .t-popup__content { background: #0d1d33 !important; border: 1px solid rgba(110, 136, 176, 0.24); box-shadow: 0 24px 60px rgba(2, 8, 20, 0.42); } -.sentiment-date-range-popup .t-date-picker__cell, -.sentiment-date-range-popup .t-date-picker__cell-inner, -.sentiment-date-range-popup .t-date-picker__header, -.sentiment-date-range-popup .t-date-picker__text, -.sentiment-date-range-popup .t-button { +html.dark .sentiment-date-range-popup .t-date-picker__cell, +html.dark .sentiment-date-range-popup .t-date-picker__cell-inner, +html.dark .sentiment-date-range-popup .t-date-picker__header, +html.dark .sentiment-date-range-popup .t-date-picker__text, +html.dark .sentiment-date-range-popup .t-button, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__cell, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__cell-inner, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__header, +html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__text, +html[theme-mode='dark'] .sentiment-date-range-popup .t-button { color: #e6f4ff !important; } @@ -296,7 +296,7 @@ color: #ffffff; } -.strategy-data .dark-theme:hover { +.dark .strategy-data .dark-theme:hover { background: #0062d966; color: #ffffff; border-color: #0062d9 !important; @@ -307,8 +307,8 @@ border-color: var(--card-border); } -.strategy-data .dark-theme.task-active, -.strategy-data .dark-theme .task-active { +.dark .strategy-data .dark-theme.task-active, +.dark .strategy-data .dark-theme .task-active { border-color: #0062d9 !important; } @@ -351,20 +351,20 @@ background: linear-gradient(180deg, #f5f5f5, #fff 17.89% 100%); } -.strategy-data .dark-action-bg { +.dark .strategy-data .dark-action-bg { background: rgba(3, 38, 80, 0.6) !important; border: 1px solid rgba(43, 143, 254, 0.6) !important; border-radius: 8px !important; box-shadow: 0 1px 16px rgba(0, 0, 0, 0.08) !important; } -.strategy-data .dark-action-item { +.dark .strategy-data .dark-action-item { background: #0062d91a; border: 1px solid #2b8ffe99; border-radius: 8px; } -.strategy-data .dark-action-item:hover { +.dark .strategy-data .dark-action-item:hover { background: #0062d966; border-color: #0062d9; } @@ -373,6 +373,16 @@ .strategy-data .t-loading, .strategy-data .t-loading__parent { min-height: 0; + height: 100%; +} + +.strategy-data .t-loading { + display: block; +} + +.strategy-data .t-loading__parent { + display: flex; + flex-direction: column; } .strategy-data .t-loading--full { From 0bf6285287027c00026f74164a9fdd3d4c3f73e1 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 5 Jun 2026 23:41:28 +0800 Subject: [PATCH 02/20] =?UTF-8?q?fix(roundtable):=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=B4=E6=97=B6=E8=B7=B3=E8=BF=87=20Step3?= =?UTF-8?q?=20=E6=8A=A5=E5=91=8A=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台挂起路径在真模型下仍无法稳定写出报告 HTML,先只跑完 Step2 研讨并正确设置 furthest_step;测试可通过 enable_report 覆盖验证报告链路。 --- .../app/gateway/roundtable_job_executor.py | 20 ++++++++++++-- .../agents/roundtable_orchestrator/engine.py | 26 ++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py index c097d897..a4538c55 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -29,6 +29,15 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user logger = logging.getLogger(__name__) +# ⚠️ 临时禁用「后台挂起」的 Step3 结果绘制(报告 HTML 生成)。 +# 进程内驱动报告 agent 在真模型环境下仍无法稳定写出 HTML(前端手动调 +# /api/multi-agent/report/init + run/stream 可以,但后台 start_run 路径不行, +# 根因尚未定位)。临时方案:后台只跑完 Step2 研讨即收尾,不自动画报告——用户需要 +# 报告时可在前端手动跑 Step3(那条路是通的)。 +# 待修复后把本开关改回 True 即恢复后台自动报告。 +# 详见 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md「后台报告 HTML」。 +ROUNDTABLE_BACKGROUND_REPORT_ENABLED = False + class _JobUser: """最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。""" @@ -164,6 +173,9 @@ class StartParams: is_resume: bool = False # chain 模式来源链条(写进草稿 run 用,展示)。 chain: dict[str, Any] | None = None + # 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。 + # 测试可显式传 True 覆盖以验证报告链路。 + enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -229,6 +241,7 @@ class RoundtableJobExecutor: seed_message=params.seed_message, mode=params.mode, intent_text=params.intent_text, + enable_report=params.enable_report, ) # 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。 await self._write_run_to_draft( @@ -320,8 +333,11 @@ class RoundtableJobExecutor: # 顶层 draft.step3 也写一份(同 Step2 把对话写进 run 的同一次落库一并带上)。 # 前端 hydrateStep3 用**顶层** draft.step3 重建报告气泡(结果绘制的聊天记录 + HTML 预览); # 只存 run 内 step3 时,加载会被 hydrateStep3(draft.step3=None) 覆盖成空 → 报告聊天记录丢失。 - update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if status == "done" else 2} - if effective_step3 is not None: + # furthest_step 跟随**是否真有 step3**:done 且有报告→3(落到结果绘制); + # 否则停在 2(Step2 研讨)。后台禁用报告时 step3=None → 停在 Step2,用户可在前端手动画报告。 + has_report = effective_step3 is not None + update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if (status == "done" and has_report) else 2} + if has_report: update_kwargs["step3"] = effective_step3 try: await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py index 1dd314ac..30203932 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py @@ -72,6 +72,7 @@ async def run_orchestration( seed_message: str, mode: str = "recommend", intent_text: str = "", + enable_report: bool = True, ) -> OrchestrationResult: """运行一次完整编排(循环 → 共识 → 报告绘制)。 @@ -85,6 +86,9 @@ async def run_orchestration( seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。 mode: "recommend" | "chain"。 intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。 + enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、 + 不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent + 在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。 """ intent = (intent_text or seed_message or "").strip() try: @@ -104,17 +108,19 @@ async def run_orchestration( dialogues=progress.dialogues(), ) - # consensus 或 max_cycles:均收口 → 报告绘制 + # consensus 或 max_cycles:均收口。报告绘制按 enable_report 开关执行。 await progress.consensus(100) - step3 = await _run_report( - gateway, - progress, - agents=agents, - model=model, - intent_text=intent_text or seed_message, - consensus_text=outcome.last_leader_content, - deliveries=outcome.deliveries, - ) + step3 = None + if enable_report: + step3 = await _run_report( + gateway, + progress, + agents=agents, + model=model, + intent_text=intent_text or seed_message, + consensus_text=outcome.last_leader_content, + deliveries=outcome.deliveries, + ) await progress.done() return OrchestrationResult( status="done", From 9c9c912657a046106a52483cce1f91e82b634acf Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Sat, 6 Jun 2026 20:23:58 +0800 Subject: [PATCH 03/20] =?UTF-8?q?refactor(strategy):=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=86=E6=9E=90=E9=A1=B5=E7=A7=BB=E9=99=A4=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B9=B6=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 舆情分析与规划任务分析看板统一改为后端任务列表拉取,去掉 mock/演示切换,并修正 running 列状态与日期筛选交互。 --- .../task-components/SentimentAnalyzes.tsx | 514 ++++------------ .../task-components/TaskAnalyzes.tsx | 576 ++++++------------ 2 files changed, 293 insertions(+), 797 deletions(-) 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 5608ff14..d9e34a20 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -15,175 +15,16 @@ interface TaskListPageProps { subCategory: string; } +interface StateItem { + startTime: string; + endTime: string; + task: TaskListItem | null; +} + type ColumnType = 'unanalyzed' | 'running' | 'completed'; const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; -const ENABLE_SENTIMENT_DEMO_BY_DEFAULT = import.meta.env.DEV; - -const buildMockTask = ( - id: number, - taskName: string, - overview: string, - column: ColumnType, - taskType: number, - taskLevel: number, - taskDirection: string, - createTime: string, - updateTime: string, -): TaskListItem => ({ - createBy: 0, - createTime, - deptId: null, - id, - isTop: 0, - issuingDept: '', - keywords: null, - overview, - planTaskTop: 0, - platform: null, - ptaskId: null, - recommendedBasis: null, - scene: JSON.stringify({ - taskJson: JSON.stringify({ - task_title: taskName, - task_goal: overview, - }), - }), - sourceType: 0, - step: null, - taskContent: overview, - taskDirection, - taskEndDate: '', - taskLevel, - taskName, - taskNo: `TASK-${id}`, - taskStartDate: '', - taskStatus: 0, - taskSuperiorFlag: 0, - taskTimeLimit: null, - taskType, - column, - updateBy: 0, - updateTime, - endCount: 0, - sumAll: 0, - progress: 0, - taskStatusData: null, -}); - -const MOCK_TASKS: Record = { - unanalyzed: [ - buildMockTask( - 1001, - '新能源价格舆情波动分析', - '聚焦近 7 日新能源整车与动力电池价格讨论热度,识别社媒与媒体端异常扩散信号。', - 'unanalyzed', - 1, - 2, - '王晓楠', - '2026-06-05 09:10:00', - '2026-06-05 09:10:00', - ), - buildMockTask( - 1002, - '重点项目负面反馈聚类', - '从任务评论、审批意见和业务群消息中抽取高频情绪主题,辅助判断风险聚焦点。', - 'unanalyzed', - 3, - 3, - '刘志诚', - '2026-06-05 08:40:00', - '2026-06-05 08:40:00', - ), - buildMockTask( - 1003, - '竞品发布会用户情绪追踪', - '围绕发布会直播、短视频评论与论坛帖子,建立情绪转折时间线并识别主要触发事件。', - 'unanalyzed', - 2, - 1, - '陈思雨', - '2026-06-04 18:20:00', - '2026-06-04 18:20:00', - ), - ], - running: [ - buildMockTask( - 2001, - '新车上市口碑监测会商', - '正在联合多个智能体汇总平台评价、媒体测评与用户问答,生成阶段性情绪画像。', - 'running', - 4, - 3, - '张雨桐', - '2026-06-05 07:50:00', - '2026-06-05 10:05:00', - ), - buildMockTask( - 2002, - '投诉热点归因拆解', - '拆分售后投诉中关于续航、交付和门店服务的负面占比,分析主要归因与蔓延路径。', - 'running', - 3, - 2, - '何嘉成', - '2026-06-04 20:10:00', - '2026-06-05 09:48:00', - ), - ], - completed: [ - buildMockTask( - 3001, - '车展传播效果情绪复盘', - '已输出传播节点热度变化、核心评论主题以及 KOL 内容对情绪曲线的影响分析。', - 'completed', - 1, - 1, - '孙怡宁', - '2026-06-03 14:20:00', - '2026-06-04 17:30:00', - ), - buildMockTask( - 3002, - '智能驾驶争议议题扫描', - '已完成争议议题聚类、情绪倾向分层和外部平台扩散链路分析,可直接查看结论。', - 'completed', - 2, - 3, - '李承泽', - '2026-06-03 09:00:00', - '2026-06-04 11:45:00', - ), - buildMockTask( - 3003, - '品牌联名活动舆情总结', - '已完成用户态度分布统计与正负面声音对比,附带评论样本摘要和建议动作。', - 'completed', - 5, - 2, - '周清越', - '2026-06-02 16:50:00', - '2026-06-03 19:12:00', - ), - ], -}; - -const paginateTasks = (tasks: TaskListItem[], pageIndex: number, pageSize: number) => { - const start = (pageIndex - 1) * pageSize; - return tasks.slice(start, start + pageSize); -}; - -const normalizeRangeValue = (range: DateRangeValue) => { - if (!Array.isArray(range) || range.length < 2) { - return { startTime: '', endTime: '' }; - } - - return { - startTime: dayjs(range[0]).format('YYYY-MM-DD HH:mm:ss'), - endTime: dayjs(range[1]).format('YYYY-MM-DD HH:mm:ss'), - }; -}; const SentimentAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); @@ -192,9 +33,8 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa const isDark = theme === 'dark'; const [searchQuery, setSearchQuery] = useState(''); - const [useDemoData, setUseDemoData] = useState(ENABLE_SENTIMENT_DEMO_BY_DEFAULT); - const [selectedTask, setSelectedTask] = useState(null); const [showAnalysisModal, setShowAnalysisModal] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); const [runningTotal, setRunningTotal] = useState(0); const [completedTotal, setCompletedTotal] = useState(0); @@ -205,7 +45,12 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa const [runningLoading, setRunningLoading] = useState(false); const [completedLoading, setCompletedLoading] = useState(false); const [range, setRange] = useState([]); - const pageSizeRef = useRef(10); + + const stateRef = useRef({ + startTime: '', + endTime: '', + task: null, + }); const presets = useMemo( () => ({ @@ -216,164 +61,132 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa [], ); - const filteredMock = useMemo(() => { - const keyword = searchQuery.trim().toLowerCase(); - const filterList = (list: TaskListItem[]) => { - if (!keyword) return list; - return list.filter((item) => - `${item.taskName} ${item.overview} ${item.taskDirection}`.toLowerCase().includes(keyword), - ); - }; + const taskStatusMap: Record = { + unanalyzed: [0, 10], + running: [20], + completed: [25, 30, 40, 60, 70, 80, 90, 100, 110], + }; - return { - unanalyzed: filterList(MOCK_TASKS.unanalyzed), - running: filterList(MOCK_TASKS.running), - completed: filterList(MOCK_TASKS.completed), - }; - }, [searchQuery]); + const setRangeChange = async (value: DateRangeValue) => { + setRange(value); + stateRef.current.startTime = ''; + stateRef.current.endTime = ''; - const applyMockColumn = (column: ColumnType, pageIndex = 1) => { - const source = filteredMock[column]; - const pageList = paginateTasks(source, pageIndex, pageSizeRef.current); - - if (column === 'unanalyzed') { - setUnanalyzedTotal(source.length); - setUnanalyzedList(pageList); - setUnanalyzedLoading(false); - return; + if (Array.isArray(value) && value.length > 1) { + stateRef.current.startTime = String(value[0] || ''); + stateRef.current.endTime = String(value[1] || ''); } - if (column === 'running') { - setRunningTotal(source.length); - setRunningList(pageList); - setRunningLoading(false); - return; - } - - setCompletedTotal(source.length); - setCompletedList(pageList); - setCompletedLoading(false); + await taskDataChange(); }; const taskListSearch = async ( - column: ColumnType, - pageIndex = 1, - pageSize = pageSizeRef.current, + taskType: ColumnType, + pageNum = 1, + pageSize = 10, ) => { - pageSizeRef.current = pageSize; - - if (column === 'unanalyzed') setUnanalyzedLoading(true); - if (column === 'running') setRunningLoading(true); - if (column === 'completed') setCompletedLoading(true); - - if (useDemoData) { - applyMockColumn(column, pageIndex); - return; - } - - const taskStatusMap: Record = { - unanalyzed: [0, 10], - running: [20], - completed: [25, 30, 40, 60, 70, 80, 90, 100, 110], - }; + if (taskType === 'unanalyzed') setUnanalyzedLoading(true); + if (taskType === 'running') setRunningLoading(true); + if (taskType === 'completed') setCompletedLoading(true); try { - const { startTime, endTime } = normalizeRangeValue(range); const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, { - content: searchQuery.trim(), - pageNum: pageIndex, + content: searchQuery, + pageNum, pageSize, - taskStatus: taskStatusMap[column], - startTime, - endTime, + taskStatus: taskStatusMap[taskType], + startTime: stateRef.current.startTime, + endTime: stateRef.current.endTime, taskDirection: '', }); - const result = response?.data ?? response ?? {}; + const result = response?.data ?? {}; const records = Array.isArray(result.records) ? result.records : []; - const total = Number(result.total ?? records.length); - const mapped = records.map((item: TaskListItem) => ({ + const normalized = records.map((item: TaskListItem) => ({ ...item, - column, + column: taskType, taskLevel: item.taskLevel || 1, })); + const total = Number(result.total ?? normalized.length); - if (column === 'unanalyzed') { - setUnanalyzedList(mapped); + if (taskType === 'unanalyzed') { setUnanalyzedTotal(total); - } else if (column === 'running') { - setRunningList(mapped); + setUnanalyzedList(normalized); + } else if (taskType === 'running') { setRunningTotal(total); + setRunningList(normalized); } else { - setCompletedList(mapped); setCompletedTotal(total); + setCompletedList(normalized); } - } catch (error) { - console.error('sentiment task list fetch failed', error); - setUseDemoData(true); - applyMockColumn(column, pageIndex); } finally { - if (column === 'unanalyzed') setUnanalyzedLoading(false); - if (column === 'running') setRunningLoading(false); - if (column === 'completed') setCompletedLoading(false); + if (taskType === 'unanalyzed') setUnanalyzedLoading(false); + if (taskType === 'running') setRunningLoading(false); + if (taskType === 'completed') setCompletedLoading(false); } }; - const refreshColumns = () => { - taskListSearch('unanalyzed', 1); - taskListSearch('running', 1); - taskListSearch('completed', 1); + const pageIndexChange = async (type: string, pageIndex: number) => { + await taskListSearch(type as ColumnType, pageIndex); }; - useEffect(() => { - refreshColumns(); - }, [useDemoData]); - - useEffect(() => { - const timer = window.setTimeout(() => { - refreshColumns(); - }, 200); - - return () => window.clearTimeout(timer); - }, [searchQuery, range]); + const taskDataChange = async (pageNum = 1, pageSize = 10) => { + await Promise.all([ + taskListSearch('unanalyzed', pageNum, pageSize), + taskListSearch('running', pageNum, pageSize), + taskListSearch('completed', pageNum, pageSize), + ]); + }; const handleStartAnalysis = (task: TaskListItem) => { setSelectedTask(task); + stateRef.current.task = task; setShowAnalysisModal(true); }; const handleOpenTaskDetail = () => { - if (!selectedTask) return; - - setStartConfig({ - task_title: selectedTask.taskName, - task_goal: selectedTask.overview, - task_context: selectedTask.overview, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: { diverge_rounds: 3 }, - }); - + if (!stateRef.current.task) return; + window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${stateRef.current.task.id}`); setShowAnalysisModal(false); - navigate('/qa-center/mope-chat'); }; - const handleOpenRoundtable = () => { - if (!selectedTask) return; + const handleOpenConsultation = () => { + const task = stateRef.current.task; + if (!task) return; - setStartConfig({ - task_title: selectedTask.taskName, - task_goal: selectedTask.overview, - task_context: selectedTask.overview, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: { diverge_rounds: 3 }, - }); - - setShowAnalysisModal(false); - navigate('/page/workspace/roundtable/planning'); + 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 handleKeyDown = async (event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey || event.shiftKey) && event.key === 'Enter') { + event.preventDefault(); + } else if (event.key === 'Enter') { + event.preventDefault(); + await taskDataChange(); + } else if (event.key === '/') { + event.preventDefault(); + } + }; + + useEffect(() => { + taskDataChange(); + }, []); + const shellClass = isDark ? 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)]' : 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#FFFDFD_0%,#FFF7F7_46%,#FAFAFA_100%)]'; @@ -390,9 +203,8 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(8,19,35,0.9)] text-white placeholder:text-slate-500' : 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]'; - const totalCount = unanalyzedTotal + runningTotal + completedTotal; const metrics = [ - { label: '全部任务', value: totalCount }, + { label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal }, { label: '未分析', value: unanalyzedTotal }, { label: '分析中', value: runningTotal }, { label: '已完成', value: completedTotal }, @@ -405,38 +217,17 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa
- + {category} - + {subCategory}
-
-

- {title} -

- {useDemoData ? ( - - 演示数据 - - ) : null} -
+

{title}

面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。

@@ -445,13 +236,12 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa
-
-
- - - -
+
@@ -518,27 +274,11 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa

{item.label}

-

+

{item.value}

-
+
@@ -557,7 +297,7 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa tasks={unanalyzedList} taskType="unanalyzed" taskDataType="sentiment" - pageIndexSearch={taskListSearch} + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -574,7 +314,7 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa tasks={runningList} taskType="running" taskDataType="sentiment" - pageIndexSearch={taskListSearch} + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -591,7 +331,7 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa tasks={completedList} taskType="completed" taskDataType="sentiment" - pageIndexSearch={taskListSearch} + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -602,13 +342,7 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa {showAnalysisModal && selectedTask ? (
-
+

选择分析模式

@@ -629,16 +363,12 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa - -
+
@@ -481,11 +293,11 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor

{label}

-

{value}

+

+ {value} +

-
+
@@ -493,26 +305,6 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor ))}
-
-
-
-
分析工作台
-
- 当前为演示任务数据,便于预览深色模式下的多列分析工作流。 -
-
-
- 目标拆解 / 受众判断 / 会商收敛 -
-
-
-
@@ -523,9 +315,7 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor color="bg-[#7C9BFF]" tasks={unanalyzedList} taskType="unanalyzed" - pageIndexSearch={(taskType, pageIndex) => - taskListSearch(taskType as ColumnType, pageIndex) - } + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -540,10 +330,8 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor count={runningTotal} color="bg-[#22C55E]" tasks={runningList} - taskType="analyzing" - pageIndexSearch={(taskType, pageIndex) => - taskListSearch(taskType as ColumnType, pageIndex) - } + taskType="running" + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -559,9 +347,7 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor color="bg-[#38BDF8]" tasks={completedList} taskType="completed" - pageIndexSearch={(taskType, pageIndex) => - taskListSearch(taskType as ColumnType, pageIndex) - } + pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
@@ -570,28 +356,20 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor
- {showAnalysisModal && selectedTaskForAnalysis ? ( + {showAnalysisModal && selectedTask ? (
-
+

选择分析模式

- 当前任务:{selectedTaskForAnalysis.taskName} + 当前任务:{selectedTask.taskName}

@@ -600,12 +378,8 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor
+ ); + })} +
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 805d54b5..e90ca228 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -16,17 +16,25 @@ * * 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。 */ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Cpu, FileIcon, HelpCircle, Loader2, + Network, Pause, Play, Sparkles, } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + /** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */ const SHOW_BACKGROUND_SUSPEND = false; @@ -42,13 +50,19 @@ import { StreamingCursor, } from "@/roundtable-planning/components/MessageBubble"; import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; +import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip"; +import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import type { AgentConfig, + ParallelSeatView, Step2Dialogue, } from "@/roundtable-planning/hooks/useStep2Orchestration"; -import type { - RoleEntry, +import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types"; +import type { ChainNodeState } from "@/roundtable-planning/lib/job-types"; +import { + getAvatarTypeFor, + type RoleEntry, } from "@/roundtable-planning/lib/roundtable-constants"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; @@ -71,6 +85,18 @@ export interface Step2PanelProps { agentConfigs: Record; selectedAgentId: number | null; roles: RoleEntry[]; + // ─── 并行显示(§5)──────────────────────────────────────────── + parallelBatch: ParallelSeatView[]; + selectedParallelSeats: string[]; + isParallelRunning: boolean; + onToggleParallelSeat: (agentId: string) => void; + // ─── Phase 7 DAG 流程图执行态总览(§7)───────────────────────── + /** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */ + orchestrationMode: "recommend" | "chain" | "dag"; + /** dag 模式的分层计划(流程图按 stage 分列渲染)。 */ + orchestrationPlan: OrchestrationPlan | null; + /** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */ + dagNodeStates: Record; /** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */ agentNameToRole: Record< string, @@ -120,6 +146,13 @@ export function Step2Panel(props: Step2PanelProps) { agentConfigs, selectedAgentId, roles, + parallelBatch, + selectedParallelSeats, + isParallelRunning, + onToggleParallelSeat, + orchestrationMode, + orchestrationPlan, + dagNodeStates, agentNameToRole, selectedModel, availableModels, @@ -147,12 +180,74 @@ export function Step2Panel(props: Step2PanelProps) { const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); + // 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换, + // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 + // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 + useEffect(() => { + scrollStep2ToBottom(); + }, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]); + const resolveAgentName = useCallback( (idOrName: string) => agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName, [agentNameToRole], ); + // 并行两列:按 bubbleId 取席位流式气泡。固定 2 列(窄屏 1 列,见 CSS 断点)。 + const dialogueById = useMemo(() => { + const m = new Map(); + for (const d of step2RoundtableDialogues) m.set(d.id, d); + return m; + }, [step2RoundtableDialogues]); + // 主区两列要显示的席位:取被选中的(最多 2),回退到批次最先两个。 + const parallelColumns = useMemo(() => { + if (!isParallelRunning) return []; + const pick = selectedParallelSeats.length > 0 + ? selectedParallelSeats + : parallelBatch.slice(0, 2).map((s) => s.agentId); + return pick + .map((agentId) => parallelBatch.find((s) => s.agentId === agentId)) + .filter((s): s is ParallelSeatView => Boolean(s)) + .slice(0, 2); + }, [isParallelRunning, selectedParallelSeats, parallelBatch]); + + // ─── Phase 7 DAG 流程图执行态总览(§7)────────────────────────────────── + const showDagFlow = + orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0; + const resolveDagSeat = useCallback( + (agentId: string, order: number) => { + const meta = agentNameToRole[agentId.toLowerCase()]; + return { + name: meta?.displayName ?? agentId, + avatarType: meta?.avatarType ?? getAvatarTypeFor(agentId, order), + }; + }, + [agentNameToRole], + ); + // 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。 + const { dagCoordinatorState, dagConsensusState } = useMemo(() => { + const states = Object.values(dagNodeStates); + const anyActive = states.includes("active"); + const anyStarted = states.some((s) => s !== "pending"); + const allDone = states.length > 0 && states.every((s) => s === "done"); + const coordinator: ChainNodeState = anyActive + ? "done" + : isOrchestrating + ? "active" + : anyStarted + ? "done" + : "pending"; + const consensus: ChainNodeState = hasConsensus + ? "done" + : allDone + ? "active" + : "pending"; + return { dagCoordinatorState: coordinator, dagConsensusState: consensus }; + }, [dagNodeStates, isOrchestrating, hasConsensus]); + + // Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。 + const [dagFlowOpen, setDagFlowOpen] = useState(false); + // 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动 const [pauseFlash, setPauseFlash] = useState(false); @@ -249,6 +344,54 @@ export function Step2Panel(props: Step2PanelProps) { {/* Conversation log */}
+ {/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */} + {showDagFlow && orchestrationPlan && ( +
+ + 分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段 + + +
+ )} + {showDagFlow && orchestrationPlan && ( + + + + + + 分层编排流程图 + + +
+ +
+
+
+ )} + {/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */} + {isParallelRunning && ( +
+ +
+ )}
正在为各席位建立独立线程,请稍候
)} - {step2RoundtableDialogues.map((msg) => ( - openArtifactFromStep(msg, step)} - onFileCardClick={(path) => { - if (msg.agentThreadId) { - openArtifactPreview(msg.agentThreadId, path); - } - }} - /> - ))} + {isParallelRunning && parallelColumns.length > 0 ? ( + // 并行进行时:主区固定 2 列(窄屏降 1 列),每列渲染一个被选中席位的消息流。 + // 未选中席位的气泡仍在 step2RoundtableDialogues 内存里(只是此刻不渲染), + // 切换头像即可立刻看到其全部输出(§6 全接收/惰性渲染的基础)。 +
1 + ? "grid grid-cols-1 gap-4 lg:grid-cols-2" + : "grid grid-cols-1 gap-4" + } + > + {parallelColumns.map((seat) => { + const msg = dialogueById.get(seat.bubbleId); + return ( +
+ {msg ? ( + openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path); + }} + /> + ) : ( +
+ {seat.displayName} · 等待输出… +
+ )} +
+ ); + })} +
+ ) : ( + step2RoundtableDialogues.map((msg) => ( + openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) { + openArtifactPreview(msg.agentThreadId, path); + } + }} + /> + )) + )}
{!step2AtBottom && }
diff --git a/frontend-web/src/roundtable-planning/components/markdown-components.tsx b/frontend-web/src/roundtable-planning/components/markdown-components.tsx index 81f6adab..3ef23788 100644 --- a/frontend-web/src/roundtable-planning/components/markdown-components.tsx +++ b/frontend-web/src/roundtable-planning/components/markdown-components.tsx @@ -60,8 +60,11 @@ export const markdownComponents = { {children} ), + // 宽表套横向滚动容器,避免超出卡片宽度(同 MessageBubble.sharedMarkdownComponents)。 table: ({ children }: any) => ( - {children}
+
+ {children}
+
), th: ({ children }: any) => ( diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 8516181c..7decbaf5 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -25,6 +25,12 @@ import { type StreamPhase, type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; +import { + buildDagStageLeaderTask, + pickDagSeatTask, + type OrchestrationPlan, +} from "@/roundtable-planning/lib/dag-types"; +import type { ChainNodeState } from "@/roundtable-planning/lib/job-types"; import { DEFAULT_SELECTED_AGENTS, MAX_CYCLES, @@ -56,6 +62,20 @@ export type Step2Dialogue = { }>; }; +/** + * 并行批次里的一个席位视图(§5 并行显示)。`runDagOrchestration` 在真并行 stage + * 期间发布整批;头像条 / 两列主区据此渲染,`bubbleId` 指向 `step2RoundtableDialogues` + * 里该席位的流式气泡。 + */ +export interface ParallelSeatView { + agentId: string; + displayName: string; + avatarType: string; + status: "running" | "done" | "error"; + /** 该席位流式气泡在 step2RoundtableDialogues 里的 id。 */ + bubbleId: number; +} + /** * Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext * 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL @@ -110,9 +130,11 @@ export type Step2Snapshot = { * 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写, * step2 hook 的 hydrateFromDraft 不直接消费它。 */ - orchestrationMode?: "recommend" | "chain"; + orchestrationMode?: "recommend" | "chain" | "dag"; /** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */ chain?: { id: string; title: string } | null; + /** dag 模式的分层编排计划(见 `lib/dag-types.ts`)。同样是主页面 state,hook 经 option 读取。 */ + orchestrationPlan?: OrchestrationPlan | null; }; /** @@ -191,11 +213,25 @@ export interface UseStep2OrchestrationOptions { /** * 编排模式: * - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`); - * - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。 + * - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`); + * - `dag` —— **分层 DAG**:前端直派,stage 内并行、stage 间串行(`runDagOrchestration`, + * 需配合 `orchestrationPlan`)。 * 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 = * `selectedAgents` 的顺序。 */ - orchestrationMode: "recommend" | "chain"; + orchestrationMode: "recommend" | "chain" | "dag"; + /** + * dag 模式的分层编排计划。`orchestrationMode === "dag"` 且本计划非空时, + * `runActiveOrchestration` 走 `runDagOrchestration`;否则回退到 recommend / chain。 + */ + orchestrationPlan?: OrchestrationPlan | null; + /** + * dag 执行子模式(仅 `orchestrationMode === "dag"` 生效): + * - `fast`(默认):同 stage 席位并行、主区**双列**、**禁写文件**(seat_parallel); + * - `sandbox`:同 stage 席位仍并行,但主区**单列**、**允许写文件**(seat),沙箱跟随当前 + * 显示的席位(切头像 → 沙箱内容随之切换/关闭)。 + */ + dagExecMode?: "fast" | "sandbox"; /** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */ buildInitialIntent: () => string; /** 主页面统一的 toast。 */ @@ -228,6 +264,17 @@ export interface UseStep2OrchestrationReturn { budgetLimit: number; agentConfigs: Record; selectedAgentId: number | null; + // ─── 并行显示(§5)──────────────────────────────────────────────────── + /** 当前并行批次(非空 = 正在并行 stage)。 */ + parallelBatch: ParallelSeatView[]; + /** 主区当前并排显示的席位 agentId(最多 2)。 */ + selectedParallelSeats: string[]; + /** 是否正在并行执行(左侧栏折叠 / 头像条 / 两列显示的总开关)。 */ + isParallelRunning: boolean; + /** 点头像切换主区显示的席位(LRU,最多 2)。 */ + toggleParallelSeat: (agentId: string) => void; + /** Phase 7 DAG 流程图执行态:agentId(小写) → pending/active/done。非 dag 模式为空。 */ + dagNodeStates: Record; // ─── Derived ──────────────────────────────────────────────────────── roleTemplates: ReadonlyArray>; agentNameToRole: Record< @@ -297,6 +344,8 @@ export function useStep2Orchestration( currentStep, selectedModel, orchestrationMode, + orchestrationPlan, + dagExecMode = "fast", buildInitialIntent, triggerToast, setRoles, @@ -364,6 +413,30 @@ export function useStep2Orchestration( DEFAULT_AGENT_CONFIGS, ); const [selectedAgentId, setSelectedAgentId] = useState(null); + // 并行批次(§5):runDagOrchestration 在真并行 stage 期间发布;空 = 非并行。 + const [parallelBatch, setParallelBatch] = useState([]); + // Phase 7 DAG 流程图执行态(§7):agentId(小写) → pending/active/done。 + // runDagOrchestration 跨 stage 维护;DagFlowChart 据此实时点亮节点。非 dag 模式 = 空。 + const [dagNodeStates, setDagNodeStates] = useState>({}); + // 主区当前并排显示的席位(最多 2,固定 2 列;窄屏只渲染第 1 个)。 + const [selectedParallelSeats, setSelectedParallelSeats] = useState([]); + // §6 流式高性能:所有并行席位的增量文本**全量累积进 ref**(O(1),不触发渲染); + // 只有被选中的席位才在节流 flush 时把全文挂到 React state 渲染 Markdown。 + // key = bubbleId。 + const parallelStreamRef = useRef>( + new Map(), + ); + const parallelFlushTimerRef = useRef(null); + // flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免 + // 把 flush 函数绑进会频繁变化的 state 依赖。 + const selectedParallelSeatsRef = useRef([]); + const parallelBatchRef = useRef([]); + useEffect(() => { + selectedParallelSeatsRef.current = selectedParallelSeats; + }, [selectedParallelSeats]); + useEffect(() => { + parallelBatchRef.current = parallelBatch; + }, [parallelBatch]); // ─── Derived ────────────────────────────────────────────────────── const roleTemplates = useMemo( @@ -403,10 +476,29 @@ export function useStep2Orchestration( const isOrchestratingRef = useRef(isOrchestrating); // chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。 const orchestrationModeRef = useRef(orchestrationMode); + const orchestrationPlanRef = useRef(orchestrationPlan ?? null); + // dag 执行子模式(fast/sandbox) + 沙箱模式下「当前允许显示沙箱的席位 thread」。 + // sandbox 模式单列:只有**当前显示**的席位才可自动打开沙箱;后台并行席位写文件不抢占 + // (见 openSeatVirtualArtifact 的 auto 守卫)。null = 非 sandbox 模式 / 无限制。 + const dagExecModeRef = useRef<"fast" | "sandbox">(dagExecMode); + const sandboxFollowThreadIdRef = useRef(null); + // 「上次已 reconcile 的显示席位」键(`${mode}:${seat}`)。沙箱跟随 effect 只在**显示席位真正 + // 变化**时开/关沙箱,避免每次 render(deps 身份变化)都强制重开,把用户手动关闭的沙箱又弹回来。 + const lastReconciledSeatRef = useRef(""); + useEffect(() => { + dagExecModeRef.current = dagExecMode; + }, [dagExecMode]); + // dag 计划「已跑过一遍」闸门:DAG 编排只在初次执行一次计划;之后的用户干预 / 恢复 / + // 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是** + // 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。 + const dagPlanExecutedRef = useRef(false); const selectedAgentsRef = useRef(selectedAgents); useEffect(() => { orchestrationModeRef.current = orchestrationMode; }, [orchestrationMode]); + useEffect(() => { + orchestrationPlanRef.current = orchestrationPlan ?? null; + }, [orchestrationPlan]); useEffect(() => { selectedAgentsRef.current = selectedAgents; }, [selectedAgents]); @@ -504,6 +596,24 @@ export function useStep2Orchestration( if (hasAutoOpenedRef.current.has(virtualUrl)) return; const flags = autoOpenFlagsRef.current; if (!flags.autoOpen || !flags.autoSelect) return; + // 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写 + // 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。 + if ( + orchestrationModeRef.current === "dag" && + dagExecModeRef.current === "fast" && + parallelBatchRef.current.length > 0 + ) { + return; + } + // sandbox 单列模式:并行的**后台**席位写文件时不要抢占沙箱 —— 只有当前显示的 + // 席位(sandboxFollowThreadIdRef)才允许自动打开,否则沙箱内容会和主区列表错位。 + if ( + dagExecModeRef.current === "sandbox" && + sandboxFollowThreadIdRef.current && + sandboxFollowThreadIdRef.current !== seatThreadId + ) { + return; + } hasAutoOpenedRef.current.add(virtualUrl); } setActiveArtifactThreadId(seatThreadId); @@ -530,6 +640,32 @@ export function useStep2Orchestration( [], ); + /** + * sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。 + * 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。 + * 无产物返回 false(调用方据此关闭沙箱)。 + */ + const openSeatLatestArtifact = useCallback( + (seatThreadId: string): boolean => { + const stub = seatStubMessagesRef.current[seatThreadId]?.[0]; + if (!stub) return false; + for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) { + const tc = stub.tool_calls[i]; + if (tc.name !== "write_file" && tc.name !== "str_replace") continue; + const path = + typeof (tc.args as { path?: unknown })?.path === "string" + ? ((tc.args as { path?: string }).path as string) + : ""; + if (!path) continue; + const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id); + openSeatVirtualArtifact(seatThreadId, virtualUrl, false); + return true; + } + return false; + }, + [buildWriteFileVirtualUrl, openSeatVirtualArtifact], + ); + /** * 反查某个产出文件是由哪个席位 thread 写入的。 * @@ -888,6 +1024,120 @@ export function useStep2Orchestration( ); }, []); + /** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */ + const setDialogueContent = useCallback((id: number, fullText: string) => { + setStep2RoundtableDialogues((prev) => + prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)), + ); + }, []); + + // ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ─────────────────────────────── + /** + * 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到 + * React state 渲染 Markdown(≤2 个)。未选中席位只在 ref 里累积、不挂 DOM —— 6+ + * 并行也不卡。切到它时下拍即显全文(再叠加选中变化 effect 的即时挂载)。 + */ + const flushParallelStreams = useCallback(() => { + parallelFlushTimerRef.current = null; + const store = parallelStreamRef.current; + if (store.size === 0) return; + const selected = new Set(selectedParallelSeatsRef.current); + for (const seat of parallelBatchRef.current) { + if (selected.has(seat.agentId)) { + const entry = store.get(seat.bubbleId); + if (entry) setDialogueContent(seat.bubbleId, entry.text); + } + } + }, [setDialogueContent]); + + const scheduleParallelFlush = useCallback(() => { + if (parallelFlushTimerRef.current !== null) return; + parallelFlushTimerRef.current = window.setTimeout(flushParallelStreams, 80); + }, [flushParallelStreams]); + + /** 并行席位的 onTextDelta:全量累积进 ref(O(1)),再调度一次节流 flush。 */ + const ingestParallelDelta = useCallback( + (bubbleId: number, text: string) => { + if (!text) return; + const store = parallelStreamRef.current; + const cur = store.get(bubbleId) ?? { text: "", charCount: 0 }; + cur.text += text; + cur.charCount = cur.text.length; + store.set(bubbleId, cur); + scheduleParallelFlush(); + }, + [scheduleParallelFlush], + ); + + /** 清空并行流式 store + 取消挂起的 flush(stage 结束 / 抢占 / 异常兜底)。 */ + const resetParallelStreams = useCallback(() => { + parallelStreamRef.current = new Map(); + if (parallelFlushTimerRef.current !== null) { + window.clearTimeout(parallelFlushTimerRef.current); + parallelFlushTimerRef.current = null; + } + }, []); + + // 卸载兜底:清掉可能挂起的 flush 定时器。 + useEffect(() => resetParallelStreams, [resetParallelStreams]); + + // 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出, + // 证明流没丢、只是之前没渲染,§6.5)。 + useEffect(() => { + const store = parallelStreamRef.current; + if (store.size === 0) return; + const selected = new Set(selectedParallelSeats); + for (const seat of parallelBatchRef.current) { + if (selected.has(seat.agentId)) { + const entry = store.get(seat.bubbleId); + if (entry) setDialogueContent(seat.bubbleId, entry.text); + } + } + }, [selectedParallelSeats, setDialogueContent]); + + // sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时, + // 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。 + // 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact + // 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。 + useEffect(() => { + const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : ""; + // 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact / + // setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱 + // 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。 + const key = `${dagExecMode}:${seat}`; + if (lastReconciledSeatRef.current === key) return; + lastReconciledSeatRef.current = key; + + if (dagExecMode !== "sandbox" || !seat) { + sandboxFollowThreadIdRef.current = null; + return; + } + const ids = threadIdsRef.current; + if (!ids) { + sandboxFollowThreadIdRef.current = null; + return; + } + const tid = resolveSeatThreadId(ids, seat); + sandboxFollowThreadIdRef.current = tid ?? null; + if (!tid) return; + const opened = openSeatLatestArtifact(tid); + if (!opened) { + // 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→ + // 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen); + // 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false + // (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的 + // `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。 + setActiveArtifactThreadId(null); + deselectArtifact(); + } + }, [ + selectedParallelSeats, + dagExecMode, + resolveSeatThreadId, + openSeatLatestArtifact, + deselectArtifact, + ]); + const finalizeDialogue = useCallback( (id: number, finalContent: string, confidence?: string) => { setStep2RoundtableDialogues((prev) => @@ -975,6 +1225,43 @@ export function useStep2Orchestration( ); }, [setRoles]); + /** + * DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming + * 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。 + * activeSpeakerSet 天然是 Set,这里放整批 speakerId。 + */ + const markParallelSpeakers = useCallback( + (speakerIds: number[]) => { + const wanted = new Set(speakerIds); + setActiveSpeakerSet(new Set(speakerIds)); + setRoles((prev) => + prev.map((item) => { + if (wanted.has(item.id)) return { ...item, status: "研讨中" }; + if (item.status === "研讨中") return { ...item, status: "在线" }; + return item; + }), + ); + }, + [setRoles], + ); + + /** + * 点头像切换主区显示的席位(§5.3):已选中→取消;未选中→加入,超过 2 个按 LRU + * 踢掉最早选中的。被换下的席位停止渲染但流仍在内存累积(气泡始终在列表里)。 + */ + const toggleParallelSeat = useCallback((agentId: string) => { + setSelectedParallelSeats((prev) => { + // sandbox 单列:点头像 = 切换到该席位(始终保留 1 个,点已选中的不取消到空)。 + if (dagExecModeRef.current === "sandbox") { + return prev.length === 1 && prev[0] === agentId ? prev : [agentId]; + } + // fast 双列:点已选中 → 取消;未选中 → 加入,超过 2 个按 LRU 踢最早的。 + if (prev.includes(agentId)) return prev.filter((id) => id !== agentId); + const next = [...prev, agentId]; + return next.length > 2 ? next.slice(next.length - 2) : next; + }); + }, []); + // ─── Abort ──────────────────────────────────────────────────────── const abortAllInFlight = useCallback(() => { activeAbortersRef.current.forEach((ctrl) => { @@ -1483,13 +1770,363 @@ export function useStep2Orchestration( ], ); + // ─── DAG orchestration loop ─────────────────────────────────────── + // dag 模式(§3 + §3.2 方案 A,2026-06-07 改成「总控驱动」):分层执行 —— stage 内**并行** + // (Promise.all)、stage 间**串行**(Promise.all 即 barrier)。每个 stage 的流程与串行同构: + // 1) **总控派活轮**:由【总控】对本 stage 计划钉死的席位**真正派活**(buildDagStageLeaderTask + // 约束它只派给这些席位、且每个都派到;不自由乱派、不收口)。总控本轮也"承上"——后端 + // _build_seat_status_block 走轻量 last-ai-message 把此前各席位真实交付(摘要)注入其输入 + // (§传摘要按需传全文); + // 2) **批内并行**:本 stage 席位并行跑总控派给各自的 task(pickDagSeatTask 命中其 agent_name, + // 漏派则回退总控正文/模板,不借用别席位 task);完成后交付经后端 detached 广播回总控; + // 3) barrier 后进入下一 stage,总控再派下一批(它已能看到上一批交付摘要)。 + // 所有 stage 跑完后,把锁交回 runOrchestration 由总控综合 / 收口(此时 synthesisMode 才按需 + // 注入各席位**完整交付正文**——全文只在收口这一轮付出)。 + // + // 与「前端直派(方案 B)」的区别:每 stage 都经总控决策派活(像串行/链条那样),计划只钉死 + // 「派给谁」、总控负责「派什么活 + 承上启下」。与 chain 模式同构,区别是一次钉死一批、批内并行。 + // + // 与 runOrchestration / runChainOrchestration 的「不要 Promise.all」不变量不同 —— + // DAG 并行就是要 Promise.all;N 路 token 级 setState 的卡顿由 Phase 6(全接收 / + // 惰性渲染 / 节流)解决,且单 stage 并行度有 warning 上限(PARALLEL_DEGREE_WARN_THRESHOLD)。 + const runDagOrchestration = useCallback( + async (seedMessage: string, ids: ThreadIdMap, coord: string) => { + const plan = orchestrationPlanRef.current; + if (!plan || plan.stages.length === 0) { + triggerToast("⚠️ 没有可执行的 DAG 编排计划"); + return; + } + if (isOrchestratingRef.current) return; + // 同步抢占编排锁(原因同 runOrchestration):堵住人工干预 / 恢复 toggle 在同一 + // tick 内的二次触发,避免并发双发。 + isOrchestratingRef.current = true; + setIsOrchestrating(true); + orchestrationCancelRef.current = false; + const myEpoch = (orchestrationEpochRef.current += 1); + const isStale = () => + orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch; + // 标记 DAG 计划已开始执行:此后任何 runActiveOrchestration(干预 / 恢复 / 澄清) + // 都改走 leader 循环,不再重跑整张 DAG。 + dagPlanExecutedRef.current = true; + + const leaderThreadId = resolveSeatThreadId(ids, coord); + let finishedAllStages = true; + // 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context + // (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。 + const priorDeliveries: Array<{ name: string; content: string }> = []; + + // Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。 + const markDagNode = (agentId: string, state: ChainNodeState) => + setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state })); + setDagNodeStates( + Object.fromEntries( + plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])), + ), + ); + + try { + for (let s = 0; s < plan.stages.length; s += 1) { + if (isStale()) { + finishedAllStages = false; + break; + } + const stage = plan.stages[s]; + const isParallel = stage.agentIds.length > 1; + // 本 stage 计划钉死的席位元数据(供派活轮 + 并行执行共用)。 + const stageSeats = stage.agentIds.map((agentId) => { + const meta = agentNameToRoleRef.current[agentId.toLowerCase()]; + return { agentId, meta, name: meta?.displayName ?? agentId }; + }); + + // ── (a) 总控派活轮(§3.2 方案 A + 批内并行):由【总控】对本 stage 计划钉死的 + // 席位**真正派活**(约束只派给这些席位、且每个都派到),而不是前端直发。总控本轮 + // 也"承上":后端 _build_seat_status_block 已把此前各席位真实交付(摘要)注入其输入 + // (传摘要按需传全文)。它不自由乱派、不收口。与 chain 同构,区别是一次钉死一批、批内并行。 + highlightSpeaker(null); + const leaderBubbleId = appendStreamingDialogue({ + sender: "总控协调", + avatarType: "blue", + initialContent: "", + confidence: s === 0 ? "派活中" : "承上派活中", + agentThreadId: leaderThreadId, + }); + const leaderTask = buildDagStageLeaderTask({ + seedMessage, + stageIndex: s, + totalStages: plan.stages.length, + seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })), + isFirst: s === 0, + }); + const leaderAborter = new AbortController(); + activeAbortersRef.current.add(leaderAborter); + let leaderRes; + try { + leaderRes = await streamMultiAgent({ + agentType: "leader", + dAgentThreadId: ids, + agentName: coord, + newMessage: leaderTask, + // 派活轮:传 false,后端只注入各席位摘要;完整交付正文留给末尾收口的 synthesis 轮。 + synthesisMode: false, + // 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活 + // 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。 + suppressPreBroadcast: true, + model: selectedModelRef.current || undefined, + signal: leaderAborter.signal, + onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), + onPhaseChange: (phase, toolName, args) => { + const label = phaseLabel(phase, "leader", toolName); + setDialogueConfidence(leaderBubbleId, label); + appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); + handleArtifactToolPhase(phase, toolName, args, leaderThreadId); + }, + onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), + }); + } catch (err) { + activeAbortersRef.current.delete(leaderAborter); + if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") { + finalizeDialogue(leaderBubbleId, "(已停止)", "已停止"); + finishedAllStages = false; + break; + } + throw err; + } + activeAbortersRef.current.delete(leaderAborter); + if (isStale()) { + finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止"); + finishedAllStages = false; + break; + } + // 派活轮也可能要求澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG)。 + if (leaderRes.clarification) { + finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清"); + setPendingClarification(leaderRes.clarification); + setClarificationDraft(""); + triggerToast("❓ 总控需要补充信息,请回答下方问题后继续"); + finishedAllStages = false; + break; + } + finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明"); + if (leaderRes.content) setLastLeaderContent(leaderRes.content); + + // ── (b) 本 stage 席位【并行】执行总控下发的 task ── + // 预备各席位气泡(在 Promise.all 之前同步建好,这样并行显示(§5)的头像条/两列 + // 能立刻拿到每个席位的 bubbleId 去渲染对应消息流)。 + const seatPrep = stageSeats.map(({ agentId, meta, name }) => { + const avatarType = meta?.avatarType ?? "blue"; + const seatThreadId = resolveSeatThreadId(ids, agentId); + const bubbleId = appendStreamingDialogue({ + sender: name, + avatarType, + initialContent: "", + confidence: "调度中", + agentThreadId: seatThreadId, + }); + return { agentId, meta, seatName: name, avatarType, seatThreadId, bubbleId }; + }); + + // 整批高亮(并行)。单节点 stage 退化为高亮一个,行为与串行一致。 + markParallelSpeakers( + seatPrep.map((p) => p.meta?.speakerId).filter((v): v is number => typeof v === "number"), + ); + // Phase 7:本 stage 席位置 active(流程图点亮当前列)。 + for (const p of seatPrep) markDagNode(p.agentId, "active"); + + // 并行 stage(≥2 席位)才发布「并行批次」给前端头像条 + 主区显示。 + // fast(双列)默认选中最先两个;sandbox(单列)只选最先一个,点头像切换。 + if (isParallel) { + setParallelBatch( + seatPrep.map((p) => ({ + agentId: p.agentId, + displayName: p.seatName, + avatarType: p.avatarType, + status: "running" as const, + bubbleId: p.bubbleId, + })), + ); + const cols = dagExecModeRef.current === "sandbox" ? 1 : 2; + setSelectedParallelSeats(seatPrep.slice(0, cols).map((p) => p.agentId)); + } + const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) => + setParallelBatch((prev) => + prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)), + ); + + // ── 批内并行:对 stage 内每个席位发 special run,跑总控本轮派给它的 task ── + // 每个席位独立 try/catch 兜底,保证**单席位失败不拖垮整 stage**(§9): + // 任务函数永不 reject → Promise.all 不会因一个席位异常整体崩。 + const stageResults = await Promise.all( + seatPrep.map(async ({ agentId, meta, seatName, seatThreadId, bubbleId }): Promise<{ name: string; content: string } | null> => { + const perSeatModel = meta + ? agentConfigsRef.current[meta.speakerId]?.model + : undefined; + // 用总控本轮派给该席位的 task(命中其 agent_name);总控没用工具只写文字时, + // pickDagSeatTask 给干净本职任务、**不**注入总控派活叙述(根治「席位模仿派活」)。 + // 带上 seedMessage:预广播已抑制,席位 thread 里没有用户原始消息,靠 task 补背景。 + const task = pickDagSeatTask( + leaderRes.dispatched, + agentId, + seatName, + seedMessage, + priorDeliveries.length > 0, + dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文 + ); + + const subAborter = new AbortController(); + activeAbortersRef.current.add(subAborter); + try { + const specRes = await streamMultiAgent({ + agentType: "special", + dAgentThreadId: ids, + agentName: agentId, + displayName: seatName, + newMessage: task, + // 写文件只在 **沙箱模式**(seat policy)开放;**快速模式一律禁写文件**(seat_parallel, + // 产物走正文,§12.10 用户要求 fast 不生成文件)。与 stage 是否并行无关:fast 单节点 + // stage 也禁写(保持 fast=无文件的一致体验)。 + parallelNoFile: dagExecModeRef.current !== "sandbox", + // 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。 + peerDeliveries: priorDeliveries, + model: perSeatModel || selectedModelRef.current || undefined, + signal: subAborter.signal, + // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 + // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 + onTextDelta: (text) => + isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), + onPhaseChange: (phase, toolName, args) => { + const label = phaseLabel(phase, "special", toolName); + setDialogueConfidence(bubbleId, label); + appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); + handleArtifactToolPhase(phase, toolName, args, seatThreadId); + }, + onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId), + }); + if (isStale()) { + finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止"); + markBatchStatus(agentId, "done"); + return null; + } + finalizeDialogue(bubbleId, specRes.content, "子智能体交付"); + markBatchStatus(agentId, "done"); + return specRes.content?.trim() ? { name: seatName, content: specRes.content } : null; + } catch (err) { + if (subAborter.signal.aborted || (err as Error).name === "AbortError") { + finalizeDialogue(bubbleId, "(已停止)", "已停止"); + markBatchStatus(agentId, "done"); + } else { + console.error(`[dag-special:${agentId}] error:`, err); + finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败"); + markBatchStatus(agentId, "error"); + } + return null; + } finally { + activeAbortersRef.current.delete(subAborter); + if (meta) markSpeakerIdle(meta.speakerId); + // Phase 7:席位到终态(成功/失败/抢占)→ 流程图节点打勾。ChainNodeState 无 + // error 态,失败也归 done(节点已结束);失败细节体现在气泡正文。 + markDagNode(agentId, "done"); + // 终态后从 ref store 删除,节流 flush 不再覆盖已 finalize 的正文。 + parallelStreamRef.current.delete(bubbleId); + } + }), + ); + + // 本 stage 结束(barrier 到达):收起并行批次,回到普通流式列表 + 清流式 store。 + if (isParallel) { + setParallelBatch([]); + setSelectedParallelSeats([]); + resetParallelStreams(); + } + + // Promise.all 即 barrier:本 stage 全部终态后才进入下一 stage(批间串行)。 + // 下一 stage 的总控派活轮会"承上"(后端注入本批真实交付摘要),无需前端再拼摘要。 + if (isStale()) { + finishedAllStages = false; + break; + } + // 累积本 stage 的**完整交付**,供后续 stage 席位 read_peer_delivery 按需取全文(§12.9)。 + for (const r of stageResults) { + if (r && r.content?.trim()) priorDeliveries.push(r); + } + setConsensusPercentage( + Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)), + ); + } + + // ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ── + // 关键修复:并行/各阶段输出完后,一定要回到总控,由它综合并**按需继续派活 / 收口**, + // 而不是停在一次性 synthesis。这里把 DAG 的编排锁释放,委托给 runOrchestration + // (它有自己的重入守卫 + epoch + 收敛逻辑)。后续用户干预 / 恢复同样走 leader + // (见 runActiveOrchestration 的 dagPlanExecutedRef 闸门),不再重跑整张 DAG。 + if (!isStale() && finishedAllStages && plan.finalSynthesis) { + highlightSpeaker(null); + isOrchestratingRef.current = false; // 释放锁,让 runOrchestration 即刻接管 + setIsOrchestrating(false); + await runOrchestration( + "各阶段席位已按依赖编排完成交付。请你作为总控,综合各席位的交付:若仍需补充则继续派活,否则直接输出最终方案并收口。", + ids, + coord, + ); + return; + } else if (!isStale() && finishedAllStages) { + // 不收口(plan.finalSynthesis=false):全部 stage 跑完即视为完成,不再叫总控。 + setConsensusPercentage(100); + setHasConsensus(true); + triggerToast("✅ 依赖编排各阶段已全部完成"); + } + } catch (err) { + console.error("dag orchestration error:", err); + triggerToast(`❌ 依赖编排研讨异常: ${(err as Error).message}`); + } finally { + highlightSpeaker(null); + clearAllStreamingSpeakers(); + // 兜底收起并行批次(正常路径已在 stage 结束时清空,异常 / 抢占时这里保证不残留)。 + setParallelBatch([]); + setSelectedParallelSeats([]); + resetParallelStreams(); + setIsOrchestrating(false); + } + }, + [ + appendStreamingDialogue, + appendToDialogue, + appendStepIfNew, + setDialogueConfidence, + phaseLabel, + finalizeDialogue, + highlightSpeaker, + markParallelSpeakers, + markSpeakerIdle, + clearAllStreamingSpeakers, + triggerToast, + handleArtifactToolPhase, + handleUpstreamFrame, + resolveSeatThreadId, + ingestParallelDelta, + resetParallelStreams, + runOrchestration, + ], + ); + // 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。 const runActiveOrchestration = useCallback( - (seedMessage: string, ids: ThreadIdMap, coord: string) => - orchestrationModeRef.current === "chain" - ? runChainOrchestration(seedMessage, ids, coord) - : runOrchestration(seedMessage, ids, coord), - [runOrchestration, runChainOrchestration], + (seedMessage: string, ids: ThreadIdMap, coord: string) => { + const mode = orchestrationModeRef.current; + // dag:**只在计划尚未跑过时**执行整张 DAG;一旦跑过(用户干预 / 恢复 / 澄清回复), + // 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。 + if ( + mode === "dag" && + !dagPlanExecutedRef.current && + (orchestrationPlanRef.current?.stages.length ?? 0) > 0 + ) { + return runDagOrchestration(seedMessage, ids, coord); + } + if (mode === "chain") { + return runChainOrchestration(seedMessage, ids, coord); + } + return runOrchestration(seedMessage, ids, coord); + }, + [runOrchestration, runChainOrchestration, runDagOrchestration], ); // ─── Effects ───────────────────────────────────────────────────── @@ -1766,6 +2403,8 @@ export function useStep2Orchestration( const resetInitGate = useCallback(() => { step2InitRef.current = false; + // 新一轮研讨:允许 DAG 计划重新执行一遍。 + dagPlanExecutedRef.current = false; setInitNonce((n) => n + 1); }, []); @@ -1821,6 +2460,10 @@ export function useStep2Orchestration( setLastLeaderContent(""); setHasConsensus(false); setConsensusPercentage(0); + setParallelBatch([]); + setSelectedParallelSeats([]); + setDagNodeStates({}); + resetParallelStreams(); setActiveArtifactThreadId(null); artifactPathsRef.current = []; setArtifacts([]); @@ -1828,9 +2471,10 @@ export function useStep2Orchestration( setSeatStubMessages({}); hasAutoOpenedRef.current = new Set(); pendingInjectPromptRef.current = null; + dagPlanExecutedRef.current = false; deselectArtifact(); step2InitRef.current = false; - }, [abortAllInFlight, deselectArtifact, setArtifacts]); + }, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]); return { selectedAgents, @@ -1850,6 +2494,11 @@ export function useStep2Orchestration( activeSpeakerSet, customAgentInput, isUpperPanelCollapsed, + parallelBatch, + selectedParallelSeats, + isParallelRunning: parallelBatch.length > 0, + toggleParallelSeat, + dagNodeStates, budgetLimit, agentConfigs, selectedAgentId, diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 93cc14ca..b82fe4e0 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -27,21 +27,43 @@ export interface BusinessChain { description?: string; /** 有序席位列表。 */ seats: ChainSeat[]; + /** + * 可选「分层编排」:把 `seats` 分成若干阶段(stage),每个阶段内的 agent_id + * 并行、阶段之间串行。如 `[["a","b"],["c"]]` = (a‖b) → c。缺省 / null = 线性链 + * (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。 + */ + stages?: string[][] | null; createdAt?: string; updatedAt?: string; } /** * 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。 - * 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。 + * 三种模式(智能推荐 / 业务链条线性 / 业务链条分层 DAG)最终都产出**有序**的 + * `agents` + 一个 `mode`。 */ export interface SeatSelectionResult { - /** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */ + /** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */ agents: Array<{ agent_id: string; name: string }>; - /** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */ - mode: "recommend" | "chain"; - /** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */ + /** + * 决定 Step 2 走哪种编排: + * - `recommend` 总控自由派活; + * - `chain` 按链条顺序逐位派活; + * - `dag` 分层并行(来源链条带 `stages` 时)。 + */ + mode: "recommend" | "chain" | "dag"; + /** chain / dag 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */ chain?: Pick; + /** dag 模式带上由链条 `stages` 构造的执行计划(见 `lib/dag-types.ts`)。 */ + plan?: import("./dag-types").OrchestrationPlan; + /** + * dag 模式的执行子模式(仅 `mode === "dag"` 有意义): + * - `fast`(快速):同 stage 席位**并行**、主区**双列**显示、**禁写文件**(seat_parallel); + * - `sandbox`(沙箱):同 stage 席位仍**并行**,但主区**单列**显示、**允许写文件**(seat), + * 沙箱跟随当前显示的席位(点头像切换,沙箱内容随之切换/关闭)。 + * 缺省 `fast`。 + */ + dagExecMode?: "fast" | "sandbox"; } /** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */ diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts new file mode 100644 index 00000000..fcdb5286 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + addEmptyStage, + addSeatAsNewStage, + addSeatToStage, + chainToStageGroups, + countSeats, + deleteStage, + findStageIndexOfSeat, + moveSeatToStage, + moveStage, + removeSeat, + splitSeatToNewStage, + stageGroupsToSeats, + stageGroupsToStagePayload, + type StageGroups, +} from "./chain-stages"; + +const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" }); +const ids = (groups: StageGroups) => groups.map((g) => g.map((s) => s.agent_id)); + +// ── chainToStageGroups ──────────────────────────────────────────────────────── + +test("chainToStageGroups: linear chain (no stages) → one seat per stage", () => { + const groups = chainToStageGroups({ seats: [seat("a"), seat("b"), seat("c")] }); + assert.deepEqual(ids(groups), [["a"], ["b"], ["c"]]); +}); + +test("chainToStageGroups: with stages → grouped, mapping ids to seats", () => { + const groups = chainToStageGroups({ + seats: [seat("a"), seat("b"), seat("c")], + stages: [["a", "b"], ["c"]], + }); + assert.deepEqual(ids(groups), [["a", "b"], ["c"]]); +}); + +test("chainToStageGroups: drops stage ids with no matching seat, and empty groups", () => { + const groups = chainToStageGroups({ + seats: [seat("a"), seat("c")], + stages: [["a", "ghost"], ["c"], ["onlyghost"]], + }); + assert.deepEqual(ids(groups), [["a"], ["c"]]); +}); + +test("chainToStageGroups: stages that resolve to nothing → linear fallback", () => { + const groups = chainToStageGroups({ seats: [seat("a"), seat("b")], stages: [["ghost"]] }); + assert.deepEqual(ids(groups), [["a"], ["b"]]); +}); + +// ── serialization ───────────────────────────────────────────────────────────── + +test("stageGroupsToSeats flattens in order", () => { + const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + assert.deepEqual( + stageGroupsToSeats(groups).map((s) => s.agent_id), + ["a", "b", "c"], + ); +}); + +test("stageGroupsToStagePayload returns null when all singletons (linear)", () => { + const groups: StageGroups = [[seat("a")], [seat("b")]]; + assert.equal(stageGroupsToStagePayload(groups), null); +}); + +test("stageGroupsToStagePayload returns groups when any stage is parallel", () => { + const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]); +}); + +test("stageGroupsToStagePayload ignores empty stages", () => { + const groups: StageGroups = [[seat("a"), seat("b")], []]; + assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"]]); +}); + +// ── lookups ─────────────────────────────────────────────────────────────────── + +test("findStageIndexOfSeat / countSeats", () => { + const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + assert.equal(findStageIndexOfSeat(groups, "c"), 1); + assert.equal(findStageIndexOfSeat(groups, "zzz"), -1); + assert.equal(countSeats(groups), 3); +}); + +// ── mutations ───────────────────────────────────────────────────────────────── + +test("addSeatAsNewStage appends a singleton stage, dedupes", () => { + let g: StageGroups = [[seat("a")]]; + g = addSeatAsNewStage(g, seat("b")); + assert.deepEqual(ids(g), [["a"], ["b"]]); + g = addSeatAsNewStage(g, seat("a")); // dup → no-op + assert.deepEqual(ids(g), [["a"], ["b"]]); +}); + +test("addSeatToStage inserts into a stage at index", () => { + const g = addSeatToStage([[seat("a")], [seat("c")]], seat("b"), 0, 0); + assert.deepEqual(ids(g), [["b", "a"], ["c"]]); +}); + +test("removeSeat drops the seat and prunes an emptied stage", () => { + const g = removeSeat([[seat("a")], [seat("b"), seat("c")]], "a"); + assert.deepEqual(ids(g), [["b", "c"]]); +}); + +test("moveSeatToStage merges across stages and prunes empty source", () => { + const g = moveSeatToStage([[seat("a")], [seat("b")]], "a", 1); + assert.deepEqual(ids(g), [["b", "a"]]); +}); + +test("moveSeatToStage within same stage reorders", () => { + const g = moveSeatToStage([[seat("a"), seat("b"), seat("c")]], "c", 0, 0); + assert.deepEqual(ids(g), [["c", "a", "b"]]); +}); + +test("splitSeatToNewStage pulls a seat out into a new following stage", () => { + const g = splitSeatToNewStage([[seat("a"), seat("b")], [seat("c")]], "b"); + assert.deepEqual(ids(g), [["a"], ["b"], ["c"]]); +}); + +test("splitSeatToNewStage on an already-solo seat is a no-op", () => { + const g = splitSeatToNewStage([[seat("a")], [seat("b")]], "a"); + assert.deepEqual(ids(g), [["a"], ["b"]]); +}); + +test("moveStage reorders serial stages", () => { + const g = moveStage([[seat("a")], [seat("b")], [seat("c")]], 2, -1); + assert.deepEqual(ids(g), [["a"], ["c"], ["b"]]); +}); + +test("addEmptyStage / deleteStage", () => { + let g = addEmptyStage([[seat("a")]]); + assert.deepEqual(ids(g), [["a"], []]); + g = deleteStage(g, 1); + assert.deepEqual(ids(g), [["a"]]); +}); + +// round-trip: editor groups → payload → reload preserves structure +test("round-trip: groups → {seats, stages} → chainToStageGroups", () => { + const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + const seats = stageGroupsToSeats(groups); + const stages = stageGroupsToStagePayload(groups); + const restored = chainToStageGroups({ seats, stages }); + assert.deepEqual(ids(restored), [["a", "b"], ["c"]]); +}); diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts new file mode 100644 index 00000000..cd55cbc4 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -0,0 +1,178 @@ +/** + * 业务链条「分层编排」编辑器的纯数据操作。 + * + * 编辑器内部用 `StageGroups`(`ChainSeat[][]`,每个内层数组 = 一个 stage,stage 内 + * 并行、stage 间串行)作为**单一数据源**: + * - 展平得到落库的有序 `seats`({@link stageGroupsToSeats}); + * - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。 + * + * 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。 + * 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。 + */ +import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; + +/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */ +export type StageGroups = ChainSeat[][]; + +/** + * 从一条业务链条还原分层结构。 + * - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组; + * 分层全空时回退到线性。 + * - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。 + */ +export function chainToStageGroups(chain: Pick): StageGroups { + const seats = chain.seats ?? []; + const byId = new Map(seats.map((s) => [s.agent_id, s])); + if (chain.stages && chain.stages.length > 0) { + const groups: StageGroups = []; + for (const group of chain.stages) { + const mapped = group.map((id) => byId.get(id)).filter((s): s is ChainSeat => Boolean(s)); + if (mapped.length > 0) groups.push(mapped); + } + if (groups.length > 0) return groups; + } + return seats.map((s) => [s]); +} + +/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */ +export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] { + return groups.flat(); +} + +/** + * 折叠成落库的 `stages`。**全是单元素 stage(无并行)时返回 null** —— 语义上就是线性 + * 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位 + * 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。 + */ +export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null { + const nonEmpty = groups.filter((g) => g.length > 0); + const hasParallel = nonEmpty.some((g) => g.length > 1); + if (!hasParallel) return null; + return nonEmpty.map((g) => g.map((s) => s.agent_id)); +} + +/** 该席位所在 stage 下标;不存在返回 -1。 */ +export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number { + return groups.findIndex((g) => g.some((s) => s.agent_id === agentId)); +} + +/** 席位是否已在任意 stage 中。 */ +export function seatExistsInGroups(groups: StageGroups, agentId: string): boolean { + return findStageIndexOfSeat(groups, agentId) >= 0; +} + +/** 总席位数(用于 2–8 约束)。 */ +export function countSeats(groups: StageGroups): number { + return groups.reduce((n, g) => n + g.length, 0); +} + +// ── 变换(均返回新结构,不可变)────────────────────────────────────────────── + +/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ +export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups { + if (seatExistsInGroups(groups, seat.agent_id)) return groups; + return [...groups, [seat]]; +} + +/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */ +export function addSeatToStage( + groups: StageGroups, + seat: ChainSeat, + stageIdx: number, + atIndex?: number, +): StageGroups { + if (seatExistsInGroups(groups, seat.agent_id)) return groups; + if (stageIdx < 0 || stageIdx >= groups.length) return groups; + return groups.map((g, i) => { + if (i !== stageIdx) return g; + const next = [...g]; + const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length)); + next.splice(at, 0, seat); + return next; + }); +} + +/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */ +export function removeSeat(groups: StageGroups, agentId: string): StageGroups { + return groups + .map((g) => g.filter((s) => s.agent_id !== agentId)) + .filter((g) => g.length > 0); +} + +/** + * 把席位移动到目标 stage(跨 stage 合并 / stage 内移动)。 + * 源 stage 变空则删除;删除会改变下标,这里用 agent_id 与目标 stage 的**身份**而非下标 + * 重新定位,保证语义稳定。`atIndex` 为目标 stage 内插入位置。 + */ +export function moveSeatToStage( + groups: StageGroups, + agentId: string, + targetStageIdx: number, + atIndex?: number, +): StageGroups { + const from = findStageIndexOfSeat(groups, agentId); + if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups; + const seat = groups[from].find((s) => s.agent_id === agentId); + if (!seat) return groups; + // 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。 + const targetRef = groups[targetStageIdx]; + let next: StageGroups = groups.map((g) => + g === targetRef ? g : g.filter((s) => s.agent_id !== agentId), + ); + next = next.map((g) => { + if (g !== targetRef) return g; + if (g.some((s) => s.agent_id === agentId)) { + // stage 内移动:先抽出再按 atIndex 插回。 + const without = g.filter((s) => s.agent_id !== agentId); + const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length)); + without.splice(at, 0, seat); + return without; + } + const merged = [...g]; + const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length)); + merged.splice(at, 0, seat); + return merged; + }); + return next.filter((g) => g.length > 0); +} + +/** 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。 */ +export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups { + const from = findStageIndexOfSeat(groups, agentId); + if (from < 0) return groups; + const seat = groups[from].find((s) => s.agent_id === agentId); + if (!seat) return groups; + if (groups[from].length === 1) return groups; // 已经独占一层,无需拆 + const out: StageGroups = []; + groups.forEach((g, i) => { + if (i === from) { + out.push(g.filter((s) => s.agent_id !== agentId)); + out.push([seat]); + } else { + out.push(g); + } + }); + return out.filter((g) => g.length > 0); +} + +/** 整段 stage 上移 / 下移(改串行顺序)。 */ +export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups { + const target = stageIdx + dir; + if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) { + return groups; + } + const next = [...groups]; + [next[stageIdx], next[target]] = [next[target], next[stageIdx]]; + return next; +} + +/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */ +export function addEmptyStage(groups: StageGroups): StageGroups { + return [...groups, []]; +} + +/** 删除整个 stage(含其席位)。 */ +export function deleteStage(groups: StageGroups, stageIdx: number): StageGroups { + if (stageIdx < 0 || stageIdx >= groups.length) return groups; + return groups.filter((_, i) => i !== stageIdx); +} diff --git a/frontend-web/src/roundtable-planning/lib/dag-types.test.ts b/frontend-web/src/roundtable-planning/lib/dag-types.test.ts new file mode 100644 index 00000000..4b90402c --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/dag-types.test.ts @@ -0,0 +1,426 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDagStageLeaderTask, + buildDagTransitTask, + buildDependencySummary, + buildPlanFromStages, + buildStageTask, + chainToDagPlan, + emptyPlan, + isParallelStage, + PARALLEL_DEGREE_WARN_THRESHOLD, + pickDagSeatTask, + validatePlan, + type OrchestrationPlan, +} from "./dag-types"; + +// ── chainToDagPlan:链条 → 单节点-stage DAG(§1.3 兼容转换)──────────────────── + +test("chainToDagPlan turns a linear chain into one-node-per-stage DAG", () => { + const plan = chainToDagPlan(["a", "b", "c"]); + assert.equal(plan.mode, "dag"); + assert.equal(plan.finalSynthesis, true); + assert.deepEqual( + plan.stages.map((s) => s.agentIds), + [["a"], ["b"], ["c"]], + ); + // stage id 稳定且唯一(diff / 拖拽重排用)。 + assert.deepEqual( + plan.stages.map((s) => s.id), + ["stage-0", "stage-1", "stage-2"], + ); +}); + +test("chainToDagPlan preserves order and respects finalSynthesis=false", () => { + const plan = chainToDagPlan(["x", "y"], false); + assert.equal(plan.finalSynthesis, false); + assert.deepEqual( + plan.stages.map((s) => s.agentIds[0]), + ["x", "y"], + ); +}); + +test("chainToDagPlan on empty chain yields an empty plan", () => { + const plan = chainToDagPlan([]); + assert.deepEqual(plan.stages, []); +}); + +test("emptyPlan is a valid empty dag plan", () => { + assert.deepEqual(emptyPlan(), { mode: "dag", stages: [], finalSynthesis: true }); +}); + +// ── isParallelStage ────────────────────────────────────────────────────────── + +test("isParallelStage is true only for ≥2 seats", () => { + assert.equal(isParallelStage({ id: "s", agentIds: ["a"] }), false); + assert.equal(isParallelStage({ id: "s", agentIds: ["a", "b"] }), true); +}); + +// ── validatePlan ────────────────────────────────────────────────────────────── + +const roster = ["a", "b", "c", "d"]; + +test("validatePlan accepts a valid mixed serial/parallel plan", () => { + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [ + { id: "s0", agentIds: ["a"] }, + { id: "s1", agentIds: ["b", "c"] }, + { id: "s2", agentIds: ["d"] }, + ], + }; + const res = validatePlan(plan, roster); + assert.equal(res.ok, true); + assert.deepEqual(res.errors, []); + assert.deepEqual(res.warnings, []); +}); + +test("validatePlan rejects an empty plan", () => { + const res = validatePlan(emptyPlan(), roster); + assert.equal(res.ok, false); + assert.equal(res.errors.length, 1); +}); + +test("validatePlan rejects an empty stage", () => { + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [{ id: "s0", agentIds: ["a"] }, { id: "s1", agentIds: [] }], + }; + const res = validatePlan(plan, roster); + assert.equal(res.ok, false); + assert.match(res.errors[0], /第 2 阶段为空/); +}); + +test("validatePlan rejects a seat not in the roster", () => { + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [{ id: "s0", agentIds: ["a", "zzz"] }], + }; + const res = validatePlan(plan, roster); + assert.equal(res.ok, false); + assert.match(res.errors[0], /未选定的席位/); +}); + +test("validatePlan rejects the same seat across two stages", () => { + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [ + { id: "s0", agentIds: ["a"] }, + { id: "s1", agentIds: ["a"] }, + ], + }; + const res = validatePlan(plan, roster); + assert.equal(res.ok, false); + assert.match(res.errors[0], /只能跑一次/); +}); + +test("validatePlan rejects a duplicate seat within one stage", () => { + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [{ id: "s0", agentIds: ["a", "a"] }], + }; + const res = validatePlan(plan, roster); + assert.equal(res.ok, false); + assert.match(res.errors[0], /重复/); +}); + +test("validatePlan warns (but allows) when parallel degree exceeds the threshold", () => { + const big = Array.from({ length: PARALLEL_DEGREE_WARN_THRESHOLD + 1 }, (_, i) => `p${i}`); + const plan: OrchestrationPlan = { + mode: "dag", + finalSynthesis: true, + stages: [{ id: "s0", agentIds: big }], + }; + const res = validatePlan(plan, big); + assert.equal(res.ok, true); + assert.equal(res.errors.length, 0); + assert.equal(res.warnings.length, 1); + assert.match(res.warnings[0], /并发限流/); +}); + +// chain→dag round-trip is always valid against its own roster. +test("a chainToDagPlan validates clean against its own roster", () => { + const chain = ["alpha", "beta", "gamma"]; + const res = validatePlan(chainToDagPlan(chain), chain); + assert.equal(res.ok, true); +}); + +// ── buildPlanFromStages (链条 stages → 执行计划) ────────────────────────────── + +test("buildPlanFromStages maps groups to stages with stable ids", () => { + const plan = buildPlanFromStages([["a", "b"], ["c"]]); + assert.equal(plan.mode, "dag"); + assert.equal(plan.finalSynthesis, true); + assert.deepEqual( + plan.stages.map((s) => s.agentIds), + [["a", "b"], ["c"]], + ); + assert.deepEqual( + plan.stages.map((s) => s.id), + ["stage-0", "stage-1"], + ); +}); + +test("buildPlanFromStages filters out non-surviving ids and drops emptied stages", () => { + // "b" deleted; stage that becomes empty is dropped. + const plan = buildPlanFromStages([["a", "b"], ["x"]], ["a", "c"]); + assert.deepEqual( + plan.stages.map((s) => s.agentIds), + [["a"]], + ); +}); + +test("buildPlanFromStages on all-filtered yields empty stages (caller falls back)", () => { + const plan = buildPlanFromStages([["x"], ["y"]], ["a"]); + assert.deepEqual(plan.stages, []); +}); + +test("a chain plan validates clean against its surviving roster", () => { + const plan = buildPlanFromStages([["a", "b"], ["c"]], ["a", "b", "c"]); + const res = validatePlan(plan, ["a", "b", "c"]); + assert.equal(res.ok, true); +}); + +// ── buildDependencySummary (§3.1 依赖摘要注入) ───────────────────────────────── + +test("buildDependencySummary is empty for no deliveries (first stage)", () => { + assert.equal(buildDependencySummary([]), ""); +}); + +test("buildDependencySummary lists each seat's delivery with its name", () => { + const out = buildDependencySummary([ + { seatName: "情报收集", content: "情报正文" }, + { seatName: "环境评估", content: "评估正文" }, + ]); + assert.match(out, /【情报收集】/); + assert.match(out, /情报正文/); + assert.match(out, /【环境评估】/); + assert.match(out, /评估正文/); +}); + +test("buildDependencySummary truncates long content", () => { + const long = "x".repeat(3000); + const out = buildDependencySummary([{ seatName: "A", content: long }], 100); + assert.match(out, /…(已截断)/); + assert.ok(out.length < 3000); +}); + +test("buildDependencySummary handles empty content gracefully", () => { + const out = buildDependencySummary([{ seatName: "A", content: " " }]); + assert.match(out, /(无文本输出)/); +}); + +// ── buildStageTask (§3.2 直派提示) ──────────────────────────────────────────── + +test("buildStageTask for first stage embeds the task background", () => { + const task = buildStageTask({ + seedMessage: "做一个容灾方案", + seatName: "情报收集", + priorSummary: "", + isParallel: false, + }); + assert.match(task, /任务背景/); + assert.match(task, /做一个容灾方案/); + assert.match(task, /情报收集/); + // serial node — no parallel no-file warning. + assert.doesNotMatch(task, /并行研讨/); +}); + +test("buildStageTask for a later stage embeds the prior summary, not the seed", () => { + const task = buildStageTask({ + seedMessage: "原始任务", + seatName: "方案设计", + priorSummary: "【情报收集】的交付:\n情报正文", + isParallel: false, + }); + assert.match(task, /此前各阶段已完成席位的交付摘要/); + assert.match(task, /情报正文/); + assert.doesNotMatch(task, /原始任务/); +}); + +test("buildStageTask in a parallel stage forbids writing files", () => { + const task = buildStageTask({ + seedMessage: "原始任务", + seatName: "方案设计", + priorSummary: "", + isParallel: true, + }); + assert.match(task, /并行研讨/); + assert.match(task, /不要写文件/); +}); + +test("buildStageTask with priorIsBridge frames prior context as the leader's bridge", () => { + const bridge = "总控综合:情报已收集,下一步聚焦架构设计"; + const task = buildStageTask({ + seedMessage: "原始任务", + seatName: "方案设计", + priorSummary: bridge, + isParallel: false, + priorIsBridge: true, + }); + // 措辞切换到「承上启下」综合说明,而非机械「交付摘要」。 + assert.match(task, /承上启下/); + assert.match(task, /综合说明/); + assert.match(task, /总控综合/); + assert.doesNotMatch(task, /已完成席位的交付摘要/); +}); + +// ── buildDagTransitTask (§12.3 Stage 间总控中转 + 广播) ───────────────────────── + +test("buildDagTransitTask names completed + next seats and forbids dispatch/closeout", () => { + const task = buildDagTransitTask({ + completedSeatNames: ["情报收集", "环境评估"], + nextSeatNames: ["方案设计", "风险审查"], + }); + assert.match(task, /中转轮/); + assert.match(task, /情报收集、环境评估/); + assert.match(task, /方案设计、风险审查/); + assert.match(task, /承上启下/); + // 计划钉死下一批 → 总控不自由派活、不收口。 + assert.match(task, /不要.*派活/); + assert.match(task, /不要现在收口/); +}); + +test("buildDagTransitTask falls back gracefully when seat name lists are empty", () => { + const task = buildDagTransitTask({ completedSeatNames: [], nextSeatNames: [] }); + assert.match(task, /本批席位/); + assert.match(task, /下一阶段席位/); +}); + +// ── buildDagStageLeaderTask (§3.2 方案 A:总控驱动每 stage 派活) ───────────────── + +test("buildDagStageLeaderTask first stage embeds task background + pins seats", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "做一个容灾方案", + stageIndex: 0, + totalStages: 3, + seats: [ + { agentId: "intel", name: "情报收集" }, + { agentId: "env", name: "环境评估" }, + ], + isFirst: true, + }); + assert.match(task, /任务背景/); + assert.match(task, /做一个容灾方案/); + assert.match(task, /第 1\/3 个阶段/); + // 每个钉死席位都要带 agent_name 让总控逐字派活。 + assert.match(task, /agent_name: intel/); + assert.match(task, /agent_name: env/); + assert.match(task, /情报收集/); + assert.match(task, /环境评估/); + // 约束:只派给清单内、不收口。 + assert.match(task, /不要派给清单外/); + assert.match(task, /不要.*给出最终结论/); + // 多席位 → 并行提示。 + assert.match(task, /并行/); +}); + +test("buildDagStageLeaderTask later stage relies on injected prior deliveries, not seed", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "原始任务", + stageIndex: 1, + totalStages: 2, + seats: [{ agentId: "plan", name: "方案设计" }], + isFirst: false, + }); + assert.match(task, /承上启下/); + assert.match(task, /第 2\/2 个阶段/); + assert.doesNotMatch(task, /原始任务/); + // 单席位 stage → 不加并行提示。 + assert.doesNotMatch(task, /会\*\*并行\*\*执行/); +}); + +// ── pickDagSeatTask (§3.2 方案 A:取总控派给某席位的 task,多席位不张冠李戴) ──────── + +test("pickDagSeatTask returns the exact dispatch matched by agent_name (+ role guard)", () => { + const dispatched: [string, string][] = [ + ["plan", "设计架构"], + ["risk", "审查风险"], + ]; + const riskTask = pickDagSeatTask(dispatched, "risk", "风险审查"); + assert.match(riskTask, /审查风险/); + assert.doesNotMatch(riskTask, /设计架构/); + const planTask = pickDagSeatTask(dispatched, "plan", "方案设计"); + assert.match(planTask, /设计架构/); + // 角色边界护栏:禁止席位派活/调度。 + assert.match(riskTask, /执行席位/); + assert.match(riskTask, /严禁.*派活|严禁.*调度/); +}); + +test("pickDagSeatTask gives a CLEAN own-role task on miss — never injects the dispatch narrative", () => { + const dispatched: [string, string][] = [["plan", "设计架构"]]; + // risk 没被派到 → 既不借 plan 的「设计架构」,也**不**注入总控派活叙述(防模仿派活)。 + const task = pickDagSeatTask(dispatched, "risk", "风险审查"); + assert.doesNotMatch(task, /设计架构/); + assert.match(task, /风险审查/); + assert.match(task, /独立完成/); + // 角色边界护栏:禁止席位派活/调度/绕道脚本。 + assert.match(task, /执行席位/); + assert.match(task, /严禁/); + assert.match(task, /bash|脚本|内部接口/); +}); + +test("pickDagSeatTask with empty dispatched falls back to clean own-role task", () => { + const task = pickDagSeatTask([], "risk", "风险审查"); + assert.match(task, /风险审查/); + assert.match(task, /本阶段/); + assert.match(task, /执行席位/); +}); + +test("pickDagSeatTask embeds the seed message as task background when provided", () => { + const task = pickDagSeatTask([], "risk", "风险审查", "做一个容灾方案"); + assert.match(task, /任务背景/); + assert.match(task, /做一个容灾方案/); + // exact-match 分支也带背景。 + const exactTask = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "做一个容灾方案"); + assert.match(exactTask, /做一个容灾方案/); + assert.match(exactTask, /审查风险/); +}); + +test("pickDagSeatTask mentions read_peer_delivery only when peers exist (§12.9)", () => { + const noPeers = pickDagSeatTask([], "risk", "风险审查", "种子", false); + assert.doesNotMatch(noPeers, /read_peer_delivery/); + const withPeers = pickDagSeatTask([], "risk", "风险审查", "种子", true); + assert.match(withPeers, /read_peer_delivery/); + assert.match(withPeers, /按需取全文|完整交付/); +}); + +test("pickDagSeatTask (sandbox mode, allowFiles) asks for .md and forbids .py (§12.8)", () => { + // allowFiles=true(沙箱模式):产出 md、禁 .py。 + const miss = pickDagSeatTask([], "risk", "风险审查", "", false, true); + const hit = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "", false, true); + for (const t of [miss, hit]) { + assert.match(t, /Markdown(\.md)文件|\.md/); + assert.match(t, /不要写 \.py|不要 \.py/); + assert.match(t, /产物要求/); + } +}); + +test("pickDagSeatTask (fast mode, default) keeps output in the message body — no file (§12.10)", () => { + // allowFiles 默认 false(快速模式):产物走正文,不写文件、不写 md。 + const t = pickDagSeatTask([], "risk", "风险审查"); + assert.match(t, /直接写在回复正文|产物走正文|不要写文件/); + assert.doesNotMatch(t, /Markdown(\.md)文件/); +}); + +test("buildDagStageLeaderTask insists on actually calling the tool (not narrating)", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 0, + totalStages: 2, + seats: [ + { agentId: "a", name: "甲" }, + { agentId: "b", name: "乙" }, + ], + isFirst: true, + }); + assert.match(task, /真正调用 agent_orchestration 工具/); + assert.match(task, /不要.*只用文字/); +}); diff --git a/frontend-web/src/roundtable-planning/lib/dag-types.ts b/frontend-web/src/roundtable-planning/lib/dag-types.ts new file mode 100644 index 00000000..bfcdd3d9 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/dag-types.ts @@ -0,0 +1,418 @@ +/** + * 圆桌 DAG 编排(分层 Stage 模型)共享类型 + 纯函数。 + * + * 对应 `docs/roundtable-dag-orchestration.md` §1「数据模型」。本文件只放纯类型 + + * 可单测的纯函数(校验 / chain→dag 兼容转换),不含任何 React / 网络副作用, + * 后续 Phase 2(编辑器)/ Phase 3(`runDagOrchestration`)会引用这些类型。 + * + * 设计要点(§0.3 / §1.1): + * - DAG 用「有序的 stage 列表」表示:stage 内并行、stage 间串行。 + * - 线性链是 DAG 的特例(每层 1 个节点),见 {@link chainToDagPlan}。 + * - 自由连线画布本期不做,仅在数据模型上预留 {@link DagGraph}(§1.2)避免返工。 + */ + +// ── 分层(Stage)表示:本期主模型(§1.1)────────────────────────────────────── + +/** 一个并行批次:stage 内所有席位并行执行。 */ +export interface OrchestrationStage { + /** stage 稳定 id(拖拽重排 / diff 用)。 */ + id: string; + /** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */ + agentIds: string[]; +} + +/** 用户编排出的执行计划。stages 顺序即串行顺序。 */ +export interface OrchestrationPlan { + /** 编排模式标识,持久化进 step2。 */ + mode: "dag"; + /** 串行执行的批次;每批内并行。 */ + stages: OrchestrationStage[]; + /** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */ + finalSynthesis: boolean; +} + +// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)─────────────────────── + +/** from 完成后才能跑 to。 */ +export interface DagEdge { + from: string; + to: string; +} + +/** 自由连线画布的图表示。执行前需 toposort → 分层落回 {@link OrchestrationStage}。 */ +export interface DagGraph { + nodes: string[]; + edges: DagEdge[]; +} + +// ── 常量 ────────────────────────────────────────────────────────────────────── + +/** + * 单 stage 并行度软上限:超过给 warning(远程模型并发限流 + 沙箱压力,§1.4 / §9)。 + * 仅提示,不阻断保存。 + */ +export const PARALLEL_DEGREE_WARN_THRESHOLD = 6; + +// ── 校验(纯函数,可单测,§1.4)────────────────────────────────────────────── + +/** {@link validatePlan} 的返回。`errors` 阻断保存,`warnings` 仅提示。 */ +export interface PlanValidation { + ok: boolean; + /** 面向用户的中文错误(非空 = 无法保存)。 */ + errors: string[]; + /** 面向用户的中文提示(不阻断保存)。 */ + warnings: string[]; +} + +/** + * 校验一份编排计划是否可执行 / 可保存。 + * + * 校验项(§1.4): + * - 至少 1 个 stage。 + * - 不允许空 stage。 + * - 每个席位必须在本次选定席位 `selectedAgentIds` 内(防编排不存在的席位)。 + * - 同一席位不允许出现在多个 stage(一个席位只跑一次);同一 stage 内也不允许重复。 + * - (warning)单 stage 并行度 > {@link PARALLEL_DEGREE_WARN_THRESHOLD}。 + * + * @param plan 待校验的计划。 + * @param selectedAgentIds 本次选定的席位 agent_id 全集(roster)。 + */ +export function validatePlan( + plan: OrchestrationPlan, + selectedAgentIds: string[], +): PlanValidation { + const errors: string[] = []; + const warnings: string[] = []; + const roster = new Set(selectedAgentIds); + + if (!plan.stages || plan.stages.length === 0) { + errors.push("编排为空:请至少添加一个阶段(Stage)。"); + return { ok: false, errors, warnings }; + } + + // 记录每个席位首次出现的 stage 序号,用来检测跨 stage 重复。 + const seenInStage = new Map(); + + plan.stages.forEach((stage, idx) => { + const human = idx + 1; + + if (!stage.agentIds || stage.agentIds.length === 0) { + errors.push(`第 ${human} 阶段为空:请至少放入一个席位,或删除该阶段。`); + return; + } + + const seenInThisStage = new Set(); + for (const agentId of stage.agentIds) { + if (!roster.has(agentId)) { + errors.push(`第 ${human} 阶段包含未选定的席位「${agentId}」,请先把它加入参与角色。`); + continue; + } + if (seenInThisStage.has(agentId)) { + errors.push(`第 ${human} 阶段内席位「${agentId}」重复,一个席位每阶段只能出现一次。`); + continue; + } + seenInThisStage.add(agentId); + + const firstStage = seenInStage.get(agentId); + if (firstStage !== undefined) { + errors.push( + `席位「${agentId}」同时出现在第 ${firstStage + 1} 与第 ${human} 阶段,一个席位只能跑一次。`, + ); + } else { + seenInStage.set(agentId, idx); + } + } + + if (stage.agentIds.length > PARALLEL_DEGREE_WARN_THRESHOLD) { + warnings.push( + `第 ${human} 阶段并行 ${stage.agentIds.length} 个席位,可能触发远程模型并发限流或沙箱压力,建议拆分。`, + ); + } + }); + + return { ok: errors.length === 0, errors, warnings }; +} + +// ── 链条 → DAG 兼容转换(§1.3)─────────────────────────────────────────────── + +/** 生成稳定的 stage id。不依赖随机数,便于单测与 diff(用序号即可,stages 不重排前唯一)。 */ +function stageId(index: number): string { + return `stage-${index}`; +} + +/** + * 把线性链条(有序席位数组)转换成「每层 1 个节点」的 DAG 计划。 + * + * 老 `chain` 模式的数据只是有序的 `selectedAgents`(线性),读取时即时转换为 + * 单节点-stage 的 dag,不破坏老 draft(§1.3 兼容层)。 + * + * @param orderedAgentIds 链条顺序的席位 agent_id。 + * @param finalSynthesis 是否收口综合,默认 true。 + */ +export function chainToDagPlan( + orderedAgentIds: string[], + finalSynthesis = true, +): OrchestrationPlan { + return { + mode: "dag", + stages: orderedAgentIds.map((agentId, idx) => ({ + id: stageId(idx), + agentIds: [agentId], + })), + finalSynthesis, + }; +} + +/** + * 构造一个空计划(编辑器初始态 / 兜底)。 + */ +export function emptyPlan(finalSynthesis = true): OrchestrationPlan { + return { mode: "dag", stages: [], finalSynthesis }; +} + +/** + * 把业务链条的 `stages`(agent_id 分组的 string[][])转成执行计划。 + * + * - `allowedIds` 提供时,过滤掉不在其中的 agent_id(如链条里有席位被删除), + * 并丢弃过滤后变空的 stage —— 保证选入 Step 2 的计划只含存活席位。 + * - 全部为空时返回空 stages(调用方据此判断退回 chain / recommend)。 + * + * @param stages 链条分层(每组并行、组间串行)。 + * @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。 + * @param finalSynthesis 是否收口综合,默认 true。 + */ +export function buildPlanFromStages( + stages: string[][], + allowedIds?: Iterable, + finalSynthesis = true, +): OrchestrationPlan { + const allow = allowedIds ? new Set(allowedIds) : null; + const cleaned: OrchestrationStage[] = []; + stages.forEach((group, idx) => { + const ids = allow ? group.filter((id) => allow.has(id)) : group.slice(); + if (ids.length > 0) cleaned.push({ id: stageId(idx), agentIds: ids }); + }); + return { mode: "dag", stages: cleaned, finalSynthesis }; +} + +/** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */ +export function isParallelStage(stage: OrchestrationStage): boolean { + return stage.agentIds.length > 1; +} + +// ── 派活提示构造(纯函数,Phase 3 调度用,可单测,§3.1 依赖摘要注入)────────────── + +/** 已完成席位的一条交付(喂给后续 stage 当依赖上下文)。 */ +export interface StageDelivery { + /** 席位显示名(中文优先)。 */ + seatName: string; + /** 该席位本轮交付正文。 */ + content: string; +} + +/** + * 把前面所有 stage 已交付席位的正文拼成「依赖摘要」,注入下一 stage 的派活提示。 + * 每条按 `maxCharsPerSeat` 截断,避免后续席位上下文过长(§3.1:stage 边界拉一次摘要)。 + * 无交付(首 stage)返回空串。 + */ +export function buildDependencySummary( + deliveries: StageDelivery[], + maxCharsPerSeat = 1200, +): string { + if (deliveries.length === 0) return ""; + return deliveries + .map((d) => { + const body = (d.content ?? "").trim(); + const clipped = + body.length > maxCharsPerSeat ? `${body.slice(0, maxCharsPerSeat)}…(已截断)` : body; + return `【${d.seatName}】的交付:\n${clipped || "(无文本输出)"}`; + }) + .join("\n\n"); +} + +/** + * 为某 stage 内一个席位构造 special 派活提示(DAG 直派,无 leader,§3.2 方案 B)。 + * + * - 首 stage(`priorSummary` 为空):带任务背景。 + * - 后续 stage:带前序交付摘要让其承接(§3.1 依赖可见性)。 + * - 并行 stage(`isParallel`):明确要求**产出写在正文、禁止写文件 / 沙箱命令** + * (§4.3 / §9:并行禁写文件,降低模型困惑,与后端 `seat_parallel` policy 呼应)。 + */ +export function buildStageTask(args: { + seedMessage: string; + seatName: string; + /** + * 上一阶段的上下文: + * - 默认(`priorIsBridge` 假):{@link buildDependencySummary} 拼出的机械交付摘要; + * - `priorIsBridge` 真:总控「承上启下」中转轮({@link buildDagTransitTask})的综合说明。 + * 首 stage 传空串。 + */ + priorSummary: string; + isParallel: boolean; + /** + * `priorSummary` 是否为总控中转轮(§12.3)产出的「承上启下」说明(而非机械交付摘要)。 + * 真时改用对应措辞,让席位知道这是总控综合后的指引。缺省 false(机械摘要)。 + */ + priorIsBridge?: boolean; +}): string { + const { seedMessage, seatName, priorSummary, isParallel, priorIsBridge } = args; + const lines: string[] = []; + if (!priorSummary) { + lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, ""); + } else if (priorIsBridge) { + lines.push("分层研讨继续。以下是总控对此前各阶段的【承上启下】综合说明,请据此承接:", "", priorSummary, ""); + } else { + lines.push("分层研讨继续。以下是此前各阶段已完成席位的交付摘要,供你承接:", "", priorSummary, ""); + } + lines.push(`请你作为「${seatName}」,从你的专业职责出发,完成本阶段的分析与交付。`); + if (isParallel) { + lines.push( + "⚠️ 本阶段为并行研讨:请把你的产出**直接写在回复正文里**,不要写文件、不要执行沙箱命令——并行期间禁止文件写入,以免与同批其他席位互相打架。需要落文件的产物会留到后续串行 / 收口阶段单独处理。", + ); + } + return lines.join("\n"); +} + +/** + * 总控「中转轮」派活提示(§12.3 Stage 间经总控中转 + 广播)。 + * + * DAG 各 stage 之间插入一轮 leader:综合上一批并行席位的交付,产出一段「承上启下」 + * 说明,再作为下一 stage 席位 task 的上下文(见 {@link buildStageTask} 的 + * `priorIsBridge`)。下一批席位由编排计划**钉死**(已在 `nextSeatNames` 列出), + * 故提示明确要求总控**不要自己派活、不要收口**——只做综合(前端照计划直派下一批)。 + * + * 「传摘要按需传全文」:各席位真实交付由后端 `_collect_delivery_status`(轻量 + * `GET /last-ai-message?max_chars=N` 接口)实读后注入总控输入——中转轮默认只注入 + * 每席位 ~600 字**摘要**,完整交付正文留到最终收口的 synthesis 轮才按需读取。 + * 本提示因此不重复塞交付正文,保持总控上下文精简、响应快。 + */ +export function buildDagTransitTask(args: { + /** 刚完成的本批席位显示名(仅用于提示文案,真实交付由后端核对注入)。 */ + completedSeatNames: string[]; + /** 下一阶段由计划钉死、即将执行的席位显示名。 */ + nextSeatNames: string[]; +}): string { + const { completedSeatNames, nextSeatNames } = args; + const done = completedSeatNames.join("、") || "本批席位"; + const next = nextSeatNames.join("、") || "下一阶段席位"; + return [ + "【分层 DAG · 总控中转轮】本阶段并行席位已全部完成交付。", + `已完成席位:${done}。系统已把各席位的真实交付(摘要)客观核对后注入到你本轮输入里,以此为准。`, + "请你作为总控,综合这一批的交付,产出一段【承上启下】说明:提炼关键结论与未决问题,并给出对下一阶段的指引。", + `下一阶段将由编排计划**钉死**执行的席位是:${next}。这些席位由系统按计划直接派活,` + + "**你不需要、也不要**通过 agent_orchestration 自行派活,更不要现在收口或输出最终方案。", + "只输出这段承上启下的综合说明即可——它会作为下一阶段席位承接前序结论的上下文。", + ].join("\n"); +} + +// ── 总控驱动的「每 stage 派活轮」(§3.2 方案 A + 批内并行,2026-06-07 改)───────── +// +// 把 DAG 执行从「前端直派」改成「总控驱动」:每个 stage 由总控**真正派活**给该 stage +// 计划钉死的席位(约束总控只派给这些席位、且每个都派到),席位并行执行、完成后交付经 +// 后端广播 + `_collect_delivery_status` 回到总控,总控下一 stage 继续派活。与 chain 模式 +// 同构(`buildChainLeaderTask` 单席位),区别是一次钉死**一批**席位、批内并行。 + +/** + * 总控「每 stage 派活轮」提示:约束总控只对本 stage 钉死的若干席位派活(§3.2 方案 A)。 + * + * - 首 stage(`isFirst`):带任务背景;后续 stage:靠后端注入的此前交付摘要承上。 + * - `seats` 是本 stage 计划钉死的席位(≥2 即并行批次),要求总控对**每个**席位都 + * 通过 agent_orchestration 派一个聚焦其职责的 task,不要派给清单外席位、不要自答 / 收口。 + * + * 「传摘要按需传全文」:此处不塞各席位交付正文——后端 `_build_seat_status_block` 已把 + * 此前各席位的真实交付(摘要)注入总控本轮输入;完整正文留到末尾收口的 synthesis 轮。 + */ +export function buildDagStageLeaderTask(args: { + seedMessage: string; + /** 0-based stage 序号。 */ + stageIndex: number; + totalStages: number; + /** 本 stage 计划钉死的席位。 */ + seats: Array<{ agentId: string; name: string }>; + isFirst: boolean; +}): string { + const { seedMessage, stageIndex, totalStages, seats, isFirst } = args; + const ordinal = `${stageIndex + 1}/${totalStages}`; + const seatList = seats + .map((s) => `「${s.name}」(agent_name: ${s.agentId})`) + .join("、"); + const isParallel = seats.length > 1; + const lines: string[] = []; + if (isFirst) { + lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, ""); + } else { + lines.push( + "分层研讨继续。系统已把此前各阶段席位的真实交付(摘要)客观核对后注入到你本轮输入里,请据此承上启下。", + "", + ); + } + lines.push( + `本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`, + `本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`, + `请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`, + "只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。", + "⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。", + ); + if (isParallel) { + lines.push( + `注意:本阶段这 ${seats.length} 个席位会**并行**执行,请确保给每个席位的 task 都能独立完成,不要让它们互相等待。`, + ); + } + return lines.join("\n"); +} + +/** + * 从总控「每 stage 派活轮」的终态里,取出派给**某个**钉死席位的 task 文本: + * 1) 命中 agent_name === seatAgentId 的结构化派活(总控真调了 agent_orchestration 工具) + * → 用它的 task(正常路径); + * 2) 总控**没用工具、只写了文字**(弱模型常见,dispatched 为空 / 没命中该席位)→ **绝不**把 + * 总控的整段派活叙述喂给席位 —— 否则席位会把「我来调度…席位1/2/3」当成自己的任务照抄、 + * 模仿派活,甚至绕道 bash/python 调内部接口去派活(已在生产观测到)。改给一个**干净的本职 + * 任务**:任务背景与此前各阶段交付已由后端广播进席位线程上下文,席位据此承接即可。 + * + * 所有分支都追加「角色边界护栏」,明确席位**不是总控、不许派活**(席位本就被 run policy 禁了 + * agent_orchestration,但弱模型会空转叙述 / 绕道,故再从提示层钉死)。 + */ +export function pickDagSeatTask( + dispatched: ReadonlyArray, + seatAgentId: string, + seatName: string, + seedMessage = "", + /** 是否已有前序席位交付(决定是否提示 read_peer_delivery 工具按需取全文,§12.9)。 */ + hasPeers = false, + /** + * 是否允许写文件(§12.10): + * - `true`(**沙箱模式**):要求把交付**写成 .md 文件** + 正文摘要,禁 .py; + * - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。 + */ + allowFiles = false, +): string { + const guard = + `\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`; + // 产物要求(§12.8 / §12.10):沙箱模式产出 .md 文件(禁 .py);快速模式产物走正文、不写文件。 + const fileRule = allowFiles + ? `\n\n【产物要求】请把你的**完整交付写成一个 Markdown(.md)文件**(用 write_file 写到 outputs 目录,文件名体现你的职责),并在回复正文里给出要点摘要。**不要写 .py 或任何可执行脚本文件**(不要写脚本去生成产物);直接产出成品文档,.md / .html / .txt 等其它格式都可以,唯独不要 .py。` + : `\n\n【产物要求】本轮为**快速并行**模式:请把你的产物**直接写在回复正文里**,**不要写文件、不要执行脚本/命令**(需要落文件的产物留给沙箱模式或后续阶段)。`; + // 任务背景:DAG 派活轮抑制了 leader 预广播(防席位模仿派活),所以席位 thread 里可能没有 + // 用户原始消息 —— 把任务背景直接写进席位 task,确保它有上下文(此前各阶段交付仍由后端 + // 席位间 detached 广播注入,故只补背景)。 + const bg = seedMessage.trim() ? `本次研讨的任务背景:\n${seedMessage.trim()}\n\n` : ""; + // §12.9:此前各阶段席位的交付,你默认只看到**摘要**(广播进来的);若某个前序席位的摘要不够、 + // 你需要它的**完整交付**,调用 read_peer_delivery('<席位名>') 按需取全文(平时不必调,优先用摘要)。 + const peerRule = hasPeers + ? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。` + : ""; + const wanted = seatAgentId.toLowerCase(); + const exact = dispatched.find(([name]) => name.toLowerCase() === wanted); + if (exact && exact[1]?.trim()) { + return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${fileRule}${peerRule}${guard}`; + } + // dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。 + return ( + `${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` + + `此前各阶段席位的交付已在你的会话上下文里,请据此承接。` + + fileRule + + peerRule + + guard + ); +} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 2ed3f44f..940d055b 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -5,21 +5,18 @@ * - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除 * intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`, * 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。 - * - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、上/下移、 - * 删除单项;左栏拖入时按落点插入到对应位置。 + * - 右栏:**分层编排** —— 把席位分到若干 **Stage**(竖向分组):同一 Stage 内并行、 + * Stage 之间串行。可拖席位跨 Stage、整段 Stage 上下移、新建 / 删除 Stage;也提供 + * 按钮(并入上/下一阶段、独立成阶段、移除)做无拖拽兜底。全单席位 Stage = 线性链。 * - * 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是 - * `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。 + * 拖拽用 `@dnd-kit`:每个 Stage 是一个 droppable,Stage 内席位 `SortableContext`; + * 左栏卡片 `useDraggable`(pool→某 Stage / 新建 Stage 区插入)。落点计算在 `onDragEnd`。 * - * 视觉:对齐设计稿 `yewuliantiao`(浅红 / 绛红风格)。统一走 app 语义 token - * (`primary` 在浅色主题即设计稿的绛红 #c25555,深色主题自动切蓝),配规范小圆角 - * (输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页的 - * `roundtable-planning` scope。 + * 持久化走 `api/chains.ts`:`seats` = 各 Stage 展平的有序席位;`stages` = 分层(全单 + * 席位时存 null,即线性,老行为不变)。席位数约束 2 ≤ N ≤ 8、agent 去重。分层操作的 + * 纯逻辑在 `lib/chain-stages.ts`(可单测)。 * - * 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束 - * 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。 - * - * 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。 + * 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -30,11 +27,13 @@ import { Bot, ChevronDown, ChevronUp, + GitBranch, GripVertical, - ListOrdered, + Layers, Loader2, Plus, Save, + SplitSquareVertical, Trash2, UserPlus, X, @@ -53,7 +52,6 @@ import { } from "@dnd-kit/core"; import { SortableContext, - arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; @@ -81,11 +79,30 @@ import { type BusinessChain, type ChainSeat, } from "@/roundtable-planning/lib/business-chain"; +import { + addEmptyStage, + addSeatAsNewStage, + addSeatToStage, + chainToStageGroups, + countSeats, + deleteStage as deleteStageGroup, + findStageIndexOfSeat, + moveSeatToStage, + moveStage, + removeSeat as removeSeatFromGroups, + seatExistsInGroups, + splitSeatToNewStage, + stageGroupsToSeats, + stageGroupsToStagePayload, + type StageGroups, +} from "@/roundtable-planning/lib/chain-stages"; import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants"; +import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; const POOL_PREFIX = "pool::"; const SEAT_PREFIX = "seat::"; -const SEATS_DROPPABLE_ID = "seats-droppable"; +const STAGE_PREFIX = "stage::"; +const NEWSTAGE_DROPPABLE_ID = "newstage-droppable"; // 规范圆角(§4):输入/按钮/tag 2px,卡片/分栏 4px。app 默认 --radius=12px 偏大, // 这里用 arbitrary 值精确命中规范。 @@ -115,7 +132,8 @@ export default function BusinessChainEditorPage() { const [currentChainId, setCurrentChainId] = useState(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); - const [seats, setSeats] = useState([]); + // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 + const [stageGroups, setStageGroups] = useState([]); const [saving, setSaving] = useState(false); const [activeDrag, setActiveDrag] = useState(null); @@ -130,7 +148,34 @@ export default function BusinessChainEditorPage() { for (const a of pool) m.set(a.agent_id, a); return m; }, [pool]); - const seatIdSet = useMemo(() => new Set(seats.map((s) => s.agent_id)), [seats]); + const seatIdSet = useMemo( + () => new Set(stageGroups.flat().map((s) => s.agent_id)), + [stageGroups], + ); + const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]); + + // ─── Phase 7 只读流程图预览(§2.2 底部 / §7)────────────────────────────── + // 把当前分层(去掉空 stage)映射成 DagFlowChart 的 stages,并提供席位名/配色解析。 + const previewStages = useMemo( + () => + stageGroups + .filter((g) => g.length > 0) + .map((g, i) => ({ id: `stage-${i}`, agentIds: g.map((s) => s.agent_id) })), + [stageGroups], + ); + const seatNameById = useMemo(() => { + const m = new Map(); + for (const g of stageGroups) + for (const s of g) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id); + return m; + }, [stageGroups]); + const resolvePreviewSeat = useCallback( + (agentId: string, order: number) => ({ + name: seatNameById.get(agentId.toLowerCase()) ?? agentId, + avatarType: getAvatarTypeFor(agentId, order), + }), + [seatNameById], + ); // ─── 链条列表(一次性)───────────────────────────────────────────────── const refreshChains = useCallback(async () => { @@ -175,14 +220,14 @@ export default function BusinessChainEditorPage() { setCurrentChainId(chain.id); setTitle(chain.title); setDescription(chain.description ?? ""); - setSeats(chain.seats.map((s) => ({ ...s }))); + setStageGroups(chainToStageGroups(chain)); }, []); const startNewChain = useCallback(() => { setCurrentChainId(null); setTitle(""); setDescription(""); - setSeats([]); + setStageGroups([]); }, []); // ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ────── @@ -198,43 +243,65 @@ export default function BusinessChainEditorPage() { appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试 }, [chains, searchParams, loadChain]); - // ─── 席位增删改 ─────────────────────────────────────────────────────── - const addAgentToSeats = useCallback( - (agentId: string, atIndex?: number) => { + // ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)──────────────────── + const toChainSeat = useCallback( + (agentId: string): ChainSeat | null => { const agent = poolById.get(agentId); - if (!agent) return; - setSeats((prev) => { - if (prev.some((s) => s.agent_id === agentId)) { - toast.info(`「${agent.name}」已在链条中`); - return prev; - } - if (prev.length >= CHAIN_MAX_SEATS) { - toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`); - return prev; - } - const seat: ChainSeat = { - agent_id: agent.agent_id, - name: agent.name, - description: agent.description, - }; - const next = [...prev]; - const i = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length)); - next.splice(i, 0, seat); - return next; - }); + if (!agent) return null; + return { agent_id: agent.agent_id, name: agent.name, description: agent.description }; }, [poolById], ); + /** pool 点击加入:末尾追加一个新单席位 Stage(线性追加,默认行为)。 */ + const addAgentAsNewStage = useCallback( + (agentId: string) => { + const seat = toChainSeat(agentId); + if (!seat) return; + setStageGroups((prev) => { + if (seatExistsInGroups(prev, agentId)) { + toast.info(`「${seat.name}」已在链条中`); + return prev; + } + if (countSeats(prev) >= CHAIN_MAX_SEATS) { + toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`); + return prev; + } + return addSeatAsNewStage(prev, seat); + }); + }, + [toChainSeat], + ); + const removeSeat = useCallback((agentId: string) => { - setSeats((prev) => prev.filter((s) => s.agent_id !== agentId)); + setStageGroups((prev) => removeSeatFromGroups(prev, agentId)); }, []); - const moveSeat = useCallback((index: number, dir: -1 | 1) => { - setSeats((prev) => { - const target = index + dir; - if (target < 0 || target >= prev.length) return prev; - return arrayMove(prev, index, target); + const mergeSeatToStage = useCallback((agentId: string, targetStageIdx: number) => { + setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx)); + }, []); + + const splitSeat = useCallback((agentId: string) => { + setStageGroups((prev) => splitSeatToNewStage(prev, agentId)); + }, []); + + const reorderStage = useCallback((stageIdx: number, dir: -1 | 1) => { + setStageGroups((prev) => moveStage(prev, stageIdx, dir)); + }, []); + + const appendEmptyStage = useCallback(() => { + setStageGroups((prev) => addEmptyStage(prev)); + }, []); + + const removeStage = useCallback((stageIdx: number) => { + setStageGroups((prev) => { + const stage = prev[stageIdx]; + if (stage && stage.length > 0) { + if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) { + return prev; + } + } + return deleteStageGroup(prev, stageIdx); }); }, []); @@ -248,11 +315,11 @@ export default function BusinessChainEditorPage() { setActiveDrag({ kind: "pool", agentId, name: a?.name ?? agentId }); } else if (id.startsWith(SEAT_PREFIX)) { const agentId = id.slice(SEAT_PREFIX.length); - const s = seats.find((x) => x.agent_id === agentId); - setActiveDrag({ kind: "seat", agentId, name: s?.name ?? agentId }); + const a = poolById.get(agentId); + setActiveDrag({ kind: "seat", agentId, name: a?.name ?? agentId }); } }, - [poolById, seats], + [poolById], ); const onDragEnd = useCallback( @@ -262,33 +329,57 @@ export default function BusinessChainEditorPage() { const overId = e.over ? String(e.over.id) : null; if (!overId) return; - const overSeatIndex = overId.startsWith(SEAT_PREFIX) - ? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId) - : -1; + // 解析落点 → 目标 Stage 下标 + 该 Stage 内插入位置;-1 表示「新建 Stage 区」。 + let targetStage = -1; + let atIndex: number | undefined; + if (overId === NEWSTAGE_DROPPABLE_ID) { + targetStage = -1; + } else if (overId.startsWith(STAGE_PREFIX)) { + targetStage = Number(overId.slice(STAGE_PREFIX.length)); + } else if (overId.startsWith(SEAT_PREFIX)) { + const overAgent = overId.slice(SEAT_PREFIX.length); + targetStage = findStageIndexOfSeat(stageGroups, overAgent); + atIndex = stageGroups[targetStage]?.findIndex((s) => s.agent_id === overAgent); + } + + const toNewStage = overId === NEWSTAGE_DROPPABLE_ID; if (activeId.startsWith(POOL_PREFIX)) { const agentId = activeId.slice(POOL_PREFIX.length); - const insertAt = overSeatIndex >= 0 ? overSeatIndex : seats.length; - addAgentToSeats(agentId, insertAt); + if (seatIdSet.has(agentId)) return; + const seat = toChainSeat(agentId); + if (!seat) return; + if (seatCount >= CHAIN_MAX_SEATS) { + toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`); + return; + } + setStageGroups((prev) => + toNewStage || targetStage < 0 + ? addSeatAsNewStage(prev, seat) + : addSeatToStage(prev, seat, targetStage, atIndex), + ); return; } if (activeId.startsWith(SEAT_PREFIX)) { - const fromIndex = seats.findIndex( - (s) => SEAT_PREFIX + s.agent_id === activeId, - ); - if (fromIndex < 0) return; - const toIndex = overSeatIndex >= 0 ? overSeatIndex : seats.length - 1; - if (fromIndex === toIndex) return; - setSeats((prev) => arrayMove(prev, fromIndex, toIndex)); + const agentId = activeId.slice(SEAT_PREFIX.length); + setStageGroups((prev) => { + if (toNewStage) { + // 拖到「新建 Stage」区 = 抽出独立成最后一个 Stage。 + const withEmpty = addEmptyStage(prev); + return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1); + } + if (targetStage < 0) return prev; + return moveSeatToStage(prev, agentId, targetStage, atIndex); + }); } }, - [seats, addAgentToSeats], + [stageGroups, seatIdSet, seatCount, toChainSeat], ); // ─── 保存 / 删除 ────────────────────────────────────────────────────── - const seatCountInvalid = - seats.length < CHAIN_MIN_SEATS || seats.length > CHAIN_MAX_SEATS; + const seatCountInvalid = seatCount < CHAIN_MIN_SEATS || seatCount > CHAIN_MAX_SEATS; + const hasEmptyStage = stageGroups.some((g) => g.length === 0); const canSave = title.trim().length > 0 && !seatCountInvalid && !saving; const handleSave = useCallback(async () => { @@ -302,16 +393,20 @@ export default function BusinessChainEditorPage() { } setSaving(true); try { - const payloadSeats = seats.map((s) => ({ + // 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。 + const flat = stageGroupsToSeats(stageGroups); + const payloadSeats = flat.map((s) => ({ agent_id: s.agent_id, name: poolById.get(s.agent_id)?.name ?? s.name, description: poolById.get(s.agent_id)?.description ?? s.description ?? "", })); + const stages = stageGroupsToStagePayload(stageGroups); if (currentChainId) { const updated = await updateChain(currentChainId, { title: title.trim(), description: description.trim() || undefined, seats: payloadSeats, + stages, }); if (!updated) { toast.error("链条已不存在,可能被删除"); @@ -327,6 +422,7 @@ export default function BusinessChainEditorPage() { title: title.trim(), description: description.trim() || undefined, seats: payloadSeats, + stages, }); setCurrentChainId(created.id); toast.success("已创建业务链条"); @@ -340,7 +436,7 @@ export default function BusinessChainEditorPage() { }, [ title, description, - seats, + stageGroups, seatCountInvalid, currentChainId, poolById, @@ -367,7 +463,6 @@ export default function BusinessChainEditorPage() { }, [currentChainId, title, startNewChain, refreshChains]); // ─── Render ────────────────────────────────────────────────────────── - const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id); const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…"; return ( @@ -399,7 +494,7 @@ export default function BusinessChainEditorPage() {
业务链条配置
- 编排有序的智能体研讨链路 + 分层编排:同层并行 · 层间串行
@@ -449,7 +544,7 @@ export default function BusinessChainEditorPage() { )} > - {c.title || "未命名链条"}({c.seats.length} 席) + {c.title || "未命名链条"}({c.seats.length} 席{c.stages?.length ? " · 分层" : ""}) {c.id === currentChainId && ( @@ -620,20 +715,25 @@ export default function BusinessChainEditorPage() { key={a.agent_id} agent={a} inChain={seatIdSet.has(a.agent_id)} - onAdd={() => addAgentToSeats(a.agent_id)} + onAdd={() => addAgentAsNewStage(a.agent_id)} /> )) )}
- {/* 右栏:有序席位 */} - @@ -652,9 +752,27 @@ export default function BusinessChainEditorPage() { + {/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */} + {previewStages.length > 0 && ( +
+
+ 编排预览 + + 总控派活 → 各 Stage(串行)→ 共识 → 结果绘制 + +
+ +
+ )} + {/* 页脚 */}
- 编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。 + 分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
); @@ -733,22 +851,42 @@ function PoolAgentCard({ ); } -// ── 右栏(droppable 容器 + sortable 列表)─────────────────────────────── +// ── 右栏(分层 Stage 容器)─────────────────────────────────────────────── -function SeatsColumn({ - seats, - seatItemIds, +function StagesColumn({ + stageGroups, + seatCount, seatCountInvalid, - onRemove, - onMove, + hasEmptyStage, + onAddStage, + onRemoveStage, + onReorderStage, + onRemoveSeat, + onMergeSeat, + onSplitSeat, }: { - seats: ChainSeat[]; - seatItemIds: string[]; + stageGroups: StageGroups; + seatCount: number; seatCountInvalid: boolean; - onRemove: (agentId: string) => void; - onMove: (index: number, dir: -1 | 1) => void; + hasEmptyStage: boolean; + onAddStage: () => void; + onRemoveStage: (stageIdx: number) => void; + onReorderStage: (stageIdx: number, dir: -1 | 1) => void; + onRemoveSeat: (agentId: string) => void; + onMergeSeat: (agentId: string, targetStageIdx: number) => void; + onSplitSeat: (agentId: string) => void; }) { - const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID }); + // 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。 + const stageStartIndex = useMemo(() => { + const out: number[] = []; + let acc = 0; + for (const g of stageGroups) { + out.push(acc); + acc += g.length; + } + return out; + }, [stageGroups]); + return (
- + -

链条席位

+

链条编排

- 按顺序发言 + 同层并行 · 层间串行
-
- 配置席位 +
+ - {seats.length} / {CHAIN_MAX_SEATS} - {seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`} + {seatCount} / {CHAIN_MAX_SEATS} + {seatCount < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
-
- {seats.length === 0 ? ( +
+ {seatCount === 0 && stageGroups.length === 0 ? (
- +

暂无席位

- 该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。 + 从左侧「候选智能体」点击或拖拽加入。每次加入是一个串行 Stage;把席位拖到同一个 + Stage 里即可让它们并行。

+ ) : ( + <> + {stageGroups.map((group, stageIdx) => ( + onRemoveStage(stageIdx)} + onReorderStage={(dir) => onReorderStage(stageIdx, dir)} + onRemoveSeat={onRemoveSeat} + onMergeSeat={onMergeSeat} + onSplitSeat={onSplitSeat} + /> + ))} + + + )} +
+
+ ); +} + +// ── 单个 Stage(droppable 容器 + Stage 内 sortable 列表)────────────────── + +function StageSection({ + stageIdx, + group, + stageCount, + startIndex, + onRemoveStage, + onReorderStage, + onRemoveSeat, + onMergeSeat, + onSplitSeat, +}: { + stageIdx: number; + group: ChainSeat[]; + stageCount: number; + startIndex: number; + onRemoveStage: () => void; + onReorderStage: (dir: -1 | 1) => void; + onRemoveSeat: (agentId: string) => void; + onMergeSeat: (agentId: string, targetStageIdx: number) => void; + onSplitSeat: (agentId: string) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx }); + const isParallel = group.length > 1; + const isFirstStage = stageIdx === 0; + const isLastStage = stageIdx === stageCount - 1; + const seatItemIds = group.map((s) => SEAT_PREFIX + s.agent_id); + + return ( +
+ {/* Stage 头 */} +
+
+ + + Stage {stageIdx + 1} + + + {isParallel ? `并行 ${group.length}` : "串行"} + +
+
+ + + +
+
+ + {/* Stage 内席位 */} +
+ {group.length === 0 ? ( +
+ 空 Stage —— 把席位拖到此处,或删除该 Stage。 +
) : ( - {seats.map((seat, index) => ( + {group.map((seat, i) => ( onRemove(seat.agent_id)} - onMove={(dir) => onMove(index, dir)} + globalIndex={startIndex + i} + stageIdx={stageIdx} + isFirstStage={isFirstStage} + isLastStage={isLastStage} + canSplit={isParallel} + onRemove={() => onRemoveSeat(seat.agent_id)} + onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)} + onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)} + onSplit={() => onSplitSeat(seat.agent_id)} /> ))} )}
- +
); } @@ -830,22 +1109,29 @@ function SeatsColumn({ function SeatStrip({ seat, - index, - total, + globalIndex, + isFirstStage, + isLastStage, + canSplit, onRemove, - onMove, + onMergeUp, + onMergeDown, + onSplit, }: { seat: ChainSeat; - index: number; - total: number; + globalIndex: number; + stageIdx: number; + isFirstStage: boolean; + isLastStage: boolean; + canSplit: boolean; onRemove: () => void; - onMove: (dir: -1 | 1) => void; + onMergeUp: () => void; + onMergeDown: () => void; + onSplit: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: SEAT_PREFIX + seat.agent_id }); - const avatarType = getAvatarTypeFor(seat.agent_id, index); - const isFirst = index === 0; - const isLast = index === total - 1; + const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex); return (
@@ -877,7 +1163,7 @@ function SeatStrip({ chainTagClasses(avatarType), )} > - {index + 1} + {globalIndex + 1}
@@ -891,17 +1177,17 @@ function SeatStrip({ )}
- {/* 控制:上移 / 下移 / 移除 */} + {/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
+ + ); +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 6bbbee44..1ee2c3f7 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -51,6 +51,7 @@ import { import { useTheme } from "@/contexts/ThemeContext"; import { useArtifacts } from "@/components/workspace/artifacts"; +import { useSidebarSafe } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import { useModels } from "@/core/models/hooks"; @@ -84,6 +85,7 @@ import { type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; import 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"; export default function RoundtablePlanningPage() { @@ -104,13 +106,19 @@ export default function RoundtablePlanningPage() { // 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。 const [isImportOpen, setIsImportOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); - // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。 + // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活; + // dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 - const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">( + const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain" | "dag">( "recommend", ); // chain 模式来源链条(仅展示/持久化用)。 const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null); + // dag 模式的分层编排计划(Phase 2 编辑器产出;持久化进 step2.orchestrationPlan)。 + const [orchestrationPlan, setOrchestrationPlan] = useState(null); + // dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。 + // 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。 + const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast"); const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); const [step2Runs, setStep2Runs] = useState([]); const [activeStep2RunId, setActiveStep2RunId] = useState(null); @@ -121,6 +129,9 @@ export default function RoundtablePlanningPage() { const [step3SavedTick, setStep3SavedTick] = useState(0); const step2RunsRef = useRef([]); const activeStep2RunIdRef = useRef(null); + // 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径, + // 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。 + const sidebar = useSidebarSafe(); // 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在 // 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。 @@ -194,6 +205,8 @@ export default function RoundtablePlanningPage() { currentStep, selectedModel, orchestrationMode, + orchestrationPlan, + dagExecMode, buildInitialIntent, triggerToast, setRoles, @@ -243,10 +256,14 @@ export default function RoundtablePlanningPage() { seatStubMessages: step2.seatStubMessages, orchestrationMode, chain: chainMeta, + orchestrationPlan, + dagExecMode, }), [ chainMeta, orchestrationMode, + orchestrationPlan, + dagExecMode, step2.budgetLimit, step2.consensusPercentage, step2.coordinatorName, @@ -279,6 +296,8 @@ export default function RoundtablePlanningPage() { seatStubMessages: snapshot.seatStubMessages ?? {}, orchestrationMode: snapshot.orchestrationMode, chain: snapshot.chain ?? null, + orchestrationPlan: snapshot.orchestrationPlan ?? null, + dagExecMode: snapshot.dagExecMode, step3: step3Report, }), [step3Report], @@ -362,6 +381,8 @@ export default function RoundtablePlanningPage() { (run: DraftStep2RunSnapshot) => { setOrchestrationMode(run.orchestrationMode ?? "recommend"); setChainMeta(run.chain ?? null); + setOrchestrationPlan(run.orchestrationPlan ?? null); + setDagExecMode(run.dagExecMode ?? "fast"); setStep3Report(run.step3 ?? null); step2.hydrateFromDraft(run as unknown as Step2Snapshot); }, @@ -421,6 +442,8 @@ export default function RoundtablePlanningPage() { } setOrchestrationMode(snapshot.orchestrationMode ?? "recommend"); setChainMeta(snapshot.chain ?? null); + setOrchestrationPlan(snapshot.orchestrationPlan ?? null); + setDagExecMode(snapshot.dagExecMode ?? "fast"); step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot); }, cancelAllStreams: () => { @@ -504,6 +527,19 @@ export default function RoundtablePlanningPage() { drafts.loadedFromDraftRef, ]); + // 并行执行 → 收起左侧栏腾出横向空间放两列(和「打开沙箱」一样的收起体验): + // - 全局工作区侧栏(最左):sidebar.setOpen(false)(与 artifacts 打开沙箱同一路径); + // - 圆桌「参与角色」栏:setStep2SidebarExpanded(false)。 + // **不自动复原**:并行结束后再进入单智能体回答时保持收起(用户需要时手动展开), + // 与沙箱收起后不自动回弹一致。 + useEffect(() => { + if (step2.isParallelRunning) { + sidebar?.setOpen(false); + setStep2SidebarExpanded(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step2.isParallelRunning]); + // ─── 步骤间转场 handler ─────────────────────────────────── const handleConfirmAgents = useCallback( (result: SeatSelectionResult) => { @@ -537,7 +573,10 @@ export default function RoundtablePlanningPage() { budgetLimit: step2.budgetLimit, seatStubMessages: {}, orchestrationMode: result.mode, - chain: result.mode === "chain" ? result.chain ?? null : null, + // chain / dag 都来自业务链条,保留来源链名;recommend 无。 + chain: result.mode === "recommend" ? null : result.chain ?? null, + orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null, + dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "fast" : undefined, step3: null, }; setStep2Runs([...syncedRuns, newRun]); @@ -546,15 +585,20 @@ export default function RoundtablePlanningPage() { step2.resetStep2State(); step2.setSelectedAgents(normalized); step2.resetInitGate(); - // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。 + // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活; + // dag = 分层并行(来源链条带 stages,携带 result.plan)。 setOrchestrationMode(result.mode); - setChainMeta(result.mode === "chain" ? result.chain ?? null : null); + setChainMeta(result.mode === "recommend" ? null : result.chain ?? null); + setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null); + setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast"); setIsRecommendDialogOpen(false); setCurrentStep(2); triggerToast( - result.mode === "chain" - ? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨` - : "成功确认!已切换至第二阶段圆桌研讨系统", + result.mode === "dag" + ? `成功确认!将按业务链条「${result.chain?.title ?? ""}」分层并行研讨` + : result.mode === "chain" + ? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨` + : "成功确认!已切换至第二阶段圆桌研讨系统", ); }, [buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast], @@ -570,6 +614,8 @@ export default function RoundtablePlanningPage() { setActiveStep2RunId(null); setOrchestrationMode("recommend"); setChainMeta(null); + setOrchestrationPlan(null); + setDagExecMode("fast"); setStep3Report(null); step1.stopIntentStream(); drafts.loadedFromDraftRef.current = false; @@ -609,7 +655,9 @@ export default function RoundtablePlanningPage() { intent: buildInitialIntent(), agents, model: selectedModel || undefined, // 模型沿用第二步选择 - orchestrationMode, + // 后台作业引擎暂不支持 dag(分层 DAG 只在前端 runDagOrchestration 跑); + // 挂起到后台时降级为 recommend(总控自由派活)。 + orchestrationMode: orchestrationMode === "dag" ? "recommend" : orchestrationMode, chain: chainMeta, // 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程 // (那些线程可能有活跃 run,复用会撞 409)。 @@ -878,6 +926,13 @@ export default function RoundtablePlanningPage() { agentConfigs={step2.agentConfigs} selectedAgentId={step2.selectedAgentId} roles={roles} + parallelBatch={step2.parallelBatch} + selectedParallelSeats={step2.selectedParallelSeats} + isParallelRunning={step2.isParallelRunning} + onToggleParallelSeat={step2.toggleParallelSeat} + orchestrationMode={orchestrationMode} + orchestrationPlan={orchestrationPlan} + dagNodeStates={step2.dagNodeStates} agentNameToRole={step2.agentNameToRole} selectedModel={selectedModel} availableModels={availableModels} diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py index 4d4a378a..6747a20e 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py @@ -18,13 +18,25 @@ 总控的唯一职责是「派活 / 澄清 / 收口呈现」,没有任何理由动文件系统或执行命令。 并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从 AIMessage.tool_calls 解析派活列表。 - - ``seat`` —— 子智能体交付:开 thinking + medium reasoning;禁派活 / - 澄清 / 展示文件 / 看图 / 联网搜索(与前端 ``_special_run`` 一致)。 - - ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``bash`` / + - ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件** + (``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 / + 展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是 + 「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与 + ``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。 + - ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件** + (``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类, + **放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)—— + 否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。 + 「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览); + fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见 + ``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。 + 与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」), + ``seat_parallel`` **放开搜索**(本期诉求)。 + - ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁 ``str_replace``**: - * 禁 ``bash`` → 杜绝它写 Python 脚本再跑出 HTML(脚本产物落在 outputs, - 但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本产物 → - 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象); + * ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在 + outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本 + 产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象); * 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写 (``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。 @@ -58,9 +70,36 @@ _LEADER_EXCLUDED: list[str] = [ "hindsight_recall", "hindsight_reflect", "hindsight_retain", + "read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具 ] -_SEAT_EXCLUDED: list[str] = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"] -_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "bash", "str_replace"] +# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** —— +# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又 +# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。 +_SEAT_EXCLUDED: list[str] = [ + "web_search", + "ask_clarification", + "present_files", + "view_image", + "agent_orchestration", + "bash", +] +# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类, +# **放开 web_search / 技能**(搜索不碰 sandbox;技能靠 read_file;注意不继承 seat 的 web_search +# 禁用,故意显式列举,以便并行席位能联网搜索、产生工具步骤步骤条)。 +# 「写文件」归 **沙箱(sandbox)模式**(seat policy,单列 + 沙箱跟随预览);fast 模式快、双列、 +# 产物走正文 —— 用户明确要求 fast 不生成文件(2026-06 §12.10)。 +_SEAT_PARALLEL_EXCLUDED: list[str] = [ + "ask_clarification", # 并行批次不打断问澄清 + "present_files", # 产物走正文,不呈现文件 + "view_image", + "agent_orchestration", # 席位不派活 + "bash", # 禁跑脚本(防 .py 脚本生成产物) + "write_file", # fast 模式产物走正文,不写文件(写文件归 sandbox 模式) + "str_replace", +] +# report:在 seat 基础上额外禁 str_replace(bash 已在 seat 禁)——强制整文件 write_file 重写, +# 让前端虚拟预览能拿到完整 args.content。report 是 Step3 单独绘制,不读前序席位 → 禁 read_peer_delivery。 +_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"] # leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上 # 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。 @@ -82,7 +121,7 @@ class RoundtableRunPolicy: def roundtable_run_policy(role: str) -> RoundtableRunPolicy: - """返回某角色(``leader`` / ``seat`` / ``report``)的单轮 run 参数。 + """返回某角色(``leader`` / ``seat`` / ``seat_parallel`` / ``report``)的单轮 run 参数。 返回值里的列表是**新副本**,调用方可安全地再 append/merge 而不污染本模块常量。 未知角色抛 ``ValueError``(早失败,避免悄悄退化到错误的工具面)。 @@ -101,6 +140,14 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy: thinking_enabled=True, reasoning_effort="medium", ) + if role == "seat_parallel": + # 同 seat(thinking + medium),但额外禁写文件 / 跑命令(防并行沙箱打架)。 + return RoundtableRunPolicy( + excluded_tools=list(_SEAT_PARALLEL_EXCLUDED), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + ) if role == "report": return RoundtableRunPolicy( excluded_tools=list(_REPORT_EXCLUDED), @@ -108,7 +155,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy: thinking_enabled=True, reasoning_effort="medium", ) - raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/report)") + raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report)") def merge_skill_stop_names(policy_names: list[str], caller_names: list[str] | None) -> list[str]: diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 6aab16ca..eb5f7179 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -637,6 +637,7 @@ def _run_payload( thinking_enabled: bool = True, reasoning_effort: str = "medium", multitask_strategy: str = "reject", + extra_context: dict[str, Any] | None = None, ) -> dict[str, Any]: """构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。 @@ -649,7 +650,7 @@ def _run_payload( 应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐 reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。 """ - return { + payload: dict[str, Any] = { "input": { "messages": [ { @@ -697,6 +698,11 @@ def _run_payload( "excluded_tools": excluded_tools, "skill_stop_names": skill_stop_names, } + # 额外注入 run context(如圆桌席位的 roundtable_peer_deliveries:前序席位完整交付, + # 供 read_peer_delivery 工具按需读取)。只进 context dict,不进 prompt → 不调工具就不耗 token。 + if extra_context: + payload["context"].update(extra_context) + return payload # --------------------------------------------------------------------------- @@ -1251,6 +1257,7 @@ async def _leader_run( model_name: str, seat_meta: dict[str, dict[str, Any]] | None = None, synthesis_mode: bool = False, + suppress_pre_broadcast: bool = False, ) -> AsyncIterator[str | dict[str, Any]]: """总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。 @@ -1319,8 +1326,19 @@ async def _leader_run( ) # 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。 - pre_broadcast_task = asyncio.create_task( - _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name]) + # + # ``suppress_pre_broadcast``:DAG「每 stage 派活轮」(方案 A)置真——此时 ``new_message`` 是 + # 「请你作为总控…通过 agent_orchestration 派活给席位…」的**派活 prompt**,若广播进各席位 + # thread,弱模型(deepseek)会从历史里捡到「派活」意图,**席位跟着模仿派活 / 绕道 bash 调 + # 内部接口**(已观测 bug-1~4)。DAG 下席位的任务由前端 ``pickDagSeatTask`` 直接、干净地下发 + # (含任务背景),此处不需要、也不应把总控的派活 prompt 播给席位。recommend/chain 不传该 flag, + # 行为不变。 + pre_broadcast_task = ( + None + if suppress_pre_broadcast + else asyncio.create_task( + _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name]) + ) ) thread_id = agent_threads[agent_name] # 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停 @@ -1399,15 +1417,16 @@ async def _leader_run( "(expected on rollback preemption; otherwise check the upstream run's server-side error)", agent_name, ) - with contextlib.suppress(Exception): - await pre_broadcast_task + if pre_broadcast_task is not None: + with contextlib.suppress(Exception): + await pre_broadcast_task return # Pre-broadcast must finish before we yield the final status frame on any # branch — the seats that get dispatched next rely on having seen the # original user message. Only await it once (first attempt); the rare # self-correction retry reuses the same already-awaited broadcast. - if attempt == 0: + if attempt == 0 and pre_broadcast_task is not None: t_before_broadcast_await = time.perf_counter() try: await pre_broadcast_task @@ -1485,14 +1504,18 @@ async def _leader_run( # 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播 # 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。 yield {"status": dispatched, "content": leader_text, "agent_name": agent_name} - for sub_name, task_text in valid_dispatch: - _spawn_detached_broadcast( - base, - headers, - f"总控智能体给{sub_name}派活,内容为:" + task_text, - agent_threads, - [agent_name, sub_name], - ) + # DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 —— + # 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发, + # 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。 + if not suppress_pre_broadcast: + for sub_name, task_text in valid_dispatch: + _spawn_detached_broadcast( + base, + headers, + f"总控智能体给{sub_name}派活,内容为:" + task_text, + agent_threads, + [agent_name, sub_name], + ) return @@ -1507,6 +1530,8 @@ async def _special_run( model_name: str, *, display_name: str = "", + no_file: bool = False, + peer_deliveries: list[dict[str, Any]] | None = None, ) -> AsyncIterator[str | dict[str, Any]]: """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。 @@ -1514,13 +1539,26 @@ async def _special_run( - 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文; - **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要, 同时尽量让前端先收到结果关闭 loading。 + + ``no_file``:DAG 并行批次里的席位置真 —— 用 ``seat_parallel`` policy 额外禁 + write_file / bash / str_replace,避免并行席位同时写沙箱打架(§4)。 + + ``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到 + run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具 + **按需**取全文(平时只看广播摘要,§12.9 传摘要按需传全文)。不进 prompt,不耗 token。 """ t0 = time.perf_counter() thread_id = agent_threads[agent_name] - # 角色参数取自单一数据源 roundtable_run_policy(seat / report)。report 在 seat - # 基础上额外禁 bash / str_replace(理由见 roundtable_run_policy 模块注释)。 + # 角色参数取自单一数据源 roundtable_run_policy(seat / seat_parallel / report)。 + # report 在 seat 基础上额外禁 bash / str_replace;seat_parallel 额外禁 + # write_file / bash / str_replace(理由见 roundtable_run_policy 模块注释)。 # 后台进程内网关用同一份 policy,保证「后台挂起」与「前端手动调用」传参一致。 - role = "report" if agent_name == REPORT_AGENT_ID else "seat" + if agent_name == REPORT_AGENT_ID: + role = "report" + elif no_file: + role = "seat_parallel" + else: + role = "seat" policy = roundtable_run_policy(role) body = _run_payload( agent_name=agent_name, @@ -1533,6 +1571,10 @@ async def _special_run( reasoning_effort=policy.reasoning_effort, # 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。 multitask_strategy="rollback", + # 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。 + extra_context=( + {"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None + ), ) t_payload_built = time.perf_counter() logger.info( @@ -1639,6 +1681,25 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> # 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮 # 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。 synthesis_mode: bool = bool(payload.get("synthesis_mode") or False) + # 并行禁写文件标记(仅 special 用):前端 runDagOrchestration 在真并行 stage(≥2 席位) + # 置真,网关据此用 seat_parallel policy 额外禁 write_file/bash/str_replace(§4)。 + # 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。 + no_file: bool = bool(payload.get("parallel_no_file") or payload.get("no_file") or False) + # 抑制 leader 预广播(仅 leader 用):DAG「每 stage 派活轮」置真,避免把总控的派活 prompt + # 广播进席位 thread 让弱模型模仿派活(详见 _leader_run 的 suppress_pre_broadcast 注释)。 + # 缺省 False,recommend/chain 行为不变。 + suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast") or False) + # 前序席位完整交付(仅 special 用):前端 DAG stage>0 席位带上此前各阶段的全文,网关注入 + # run context,供 read_peer_delivery 工具按需取全文(平时只看广播摘要,§12.9)。规整成 + # [{name, content}] 且过滤空项;缺省 None。 + peer_deliveries: list[dict[str, Any]] | None = None + _pd_raw = payload.get("peer_deliveries") + if isinstance(_pd_raw, list): + peer_deliveries = [ + {"name": str(p.get("name") or ""), "content": str(p.get("content") or "")} + for p in _pd_raw + if isinstance(p, dict) and str(p.get("name") or "").strip() and str(p.get("content") or "").strip() + ] or None # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到 # config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。 model_name: str = str(payload.get("model") or "") @@ -1679,11 +1740,14 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name, seat_meta=seat_meta, synthesis_mode=synthesis_mode, + suppress_pre_broadcast=suppress_pre_broadcast, ) else: gen = _special_run( client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name, display_name=display_name, + no_file=no_file, + peer_deliveries=peer_deliveries, ) async for chunk in gen: if isinstance(chunk, dict): diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py index 100a33f3..b57185ea 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -46,6 +46,9 @@ class ChainResponse(BaseModel): title: str = "" description: str | None = None seats: list[ChainSeatSchema] = [] + # Optional layered orchestration: list of stages, each a list of agent_ids + # run in parallel (stages run serially). None/absent = linear chain. + stages: list[list[str]] | None = None created_at: datetime | str | None = None updated_at: datetime | str | None = None @@ -61,6 +64,7 @@ class ChainCreateRequest(BaseModel): title: str = Field(default="", max_length=512) description: str | None = Field(default=None, max_length=2048) seats: list[ChainSeatSchema] = [] + stages: list[list[str]] | None = None class ChainUpdateRequest(BaseModel): @@ -68,6 +72,7 @@ class ChainUpdateRequest(BaseModel): title: str | None = Field(default=None, max_length=512) description: str | None = Field(default=None, max_length=2048) seats: list[ChainSeatSchema] | None = None + stages: list[list[str]] | None = None # ── helpers ────────────────────────────────────────────────────────────── @@ -101,6 +106,45 @@ def _validate_seats(seats: list[ChainSeatSchema]) -> None: seen.add(s.agent_id) +def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | None) -> None: + """Validate the optional layering. ``stages`` must partition the seats. + + Rules (None = linear chain, always valid): + - at least one stage, no empty stage; + - every agent_id in a stage must be one of the chain's seats; + - no agent_id appears twice (within or across stages); + - every seat must appear in exactly one stage (full coverage), so the + stored layering and the seat list never drift apart. + """ + if stages is None: + return + if len(stages) == 0: + raise HTTPException(status_code=400, detail="编排分层不能为空(至少一个阶段)") + seat_ids = {s.agent_id for s in seats} + seen: set[str] = set() + for idx, stage in enumerate(stages): + if not stage: + raise HTTPException(status_code=400, detail=f"第 {idx + 1} 阶段为空,请删除或放入席位") + for agent_id in stage: + if agent_id not in seat_ids: + raise HTTPException( + status_code=400, + detail=f"编排分层包含不在席位列表中的智能体: {agent_id}", + ) + if agent_id in seen: + raise HTTPException( + status_code=400, + detail=f"智能体 {agent_id} 在编排分层中重复出现,每个席位只能跑一次", + ) + seen.add(agent_id) + if seen != seat_ids: + missing = seat_ids - seen + raise HTTPException( + status_code=400, + detail=f"编排分层未覆盖全部席位,缺少: {', '.join(sorted(missing))}", + ) + + # ── routes ─────────────────────────────────────────────────────────────── @@ -115,6 +159,7 @@ async def list_chains(request: Request) -> ChainListResponse: @router.post("", response_model=ChainResponse, status_code=201) async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse: _validate_seats(body.seats) + _validate_stages(body.seats, body.stages) store = _get_store(request) user_id = _current_user_id(request) row = await store.create_chain( @@ -124,6 +169,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo "title": body.title.strip(), "description": body.description, "seats": [s.model_dump() for s in body.seats], + "stages": body.stages, }, ) return ChainResponse(**row) @@ -143,6 +189,12 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse: async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse: if body.seats is not None: _validate_seats(body.seats) + # A non-null stages layering can only be validated against seats; the editor + # always saves both together. Reject a layering update that omits seats. + if body.stages is not None: + if body.seats is None: + raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表") + _validate_stages(body.seats, body.stages) store = _get_store(request) user_id = _current_user_id(request) # Distinguish "omitted" from "explicitly sent"; only forward provided fields. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260607_03_chain_stages.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260607_03_chain_stages.py new file mode 100644 index 00000000..1b38052b --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260607_03_chain_stages.py @@ -0,0 +1,49 @@ +"""Add stages column to roundtable_chains. + +Layered orchestration (分层编排) for business chains: an optional list-of-lists of +agent_ids grouping the chain's seats into parallel stages (stages run serially). +NULL = linear chain. The column is LONGTEXT on MySQL (mirrors ``seats`` headroom) +and plain TEXT elsewhere — matching ``PortableLongText`` on +``RoundtableChainRow.stages``. ``create_all`` adds the column for brand-new DBs; +this revision patches DBs that already have the table. + +Revision ID: 20260607_03 +Revises: 20260607_02 +Create Date: 2026-06-07 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect +from sqlalchemy.dialects.mysql import LONGTEXT + + +revision = "20260607_03" +down_revision = "20260607_02" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "stages"): + col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text() + op.add_column("roundtable_chains", sa.Column("stages", col_type, nullable=True)) + + +def downgrade() -> None: + if _has_column("roundtable_chains", "stages"): + op.drop_column("roundtable_chains", "stages") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py index 040abb70..a31ecc70 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py @@ -33,6 +33,12 @@ class RoundtableChainRow(Base): # JSON: ordered [{"agent_id": str, "name": str, "description": str}, ...]. # seats[0] is the first seat to speak. Stored as LONGTEXT-backed JSON string. seats: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) + # Optional JSON: layered orchestration grouping over ``seats`` — a list of + # stages, each a list of agent_ids run in parallel; stages run serially in + # order. e.g. [["a","b"],["c"]] = (a‖b) → c. NULL/absent = linear chain + # (each seat its own stage). Drives Step 2 dag-mode orchestration; see + # frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string. + stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) created_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py index 4247fc57..5f5ab592 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py @@ -50,6 +50,8 @@ class RoundtableChainRepository: "title": row.title, "description": row.description, "seats": _loads(row.seats) or [], + # None when absent → frontend reads it as "linear chain" (no layering). + "stages": _loads(row.stages), "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, } @@ -80,6 +82,7 @@ class RoundtableChainRepository: title=(data.get("title") or "").strip(), description=(data.get("description") or None), seats=_dumps(data.get("seats") or []), + stages=_dumps(data.get("stages")), created_at=now, updated_at=now, ) @@ -97,6 +100,7 @@ class RoundtableChainRepository: title=_UNSET, description=_UNSET, seats=_UNSET, + stages=_UNSET, ) -> dict[str, Any] | None: async with self._sf() as session: row = await session.get(RoundtableChainRow, chain_id) @@ -108,6 +112,8 @@ class RoundtableChainRepository: row.description = description or None if seats is not _UNSET: row.seats = _dumps(seats) + if stages is not _UNSET: + row.stages = _dumps(stages) row.updated_at = datetime.now(UTC) await session.commit() await session.refresh(row) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py new file mode 100644 index 00000000..3cc9ae2e --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py @@ -0,0 +1,82 @@ +"""read_peer_delivery —— 圆桌席位「按需读取前序席位**完整交付**」的只读工具。 + +背景:圆桌每个席位是独立 thread(独立 sandbox),彼此隔离。默认情况下,一个席位只能 +从跨席位广播里看到前序席位的**截断摘要**(≤1500 字)。当席位识别到自己需要某个前序 +席位的**全文**时,调用本工具按席位名取回完整交付。 + +数据来源:全文由**网关层**(``app.gateway``)在发起本席位 run 时,注入到 +``runtime.context["roundtable_peer_deliveries"]`` —— 一个 +``[{"name": <席位显示名>, "content": <完整交付正文>}, ...]`` 列表。本工具**只读该上下文 +dict**,不跨 thread、不碰 sandbox、不发网络请求,因此完全在 ``deerflow.*`` 内(不违反 +harness→app 导入防火墙)。非圆桌场景下该上下文为空,工具返回「无」。 + +「传摘要按需传全文」:完整交付**只在席位真正调用本工具时**才进入它的 LLM 上下文,平时 +只有摘要,既省 token 又满足「需要时能拿全文」。详见 +``frontend-web/docs/roundtable-dag-orchestration.md`` §12.9。 +""" + +from __future__ import annotations + +import logging + +from langchain.tools import ToolRuntime, tool + +logger = logging.getLogger(__name__) + +_CTX_KEY = "roundtable_peer_deliveries" + + +def _peer_deliveries(runtime: ToolRuntime | None) -> list[dict]: + """从 runtime.context 取出网关注入的前序席位完整交付列表(缺失/非法时返回 [])。""" + if runtime is None: + return [] + ctx = getattr(runtime, "context", None) + if not ctx: + return [] + try: + peers = ctx.get(_CTX_KEY) + except Exception: + return [] + if not isinstance(peers, list): + return [] + return [p for p in peers if isinstance(p, dict)] + + +@tool("read_peer_delivery", parse_docstring=True) +def read_peer_delivery_tool(runtime: ToolRuntime, seat_name: str = "") -> str: + """Read another roundtable seat's FULL deliverable on demand. + + In a multi-agent roundtable you (a seat) normally only see **truncated + summaries** of earlier seats' deliverables. When you decide you genuinely + need the **complete text** of a specific earlier seat (e.g. to build on its + detailed data/conclusions), call this tool with that seat's name to fetch + its full deliverable. Prefer the summaries you already have; only call this + when the summary is insufficient. + + Args: + seat_name: The earlier seat's display name (the Chinese name shown in + the summaries, e.g. "情报收集"). Leave empty to list which peers' + full deliverables are available to read. + """ + peers = _peer_deliveries(runtime) + if not peers: + return "(当前没有可读取的前序席位完整交付。请直接基于你已有的上下文与摘要完成交付。)" + + names = [str(p.get("name") or "") for p in peers if p.get("name")] + query = (seat_name or "").strip() + if not query: + return ( + "可读取**完整交付**的前序席位:" + "、".join(names) + "。\n" + "需要哪个席位的全文,就再次调用 read_peer_delivery 并把它的名称传给 seat_name。" + ) + + ql = query.lower() + # 精确匹配(忽略大小写)优先,其次子串互含(容忍模型给「情报收集席位」这类带后缀的名)。 + for p in peers: + if str(p.get("name") or "").lower() == ql: + return str(p.get("content") or "(该席位交付为空。)") + for p in peers: + nm = str(p.get("name") or "") + if nm and (ql in nm.lower() or nm.lower() in ql): + return str(p.get("content") or "(该席位交付为空。)") + return f"未找到名为「{query}」的前序席位。可读取的有:" + "、".join(names) + "。" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py index 8fa87aac..8a14e0a1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py @@ -114,6 +114,13 @@ def get_available_tools( builtin_tools.extend([skill_list_tool, skill_view_tool]) + # read_peer_delivery —— 圆桌席位「按需读取前序席位完整交付」的只读工具(从 run context + # 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌 + # 总控/报告角色由 run policy 显式排除,避免干扰它们。 + from deerflow.tools.builtins.roundtable_peers_tool import read_peer_delivery_tool + + builtin_tools.append(read_peer_delivery_tool) + # skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled # 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 —— # Gateway 每次 run 重建 agent,用户改了配置下次 run 即生效;解析失败退回全局 config.yaml。 From 7affaaf7c0f84e662398c30da60f08955324ecf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 7 Jun 2026 22:30:23 +0800 Subject: [PATCH 09/20] =?UTF-8?q?feat(roundtable):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=93=BE=E6=9D=A1=E3=80=8C=E5=8F=91=E5=B8=83=E4=B8=BA=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E3=80=8D+=20=E6=88=91=E7=9A=84/=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。 前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/roundtable-planning/api/chains.ts | 27 +++- .../components/BusinessChainListDialog.tsx | 130 +++++++++++++++--- .../components/BusinessChainPicker.tsx | 48 +++++-- .../roundtable-planning/lib/business-chain.ts | 4 + .../pages/BusinessChainEditorPage.tsx | 59 ++++++++ .../app/gateway/routers/roundtable_chains.py | 18 ++- ...erge_chain_stages_and_leaderboard_heads.py | 27 ++++ .../versions/20260608_03_chain_is_public.py | 58 ++++++++ .../persistence/roundtable_chains/model.py | 7 +- .../persistence/roundtable_chains/sql.py | 39 ++++-- 10 files changed, 373 insertions(+), 44 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260608_02_merge_chain_stages_and_leaderboard_heads.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260608_03_chain_is_public.py diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index d3078ede..461a006e 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -33,8 +33,13 @@ export interface ChainUpdatePayload { seats?: ChainSeat[]; /** Send `null` to clear layering back to linear; omit to leave unchanged. */ stages?: string[][] | null; + /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ + isPublic?: boolean; } +/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */ +export type ChainScope = "mine" | "public"; + const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`; async function readJson(res: Response, ctx: string): Promise { @@ -55,6 +60,8 @@ interface ServerChain { description: string | null; seats: ChainSeat[]; stages?: string[][] | null; + is_public?: boolean; + is_mine?: boolean; created_at: string | null; updated_at: string | null; } @@ -66,6 +73,9 @@ function toChain(s: ServerChain): BusinessChain { description: s.description ?? undefined, seats: Array.isArray(s.seats) ? s.seats : [], stages: Array.isArray(s.stages) ? s.stages : null, + isPublic: s.is_public ?? false, + // 后端缺省时按「我的」处理(向后兼容旧响应)。 + isMine: s.is_mine ?? true, createdAt: s.created_at ?? undefined, updatedAt: s.updated_at ?? undefined, }; @@ -88,18 +98,29 @@ function fromUpdate(p: ChainUpdatePayload) { if (p.seats !== undefined) out.seats = p.seats; // Forward stages even when explicitly null (clears layering server-side). if (p.stages !== undefined) out.stages = p.stages; + if (p.isPublic !== undefined) out.is_public = p.isPublic; return out; } // ── CRUD ──────────────────────────────────────────────────────────────────── -/** List the current user's chains, newest first. */ -export async function listChains(): Promise { - const res = await apiFetch(BASE(), { method: "GET" }); +/** + * List chains, newest first. `scope`: + * - `"mine"`(默认):当前用户自己的链条; + * - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。 + */ +export async function listChains(scope: ChainScope = "mine"): Promise { + const url = scope === "public" ? `${BASE()}?scope=public` : BASE(); + const res = await apiFetch(url, { method: "GET" }); const data = await readJson<{ chains: ServerChain[] }>(res, "list chains"); return (data.chains ?? []).map(toChain); } +/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */ +export async function setChainPublic(id: string, isPublic: boolean): Promise { + return updateChain(id, { isPublic }); +} + /** Fetch one chain. Returns null on 404. */ export async function getChain(id: string): Promise { const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" }); diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx index d17e6e09..1e961c58 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -9,9 +9,10 @@ * 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后 * 会看到实时数据。 */ -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useCallback, useEffect, useState } from "react"; import { ArrowRightIcon, + GlobeIcon, LayersIcon, Loader2Icon, SettingsIcon, @@ -28,7 +29,7 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -import { listChains } from "../api/chains"; +import { listChains, setChainPublic, type ChainScope } from "../api/chains"; import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; @@ -47,16 +48,32 @@ export function BusinessChainListDialog({ const [chains, setChains] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 筛选:我的 / 公共(已发布)。 + const [scope, setScope] = useState("mine"); + const [publishingId, setPublishingId] = useState(null); - // 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。 + const refresh = useCallback(async (s: ChainScope) => { + setLoading(true); + setError(null); + try { + const list = await listChains(s); + setChains(list); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + // 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。 useEffect(() => { if (!open) return; let cancelled = false; - setLoading(true); - setError(null); void (async () => { + setLoading(true); + setError(null); try { - const list = await listChains(); + const list = await listChains(scope); if (!cancelled) setChains(list); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); @@ -67,7 +84,22 @@ export function BusinessChainListDialog({ return () => { cancelled = true; }; - }, [open]); + }, [open, scope]); + + const handleTogglePublic = useCallback( + async (chain: BusinessChain) => { + setPublishingId(chain.id); + try { + await setChainPublic(chain.id, !chain.isPublic); + await refresh(scope); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishingId(null); + } + }, + [refresh, scope], + ); return ( @@ -78,10 +110,29 @@ export function BusinessChainListDialog({ 业务链条 - 可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。 + 可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。 + {/* 筛选:我的 / 公共 */} +
+ {(["mine", "public"] as const).map((s) => ( + + ))} +
+
{loading ? (
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({ ) : chains.length === 0 ? (
-
还没有业务链条
+
+ {scope === "public" ? "还没有公共链条" : "还没有业务链条"} +

- 业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。 + {scope === "public" + ? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。" + : "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}

) : ( @@ -106,7 +161,9 @@ export function BusinessChainListDialog({ onConfigure(chain.id)} + onTogglePublic={() => handleTogglePublic(chain)} /> ))}
@@ -128,28 +185,61 @@ export function BusinessChainListDialog({ function ChainRow({ chain, + publishing, onSettings, + onTogglePublic, }: { chain: BusinessChain; + publishing: boolean; onSettings: () => void; + onTogglePublic: () => void; }) { + const isMine = chain.isMine !== false; return (
- {/* 标题行 + 设置按钮 */} + {/* 标题行 + 公共徽标 + 发布/设置按钮 */}
{chain.title || "未命名链条"}
{chain.seats.length} 席 - + {chain.isPublic && ( + + + 公共 + + )} +
+ {/* 发布 / 取消发布:仅本人的链条可操作。 */} + {isMine && ( + + )} + {/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */} + {isMine && ( + + )} +
{chain.description && ( diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 3bc9f72f..154c088c 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -29,7 +29,7 @@ import { cn } from "@/lib/utils"; import { listAgents } from "@/core/agents"; import { filterRecommendCandidates } from "../api/recommend"; -import { listChains } from "../api/chains"; +import { listChains, type ChainScope } from "../api/chains"; import { CHAIN_MIN_SEATS, chainTagClasses, @@ -55,16 +55,21 @@ export function BusinessChainPicker({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); + // 筛选:我的 / 公共(已发布)。 + const [scope, setScope] = useState("mine"); // null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。 const [existingIds, setExistingIds] = useState | null>(null); const [nameById, setNameById] = useState>(new Map()); useEffect(() => { let cancelled = false; + setLoading(true); + setError(null); + setSelectedId(null); // 切换筛选后清空选中(旧选中可能不在新列表里)。 void (async () => { try { const [chainList, agents] = await Promise.all([ - listChains(), + listChains(scope), listAgents().catch(() => null), // agent 列表失败不阻断链条展示 ]); if (cancelled) return; @@ -86,7 +91,7 @@ export function BusinessChainPicker({ return () => { cancelled = true; }; - }, []); + }, [scope]); const isExisting = useMemo( () => (agentId: string) => existingIds === null || existingIds.has(agentId), @@ -136,6 +141,24 @@ export function BusinessChainPicker({ return ( <>
+ {/* 筛选:我的 / 公共 */} +
+ {(["mine", "public"] as const).map((s) => ( + + ))} +
{loading ? (
@@ -149,15 +172,20 @@ export function BusinessChainPicker({ ) : chains.length === 0 ? (
-
还没有业务链条
+
+ {scope === "public" ? "还没有公共链条" : "还没有业务链条"} +

- 业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条, - 之后即可在这里选择使用。 + {scope === "public" + ? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。" + : "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}

- + {scope === "mine" && ( + + )}
) : (
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index b82fe4e0..33cee3ee 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -33,6 +33,10 @@ export interface BusinessChain { * (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。 */ stages?: string[][] | null; + /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ + isPublic?: boolean; + /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ + isMine?: boolean; createdAt?: string; updatedAt?: string; } diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 940d055b..a1d484b5 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -28,6 +28,7 @@ import { ChevronDown, ChevronUp, GitBranch, + Globe, GripVertical, Layers, Loader2, @@ -70,6 +71,7 @@ import { createChain, deleteChain, listChains, + setChainPublic, updateChain, } from "@/roundtable-planning/api/chains"; import { @@ -130,6 +132,10 @@ export default function BusinessChainEditorPage() { // ─── 当前编辑中的链条 ───────────────────────────────────────────────── const [currentChainId, setCurrentChainId] = useState(null); + // 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。 + const [currentChainPublic, setCurrentChainPublic] = useState(false); + const [currentChainMine, setCurrentChainMine] = useState(true); + const [publishing, setPublishing] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 @@ -218,6 +224,8 @@ export default function BusinessChainEditorPage() { // ─── 链条选择 / 新建 ────────────────────────────────────────────────── const loadChain = useCallback((chain: BusinessChain) => { setCurrentChainId(chain.id); + setCurrentChainPublic(chain.isPublic ?? false); + setCurrentChainMine(chain.isMine !== false); setTitle(chain.title); setDescription(chain.description ?? ""); setStageGroups(chainToStageGroups(chain)); @@ -225,6 +233,8 @@ export default function BusinessChainEditorPage() { const startNewChain = useCallback(() => { setCurrentChainId(null); + setCurrentChainPublic(false); + setCurrentChainMine(true); setTitle(""); setDescription(""); setStageGroups([]); @@ -462,6 +472,32 @@ export default function BusinessChainEditorPage() { } }, [currentChainId, title, startNewChain, refreshChains]); + // 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。 + const handleTogglePublish = useCallback(async () => { + if (!currentChainId) { + toast.warning("请先保存链条再发布"); + return; + } + const next = !currentChainPublic; + setPublishing(true); + try { + const updated = await setChainPublic(currentChainId, next); + if (!updated) { + toast.error("链条已不存在,可能被删除"); + startNewChain(); + await refreshChains(); + return; + } + setCurrentChainPublic(updated.isPublic ?? next); + toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布"); + await refreshChains(); + } catch (err) { + toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setPublishing(false); + } + }, [currentChainId, currentChainPublic, startNewChain, refreshChains]); + // ─── Render ────────────────────────────────────────────────────────── const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…"; @@ -587,6 +623,29 @@ export default function BusinessChainEditorPage() { {/* 右侧操作 */}
+ {/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */} + {currentChainId && currentChainMine && ( + + )}
)} + {/* 编排流程图弹窗 */} + {previewStages.length > 0 && ( + + + + + + 编排流程图 + + +
+ +
+
+
+ )} + {/* 页脚 */}
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。 @@ -923,6 +967,7 @@ function StagesColumn({ onRemoveSeat, onMergeSeat, onSplitSeat, + onStageGoalChange, }: { stageGroups: StageGroups; seatCount: number; @@ -934,6 +979,7 @@ function StagesColumn({ onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onStageGoalChange: (stageIdx: number, goal: string) => void; }) { // 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。 const stageStartIndex = useMemo(() => { @@ -941,10 +987,12 @@ function StagesColumn({ let acc = 0; for (const g of stageGroups) { out.push(acc); - acc += g.length; + acc += g.seats.length; } return out; }, [stageGroups]); + // 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。 + const hasParallelStage = stageGroups.some((g) => g.seats.length > 1); return (
onRemoveStage(stageIdx)} onReorderStage={(dir) => onReorderStage(stageIdx, dir)} onRemoveSeat={onRemoveSeat} onMergeSeat={onMergeSeat} onSplitSeat={onSplitSeat} + onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} /> ))} @@ -1040,27 +1090,33 @@ function StageSection({ group, stageCount, startIndex, + hasParallelStage, onRemoveStage, onReorderStage, onRemoveSeat, onMergeSeat, onSplitSeat, + onGoalChange, }: { stageIdx: number; - group: ChainSeat[]; + group: StageGroup; stageCount: number; startIndex: number; + /** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */ + hasParallelStage: boolean; onRemoveStage: () => void; onReorderStage: (dir: -1 | 1) => void; onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onGoalChange: (goal: string) => void; }) { const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx }); - const isParallel = group.length > 1; + const seats = group.seats; + const isParallel = seats.length > 1; const isFirstStage = stageIdx === 0; const isLastStage = stageIdx === stageCount - 1; - const seatItemIds = group.map((s) => SEAT_PREFIX + s.agent_id); + const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id); return (
@@ -1086,7 +1142,7 @@ function StageSection({ : "border-border bg-muted text-muted-foreground", )} > - {isParallel ? `并行 ${group.length}` : "串行"} + {isParallel ? `并行 ${seats.length}` : "串行"}
@@ -1131,18 +1187,45 @@ function StageSection({
+ {/* 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出(仅含并行的分层链条生效)。 */} + {seats.length > 0 && ( +
+