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>
|
||
);
|
||
}
|