Files
evotraders/frontend/src/App.jsx

2559 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Suspense, lazy, useEffect, useMemo, useRef, useState, useCallback } from "react";
// Configuration and constants
import { AGENTS, INITIAL_TICKERS } from './config/constants';
// Services
import { ReadOnlyClient } from './services/websocket';
// Hooks
import { useFeedProcessor } from './hooks/useFeedProcessor';
// Styles
import GlobalStyles from './styles/GlobalStyles';
// Components
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';
// Utils
import { formatNumber, formatTickerPrice } from './utils/formatters';
const RoomView = lazy(() => import('./components/RoomView'));
const AgentFeed = lazy(() => import('./components/AgentFeed'));
const StatisticsView = lazy(() => import('./components/StatisticsView'));
const StockExplainView = lazy(() => import('./components/StockExplainView.jsx'));
const TraderView = lazy(() => import('./components/TraderView.jsx'));
const EDITABLE_AGENT_WORKSPACE_FILES = ['SOUL.md', 'PROFILE.md', 'AGENTS.md', 'MEMORY.md', 'POLICY.md', 'HEARTBEAT.md', 'ROLE.md', 'STYLE.md'];
function ViewLoadingFallback({ label = '加载中...' }) {
return (
<div
style={{
minHeight: 240,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #000000',
background: '#ffffff',
fontSize: 12,
fontWeight: 700,
letterSpacing: 0.4
}}
>
{label}
</div>
);
}
/**
* Live Trading Intelligence Platform - Read-Only Dashboard
* Geek Style - Terminal-inspired, minimal, monochrome
*
*/
export default function LiveTradingApp() {
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('connecting'); // 'connecting' | 'connected' | 'disconnected'
const [systemStatus, setSystemStatus] = useState('initializing'); // 'initializing' | 'running' | 'completed'
const [currentDate, setCurrentDate] = useState(null);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [now, setNow] = useState(() => new Date());
// View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics'
const [currentView, setCurrentView] = useState('traders');
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
const [isUpdating, setIsUpdating] = useState(false);
// Chart data
const [chartTab, setChartTab] = useState('all');
const [portfolioData, setPortfolioData] = useState({
netValue: 10000,
pnl: 0,
equity: [],
baseline: [], // Baseline strategy (Buy & Hold - Equal Weight)
baseline_vw: [], // Baseline strategy (Buy & Hold - Value Weighted)
momentum: [], // Momentum strategy
strategies: [] // Other strategies
});
// Feed data (using hook for simplified processing)
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage } = useFeedProcessor();
// Statistics data
const [holdings, setHoldings] = useState([]);
const [trades, setTrades] = useState([]);
const [stats, setStats] = useState(null);
const [leaderboard, setLeaderboard] = useState([]);
// Ticker prices (now from real-time data)
const [tickers, setTickers] = useState(INITIAL_TICKERS);
const [rollingTickers, setRollingTickers] = useState({});
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
const [newsByTicker, setNewsByTicker] = useState({});
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
const [historySourceByTicker, setHistorySourceByTicker] = useState({});
// Room bubbles
const [bubbles, setBubbles] = useState({});
// Resizable panels
const [leftWidth, setLeftWidth] = useState(70); // percentage
const [isResizing, setIsResizing] = useState(false);
// Market status
const [serverMode, setServerMode] = useState(null); // 'live' | 'backtest' | null
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
const [dataSources, setDataSources] = useState(null);
const [runtimeConfig, setRuntimeConfig] = useState(null);
const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false);
const [isRuntimeSettingsOpen, setIsRuntimeSettingsOpen] = useState(false);
const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]);
const [watchlistInputValue, setWatchlistInputValue] = useState('');
const [watchlistFeedback, setWatchlistFeedback] = useState(null);
const [isWatchlistSaving, setIsWatchlistSaving] = useState(false);
const [scheduleModeDraft, setScheduleModeDraft] = useState('daily');
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2');
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
const [agentProfilesByAgent, setAgentProfilesByAgent] = useState({});
const [agentSkillsByAgent, setAgentSkillsByAgent] = useState({});
const [skillDetailsByName, setSkillDetailsByName] = useState({});
const [localSkillDraftsByKey, setLocalSkillDraftsByKey] = useState({});
const [isAgentSkillsLoading, setIsAgentSkillsLoading] = useState(false);
const [skillDetailLoadingKey, setSkillDetailLoadingKey] = useState(null);
const [agentSkillsSavingKey, setAgentSkillsSavingKey] = useState(null);
const [agentSkillsFeedback, setAgentSkillsFeedback] = useState(null);
const [selectedWorkspaceFile, setSelectedWorkspaceFile] = useState(EDITABLE_AGENT_WORKSPACE_FILES[0]);
const [workspaceFilesByAgent, setWorkspaceFilesByAgent] = useState({});
const [workspaceDraftContent, setWorkspaceDraftContent] = useState('');
const [isWorkspaceFileLoading, setIsWorkspaceFileLoading] = useState(false);
const [workspaceFileSavingKey, setWorkspaceFileSavingKey] = useState(null);
const [workspaceFileFeedback, setWorkspaceFileFeedback] = useState(null);
const clientRef = useRef(null);
const containerRef = useRef(null);
const agentFeedRef = useRef(null);
const isWatchlistSavingRef = useRef(false);
const isRuntimeConfigSavingRef = useRef(false);
const requestedStockHistoryRef = useRef(new Set());
// Track last virtual time update to calculate increment
const lastVirtualTimeRef = useRef(null);
const virtualTimeOffsetRef = useRef(0);
// Last day history for replay
const [lastDayHistory, setLastDayHistory] = useState([]);
const buildTickersFromSymbols = useCallback((symbols, previousTickers = []) => {
if (!Array.isArray(symbols) || symbols.length === 0) {
return previousTickers;
}
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => {
const normalized = symbol.trim().toUpperCase();
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
return existing || {
symbol: normalized,
price: null,
change: null
};
});
}, []);
const normalizePriceHistory = useCallback((payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const normalized = {};
Object.entries(payload).forEach(([symbol, points]) => {
const ticker = String(symbol || '').trim().toUpperCase();
if (!ticker || !Array.isArray(points)) {
return;
}
normalized[ticker] = points
.map((point) => {
if (Array.isArray(point) && point.length >= 2) {
const [label, value] = point;
const price = Number(value);
if (!label || !Number.isFinite(price)) return null;
return {
timestamp: String(label),
label: String(label),
price
};
}
if (point && typeof point === 'object') {
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
if (!rawTimestamp || !Number.isFinite(price)) return null;
return {
timestamp: String(rawTimestamp),
label: String(rawTimestamp),
price
};
}
return null;
})
.filter(Boolean)
.slice(-120);
});
return normalized;
}, []);
// Determine if LIVE tab should be enabled
const isLiveEnabled = useMemo(() => {
if (!marketStatus) return false;
return marketStatus.status === 'open';
}, [marketStatus]);
const displayTickers = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return buildTickersFromSymbols(symbols, tickers);
}
return tickers;
}, [buildTickersFromSymbols, runtimeConfig, tickers]);
const runtimeWatchlistSymbols = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => symbol.trim().toUpperCase());
}
return displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
}, [displayTickers, runtimeConfig]);
const runtimeSummaryLabel = useMemo(() => {
if (!runtimeConfig) {
return null;
}
const scheduleMode = String(runtimeConfig.schedule_mode || 'daily');
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
const triggerTime = String(runtimeConfig.trigger_time || '09:30');
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
if (scheduleMode === 'intraday') {
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles}`;
}
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles}`;
}, [runtimeConfig]);
const selectedAgentSkills = useMemo(
() => agentSkillsByAgent[selectedSkillAgentId] || [],
[agentSkillsByAgent, selectedSkillAgentId]
);
const selectedAgentProfile = useMemo(
() => agentProfilesByAgent[selectedSkillAgentId] || null,
[agentProfilesByAgent, selectedSkillAgentId]
);
const selectedWorkspaceContent = useMemo(
() => workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '',
[selectedSkillAgentId, selectedWorkspaceFile, workspaceFilesByAgent]
);
useEffect(() => {
const symbols = displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
if (!symbols.length) {
setSelectedExplainSymbol('');
return;
}
if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) {
setSelectedExplainSymbol(symbols[0]);
}
}, [displayTickers, selectedExplainSymbol]);
useEffect(() => {
if (!runtimeConfig) {
return;
}
setScheduleModeDraft(String(runtimeConfig.schedule_mode || 'daily'));
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2));
}, [runtimeConfig]);
const watchlistSuggestions = useMemo(
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
[]
);
const isWatchlistDraftDirty = useMemo(() => {
if (watchlistInputValue.trim()) {
return true;
}
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
return true;
}
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
const marketStatusLabel = useMemo(() => {
if (!marketStatus) {
return null;
}
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
const normalized = raw.toLowerCase();
const byStatus = {
open: '开盘',
closed: '休市',
premarket: '盘前',
afterhours: '盘后',
};
const byText = {
'market closed (non-trading day)': '休市',
'market open': '开盘',
'market closed': '收盘',
'pre-market': '盘前',
'after-hours': '盘后',
'after hours': '盘后',
'backtest mode': '回测模式',
};
if (normalized && byText[normalized]) {
return byText[normalized];
}
if (marketStatus.status && byStatus[marketStatus.status]) {
return byStatus[marketStatus.status];
}
return raw || '状态未知';
}, [marketStatus]);
const providerLabelMap = useMemo(() => ({
yfinance: 'YFinance',
finnhub: 'Finnhub',
financial_datasets: 'Financial Datasets',
local_csv: 'CSV',
polygon: 'Polygon',
mock: 'Mock',
backtest: 'Backtest'
}), []);
const livePriceSourceLabel = useMemo(() => {
const source = marketStatus?.live_quote_provider;
if (!source) {
return null;
}
const normalized = String(source).trim().toLowerCase();
return `实时 ${providerLabelMap[normalized] || String(source).trim()}`;
}, [marketStatus, providerLabelMap]);
const historicalPriceSourceLabel = useMemo(() => {
const source = dataSources?.last_success?.prices;
if (!source) {
return null;
}
const normalized = String(source).trim().toLowerCase();
return `历史 ${providerLabelMap[normalized] || String(source).trim()}`;
}, [dataSources, providerLabelMap]);
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,]+/)
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean)
)
);
}, []);
const commitWatchlistInput = useCallback((value) => {
const parsed = parseWatchlistInput(value);
if (parsed.length === 0) {
return [];
}
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
setWatchlistInputValue('');
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
return parsed;
}, [parseWatchlistInput, watchlistFeedback]);
const handleWatchlistRemove = useCallback((symbolToRemove) => {
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistPanelToggle = useCallback(() => {
setIsRuntimeSettingsOpen(false);
setIsWatchlistPanelOpen((open) => {
const nextOpen = !open;
if (nextOpen) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}
return nextOpen;
});
}, [runtimeWatchlistSymbols]);
const handleWatchlistInputChange = useCallback((value) => {
setWatchlistInputValue(value);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitWatchlistInput(watchlistInputValue);
}
}, [commitWatchlistInput, watchlistInputValue]);
const handleWatchlistSuggestionClick = useCallback((symbol) => {
if (watchlistDraftSymbols.includes(symbol)) {
return;
}
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistDraftSymbols, watchlistFeedback]);
const handleWatchlistRestoreCurrent = useCallback(() => {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [runtimeWatchlistSymbols]);
const handleWatchlistRestoreDefault = useCallback(() => {
setWatchlistDraftSymbols(watchlistSuggestions);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [watchlistSuggestions]);
const handleWatchlistSave = useCallback(() => {
const pendingTickers = parseWatchlistInput(watchlistInputValue);
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
if (nextTickers.length === 0) {
setWatchlistFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
return;
}
if (!clientRef.current) {
setWatchlistFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setIsWatchlistSaving(true);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue('');
const success = clientRef.current.send({
type: 'update_watchlist',
tickers: nextTickers
});
if (!success) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]);
const handleManualTrigger = useCallback(() => {
if (!clientRef.current) {
addSystemMessage('连接未就绪,无法手动触发');
return;
}
const success = clientRef.current.send({
type: 'trigger_strategy'
});
if (!success) {
addSystemMessage('手动触发发送失败,请检查连接状态');
return;
}
addSystemMessage('已发送手动触发请求');
}, [addSystemMessage]);
const handleRuntimeConfigSave = useCallback(() => {
if (!clientRef.current) {
setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const interval = Number(intervalMinutesDraft);
const maxCommCycles = Number(maxCommCyclesDraft);
if (!Number.isInteger(interval) || interval <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
return;
}
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
return;
}
setIsRuntimeConfigSaving(true);
setRuntimeConfigFeedback(null);
const success = clientRef.current.send({
type: 'update_runtime_config',
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
trigger_time: triggerTimeDraft,
max_comm_cycles: maxCommCycles
});
if (!success) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [intervalMinutesDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
const handleRuntimeDefaultsRestore = useCallback(() => {
setScheduleModeDraft('daily');
setIntervalMinutesDraft('60');
setTriggerTimeDraft('09:30');
setMaxCommCyclesDraft('2');
setRuntimeConfigFeedback(null);
}, []);
const handleRuntimeSettingsToggle = useCallback(() => {
setRuntimeConfigFeedback(null);
setAgentSkillsFeedback(null);
setWorkspaceFileFeedback(null);
setIsRuntimeSettingsOpen((prev) => !prev);
setIsWatchlistPanelOpen(false);
}, []);
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
return clientRef.current.send({
type: 'get_agent_skills',
agent_id: normalized
});
}, []);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_agent_profile',
agent_id: normalized
});
}, []);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized || !clientRef.current) {
return false;
}
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
return clientRef.current.send({
type: 'get_skill_detail',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
}, [selectedSkillAgentId]);
const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) {
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
return;
}
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'create_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: normalized
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: content
}));
}, [selectedSkillAgentId]);
const handleLocalSkillSave = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const detailKey = `${selectedSkillAgentId}:${skillName}`;
const content = localSkillDraftsByKey[detailKey];
if (typeof content !== 'string') {
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName,
content
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [localSkillDraftsByKey, selectedSkillAgentId]);
const handleLocalSkillDelete = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'delete_agent_local_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleRemoveSharedSkill = useCallback((skillName) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'remove_agent_skill',
agent_id: selectedSkillAgentId,
skill_name: skillName
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const requestWorkspaceFile = useCallback((agentId, filename) => {
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
return false;
}
setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null);
return clientRef.current.send({
type: 'get_agent_workspace_file',
agent_id: normalizedAgentId,
filename: normalizedFilename
});
}, []);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
if (!clientRef.current) {
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const agentId = selectedSkillAgentId;
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
setAgentSkillsFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_skill',
agent_id: agentId,
skill_name: skillName,
enabled
});
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId]);
const handleSkillAgentChange = useCallback((agentId) => {
setSelectedSkillAgentId(agentId);
requestAgentProfile(agentId);
requestAgentSkills(agentId);
requestWorkspaceFile(agentId, selectedWorkspaceFile);
}, [requestAgentProfile, requestAgentSkills, requestWorkspaceFile, selectedWorkspaceFile]);
const handleWorkspaceFileChange = useCallback((filename) => {
setSelectedWorkspaceFile(filename);
requestWorkspaceFile(selectedSkillAgentId, filename);
}, [requestWorkspaceFile, selectedSkillAgentId]);
const handleWorkspaceFileSave = useCallback(() => {
if (!clientRef.current) {
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null);
const success = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent]);
useEffect(() => {
setWorkspaceDraftContent(selectedWorkspaceContent);
}, [selectedWorkspaceContent]);
useEffect(() => {
if (currentView !== 'traders' || !isConnected) {
return;
}
AGENTS.forEach((agent) => {
if (!agentProfilesByAgent[agent.id]) {
requestAgentProfile(agent.id);
}
if (!agentSkillsByAgent[agent.id]) {
requestAgentSkills(agent.id);
}
if (!workspaceFilesByAgent[agent.id]?.['MEMORY.md']) {
requestWorkspaceFile(agent.id, 'MEMORY.md');
}
});
}, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent]);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
if (!force && requestedStockHistoryRef.current.has(normalized)) {
return false;
}
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) {
requestedStockHistoryRef.current.add(normalized);
}
return success;
}, []);
const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_explain_events',
ticker: normalized
});
}, []);
const requestStockNews = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news',
ticker: normalized,
lookback_days: 45,
limit: 12
});
}, []);
const requestStockNewsForDate = useCallback((symbol, date) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_for_date',
ticker: normalized,
date,
limit: 20
});
}, []);
const requestStockNewsTimeline = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_timeline',
ticker: normalized,
lookback_days: 90
});
}, []);
const requestStockNewsCategories = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_news_categories',
ticker: normalized,
lookback_days: 90
});
}, []);
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !startDate || !endDate || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_range_explain',
ticker: normalized,
start_date: startDate,
end_date: endDate,
article_ids: Array.isArray(articleIds) ? articleIds : []
});
}, []);
const requestStockStory = useCallback((symbol, asOfDate = null) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_story',
ticker: normalized,
as_of_date: asOfDate
});
}, []);
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_similar_days',
ticker: normalized,
date,
top_k: topK
});
}, []);
const requestStockEnrich = useCallback((symbol, options = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
if (!startDate || !endDate) {
return false;
}
setNewsByTicker((prev) => ({
...prev,
[normalized]: {
...(prev[normalized] || {}),
maintenanceStatus: {
running: true,
error: null,
updatedAt: new Date().toISOString(),
stats: null
}
}
}));
return clientRef.current.send({
type: 'run_stock_enrich',
ticker: normalized,
start_date: startDate,
end_date: endDate,
force: Boolean(options.force),
only_local_to_llm: Boolean(options.onlyLocalToLlm),
rebuild_story: Boolean(options.rebuildStory),
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
story_date: options.storyDate || null,
target_date: options.targetDate || null
});
}, []);
// Switch away from LIVE tab when market closes
useEffect(() => {
if (!isLiveEnabled && chartTab === 'live') {
setChartTab('all');
}
}, [isLiveEnabled, chartTab]);
useEffect(() => {
if (!isWatchlistPanelOpen || !isWatchlistDraftDirty) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
if (!isWatchlistPanelOpen) {
setWatchlistInputValue('');
}
}
}, [isWatchlistDraftDirty, isWatchlistPanelOpen, runtimeWatchlistSymbols]);
useEffect(() => {
isWatchlistSavingRef.current = isWatchlistSaving;
}, [isWatchlistSaving]);
useEffect(() => {
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
}, [isRuntimeConfigSaving]);
useEffect(() => {
if (currentView !== 'explain' || !selectedExplainSymbol) {
return;
}
requestStockHistory(selectedExplainSymbol);
requestStockExplainEvents(selectedExplainSymbol);
requestStockNews(selectedExplainSymbol);
requestStockNewsTimeline(selectedExplainSymbol);
requestStockNewsCategories(selectedExplainSymbol);
requestStockStory(selectedExplainSymbol, currentDate);
}, [
currentDate,
currentView,
requestStockExplainEvents,
requestStockHistory,
requestStockNews,
requestStockNewsCategories,
requestStockNewsTimeline,
requestStockStory,
selectedExplainSymbol
]);
// Clock - use virtual time if available (for mock mode)
useEffect(() => {
if (virtualTime) {
// In mock mode, calculate offset from real time
const virtualTimeMs = new Date(virtualTime).getTime();
const realTimeMs = Date.now();
virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs;
lastVirtualTimeRef.current = virtualTimeMs;
setNow(new Date(virtualTime));
// Update clock every second based on offset
const id = setInterval(() => {
const currentRealTime = Date.now();
const currentVirtualTime = currentRealTime + virtualTimeOffsetRef.current;
setNow(new Date(currentVirtualTime));
}, 1000);
return () => clearInterval(id);
} else {
// In live mode, use real time
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}
}, [virtualTime]);
// Update clock when virtual time changes (recalculate offset)
useEffect(() => {
if (virtualTime) {
const virtualTimeMs = new Date(virtualTime).getTime();
const realTimeMs = Date.now();
virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs;
lastVirtualTimeRef.current = virtualTimeMs;
setNow(new Date(virtualTime));
}
}, [virtualTime]);
// Track updates with visual feedback
useEffect(() => {
setLastUpdate(new Date());
setIsUpdating(true);
const timer = setTimeout(() => setIsUpdating(false), 500);
return () => clearTimeout(timer);
}, [holdings, stats, trades, portfolioData.netValue]);
// Initial animation flag for slider speed
useEffect(() => {
const completeTimer = setTimeout(() => {
setIsInitialAnimating(false);
}, 1800);
return () => {
clearTimeout(completeTimer);
};
}, []);
// Helper to check if bubble should still be visible
// Bubbles persist until replaced by ANY new message (cross-role)
// When any agent sends a new message, all previous bubbles are cleared
// Can search by agentId or agentName
const bubbleFor = (idOrName) => {
// First try direct lookup by id
let b = bubbles[idOrName];
if (b) {
return b;
}
// If not found, search by agentName
const agent = AGENTS.find(a => a.name === idOrName || a.id === idOrName);
if (agent) {
b = bubbles[agent.id];
if (b) {
return b;
}
}
return null;
};
// Handle jump to message in feed
const handleJumpToMessage = useCallback((bubble) => {
// Switch to room tab (if not already there) for better context
// Then scroll AgentFeed to the message
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
agentFeedRef.current.scrollToMessage(bubble);
}
}, []);
// Auto-connect to server on mount
useEffect(() => {
// Define pushEvent inside useEffect to avoid dependency issues
const handlePushEvent = (evt) => {
if (!evt) return;
try {
handleEventInternal(evt);
} catch (error) {
console.error('[Event Handler] Error:', error);
}
};
const handleEventInternal = (evt) => {
if (evt?.type && evt.type !== 'pong') {
setConnectionStatus('connected');
setIsConnected(true);
}
// Helper: Update tickers from realtime prices
const updateTickersFromPrices = (realtimePrices) => {
try {
setTickers(prevTickers => {
return prevTickers.map(ticker => {
const realtimeData = realtimePrices[ticker.symbol];
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
// Use 'ret' from realtime data (relative to open price) if available
const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
? realtimeData.ret
: (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
return {
...ticker,
price: realtimeData.price,
change: newChange,
open: realtimeData.open || ticker.open
};
}
return ticker;
});
});
} catch (error) {
console.error('Error updating tickers from prices:', error);
}
};
const handlers = {
// Error response (for fast forward errors)
error: (e) => {
const message = typeof e.message === 'string' ? e.message : '请求失败';
console.error('[Error]', message);
setIsAgentSkillsLoading(false);
setSkillDetailLoadingKey(null);
setAgentSkillsSavingKey(null);
setIsWorkspaceFileLoading(false);
setWorkspaceFileSavingKey(null);
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' });
}
if (isRuntimeConfigSavingRef.current) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'error', text: message });
}
if (message.includes('skill') || message.includes('agent_id')) {
setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' });
}
if (message.includes('workspace_file') || message.includes('filename')) {
setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' });
}
// Handle fast forward errors
if (message.includes('fast forward')) {
console.warn(`⚠️ ${message}`);
handlePushEvent({
type: 'system',
content: `⚠️ ${message}`,
timestamp: Date.now()
});
}
addSystemMessage(message);
},
// Connection events
system: (e) => {
console.log('[System]', e.content);
if (
e.content.includes('Connected')
|| e.content.includes('已连接')
) {
setConnectionStatus('connected');
setIsConnected(true);
} else if (
e.content.includes('Disconnected')
|| e.content.includes('断开')
) {
setConnectionStatus('disconnected');
setIsConnected(false);
}
processFeedEvent(e);
},
// Pong response from server
pong: (e) => {
console.log('[Heartbeat] Pong received');
},
// Initial state from server
initial_state: (e) => {
try {
const state = e.state;
if (!state) return;
setConnectionStatus('connected');
setIsConnected(true);
setSystemStatus(state.status || 'initializing');
setCurrentDate(state.current_date);
// 设置服务器模式和市场状态
if (state.server_mode) {
setServerMode(state.server_mode);
}
if (state.data_sources) {
setDataSources(state.data_sources);
}
if (state.runtime_config) {
setRuntimeConfig(state.runtime_config);
}
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
setTickers(prevTickers => buildTickersFromSymbols(state.tickers, prevTickers));
}
// 检查是否是mock模式
const isMockMode = state.is_mock_mode === true;
if (state.market_status) {
setMarketStatus(state.market_status);
// 只在Mock模式下如果市场状态包含虚拟时间才保存它
if (isMockMode && state.market_status.current_time) {
try {
const virtualTimeDate = new Date(state.market_status.current_time);
setVirtualTime(virtualTimeDate);
} catch (error) {
console.error('Error parsing virtual time from market_status:', error);
}
} else {
// 非Mock模式下清除virtualTime
setVirtualTime(null);
}
}
if (state.trading_days_total) {
setProgress({
current: state.trading_days_completed || 0,
total: state.trading_days_total
});
}
if (state.portfolio) {
setPortfolioData(prev => ({
...prev,
netValue: state.portfolio.total_value || prev.netValue,
pnl: state.portfolio.pnl_percent || 0,
equity: state.portfolio.equity || prev.equity,
baseline: state.portfolio.baseline || prev.baseline,
baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
momentum: state.portfolio.momentum || prev.momentum,
strategies: state.portfolio.strategies || prev.strategies,
equity_return: state.portfolio.equity_return || prev.equity_return,
baseline_return: state.portfolio.baseline_return || prev.baseline_return,
baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
momentum_return: state.portfolio.momentum_return || prev.momentum_return
}));
}
if (state.dashboard) {
if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
if (state.dashboard.trades) setTrades(state.dashboard.trades);
if (state.dashboard.stats) setStats(state.dashboard.stats);
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
}
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
if (state.price_history) {
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
}
// Load and process historical feed data
if (state.feed_history && Array.isArray(state.feed_history)) {
console.log(`✅ Loading ${state.feed_history.length} historical events`);
processHistoricalFeed(state.feed_history);
}
// Load last day history for replay
if (state.last_day_history && Array.isArray(state.last_day_history)) {
setLastDayHistory(state.last_day_history);
console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
}
console.log('Initial state loaded');
} catch (error) {
console.error('Error loading initial state:', error);
}
},
// Market status update
market_status_update: (e) => {
if (e.market_status) {
setMarketStatus(e.market_status);
}
},
data_sources_update: (e) => {
if (e.data_sources) {
setDataSources(e.data_sources);
}
},
runtime_assets_reloaded: (e) => {
if (e.runtime_config_applied) {
setRuntimeConfig(e.runtime_config_applied);
}
if (Array.isArray(e.runtime_config_applied?.tickers)) {
setTickers(prevTickers => buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers));
setWatchlistDraftSymbols(e.runtime_config_applied.tickers.map((symbol) => String(symbol).trim().toUpperCase()));
setWatchlistInputValue('');
}
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
}
if (isRuntimeConfigSavingRef.current) {
setIsRuntimeConfigSaving(false);
setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' });
}
const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : [];
warnings.forEach((warning) => addSystemMessage(warning));
addSystemMessage('运行时配置已热更新');
},
agent_skills_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
if (!agentId) {
setIsAgentSkillsLoading(false);
return;
}
setAgentSkillsByAgent((prev) => ({
...prev,
[agentId]: Array.isArray(e.skills) ? e.skills : []
}));
setIsAgentSkillsLoading(false);
setAgentSkillsSavingKey(null);
},
agent_profile_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
if (!agentId) {
return;
}
setAgentProfilesByAgent((prev) => ({
...prev,
[agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {}
}));
},
skill_detail_loaded: (e) => {
const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : '';
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentId;
if (!skillName) {
setSkillDetailLoadingKey(null);
return;
}
const detailKey = `${agentId}:${skillName}`;
setSkillDetailsByName((prev) => ({
...prev,
[detailKey]: e.skill
}));
setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : ''
}));
setSkillDetailLoadingKey(null);
},
agent_skill_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
});
},
agent_local_skill_created: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 已创建本地技能 ${skillName}`
});
},
agent_local_skill_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 的本地技能 ${skillName} 已保存`
});
},
agent_local_skill_deleted: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setSkillDetailsByName((prev) => {
const next = { ...prev };
delete next[`${agentId}:${skillName}`];
return next;
});
setLocalSkillDraftsByKey((prev) => {
const next = { ...prev };
delete next[`${agentId}:${skillName}`];
return next;
});
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 的本地技能 ${skillName} 已删除`
});
},
agent_skill_removed: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : '';
setAgentSkillsSavingKey(null);
if (!agentId || !skillName) {
return;
}
setAgentSkillsFeedback({
type: 'success',
text: `${agentId} 已移除共享技能 ${skillName}`
});
},
agent_workspace_file_loaded: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
if (!agentId || !filename) {
setIsWorkspaceFileLoading(false);
return;
}
setWorkspaceFilesByAgent((prev) => ({
...prev,
[agentId]: {
...(prev[agentId] || {}),
[filename]: typeof e.content === 'string' ? e.content : ''
}
}));
setIsWorkspaceFileLoading(false);
setWorkspaceFileSavingKey(null);
},
agent_workspace_file_updated: (e) => {
const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : '';
const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
if (!agentId || !filename) {
return;
}
setWorkspaceFileFeedback({
type: 'success',
text: `${agentId}${filename} 已保存`
});
},
watchlist_updated: (e) => {
if (Array.isArray(e.tickers)) {
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
requestedStockHistoryRef.current = new Set(
Array.from(requestedStockHistoryRef.current).filter((symbol) => normalizedTickers.includes(symbol))
);
setRuntimeConfig((prev) => ({
...(prev || {}),
tickers: normalizedTickers
}));
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
setWatchlistDraftSymbols(normalizedTickers);
setWatchlistInputValue('');
}
setIsWatchlistSaving(false);
setWatchlistFeedback({
type: 'success',
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
});
},
stock_history_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
if (Array.isArray(e.prices)) {
setOhlcHistoryByTicker((prev) => ({
...prev,
[symbol]: e.prices
}));
setHistorySourceByTicker((prev) => ({
...prev,
[symbol]: e.source || null
}));
}
},
stock_explain_events_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setExplainEventsByTicker((prev) => ({
...prev,
[symbol]: {
events: Array.isArray(e.events) ? e.events : [],
signals: Array.isArray(e.signals) ? e.signals : [],
trades: Array.isArray(e.trades) ? e.trades : []
}
}));
},
stock_news_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
items: Array.isArray(e.news) ? e.news : [],
source: e.source || null,
startDate: e.start_date || null,
endDate: e.end_date || null
}
}));
requestStockNewsTimeline(symbol);
},
stock_news_for_date_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
const date = typeof e.date === 'string' ? e.date.trim() : '';
if (!symbol || !date) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
byDate: {
...((prev[symbol] && prev[symbol].byDate) || {}),
[date]: Array.isArray(e.news) ? e.news : []
}
}
}));
},
stock_news_timeline_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
timeline: Array.isArray(e.timeline) ? e.timeline : [],
timelineStartDate: e.start_date || null,
timelineEndDate: e.end_date || null
}
}));
},
stock_news_categories_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
categories: e.categories || {},
categoriesStartDate: e.start_date || null,
categoriesEndDate: e.end_date || null
}
}));
},
stock_range_explain_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
const result = e.result && typeof e.result === 'object' ? e.result : null;
if (!result?.start_date || !result?.end_date) {
return;
}
const cacheKey = `${result.start_date}:${result.end_date}`;
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
rangeExplainCache: {
...((prev[symbol] && prev[symbol].rangeExplainCache) || {}),
[cacheKey]: result
}
}
}));
},
stock_story_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : '';
if (!symbol || !asOfDate) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
storyCache: {
...((prev[symbol] && prev[symbol].storyCache) || {}),
[asOfDate]: {
story: e.story || '',
source: e.source || null,
asOfDate
}
}
}
}));
},
stock_similar_days_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
const date = typeof e.target_date === 'string'
? e.target_date.trim()
: typeof e.date === 'string'
? e.date.trim()
: '';
if (!symbol || !date) {
return;
}
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
similarDaysCache: {
...((prev[symbol] && prev[symbol].similarDaysCache) || {}),
[date]: {
target_features: e.target_features || {},
items: Array.isArray(e.items) ? e.items : [],
error: e.error || null
}
}
}
}));
},
stock_enrich_completed: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
const completedAt = new Date().toISOString();
const historyEntry = {
timestamp: completedAt,
startDate: e.start_date || '',
endDate: e.end_date || '',
force: Boolean(e.force),
onlyLocalToLlm: Boolean(e.only_local_to_llm),
error: e.error || null,
stats: e.stats || null,
storyStatus: e.story_status || null,
similarStatus: e.similar_status || null
};
setNewsByTicker((prev) => ({
...prev,
[symbol]: {
...(prev[symbol] || {}),
items: [],
byDate: {},
timeline: [],
categories: {},
rangeExplainCache: {},
storyCache: {},
similarDaysCache: {},
maintenanceStatus: {
running: false,
error: e.error || null,
updatedAt: completedAt,
stats: e.stats || null,
storyStatus: e.story_status || null,
similarStatus: e.similar_status || null
},
maintenanceHistory: [
historyEntry,
...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7))
]
}
}));
if (!e.error) {
requestStockNews(symbol);
requestStockNewsTimeline(symbol);
requestStockNewsCategories(symbol);
}
},
// Real-time price updates
price_update: (e) => {
try {
const { symbol, price, ret, open, portfolio, realtime_prices } = e;
if (!symbol || !price) {
console.warn('[Price Update] Missing symbol or price:', e);
return;
}
setConnectionStatus('connected');
setIsConnected(true);
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
setPriceHistoryByTicker((prev) => {
const ticker = String(symbol).trim().toUpperCase();
const nextPoint = {
timestamp: new Date().toISOString(),
label: now.toISOString(),
price: Number(price)
};
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
const lastPoint = existing[existing.length - 1];
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) {
return prev;
}
return {
...prev,
[ticker]: [...existing, nextPoint].slice(-120)
};
});
// Update ticker price with animation
setTickers(prevTickers => {
return prevTickers.map(ticker => {
if (ticker.symbol === symbol) {
const oldPrice = ticker.price;
// Use 'ret' from server (relative to open price) if available
// Otherwise fallback to calculating change from previous price
let newChange = ticker.change;
if (ret !== null && ret !== undefined) {
// Use server-provided ret (relative to open price)
newChange = ret;
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
// Fallback: calculate change from previous price
const priceChange = ((price - oldPrice) / oldPrice) * 100;
newChange = (newChange !== null && newChange !== undefined)
? newChange + priceChange
: priceChange;
} else {
// First price received, set change to 0
newChange = 0;
}
// Trigger rolling animation only if price actually changed
if (oldPrice !== price) {
setRollingTickers(prev => ({ ...prev, [symbol]: true }));
setTimeout(() => {
setRollingTickers(prev => ({ ...prev, [symbol]: false }));
}, 500);
}
return {
...ticker,
price: price,
change: newChange,
open: open || ticker.open // Store open price
};
}
return ticker;
});
});
// Update all tickers from realtime_prices if provided
if (realtime_prices) {
updateTickersFromPrices(realtime_prices);
}
// Update portfolio value if provided
if (portfolio && portfolio.total_value) {
setPortfolioData(prev => ({
...prev,
netValue: portfolio.total_value,
pnl: portfolio.pnl_percent || 0,
equity: portfolio.equity || prev.equity // Update equity curve
}));
}
} catch (error) {
console.error('[Price Update] Error:', error);
}
},
// Day progress events
day_start: (e) => {
setCurrentDate(e.date);
if (e.progress !== undefined) {
setProgress(prev => ({
...prev,
current: Math.floor(e.progress * (prev.total || 1))
}));
}
setSystemStatus('running');
processFeedEvent(e);
},
day_complete: (e) => {
// Update from day result
const result = e.result;
if (result && typeof result === 'object') {
// Update portfolio equity if available
if (result.portfolio_summary) {
const summary = result.portfolio_summary;
setPortfolioData(prev => {
const newEquity = [...prev.equity];
// Add new data point
const dateObj = new Date(e.date);
newEquity.push({
t: dateObj.getTime(),
v: summary.total_value || summary.cash || prev.netValue
});
return {
...prev,
netValue: summary.total_value || summary.cash || prev.netValue,
pnl: summary.pnl_percent || 0,
equity: newEquity
};
});
}
}
processFeedEvent(e);
},
day_error: (e) => {
console.error('Day error:', e.date, e.error);
processFeedEvent(e);
},
conference_start: (e) => {
processFeedEvent(e);
},
conference_end: (e) => {
processFeedEvent(e);
},
agent_message: (e) => {
const agent = AGENTS.find(a => a.id === e.agentId);
// Update bubbles for room view
setBubbles({
[e.agentId]: {
text: e.content,
ts: Date.now(),
agentName: agent?.name || e.agentName || e.agentId
}
});
processFeedEvent(e);
},
conference_message: (e) => {
const agent = AGENTS.find(a => a.id === e.agentId);
// Update bubbles for room view
setBubbles({
[e.agentId]: {
text: e.content,
ts: Date.now(),
agentName: agent?.name || e.agentName || e.agentId
}
});
processFeedEvent(e);
},
memory: (e) => {
processFeedEvent(e);
},
team_summary: (e) => {
// Update portfolio data silently without creating feed messages
setPortfolioData(prev => ({
...prev,
netValue: e.balance || prev.netValue,
pnl: e.pnlPct || 0,
equity: e.equity || prev.equity,
baseline: e.baseline || prev.baseline,
baseline_vw: e.baseline_vw || prev.baseline_vw,
momentum: e.momentum || prev.momentum,
equity_return: e.equity_return || prev.equity_return,
baseline_return: e.baseline_return || prev.baseline_return,
baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
momentum_return: e.momentum_return || prev.momentum_return
}));
// Portfolio updates are shown in the ticker bar, no need for feed messages
},
team_portfolio: (e) => {
if (e.holdings) setHoldings(e.holdings);
},
// ✅ 监听 holdings 更新(服务器广播的事件名)
team_holdings: (e) => {
if (e.data && Array.isArray(e.data)) {
setHoldings(e.data);
console.log(`✅ Holdings updated: ${e.data.length} positions`);
}
},
team_trades: (e) => {
// 支持两种格式:完整列表或单笔交易
if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
setTrades(e.data);
console.log(`✅ Trades updated (full): ${e.data.length} trades`);
} else if (Array.isArray(e.trades)) {
setTrades(e.trades);
} else if (e.trade) {
setTrades(prev => [e.trade, ...prev].slice(0, 100));
}
},
team_stats: (e) => {
if (e.data) {
setStats(e.data);
console.log('✅ Stats updated');
} else if (e.stats) {
setStats(e.stats);
}
},
team_leaderboard: (e) => {
// 服务器发送的格式: { type: 'team_leaderboard', data: [...], timestamp: ... }
if (Array.isArray(e.data)) {
setLeaderboard(e.data);
console.log('✅ Leaderboard updated:', e.data.length, 'agents');
} else if (Array.isArray(e.rows)) {
setLeaderboard(e.rows);
} else if (Array.isArray(e.leaderboard)) {
setLeaderboard(e.leaderboard);
}
},
// 虚拟时间更新Mock模式下的时间广播
time_update: (e) => {
if (e.beijing_time_str) {
const statusEmoji = {
'market_open': '📊',
'off_market': '⏸️',
'non_trading_day': '📅',
'trade_execution': '💼'
};
const emoji = statusEmoji[e.status] || '⏰';
const isMockMode = e.is_mock_mode === true;
let logMessage = `${emoji} ${isMockMode ? '虚拟时间' : '时间'}: ${e.beijing_time_str} | 状态: ${e.status}`;
if (e.hours_to_open !== undefined) {
logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
}
if (e.hours_to_trade !== undefined) {
logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
}
if (e.trading_date) {
logMessage += ` | 交易日: ${e.trading_date}`;
}
console.log(logMessage);
// 只在Mock模式下保存虚拟时间用于图表过滤和UI显示
if (isMockMode && e.beijing_time) {
try {
const virtualTimeDate = new Date(e.beijing_time);
setVirtualTime(virtualTimeDate);
} catch (error) {
console.error('Error parsing virtual time:', error);
}
} else {
// 非Mock模式下清除virtualTime
setVirtualTime(null);
}
}
// 更新市场状态如果包含在time_update中
if (e.market_status) {
setMarketStatus(e.market_status);
}
},
// 时间快进事件Mock模式
time_fast_forwarded: (e) => {
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str}${e.new_time_str}`);
// 更新虚拟时间
if (e.new_time) {
try {
const virtualTimeDate = new Date(e.new_time);
setVirtualTime(virtualTimeDate);
// 添加到feed显示
handlePushEvent({
type: 'system',
content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str}${e.new_time_str}`,
timestamp: Date.now()
});
} catch (error) {
console.error('Error parsing fast forwarded time:', error);
}
}
},
// 快进成功响应
fast_forward_success: (e) => {
console.log(`${e.message}`);
},
};
// Call handler or do nothing
try {
const handler = handlers[evt.type];
if (handler) {
handler(evt);
} else {
console.log('[handleEvent] Unknown event type:', evt.type);
}
} catch (error) {
console.error('[handleEvent] Error handling event:', evt.type, error);
}
};
// Create and connect WebSocket client
const client = new ReadOnlyClient(handlePushEvent);
clientRef.current = client;
client.connect();
setConnectionStatus('connecting');
return () => {
// Cleanup on unmount
if (clientRef.current) {
clientRef.current.disconnect();
}
};
}, [
addSystemMessage,
buildTickersFromSymbols,
processFeedEvent,
processHistoricalFeed,
requestStockNewsCategories,
requestStockNewsTimeline
]); // Only reconnect if handlers change
// Resizing handlers
const handleMouseDown = (e) => {
e.preventDefault();
setIsResizing(true);
};
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e) => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
// Limit between 30% and 85%
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
setLeftWidth(newLeftWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
return (
<div className="app">
<GlobalStyles />
{/* Header */}
<div className="header">
<Header />
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 4,
background: '#FF9800',
border: '1px solid #FFB74D'
}}>
<span style={{ fontSize: '14px' }}></span>
<span style={{
fontSize: '11px',
fontWeight: 600,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '0.5px'
}}>
模拟模式
</span>
</div>
)}
{/* Clock Display (only in Mock mode) */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
padding: '4px 12px',
borderRadius: 4,
background: '#1A237E',
border: '1px solid #3F51B5'
}}>
<span style={{
fontSize: '11px',
color: '#999',
fontFamily: '"Courier New", monospace',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
虚拟时间
</span>
<span style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '1px'
}}>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
</span>
<span style={{
fontSize: '10px',
color: '#999',
fontFamily: '"Courier New", monospace'
}}>
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Fast Forward Button (only in Mock mode) */}
<button
onClick={() => {
if (clientRef.current) {
const success = clientRef.current.send({
type: 'fast_forward_time',
minutes: 30
});
if (!success) {
console.error('Failed to send fast forward request');
}
}
}}
style={{
padding: '6px 12px',
borderRadius: 4,
background: '#3F51B5',
border: '1px solid #5C6BC0',
color: '#FFFFFF',
fontSize: '12px',
fontFamily: '"Courier New", monospace',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: 4,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
onMouseEnter={(e) => {
e.target.style.background = '#5C6BC0';
e.target.style.borderColor = '#7986CB';
}}
onMouseLeave={(e) => {
e.target.style.background = '#3F51B5';
e.target.style.borderColor = '#5C6BC0';
}}
title="快进30分钟 (Mock模式)"
>
+30min
</button>
</div>
)}
{/* Unified Status Indicator */}
<div className="header-status-inline">
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
</span>
{marketStatus && (
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatusLabel}
</span>
</>
)}
{livePriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{livePriceSourceLabel}
</span>
</>
)}
{historicalPriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{historicalPriceSourceLabel}
</span>
</>
)}
{runtimeSummaryLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest" title="当前运行配置">
{runtimeSummaryLabel}
</span>
</>
)}
<span className="status-sep">·</span>
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div>
{serverMode !== 'backtest' && (
<button
onClick={handleManualTrigger}
disabled={!isConnected}
style={{
padding: '6px 12px',
borderRadius: 4,
background: isConnected ? '#111111' : '#8a8a8a',
border: '1px solid #111111',
color: '#FFFFFF',
fontSize: '11px',
fontFamily: '"Courier New", monospace',
fontWeight: 700,
cursor: isConnected ? 'pointer' : 'not-allowed',
letterSpacing: '0.4px',
textTransform: 'uppercase'
}}
title="手动触发一轮分析与交易决策"
>
手动运行
</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
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving}
feedback={runtimeConfigFeedback}
runtimeConfig={runtimeConfig}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
onToggle={handleRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onScheduleModeChange={setScheduleModeDraft}
onIntervalMinutesChange={setIntervalMinutesDraft}
onTriggerTimeChange={setTriggerTimeDraft}
onMaxCommCyclesChange={setMaxCommCyclesDraft}
onSave={handleRuntimeConfigSave}
onRestoreDefaults={handleRuntimeDefaultsRestore}
/>
</div>
</div>
{/* Main Content */}
<>
{/* Ticker Bar */}
<div className="ticker-bar">
<div className="ticker-track">
{[0, 1].map((groupIdx) => (
<div key={groupIdx} className="ticker-group">
{displayTickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span>
<span className="ticker-price">
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
{ticker.price !== null && ticker.price !== undefined
? `$${formatTickerPrice(ticker.price)}`
: '-'}
</span>
</span>
<span className={`ticker-change ${
ticker.change === null || ticker.change === undefined
? ''
: ticker.change >= 0 ? 'positive' : 'negative'
}`}>
{ticker.change !== null && ticker.change !== undefined
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%`
: '-'}
</span>
</div>
))}
</div>
))}
</div>
<div className="portfolio-value">
<span className="portfolio-label">投资组合</span>
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
</div>
</div>
<div className="main-container" ref={containerRef}>
{/* Left Panel: Three-View Toggle (Room/Chart/Statistics) */}
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
<div className="chart-section">
<div className="view-container">
<div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
onClick={() => setCurrentView('traders')}
>
交易员
</button>
<button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')}
>
交易室
</button>
<button
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
onClick={() => setCurrentView('explain')}
>
个股分析
</button>
<button
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
onClick={() => setCurrentView('chart')}
>
业绩图表
</button>
<button
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
onClick={() => setCurrentView('statistics')}
>
统计
</button>
</div>
<div className={`view-slider-five ${
currentView === 'traders'
? 'show-traders'
: currentView === 'room'
? 'show-room'
: currentView === 'explain'
? 'show-explain'
: currentView === 'statistics'
? 'show-statistics'
: 'show-chart'
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
<TraderView
agents={AGENTS}
agentProfilesByAgent={agentProfilesByAgent}
agentSkillsByAgent={agentSkillsByAgent}
workspaceFilesByAgent={workspaceFilesByAgent}
selectedAgentId={selectedSkillAgentId}
selectedAgentProfile={selectedAgentProfile}
selectedAgentSkills={selectedAgentSkills}
skillDetailsByName={skillDetailsByName}
localSkillDraftsByKey={localSkillDraftsByKey}
skillDetailLoadingKey={skillDetailLoadingKey}
editableFiles={EDITABLE_AGENT_WORKSPACE_FILES}
selectedWorkspaceFile={selectedWorkspaceFile}
workspaceFileContent={selectedWorkspaceContent}
workspaceDraftContent={workspaceDraftContent}
isConnected={isConnected}
isAgentSkillsLoading={isAgentSkillsLoading}
agentSkillsSavingKey={agentSkillsSavingKey}
agentSkillsFeedback={agentSkillsFeedback}
isWorkspaceFileLoading={isWorkspaceFileLoading}
workspaceFileSavingKey={workspaceFileSavingKey}
workspaceFileFeedback={workspaceFileFeedback}
onAgentChange={handleSkillAgentChange}
onCreateLocalSkill={handleCreateLocalSkill}
onSkillDetailRequest={requestSkillDetail}
onLocalSkillDraftChange={handleLocalSkillDraftChange}
onLocalSkillDelete={handleLocalSkillDelete}
onLocalSkillSave={handleLocalSkillSave}
onRemoveSharedSkill={handleRemoveSharedSkill}
onSkillToggle={handleAgentSkillToggle}
onWorkspaceFileChange={handleWorkspaceFileChange}
onWorkspaceDraftChange={setWorkspaceDraftContent}
onWorkspaceFileSave={handleWorkspaceFileSave}
/>
</Suspense>
</div>
{/* Room View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
<RoomView
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
feed={feed}
onJumpToMessage={handleJumpToMessage}
/>
</Suspense>
</div>
{/* Stock Explain View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
<StockExplainView
tickers={displayTickers}
holdings={holdings}
trades={trades}
leaderboard={leaderboard}
feed={feed}
priceHistoryByTicker={priceHistoryByTicker}
ohlcHistoryByTicker={ohlcHistoryByTicker}
selectedSymbol={selectedExplainSymbol}
onSelectedSymbolChange={setSelectedExplainSymbol}
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
onRequestRangeExplain={requestStockRangeExplain}
onRequestNewsForDate={requestStockNewsForDate}
onRequestStory={requestStockStory}
currentDate={currentDate}
onRequestSimilarDays={requestStockSimilarDays}
onRequestStockEnrich={requestStockEnrich}
/>
</Suspense>
</div>
{/* Chart View Panel */}
<div className="view-panel">
<div className="chart-container">
{/* Floating Timeframe Tabs */}
<div className="chart-tabs-floating">
<button
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
onClick={() => setChartTab('all')}
>
日线
</button>
{/* <button
className={`chart-tab ${chartTab === 'live' ? 'active' : ''} ${!isLiveEnabled ? 'disabled' : ''}`}
onClick={() => isLiveEnabled && setChartTab('live')}
disabled={!isLiveEnabled}
title={!isLiveEnabled ? 'Live chart available during market hours only' : ''}
>
LIVE
</button> */}
</div>
<NetValueChart
equity={portfolioData.equity}
baseline={portfolioData.baseline}
baseline_vw={portfolioData.baseline_vw}
momentum={portfolioData.momentum}
strategies={portfolioData.strategies}
equity_return={portfolioData.equity_return}
baseline_return={portfolioData.baseline_return}
baseline_vw_return={portfolioData.baseline_vw_return}
momentum_return={portfolioData.momentum_return}
chartTab={chartTab}
virtualTime={virtualTime}
/>
</div>
</div>
{/* Statistics View Panel */}
<div className="view-panel">
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
<StatisticsView
trades={trades}
holdings={holdings}
stats={stats}
baseline_vw={portfolioData.baseline_vw}
equity={portfolioData.equity}
leaderboard={leaderboard}
/>
</Suspense>
</div>
</div>
</div>
</div>
</div>
{/* Resizer */}
<div
className={`resizer ${isResizing ? 'resizing' : ''}`}
onMouseDown={handleMouseDown}
/>
{/* Right Panel: Agent Feed */}
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
</Suspense>
</div>
</div>
</>
</div>
);
}