310 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|