Files
deerflow-code/frontend-web/src/strategy-components/components/qa/MopeChat.tsx
T
2026-09-07 18:24:55 +08:00

310 lines
9.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useTheme } from '../../../contexts/ThemeContext';
import ConfigPanel from './components/ConfigPanel';
import Sidebar from './components/Sidebar';
import Timeline from './components/Timeline';
import ProgressSidebar from './components/ProgressSidebar';
import ScenarioConfigPage from './components/ScenarioConfigPage';
import './MopeChat.css';
type StartConfig = {
task_context?: string;
[key: string]: any;
};
type SessionError = {
agent_id?: string;
error?: string;
};
type SessionHistoryPayload = {
typeName: string;
templateName: string;
resultJson: Record<string, any>;
};
const MOPE_HISTORY_API_BASE = '/api/mope/history';
/**
* 保存模型完整生成结果。
*
* 接口暂未提供,先把调用方法和请求体结构固定下来:
* - typeName:类型名字,目前使用用户输入的任务背景。
* - templateName:用户选择的生成模板名称。
* - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
*/
async function saveMopeHistory(payload: SessionHistoryPayload) {
const response = await fetch(MOPE_HISTORY_API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
}
return response.json().catch(() => null);
}
/**
* 获取当前用户最近一次 MOPE 生成历史。
*
* 接口暂未提供,先约定返回值就是保存时的 resultJson。
* 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
*/
async function fetchMopeHistory(): Promise<Record<string, any> | null> {
const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) {
throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
}
const data = await response.json();
return data?.resultJson || data || null;
}
export default function MopeChat() {
const session = useSession() as any;
const { theme } = useTheme() as { theme?: string };
const isDark = theme === 'dark';
const [taskContext, setTaskContext] = useState<string>('');
const [idlePage, setIdlePage] = useState<string>('home');
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
const {
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
pendingReview,
reviewSubmitting,
reviewRevising,
errors,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
hydrateFromSnapshot,
reset,
} = session;
const historyLoadedRef = useRef(false);
const savedHistoryKeyRef = useRef<string | null>(null);
const currentConfigRef = useRef<StartConfig | null>(null);
const buildHistorySnapshot = useCallback(() => ({
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
config: currentConfigRef.current,
}), [
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
]);
const handleStart = (config: StartConfig, backendUrl: string) => {
currentConfigRef.current = config;
savedHistoryKeyRef.current = null;
setTaskContext(config.task_context || '');
startSession(config, backendUrl);
};
const handleStop = () => {
stopSession();
};
const handleReset = () => {
reset();
currentConfigRef.current = null;
savedHistoryKeyRef.current = null;
setTaskContext('');
};
useEffect(() => {
if (historyLoadedRef.current) return;
historyLoadedRef.current = true;
void fetchMopeHistory()
.then((snapshot) => {
if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
currentConfigRef.current = snapshot.config || null;
setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
hydrateFromSnapshot(snapshot);
})
.catch((error) => {
console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
});
}, [hydrateFromSnapshot]);
useEffect(() => {
if (status !== 'done' || !timeline?.length) return;
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
}, [status, timeline, taskContext, buildHistorySnapshot]);
if (status === 'idle') {
if (idlePage === 'scenario_config') {
return (
<div className={`mope-chat app-idle app-idle-scroll ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
</div>
);
}
return (
<div className={`mope-chat app-idle ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ConfigPanel
onStart={handleStart}
isRunning={false}
onStop={handleStop}
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
/>
</div>
);
}
return (
<div className={`mope-chat app-running ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<header className="app-header">
<span className="app-header-logo">MOPE</span>
<span className="app-header-task">
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
</span>
<span className="badge badge-default">
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
</span>
{status === 'waiting_human' && (
<span className="badge badge-diverging">等待人工确认</span>
)}
<div className="app-header-actions">
{(status === 'running' || status === 'waiting_human') && (
<button className="btn-danger" onClick={handleStop}>
停止
</button>
)}
{(status === 'done' || status === 'error') && (
<button className="btn-secondary" onClick={handleReset}>
重新开始
</button>
)}
</div>
</header>
<div className="app-body">
<aside className={`app-sidebar app-side-panel${isLeftSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-left"
onClick={() => setIsLeftSidebarCollapsed(v => !v)}
aria-label={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
title={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{!isLeftSidebarCollapsed && (
<Sidebar
agents={agents}
agentStates={agentStates}
phase={phase}
currentRound={currentRound}
divergeRounds={divergeRounds}
/>
)}
</aside>
<main className="app-main">
{errors.length > 0 && errors.map((e: SessionError, i: number) => (
<div key={i} className="error-banner">
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
</div>
))}
<Timeline
timeline={timeline}
agentStates={agentStates}
reportSchema={reportSchema}
pendingReview={pendingReview}
onApproveReview={approveCoordinator}
onReviseReview={reviseCoordinator}
reviewSubmitting={reviewSubmitting}
reviewRevising={reviewRevising}
/>
</main>
<aside className={`app-progress-sidebar app-side-panel${isRightSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-right"
onClick={() => setIsRightSidebarCollapsed(v => !v)}
aria-label={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
title={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
>
{isRightSidebarCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
</button>
{!isRightSidebarCollapsed && (
<ProgressSidebar
phase={phase}
status={status}
currentRound={currentRound}
divergeRounds={divergeRounds}
reportSchema={reportSchema}
sharedMemory={sharedMemory}
finalReportPartial={finalReportPartial}
currentFinalField={currentFinalField}
/>
)}
</aside>
</div>
</div>
);
}