feat: Add agent workspace system and runtime management
- Add agent core modules (agent_core, factory, registry, skill_loader) - Add runtime system for agent execution management - Add REST API for agents, workspaces, and runtime control - Add process supervisor for agent lifecycle management - Add workspace template system with agent profiles - Add frontend RuntimeView and runtime API integration - Add per-agent skill workspaces for smoke_fullstack run - Refactor skill system with active/installed separation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,8 @@ import GlobalStyles from './styles/GlobalStyles';
|
||||
import NetValueChart from './components/NetValueChart';
|
||||
import StockLogo from './components/StockLogo';
|
||||
import Header from './components/Header.jsx';
|
||||
import WatchlistPanel from './components/WatchlistPanel.jsx';
|
||||
import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx';
|
||||
import RuntimeView from './components/RuntimeView.jsx';
|
||||
|
||||
// Utils
|
||||
import { formatNumber, formatTickerPrice } from './utils/formatters';
|
||||
@@ -64,7 +64,7 @@ export default function LiveTradingApp() {
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
// View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics'
|
||||
// View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
const [currentView, setCurrentView] = useState('traders');
|
||||
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||
@@ -124,6 +124,9 @@ export default function LiveTradingApp() {
|
||||
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
|
||||
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
|
||||
const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2');
|
||||
const [initialCashDraft, setInitialCashDraft] = useState('100000');
|
||||
const [marginRequirementDraft, setMarginRequirementDraft] = useState('0');
|
||||
const [enableMemoryDraft, setEnableMemoryDraft] = useState(false);
|
||||
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
|
||||
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
|
||||
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
|
||||
@@ -302,6 +305,9 @@ export default function LiveTradingApp() {
|
||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
|
||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
|
||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2));
|
||||
setInitialCashDraft(String(runtimeConfig.initial_cash ?? 100000));
|
||||
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? 0));
|
||||
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||
}, [runtimeConfig]);
|
||||
|
||||
const watchlistSuggestions = useMemo(
|
||||
@@ -537,20 +543,101 @@ export default function LiveTradingApp() {
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: Number(initialCashDraft),
|
||||
margin_requirement: Number(marginRequirementDraft),
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [intervalMinutesDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
|
||||
}, [enableMemoryDraft, initialCashDraft, intervalMinutesDraft, marginRequirementDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
|
||||
|
||||
const handleLaunchConfigSave = useCallback(() => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
const initialCash = Number(initialCashDraft);
|
||||
const marginRequirement = Number(marginRequirementDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '初始资金必须是正数' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '保证金要求不能为负数' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setIsWatchlistSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue('');
|
||||
|
||||
const watchlistSuccess = clientRef.current.send({
|
||||
type: 'update_watchlist',
|
||||
tickers: nextTickers
|
||||
});
|
||||
|
||||
const runtimeSuccess = clientRef.current.send({
|
||||
type: 'update_runtime_config',
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!watchlistSuccess || !runtimeSuccess) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [
|
||||
intervalMinutesDraft,
|
||||
maxCommCyclesDraft,
|
||||
parseWatchlistInput,
|
||||
scheduleModeDraft,
|
||||
triggerTimeDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||
setScheduleModeDraft('daily');
|
||||
setIntervalMinutesDraft('60');
|
||||
setTriggerTimeDraft('09:30');
|
||||
setMaxCommCyclesDraft('2');
|
||||
setInitialCashDraft('100000');
|
||||
setMarginRequirementDraft('0');
|
||||
setEnableMemoryDraft(false);
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, []);
|
||||
|
||||
@@ -2273,43 +2360,39 @@ export default function LiveTradingApp() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<WatchlistPanel
|
||||
isOpen={isWatchlistPanelOpen}
|
||||
isConnected={isConnected}
|
||||
isSaving={isWatchlistSaving}
|
||||
draftSymbols={watchlistDraftSymbols}
|
||||
inputValue={watchlistInputValue}
|
||||
feedback={watchlistFeedback}
|
||||
suggestions={watchlistSuggestions}
|
||||
onToggle={handleWatchlistPanelToggle}
|
||||
onClose={() => setIsWatchlistPanelOpen(false)}
|
||||
onInputChange={handleWatchlistInputChange}
|
||||
onInputKeyDown={handleWatchlistInputKeyDown}
|
||||
onAdd={() => commitWatchlistInput(watchlistInputValue)}
|
||||
onRemove={handleWatchlistRemove}
|
||||
onRestoreCurrent={handleWatchlistRestoreCurrent}
|
||||
onRestoreDefault={handleWatchlistRestoreDefault}
|
||||
onSuggestionClick={handleWatchlistSuggestionClick}
|
||||
onSave={handleWatchlistSave}
|
||||
/>
|
||||
|
||||
<RuntimeSettingsPanel
|
||||
showTrigger={false}
|
||||
isOpen={isRuntimeSettingsOpen}
|
||||
isConnected={isConnected}
|
||||
isSaving={isRuntimeConfigSaving}
|
||||
feedback={runtimeConfigFeedback}
|
||||
runtimeConfig={runtimeConfig}
|
||||
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||
scheduleMode={scheduleModeDraft}
|
||||
intervalMinutes={intervalMinutesDraft}
|
||||
triggerTime={triggerTimeDraft}
|
||||
maxCommCycles={maxCommCyclesDraft}
|
||||
initialCash={initialCashDraft}
|
||||
marginRequirement={marginRequirementDraft}
|
||||
enableMemory={enableMemoryDraft}
|
||||
watchlistSymbols={watchlistDraftSymbols}
|
||||
watchlistInputValue={watchlistInputValue}
|
||||
watchlistSuggestions={watchlistSuggestions}
|
||||
onToggle={handleRuntimeSettingsToggle}
|
||||
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||
onScheduleModeChange={setScheduleModeDraft}
|
||||
onIntervalMinutesChange={setIntervalMinutesDraft}
|
||||
onTriggerTimeChange={setTriggerTimeDraft}
|
||||
onMaxCommCyclesChange={setMaxCommCyclesDraft}
|
||||
onSave={handleRuntimeConfigSave}
|
||||
onInitialCashChange={setInitialCashDraft}
|
||||
onMarginRequirementChange={setMarginRequirementDraft}
|
||||
onEnableMemoryChange={setEnableMemoryDraft}
|
||||
onWatchlistInputChange={handleWatchlistInputChange}
|
||||
onWatchlistInputKeyDown={handleWatchlistInputKeyDown}
|
||||
onWatchlistAdd={() => commitWatchlistInput(watchlistInputValue)}
|
||||
onWatchlistRemove={handleWatchlistRemove}
|
||||
onWatchlistRestoreCurrent={handleWatchlistRestoreCurrent}
|
||||
onWatchlistRestoreDefault={handleWatchlistRestoreDefault}
|
||||
onWatchlistSuggestionClick={handleWatchlistSuggestionClick}
|
||||
onSave={handleLaunchConfigSave}
|
||||
onRestoreDefaults={handleRuntimeDefaultsRestore}
|
||||
/>
|
||||
</div>
|
||||
@@ -2393,8 +2476,33 @@ export default function LiveTradingApp() {
|
||||
>
|
||||
统计
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'runtime' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('runtime')}
|
||||
>
|
||||
运行态
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentView === 'runtime' ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
minWidth: 0,
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<RuntimeView />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`view-slider-five ${
|
||||
currentView === 'traders'
|
||||
? 'show-traders'
|
||||
@@ -2454,6 +2562,7 @@ export default function LiveTradingApp() {
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -2535,6 +2644,7 @@ export default function LiveTradingApp() {
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ function getRankMedal(rank) {
|
||||
* Supports click and hover (1.5s) to show agent performance cards
|
||||
* Supports replay mode - completely independent from live mode
|
||||
*/
|
||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage }) {
|
||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||
const canvasRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
@@ -719,13 +719,24 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Replay Button */}
|
||||
{showReplayButton && (
|
||||
{/* Room Controls */}
|
||||
{(showReplayButton || onOpenLaunchConfig) && (
|
||||
<div className="replay-button-container">
|
||||
{onOpenLaunchConfig && (
|
||||
<button
|
||||
className="replay-button"
|
||||
onClick={onOpenLaunchConfig}
|
||||
title="打开启动配置"
|
||||
style={{ background: '#FFFFFF', color: '#000000' }}
|
||||
>
|
||||
<span>启动</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="replay-button"
|
||||
onClick={handleReplayClick}
|
||||
title="Replay feed history"
|
||||
disabled={!showReplayButton}
|
||||
>
|
||||
<span className="replay-icon">▶▶</span>
|
||||
<span>回放</span>
|
||||
@@ -767,4 +778,3 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,247 +1,471 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function RuntimeSettingsPanel({
|
||||
showTrigger = true,
|
||||
isOpen,
|
||||
isConnected,
|
||||
isSaving,
|
||||
feedback,
|
||||
runtimeConfig,
|
||||
scheduleMode,
|
||||
intervalMinutes,
|
||||
triggerTime,
|
||||
maxCommCycles,
|
||||
initialCash,
|
||||
marginRequirement,
|
||||
enableMemory,
|
||||
watchlistSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onToggle,
|
||||
onClose,
|
||||
onScheduleModeChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
onWatchlistRemove,
|
||||
onWatchlistRestoreCurrent,
|
||||
onWatchlistRestoreDefault,
|
||||
onWatchlistSuggestionClick,
|
||||
onSave,
|
||||
onRestoreDefaults
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #333333',
|
||||
background: isOpen ? '#1E1E1E' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.6px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
运行设置
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{showTrigger && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #333333',
|
||||
background: isOpen ? '#1E1E1E' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.6px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
启动配置
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 10px)',
|
||||
right: 0,
|
||||
width: 320,
|
||||
maxWidth: 'min(320px, 92vw)',
|
||||
padding: '14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D9D9D9',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
|
||||
zIndex: 40,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
|
||||
运行设置
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
|
||||
保存后立即热更新当前运行中的调度参数
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && createPortal((
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.28)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 9998
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(760px, 92vw)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
padding: 18,
|
||||
paddingTop: 22,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#666666',
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
borderRadius: 999,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: '#111111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1
|
||||
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
|
||||
}}
|
||||
aria-label="关闭启动配置"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
|
||||
<select
|
||||
value={scheduleMode}
|
||||
onChange={(e) => onScheduleModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="daily">daily</option>
|
||||
<option value="intraday">intraday</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>间隔(分钟)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={intervalMinutes}
|
||||
onChange={(e) => onIntervalMinutesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>Daily 时间 (NYSE)</span>
|
||||
<input
|
||||
type="time"
|
||||
value={triggerTime}
|
||||
onChange={(e) => onTriggerTimeChange(e.target.value)}
|
||||
disabled={scheduleMode !== 'daily'}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>讨论轮数上限</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxCommCycles}
|
||||
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
onClick={onRestoreDefaults}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isConnected || isSaving}
|
||||
style={{
|
||||
padding: '9px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{isSaving ? '保存中' : '保存运行配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<span style={{
|
||||
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{runtimeConfig && (
|
||||
<div style={{
|
||||
borderTop: '1px solid #E5E7EB',
|
||||
paddingTop: 12,
|
||||
display: 'grid',
|
||||
gap: 8
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
|
||||
当前生效配置
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>启动配置</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
配置本次任务的启动参数与调度方式
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
|
||||
这里显示当前 run 已加载并生效的参数
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5E7EB',
|
||||
background: '#F8FAFC',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
color: '#111111'
|
||||
}}>
|
||||
<div>tickers: {(runtimeConfig.tickers || []).join(', ') || '-'}</div>
|
||||
<div>schedule_mode: {runtimeConfig.schedule_mode || '-'}</div>
|
||||
<div>interval_minutes: {runtimeConfig.interval_minutes ?? '-'}</div>
|
||||
<div>trigger_time: {runtimeConfig.trigger_time || '-'}</div>
|
||||
<div>max_comm_cycles: {runtimeConfig.max_comm_cycles ?? '-'}</div>
|
||||
<div>initial_cash: {runtimeConfig.initial_cash ?? '-'}</div>
|
||||
<div>margin_requirement: {runtimeConfig.margin_requirement ?? '-'}</div>
|
||||
<div>enable_memory: {String(runtimeConfig.enable_memory ?? false)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
minHeight: 40,
|
||||
padding: '2px 0'
|
||||
}}>
|
||||
{watchlistSymbols.map((symbol) => (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onWatchlistRemove(symbol)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span>{symbol}</span>
|
||||
<span style={{ color: '#777777' }}>×</span>
|
||||
</button>
|
||||
))}
|
||||
{watchlistSymbols.length === 0 && (
|
||||
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
|
||||
还没有股票,输入代码后回车添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={watchlistInputValue}
|
||||
onChange={(e) => onWatchlistInputChange(e.target.value)}
|
||||
onKeyDown={onWatchlistInputKeyDown}
|
||||
placeholder="输入股票代码,回车添加"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onWatchlistAdd}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{watchlistSuggestions.map((symbol) => {
|
||||
const active = watchlistSymbols.includes(symbol);
|
||||
return (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onWatchlistSuggestionClick(symbol)}
|
||||
disabled={active}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid',
|
||||
borderColor: active ? '#B6E3C5' : '#D0D7DE',
|
||||
background: active ? '#ECFDF3' : '#FFFFFF',
|
||||
color: active ? '#157347' : '#4A5568',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
cursor: active ? 'default' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onWatchlistRestoreCurrent}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onWatchlistRestoreDefault}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
|
||||
<select
|
||||
value={scheduleMode}
|
||||
onChange={(e) => onScheduleModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="daily">每日定时</option>
|
||||
<option value="intraday">盘中轮询</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>间隔(分钟)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={intervalMinutes}
|
||||
onChange={(e) => onIntervalMinutesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>每日定时时间 (NYSE)</span>
|
||||
<input
|
||||
type="time"
|
||||
value={triggerTime}
|
||||
onChange={(e) => onTriggerTimeChange(e.target.value)}
|
||||
disabled={scheduleMode !== 'daily'}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>讨论轮数上限</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxCommCycles}
|
||||
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>初始资金</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1000"
|
||||
value={initialCash}
|
||||
onChange={(e) => onInitialCashChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>保证金要求</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={marginRequirement}
|
||||
onChange={(e) => onMarginRequirementChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableMemory}
|
||||
onChange={(e) => onEnableMemoryChange(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
accentColor: '#0D47A1',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用长期记忆</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>操作</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onRestoreDefaults}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isConnected || isSaving}
|
||||
style={{
|
||||
padding: '9px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{isSaving ? '启动中' : '启动任务'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<span style={{
|
||||
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
), document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
556
frontend/src/components/RuntimeView.jsx
Normal file
556
frontend/src/components/RuntimeView.jsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
approvePendingApproval,
|
||||
denyPendingApproval,
|
||||
loadAllRuntimeState
|
||||
} from '../services/runtimeApi';
|
||||
|
||||
const AUTO_REFRESH_MS = 5000;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
idle: '空闲',
|
||||
registered: '已注册',
|
||||
initializing: '初始化中',
|
||||
ready: '就绪',
|
||||
running: '运行中',
|
||||
analysis_in_progress: '分析中',
|
||||
risk_review_in_progress: '风控处理中',
|
||||
discussion_in_progress: '会商中',
|
||||
decision_in_progress: '决策中',
|
||||
execution_in_progress: '执行中',
|
||||
settlement_in_progress: '结算中',
|
||||
reflection_in_progress: '复盘中',
|
||||
waiting_approval: '等待审批',
|
||||
approved: '已批准',
|
||||
denied: '已拒绝',
|
||||
completed: '已完成',
|
||||
error: '异常',
|
||||
stopped: '已停止'
|
||||
};
|
||||
|
||||
const EVENT_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '全部事件' },
|
||||
{ value: 'cycle', label: '运行周期' },
|
||||
{ value: 'approval', label: '审批事件' }
|
||||
];
|
||||
|
||||
function metricCard(label, value, accent, helper = null) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">
|
||||
{label}
|
||||
</div>
|
||||
<div className="stat-card-value" style={{ color: accent }}>
|
||||
{value}
|
||||
</div>
|
||||
{helper && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666', lineHeight: 1.5 }}>
|
||||
{helper}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveApprovalTone(approval) {
|
||||
const findings = Array.isArray(approval.findings) ? approval.findings : [];
|
||||
const levels = findings.map((item) => item?.severity).filter(Boolean);
|
||||
if (levels.includes('critical')) {
|
||||
return { border: '#7F1D1D', bg: '#FEF2F2', text: '#991B1B', badgeBg: '#FECACA' };
|
||||
}
|
||||
if (levels.includes('high')) {
|
||||
return { border: '#9A3412', bg: '#FFF7ED', text: '#C2410C', badgeBg: '#FED7AA' };
|
||||
}
|
||||
if (levels.includes('medium')) {
|
||||
return { border: '#92400E', bg: '#FFFBEB', text: '#B45309', badgeBg: '#FDE68A' };
|
||||
}
|
||||
return { border: '#D1D5DB', bg: '#FCFCFC', text: '#374151', badgeBg: '#E5E7EB' };
|
||||
}
|
||||
|
||||
function sectionTitle(label, action = null) {
|
||||
return (
|
||||
<div className="section-header" style={{ marginBottom: 0 }}>
|
||||
<div className="section-title" style={{ fontSize: 14 }}>
|
||||
{label}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatusLabel(status) {
|
||||
if (!status) {
|
||||
return '-';
|
||||
}
|
||||
return STATUS_LABELS[status] || status.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatSessionLabel(sessionId) {
|
||||
return sessionId || '无会话';
|
||||
}
|
||||
|
||||
function formatEventLabel(eventName) {
|
||||
if (!eventName) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const [group, action] = String(eventName).split(':');
|
||||
if (group === 'cycle') {
|
||||
if (action === 'start') return '周期开始';
|
||||
if (action === 'complete') return '周期完成';
|
||||
if (action === 'error') return '周期异常';
|
||||
return '运行周期';
|
||||
}
|
||||
if (group === 'approval') {
|
||||
if (action === 'created') return '创建审批';
|
||||
if (action === 'approved') return '审批通过';
|
||||
if (action === 'denied') return '审批拒绝';
|
||||
if (action === 'expired') return '审批超时';
|
||||
return '审批事件';
|
||||
}
|
||||
if (group === 'agent') {
|
||||
if (action === 'status') return '状态更新';
|
||||
if (action === 'registered') return '注册 Agent';
|
||||
return 'Agent 事件';
|
||||
}
|
||||
|
||||
return String(eventName).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export default function RuntimeView() {
|
||||
const [runtimeState, setRuntimeState] = useState(null);
|
||||
const [runtimeError, setRuntimeError] = useState(null);
|
||||
const [isRuntimeLoading, setIsRuntimeLoading] = useState(false);
|
||||
const [approvalActionId, setApprovalActionId] = useState(null);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||||
const [eventFilter, setEventFilter] = useState('all');
|
||||
|
||||
const refreshRuntimeState = () => {
|
||||
setIsRuntimeLoading(true);
|
||||
loadAllRuntimeState(
|
||||
(state) => {
|
||||
setRuntimeState(state);
|
||||
setRuntimeError(null);
|
||||
setIsRuntimeLoading(false);
|
||||
},
|
||||
(error) => {
|
||||
setRuntimeError(error.message || '无法加载运行状态');
|
||||
setIsRuntimeLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshRuntimeState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefreshEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
refreshRuntimeState();
|
||||
}, AUTO_REFRESH_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefreshEnabled]);
|
||||
|
||||
const handleApprovalAction = async (approvalId, action) => {
|
||||
setApprovalActionId(approvalId);
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await approvePendingApproval(approvalId);
|
||||
} else {
|
||||
await denyPendingApproval(approvalId);
|
||||
}
|
||||
refreshRuntimeState();
|
||||
} catch (error) {
|
||||
setRuntimeError(error.message || '审批操作失败');
|
||||
setIsRuntimeLoading(false);
|
||||
} finally {
|
||||
setApprovalActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const agents = runtimeState?.agents || [];
|
||||
const approvals = runtimeState?.approvals || [];
|
||||
const events = runtimeState?.events || [];
|
||||
const activeAgentsCount = agents.filter((agent) => agent.status && agent.status !== 'idle').length;
|
||||
const visibleEvents = events
|
||||
.filter((event) => eventFilter === 'all' || event.event.startsWith(eventFilter))
|
||||
.slice()
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className="performance-page" style={{ height: '100%', minHeight: 0 }}>
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<div className="section-title" style={{ fontSize: 18 }}>
|
||||
运行态控制台
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
marginTop: 4,
|
||||
maxWidth: 760,
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
查看当前运行上下文、分析师状态、待审批请求与近期事件。这里是监控面板,不再和运行设置挤在同一个小弹层里。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshRuntimeState}
|
||||
disabled={isRuntimeLoading}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #111111',
|
||||
background: isRuntimeLoading ? '#8A8A8A' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isRuntimeLoading ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{isRuntimeLoading ? '刷新中' : '刷新运行态'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
{metricCard('活跃 Agent', activeAgentsCount, '#2563EB', `共 ${agents.length} 个 agent 已注册`)}
|
||||
{metricCard('待审批', approvals.length, approvals.length > 0 ? '#C2410C' : '#059669', approvals.length > 0 ? '需要人工处理' : '当前无待处理审批')}
|
||||
{metricCard('运行事件', events.length, '#111111', '最近运行阶段和状态变化')}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">
|
||||
自动刷新
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefreshEnabled((value) => !value)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #000000',
|
||||
background: autoRefreshEnabled ? '#000000' : '#FFFFFF',
|
||||
color: autoRefreshEnabled ? '#FFFFFF' : '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{autoRefreshEnabled ? `开启 / ${AUTO_REFRESH_MS / 1000}秒` : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeError && (
|
||||
<div className="section" style={{
|
||||
borderColor: '#FF1744',
|
||||
background: '#FFF5F7',
|
||||
color: '#B91C1C',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{runtimeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 20,
|
||||
alignContent: 'start'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, 0.95fr) minmax(360px, 1.25fr)',
|
||||
gap: 20,
|
||||
alignItems: 'start'
|
||||
}}>
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('运行上下文')}
|
||||
{runtimeState?.context ? (
|
||||
<div style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>配置名</div>
|
||||
<div style={{ fontSize: 18, color: '#111111', fontWeight: 800, marginTop: 3 }}>
|
||||
{runtimeState.context.config_name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>运行目录</div>
|
||||
<div style={{ fontSize: 11, color: '#111111', lineHeight: 1.5, marginTop: 3, wordBreak: 'break-all' }}>
|
||||
{runtimeState.context.run_dir}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>启动参数</div>
|
||||
<pre style={{
|
||||
margin: '6px 0 0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.7,
|
||||
color: '#111111',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(runtimeState.context.bootstrap_values || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无运行上下文</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('待审批请求')}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
maxHeight: 640,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{approvals.length ? approvals.map((approval) => {
|
||||
const tone = resolveApprovalTone(approval);
|
||||
return (
|
||||
<div
|
||||
key={approval.approval_id}
|
||||
style={{
|
||||
border: `1px solid ${tone.border}`,
|
||||
background: '#FFFFFF',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#111111' }}>
|
||||
{approval.tool_name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
padding: '4px 6px',
|
||||
background: tone.badgeBg,
|
||||
color: tone.text,
|
||||
border: `1px solid ${tone.border}`,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{formatStatusLabel(approval.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
{approval.agent_id} · {approval.workspace_id} · {formatSessionLabel(approval.session_id)}
|
||||
</div>
|
||||
{approval.tool_input && (
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
background: '#FAFAFA',
|
||||
border: '1px solid #000000',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: '#111111',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(approval.tool_input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{approval.findings?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{approval.findings.map((finding, index) => (
|
||||
<span
|
||||
key={`${approval.approval_id}-finding-${index}`}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
background: '#FFFFFF',
|
||||
border: `1px solid ${tone.border}`,
|
||||
color: tone.text,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{finding.severity}: {finding.message}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => handleApprovalAction(approval.approval_id, 'deny')}
|
||||
disabled={approvalActionId === approval.approval_id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprovalAction(approval.approval_id, 'approve')}
|
||||
disabled={approvalActionId === approval.approval_id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#000000',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
批准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}) : (
|
||||
<div style={{
|
||||
border: '1px dashed #999999',
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
background: '#FAFAFA'
|
||||
}}>
|
||||
当前无待审批请求
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, 1fr) minmax(360px, 1fr)',
|
||||
gap: 20,
|
||||
alignItems: 'start'
|
||||
}}>
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('Agent 状态')}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{runtimeState?.agents?.length ? runtimeState.agents.map((agent) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 10,
|
||||
display: 'grid',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{agent.agent_id}</span>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', fontFamily: '"Courier New", monospace' }}>{formatStatusLabel(agent.status)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
会话: {formatSessionLabel(agent.last_session)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
更新时间: {agent.last_updated}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无 agent 状态</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle(
|
||||
'近期事件',
|
||||
<select
|
||||
value={eventFilter}
|
||||
onChange={(event) => setEventFilter(event.target.value)}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{EVENT_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{visibleEvents.length ? visibleEvents.map((event, index) => (
|
||||
<div
|
||||
key={`${event.timestamp}-${event.event}-${index}`}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 10,
|
||||
display: 'grid',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{formatEventLabel(event.event)}</span>
|
||||
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>{formatSessionLabel(event.session)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280' }}>{event.timestamp}</div>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: '#374151',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(event.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>当前筛选条件下暂无运行事件</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/services/runtimeApi.js
Normal file
83
frontend/src/services/runtimeApi.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { startTransition } from 'react';
|
||||
|
||||
const BASE_PATH = '/api';
|
||||
|
||||
async function safeFetch(endpoint) {
|
||||
const response = await fetch(`${BASE_PATH}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function safeRequest(endpoint, options = {}) {
|
||||
const response = await fetch(`${BASE_PATH}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function fetchRuntimeContext() {
|
||||
return safeFetch('/runtime/context');
|
||||
}
|
||||
|
||||
export function fetchRuntimeAgents() {
|
||||
return safeFetch('/runtime/agents');
|
||||
}
|
||||
|
||||
export function fetchRuntimeEvents() {
|
||||
return safeFetch('/runtime/events');
|
||||
}
|
||||
|
||||
export function fetchPendingApprovals() {
|
||||
return safeFetch('/guard/pending');
|
||||
}
|
||||
|
||||
export function approvePendingApproval(approvalId) {
|
||||
return safeRequest('/guard/approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
approval_id: approvalId,
|
||||
one_time: true,
|
||||
expires_in_minutes: 30
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function denyPendingApproval(approvalId, reason = 'Rejected from runtime panel') {
|
||||
return safeRequest('/guard/deny', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
approval_id: approvalId,
|
||||
reason
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function loadAllRuntimeState(onSuccess, onError) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const [context, agents, approvals, events] = await Promise.all([
|
||||
fetchRuntimeContext(),
|
||||
fetchRuntimeAgents(),
|
||||
fetchPendingApprovals(),
|
||||
fetchRuntimeEvents()
|
||||
]);
|
||||
onSuccess({
|
||||
context,
|
||||
agents: agents.agents,
|
||||
approvals: approvals.approvals,
|
||||
events: events.events
|
||||
});
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -818,6 +818,9 @@ export default function GlobalStyles() {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.replay-button {
|
||||
@@ -1053,34 +1056,38 @@ export default function GlobalStyles() {
|
||||
transform: translateX(-66.666%);
|
||||
}
|
||||
|
||||
/* Four-view slider (Room / Explain / Chart / Statistics) */
|
||||
.view-slider-four {
|
||||
/* Five-view slider (Traders / Room / Explain / Chart / Statistics) */
|
||||
.view-slider-five {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
width: 400%;
|
||||
width: 500%;
|
||||
height: calc(100% - 40px);
|
||||
display: flex;
|
||||
transition: transform 1.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.view-slider-four.normal-speed {
|
||||
.view-slider-five.normal-speed {
|
||||
transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.view-slider-four.show-room {
|
||||
.view-slider-five.show-traders {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.view-slider-four.show-explain {
|
||||
transform: translateX(-25%);
|
||||
.view-slider-five.show-room {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
|
||||
.view-slider-four.show-chart {
|
||||
transform: translateX(-50%);
|
||||
.view-slider-five.show-explain {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
.view-slider-four.show-statistics {
|
||||
transform: translateX(-75%);
|
||||
.view-slider-five.show-chart {
|
||||
transform: translateX(-60%);
|
||||
}
|
||||
|
||||
.view-slider-five.show-statistics {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
@@ -1092,10 +1099,10 @@ export default function GlobalStyles() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View panel for four-view slider */
|
||||
.view-slider-four .view-panel {
|
||||
flex: 0 0 25%;
|
||||
width: 25%;
|
||||
/* View panel for five-view slider */
|
||||
.view-slider-five .view-panel {
|
||||
flex: 0 0 20%;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* Chart Tabs - Floating inside chart */
|
||||
|
||||
Reference in New Issue
Block a user