Fix runtime logging and frontend app regressions
This commit is contained in:
3349
frontend/src/App.jsx
3349
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
532
frontend/src/components/AppShell.jsx
Normal file
532
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
|
||||
import GlobalStyles from '../styles/GlobalStyles';
|
||||
import Header from './Header.jsx';
|
||||
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
|
||||
import StockLogo from './StockLogo.jsx';
|
||||
import NetValueChart from './NetValueChart.jsx';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
|
||||
const RoomView = lazy(() => import('./RoomView'));
|
||||
const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
|
||||
*/
|
||||
export default function AppShell({
|
||||
// Connection & status
|
||||
isConnected,
|
||||
virtualTime,
|
||||
now,
|
||||
marketStatus,
|
||||
serverMode,
|
||||
marketStatusLabel,
|
||||
dataSourceLabel,
|
||||
runtimeSummaryLabel,
|
||||
isUpdating,
|
||||
// Handlers
|
||||
onManualTrigger,
|
||||
onOpenRuntimeLogs,
|
||||
onRuntimeSettingsToggle,
|
||||
// Runtime settings panel props
|
||||
isRuntimeSettingsOpen,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSaving,
|
||||
runtimeConfigFeedback,
|
||||
watchlistFeedback,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
enableMockDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onScheduleModeChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onEnableMockChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
onWatchlistRemove,
|
||||
onWatchlistRestoreCurrent,
|
||||
onWatchlistRestoreDefault,
|
||||
onWatchlistSuggestionClick,
|
||||
onLaunchConfigSave,
|
||||
onRestoreDefaults,
|
||||
// Ticker and portfolio data
|
||||
displayTickers,
|
||||
portfolioData,
|
||||
rollingTickers,
|
||||
// Feed data
|
||||
feed,
|
||||
bubbles,
|
||||
bubbleFor,
|
||||
leaderboard,
|
||||
// Views data
|
||||
currentView,
|
||||
chartTab,
|
||||
holdings,
|
||||
trades,
|
||||
stats,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedExplainSymbol,
|
||||
onSelectedExplainSymbolChange,
|
||||
historySourceByTicker,
|
||||
explainEventsByTicker,
|
||||
newsByTicker,
|
||||
insiderTradesByTicker,
|
||||
technicalIndicatorsByTicker,
|
||||
currentDate,
|
||||
// Stock request handlers
|
||||
stockRequests,
|
||||
// Agent request handlers
|
||||
agentRequests,
|
||||
// Layout
|
||||
leftWidth,
|
||||
isResizing,
|
||||
onMouseDown,
|
||||
agentFeedRef
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
|
||||
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
|
||||
|
||||
// Resize handler
|
||||
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;
|
||||
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, setIsResizing, setLeftWidth]);
|
||||
|
||||
const handleJumpToMessage = (bubble) => {
|
||||
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
|
||||
agentFeedRef.current.scrollToMessage(bubble);
|
||||
}
|
||||
};
|
||||
|
||||
const viewClassName = useMemo(() => {
|
||||
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
|
||||
currentView === 'room' ? 'show-room' :
|
||||
currentView === 'explain' ? 'show-explain' :
|
||||
currentView === 'statistics' ? 'show-statistics' : 'show-chart'}`;
|
||||
return base;
|
||||
}, [currentView]);
|
||||
|
||||
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 (agentRequests?.clientRef?.current) {
|
||||
agentRequests.clientRef.current.send({ type: 'fast_forward_time', minutes: 30 });
|
||||
}
|
||||
}}
|
||||
style={{ padding: '6px 12px', borderRadius: 4, background: '#3F51B5', border: '1px solid #5C6BC0', color: '#FFFFFF', fontSize: '12px', fontFamily: '"Courier New", monospace', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{dataSourceLabel && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className="market-text backtest">{dataSourceLabel}</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' && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onOpenRuntimeLogs && (
|
||||
<button
|
||||
onClick={onOpenRuntimeLogs}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.4px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
title="查看当前运行日志"
|
||||
>
|
||||
运行日志
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onManualTrigger}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RuntimeSettingsPanel
|
||||
showTrigger={false}
|
||||
isOpen={isRuntimeSettingsOpen}
|
||||
isConnected={isConnected}
|
||||
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||
scheduleMode={scheduleModeDraft}
|
||||
intervalMinutes={intervalMinutesDraft}
|
||||
triggerTime={triggerTimeDraft}
|
||||
maxCommCycles={maxCommCyclesDraft}
|
||||
initialCash={initialCashDraft}
|
||||
marginRequirement={marginRequirementDraft}
|
||||
enableMemory={enableMemoryDraft}
|
||||
mode={modeDraft}
|
||||
pollInterval={pollIntervalDraft}
|
||||
startDate={startDateDraft}
|
||||
endDate={endDateDraft}
|
||||
enableMock={enableMockDraft}
|
||||
watchlistSymbols={watchlistDraftSymbols}
|
||||
watchlistInputValue={watchlistInputValue}
|
||||
watchlistSuggestions={watchlistSuggestions}
|
||||
onToggle={onRuntimeSettingsToggle}
|
||||
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||
onScheduleModeChange={onScheduleModeChange}
|
||||
onIntervalMinutesChange={onIntervalMinutesChange}
|
||||
onTriggerTimeChange={onTriggerTimeChange}
|
||||
onMaxCommCyclesChange={onMaxCommCyclesChange}
|
||||
onInitialCashChange={onInitialCashChange}
|
||||
onMarginRequirementChange={onMarginRequirementChange}
|
||||
onEnableMemoryChange={onEnableMemoryChange}
|
||||
onModeChange={onModeChange}
|
||||
onPollIntervalChange={onPollIntervalChange}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
onEnableMockChange={onEnableMockChange}
|
||||
onWatchlistInputChange={onWatchlistInputChange}
|
||||
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
|
||||
onWatchlistAdd={onWatchlistAdd}
|
||||
onWatchlistRemove={onWatchlistRemove}
|
||||
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
|
||||
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
|
||||
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
|
||||
onSave={onLaunchConfigSave}
|
||||
onRestoreDefaults={onRestoreDefaults}
|
||||
/>
|
||||
</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 */}
|
||||
<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={viewClassName}>
|
||||
{/* Traders View */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||
<TraderView {...agentRequests} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Room View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||
<RoomView
|
||||
bubbles={bubbles}
|
||||
bubbleFor={bubbleFor}
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||
/>
|
||||
</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={onSelectedExplainSymbolChange}
|
||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||
onRequestStory={stockRequests?.requestStockStory}
|
||||
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||
currentDate={currentDate}
|
||||
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Chart View Panel */}
|
||||
<div className="view-panel">
|
||||
<div className="chart-container">
|
||||
<div className="chart-tabs-floating">
|
||||
<button
|
||||
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setChartTab('all')}
|
||||
>
|
||||
日线
|
||||
</button>
|
||||
</div>
|
||||
{currentView === 'chart' ? (
|
||||
<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 style={{ height: '100%', minHeight: 320 }} />
|
||||
)}
|
||||
</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={onMouseDown} />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
136
frontend/src/components/RuntimeLogsModal.jsx
Normal file
136
frontend/src/components/RuntimeLogsModal.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function RuntimeLogsModal({
|
||||
isOpen,
|
||||
isLoading,
|
||||
logPayload,
|
||||
error,
|
||||
onClose,
|
||||
onRefresh
|
||||
}) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.32)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(980px, 94vw)',
|
||||
maxHeight: '82vh',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto minmax(0, 1fr)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '18px 20px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>运行日志</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
{logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
{logPayload?.log_path || '未找到日志文件'}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#2563EB', fontWeight: 700 }}>加载中...</div>
|
||||
) : error ? (
|
||||
<div style={{ fontSize: 11, color: '#B91C1C', fontWeight: 700 }}>{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
height: '100%',
|
||||
minHeight: 320,
|
||||
maxHeight: 'calc(82vh - 140px)',
|
||||
overflow: 'auto',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#0F172A',
|
||||
color: '#E2E8F0',
|
||||
padding: 16,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{logPayload?.content || '暂无日志输出'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,18 @@ const EVENT_FILTER_OPTIONS = [
|
||||
{ value: 'approval', label: '审批事件' }
|
||||
];
|
||||
|
||||
const SR_ONLY_STYLE = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
function metricCard(label, value, accent, helper = null) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
@@ -722,6 +734,9 @@ export default function RuntimeView() {
|
||||
{sectionTitle(
|
||||
'近期事件',
|
||||
<select
|
||||
id="runtime-event-filter"
|
||||
name="runtime_event_filter"
|
||||
aria-label="筛选近期事件"
|
||||
value={eventFilter}
|
||||
onChange={(event) => setEventFilter(event.target.value)}
|
||||
style={{
|
||||
@@ -739,6 +754,9 @@ export default function RuntimeView() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
|
||||
筛选近期事件
|
||||
</label>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
|
||||
@@ -38,6 +38,18 @@ export default function TraderView({
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||
@@ -460,6 +472,9 @@ export default function TraderView({
|
||||
本地技能 SKILL.md
|
||||
</div>
|
||||
<textarea
|
||||
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
|
||||
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
|
||||
aria-label={`${skill.skill_name} 本地技能内容`}
|
||||
value={skillDraft}
|
||||
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
||||
style={{
|
||||
@@ -557,6 +572,9 @@ export default function TraderView({
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
|
||||
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
|
||||
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
|
||||
value={workspaceDraftContent}
|
||||
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
||||
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
||||
@@ -687,7 +705,13 @@ export default function TraderView({
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
|
||||
输入本地技能名称
|
||||
</label>
|
||||
<input
|
||||
id="new-local-skill-name"
|
||||
name="new_local_skill_name"
|
||||
aria-label="输入本地技能名称"
|
||||
value={newLocalSkillName}
|
||||
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
||||
placeholder="输入技能名,例如 event_playbook"
|
||||
@@ -741,7 +765,13 @@ export default function TraderView({
|
||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
|
||||
上传外部技能 zip 包
|
||||
</label>
|
||||
<input
|
||||
id="external-skill-zip"
|
||||
name="external_skill_zip"
|
||||
aria-label="上传外部技能 zip 包"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={async (e) => {
|
||||
|
||||
@@ -19,6 +19,18 @@ export default function WatchlistPanel({
|
||||
onSuggestionClick,
|
||||
onSave
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||
<button
|
||||
@@ -117,7 +129,13 @@ export default function WatchlistPanel({
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
|
||||
输入股票代码
|
||||
</label>
|
||||
<input
|
||||
id="watchlist-symbol-input"
|
||||
name="watchlist_symbol"
|
||||
aria-label="输入股票代码"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
|
||||
@@ -11,6 +11,37 @@ export default function ExplainPriceSection({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const timeTicks = (() => {
|
||||
const candles = Array.isArray(chartModel?.candles) ? chartModel.candles : [];
|
||||
if (!candles.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetCount = Math.min(4, candles.length);
|
||||
const step = Math.max(1, Math.floor((candles.length - 1) / Math.max(targetCount - 1, 1)));
|
||||
const ticks = [];
|
||||
|
||||
for (let index = 0; index < candles.length; index += step) {
|
||||
const candle = candles[index];
|
||||
const rawLabel = candle.startLabel || candle.time || candle.date || '';
|
||||
ticks.push({
|
||||
x: candle.centerX,
|
||||
label: String(rawLabel).slice(5, 16).replace('T', ' '),
|
||||
});
|
||||
}
|
||||
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
const lastLabel = String(lastCandle.endLabel || lastCandle.time || lastCandle.date || '').slice(5, 16).replace('T', ' ');
|
||||
if (ticks.length === 0 || ticks[ticks.length - 1]?.x !== lastCandle.centerX) {
|
||||
ticks.push({
|
||||
x: lastCandle.centerX,
|
||||
label: lastLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
@@ -66,12 +97,35 @@ export default function ExplainPriceSection({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{timeTicks.map((tick) => (
|
||||
<g key={`${tick.x}-${tick.label}`}>
|
||||
<line
|
||||
x1={tick.x}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={tick.x}
|
||||
y2={chartModel.height - chartModel.padding + 4}
|
||||
stroke="#666666"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tick.x}
|
||||
y={chartModel.height - chartModel.padding + 16}
|
||||
fontSize="10"
|
||||
fill="#666666"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||
const rising = candle.close >= candle.open;
|
||||
const stroke = rising ? '#00C853' : '#FF1744';
|
||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||
return (
|
||||
<g key={candle.id}>
|
||||
<title>{`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}</title>
|
||||
<line
|
||||
x1={candle.centerX}
|
||||
y1={candle.highY}
|
||||
@@ -123,7 +177,7 @@ export default function ExplainPriceSection({
|
||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||
/>
|
||||
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
211
frontend/src/hooks/useAgentDataRequests.js
Normal file
211
frontend/src/hooks/useAgentDataRequests.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useCallback } from 'react';
|
||||
import { uploadAgentSkillZip } from '../services/runtimeApi';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
|
||||
/**
|
||||
* Custom hook for agent operation callbacks.
|
||||
* Takes clientRef, uses agentStore.
|
||||
*/
|
||||
export function useAgentDataRequests(clientRef) {
|
||||
const {
|
||||
selectedSkillAgentId,
|
||||
setSelectedSkillAgentId,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey,
|
||||
setSkillDetailLoadingKey,
|
||||
localSkillDraftsByKey,
|
||||
selectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
workspaceDraftContent,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
setIsWorkspaceFileLoading
|
||||
} = useAgentStore();
|
||||
|
||||
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 });
|
||||
}, [clientRef, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
|
||||
|
||||
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 });
|
||||
}, [clientRef]);
|
||||
|
||||
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 });
|
||||
}, [clientRef, selectedSkillAgentId, setSkillDetailLoadingKey]);
|
||||
|
||||
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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
useAgentStore.getState().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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, localSkillDraftsByKey, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
|
||||
|
||||
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 });
|
||||
}, [clientRef, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
useAgentStore.getState().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: '发送失败,请检查连接状态' });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, workspaceDraftContent]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
requestWorkspaceFile,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { AGENTS } from "../config/constants";
|
||||
import { uploadAgentSkillZip } from "../services/runtimeApi";
|
||||
|
||||
export function useAgentWorkspacePanel({
|
||||
clientRef,
|
||||
currentView,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
selectedWorkspaceContent,
|
||||
localSkillDraftsByKey,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
workspaceDraftContent,
|
||||
setSelectedSkillAgentId,
|
||||
setSelectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey,
|
||||
setLocalSkillDraftsByKey,
|
||||
setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey
|
||||
}) {
|
||||
const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const sent = client.send(payload);
|
||||
if (sent || remaining <= 0) {
|
||||
return sent;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
attemptSend(remaining - 1);
|
||||
}, delayMs);
|
||||
return false;
|
||||
};
|
||||
|
||||
return attemptSend(retries);
|
||||
}, [clientRef]);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_skills",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return sendWithRetry({
|
||||
type: "get_agent_profile",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
return sendWithRetry({
|
||||
type: "get_skill_detail",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
|
||||
|
||||
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 = sendWithRetry({
|
||||
type: "create_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: content
|
||||
}));
|
||||
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
|
||||
|
||||
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 = sendWithRetry({
|
||||
type: "update_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName,
|
||||
content
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
localSkillDraftsByKey,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "delete_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "remove_agent_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
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 sendWithRetry({
|
||||
type: "get_agent_workspace_file",
|
||||
agent_id: normalizedAgentId,
|
||||
filename: normalizedFilename
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_skill",
|
||||
agent_id: agentId,
|
||||
skill_name: skillName,
|
||||
enabled
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedWorkspaceFile,
|
||||
setSelectedSkillAgentId
|
||||
]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_workspace_file",
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
sendWithRetry,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
workspaceDraftContent
|
||||
]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({
|
||||
agentId: selectedSkillAgentId,
|
||||
file,
|
||||
activate: true
|
||||
});
|
||||
setAgentSkillsFeedback({
|
||||
type: "success",
|
||||
text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
|
||||
});
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({
|
||||
type: "error",
|
||||
text: `上传失败: ${error.message || "未知错误"}`
|
||||
});
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [
|
||||
requestAgentSkills,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||
}, [selectedWorkspaceContent, setWorkspaceDraftContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders") {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders" || !selectedSkillAgentId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!agentProfilesByAgent[selectedSkillAgentId]) {
|
||||
requestAgentProfile(selectedSkillAgentId);
|
||||
}
|
||||
if (!agentSkillsByAgent[selectedSkillAgentId]) {
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
}
|
||||
if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
|
||||
requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
requestWorkspaceFile,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
538
frontend/src/hooks/useRuntimeControls.js
Normal file
538
frontend/src/hooks/useRuntimeControls.js
Normal file
@@ -0,0 +1,538 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { INITIAL_TICKERS } from "../config/constants";
|
||||
import { startRuntime } from "../services/runtimeApi";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeTickerSymbols,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
parseWatchlistInput
|
||||
} from "../services/runtimeControls";
|
||||
import { useAgentStore } from "../store/agentStore";
|
||||
import { useRuntimeStore } from "../store/runtimeStore";
|
||||
|
||||
const DEFAULT_SCHEDULE_MODE = "daily";
|
||||
const DEFAULT_INTERVAL_MINUTES = "60";
|
||||
const DEFAULT_TRIGGER_TIME = "now";
|
||||
const DEFAULT_MAX_COMM_CYCLES = "2";
|
||||
const DEFAULT_INITIAL_CASH = "100000";
|
||||
const DEFAULT_MARGIN_REQUIREMENT = "0";
|
||||
const DEFAULT_MODE = "live";
|
||||
const DEFAULT_POLL_INTERVAL = "10";
|
||||
|
||||
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage }) {
|
||||
const {
|
||||
runtimeConfig,
|
||||
setRuntimeConfig,
|
||||
isWatchlistPanelOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
setWatchlistInputValue,
|
||||
watchlistFeedback,
|
||||
setWatchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
setIsWatchlistSaving,
|
||||
scheduleModeDraft,
|
||||
setScheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
setIntervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
setTriggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
setInitialCashDraft,
|
||||
marginRequirementDraft,
|
||||
setMarginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
setEnableMemoryDraft,
|
||||
modeDraft,
|
||||
setModeDraft,
|
||||
pollIntervalDraft,
|
||||
setPollIntervalDraft,
|
||||
startDateDraft,
|
||||
setStartDateDraft,
|
||||
endDateDraft,
|
||||
setEndDateDraft,
|
||||
enableMockDraft,
|
||||
setEnableMockDraft,
|
||||
runtimeConfigFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
} = useRuntimeStore();
|
||||
|
||||
const {
|
||||
setAgentSkillsFeedback,
|
||||
setWorkspaceFileFeedback
|
||||
} = useAgentStore();
|
||||
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
const displayTickers = useMemo(
|
||||
() => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeWatchlistSymbols = useMemo(
|
||||
() => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeSummaryLabel = useMemo(
|
||||
() => buildRuntimeSummaryLabel(runtimeConfig),
|
||||
[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]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||
setWatchlistInputValue("");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
runtimeWatchlistSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
|
||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
|
||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
|
||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
|
||||
setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
|
||||
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
|
||||
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||
}, [
|
||||
runtimeConfig,
|
||||
setEnableMemoryDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setScheduleModeDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
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;
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRemove = useCallback((symbolToRemove) => {
|
||||
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistPanelToggle = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setIsWatchlistPanelOpen((open) => {
|
||||
const nextOpen = !open;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
const handleWatchlistInputChange = useCallback((value) => {
|
||||
setWatchlistInputValue(value);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistInputKeyDown = useCallback((event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitWatchlistInput(watchlistInputValue);
|
||||
}
|
||||
}, [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
const handleWatchlistSuggestionClick = useCallback((symbol) => {
|
||||
if (watchlistDraftSymbols.includes(symbol)) {
|
||||
return;
|
||||
}
|
||||
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreCurrent = useCallback(() => {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreDefault = useCallback(() => {
|
||||
setWatchlistDraftSymbols(watchlistSuggestions);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, 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: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
setIsWatchlistSaving,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
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,
|
||||
initial_cash: Number(initialCashDraft),
|
||||
margin_requirement: Number(marginRequirementDraft),
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
triggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleLaunchConfigSave = useCallback(async () => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
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("");
|
||||
|
||||
try {
|
||||
const result = await startRuntime({
|
||||
tickers: nextTickers,
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft),
|
||||
mode: modeDraft || DEFAULT_MODE,
|
||||
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
|
||||
start_date: startDateDraft || null,
|
||||
end_date: endDateDraft || null,
|
||||
enable_mock: Boolean(enableMockDraft)
|
||||
});
|
||||
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "success",
|
||||
text: `任务已启动: ${result.run_id}`
|
||||
});
|
||||
addSystemMessage(`新任务已启动: ${result.run_id}`);
|
||||
} catch (error) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "error",
|
||||
text: `启动失败: ${error.message}`
|
||||
});
|
||||
}
|
||||
}, [
|
||||
addSystemMessage,
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
enableMockDraft,
|
||||
endDateDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
startDateDraft,
|
||||
triggerTimeDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||
setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
|
||||
setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
|
||||
setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
|
||||
setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
|
||||
setInitialCashDraft(DEFAULT_INITIAL_CASH);
|
||||
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
|
||||
setEnableMemoryDraft(false);
|
||||
setModeDraft(DEFAULT_MODE);
|
||||
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
|
||||
setStartDateDraft("");
|
||||
setEndDateDraft("");
|
||||
setEnableMockDraft(false);
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, [
|
||||
setEnableMemoryDraft,
|
||||
setEnableMockDraft,
|
||||
setEndDateDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setRuntimeConfigFeedback,
|
||||
setScheduleModeDraft,
|
||||
setStartDateDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsToggle = useCallback(() => {
|
||||
setRuntimeConfigFeedback(null);
|
||||
setAgentSkillsFeedback(null);
|
||||
setWorkspaceFileFeedback(null);
|
||||
setIsRuntimeSettingsOpen((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
setIsWatchlistPanelOpen(false);
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setAgentSkillsFeedback,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
setWorkspaceFileFeedback
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsClose = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
}, [setIsRuntimeSettingsOpen]);
|
||||
|
||||
const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
return {
|
||||
runtimeConfig,
|
||||
displayTickers,
|
||||
runtimeWatchlistSymbols,
|
||||
runtimeSummaryLabel,
|
||||
watchlistSuggestions,
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
enableMockDraft,
|
||||
runtimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSavingRef,
|
||||
isRuntimeConfigSavingRef,
|
||||
commitWatchlistInput,
|
||||
handleWatchlistRemove,
|
||||
handleWatchlistPanelToggle,
|
||||
handleWatchlistInputChange,
|
||||
handleWatchlistInputKeyDown,
|
||||
handleWatchlistSuggestionClick,
|
||||
handleWatchlistRestoreCurrent,
|
||||
handleWatchlistRestoreDefault,
|
||||
handleWatchlistSave,
|
||||
handleWatchlistAdd,
|
||||
handleRuntimeConfigSave,
|
||||
handleLaunchConfigSave,
|
||||
handleRuntimeDefaultsRestore,
|
||||
handleRuntimeSettingsToggle,
|
||||
handleRuntimeSettingsClose,
|
||||
setRuntimeConfig,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue,
|
||||
setWatchlistFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
setIsWatchlistPanelOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setScheduleModeDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setTriggerTimeDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setInitialCashDraft,
|
||||
setMarginRequirementDraft,
|
||||
setEnableMemoryDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setStartDateDraft,
|
||||
setEndDateDraft,
|
||||
setEnableMockDraft,
|
||||
setIsWatchlistSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
};
|
||||
}
|
||||
352
frontend/src/hooks/useStockDataRequests.js
Normal file
352
frontend/src/hooks/useStockDataRequests.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from '../services/newsApi';
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from '../services/tradingApi';
|
||||
|
||||
/**
|
||||
* Custom hook for stock data request callbacks.
|
||||
* Takes clientRef, calls store setters directly.
|
||||
*/
|
||||
export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
const { currentDate } = useRuntimeStore();
|
||||
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
|
||||
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
|
||||
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(timestamp), label: String(timestamp), price };
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: 'get_stock_history',
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 });
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
return success;
|
||||
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
|
||||
|
||||
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 });
|
||||
}, [clientRef]);
|
||||
|
||||
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 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
|
||||
byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
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 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: { ticker: normalized, startDate, endDate, trades: rows }
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !startDate || !endDate) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct range explain fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!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 : [] });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct story fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
|
||||
if (!targetDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
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
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
// Register request functions with WebSocket connection hook
|
||||
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
|
||||
if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
|
||||
if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
546
frontend/src/hooks/useStockExplainData.js
Normal file
546
frontend/src/hooks/useStockExplainData.js
Normal file
@@ -0,0 +1,546 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from "../services/newsApi";
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from "../services/tradingApi";
|
||||
|
||||
export function useStockExplainData({
|
||||
clientRef,
|
||||
currentDate,
|
||||
currentView,
|
||||
selectedExplainSymbol,
|
||||
requestedStockHistoryRef,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker,
|
||||
setHistorySourceByTicker,
|
||||
setNewsByTicker,
|
||||
setInsiderTradesByTicker
|
||||
}) {
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timestamp: String(timestamp),
|
||||
label: String(timestamp),
|
||||
price
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct stock-history fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [
|
||||
clientRef,
|
||||
currentDate,
|
||||
requestedStockHistoryRef,
|
||||
setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker
|
||||
]);
|
||||
|
||||
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
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
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
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: {
|
||||
...((prev[normalized] && prev[normalized].byDate) || {}),
|
||||
[targetDate]: news
|
||||
},
|
||||
byDateFreshness: {
|
||||
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
|
||||
[targetDate]: freshness
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-for-date fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
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
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-categories fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
ticker: normalized,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
trades: rows
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct insider-trades fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_technical_indicators",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) {
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: {
|
||||
...result,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct range explain fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!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 : []
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: {
|
||||
story: payload.story || "",
|
||||
source: payload.source || "news_service",
|
||||
asOfDate: storyDate,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct story fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
|
||||
if (!targetDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct similar-days fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
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
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
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
|
||||
]);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
875
frontend/src/hooks/useWebSocketConnection.js
Normal file
875
frontend/src/hooks/useWebSocketConnection.js
Normal file
@@ -0,0 +1,875 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { ReadOnlyClient } from '../services/websocket';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { usePortfolioStore } from '../store/portfolioStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { normalizeTickerSymbols } from '../services/runtimeControls';
|
||||
|
||||
/**
|
||||
* Normalize price history from server format
|
||||
*/
|
||||
function normalizePriceHistory(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tickers from symbols array
|
||||
*/
|
||||
function buildTickersFromSymbols(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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for WebSocket connection lifecycle and event handling.
|
||||
* Manages clientRef, connection, and ALL event handlers.
|
||||
* Feeds directly into stores (no props drilling).
|
||||
*/
|
||||
export function useWebSocketConnection({
|
||||
processHistoricalFeed,
|
||||
processFeedEvent,
|
||||
addSystemMessage
|
||||
}) {
|
||||
const clientRef = useRef(null);
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
const selectedSkillAgentIdRef = useRef(null);
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
// Store state
|
||||
const { setIsConnected, setConnectionStatus, setSystemStatus, setCurrentDate,
|
||||
setServerMode, setDataSources, setRuntimeConfig, setMarketStatus,
|
||||
setVirtualTime, setProgress, watchlistDraftSymbols, setWatchlistInputValue,
|
||||
setIsWatchlistSaving, setWatchlistFeedback, setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback, isWatchlistSaving, isRuntimeConfigSaving,
|
||||
setLastDayHistory } = useRuntimeStore();
|
||||
|
||||
const { tickers, setTickers, setRollingTickers, setPriceHistoryByTicker,
|
||||
setExplainEventsByTicker, setNewsByTicker, setInsiderTradesByTicker,
|
||||
setTechnicalIndicatorsByTicker, setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker } = useMarketStore();
|
||||
|
||||
const { setPortfolioData, setHoldings, setTrades, setStats, setLeaderboard } = usePortfolioStore();
|
||||
|
||||
const { setAgentSkillsByAgent, setAgentProfilesByAgent, setSkillDetailsByName,
|
||||
setLocalSkillDraftsByKey, setIsAgentSkillsLoading, setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey, setAgentSkillsFeedback, setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, setWorkspaceFileFeedback,
|
||||
selectedSkillAgentId } = useAgentStore();
|
||||
|
||||
const { setBubbles } = useUIStore();
|
||||
|
||||
// Helper: Update tickers from realtime prices
|
||||
const updateTickersFromPrices = useCallback((realtimePrices) => {
|
||||
try {
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
const realtimeData = realtimePrices[ticker.symbol];
|
||||
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
|
||||
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);
|
||||
}
|
||||
}, [setTickers]);
|
||||
|
||||
// Stock request callbacks (these will be provided by useStockDataRequests)
|
||||
const requestStockHistoryRef = useRef(null);
|
||||
const requestStockNewsTimelineRef = useRef(null);
|
||||
const requestStockNewsCategoriesRef = useRef(null);
|
||||
|
||||
const setRequestStockHistory = useCallback((fn) => {
|
||||
requestStockHistoryRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsTimeline = useCallback((fn) => {
|
||||
requestStockNewsTimelineRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setRequestStockNewsCategories = useCallback((fn) => {
|
||||
requestStockNewsCategoriesRef.current = fn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
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 || '更新工作区文件失败' });
|
||||
}
|
||||
if (message.includes('fast forward')) {
|
||||
console.warn(`⚠️ ${message}`);
|
||||
handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() });
|
||||
}
|
||||
addSystemMessage(message);
|
||||
},
|
||||
|
||||
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: () => {
|
||||
console.log('[Heartbeat] Pong received');
|
||||
},
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
const isMockMode = state.is_mock_mode === true;
|
||||
if (state.market_status) {
|
||||
setMarketStatus(state.market_status);
|
||||
if (isMockMode && state.market_status.current_time) {
|
||||
try {
|
||||
setVirtualTime(new Date(state.market_status.current_time));
|
||||
} catch (error) {
|
||||
console.error('Error parsing virtual time from market_status:', error);
|
||||
}
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
|
||||
if (state.feed_history && Array.isArray(state.feed_history)) {
|
||||
console.log(`✅ Loading ${state.feed_history.length} historical events`);
|
||||
processHistoricalFeed(state.feed_history);
|
||||
}
|
||||
|
||||
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: (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)
|
||||
);
|
||||
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() : selectedSkillAgentIdRef.current;
|
||||
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());
|
||||
setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers }));
|
||||
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
|
||||
}
|
||||
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,
|
||||
freshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(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 : [] },
|
||||
byDateFreshness: { ...((prev[symbol] && prev[symbol].byDateFreshness) || {}), [date]: e.freshness || null }
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
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,
|
||||
timelineFreshness: e.freshness || 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,
|
||||
categoriesFreshness: e.freshness || null
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
stock_insider_trades_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[symbol]: { trades: Array.isArray(e.trades) ? e.trades : [], startDate: e.start_date || null, endDate: e.end_date || null }
|
||||
}));
|
||||
},
|
||||
|
||||
stock_technical_indicators_loaded: (e) => {
|
||||
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
|
||||
if (!symbol) return;
|
||||
setTechnicalIndicatorsByTicker((prev) => ({ ...prev, [symbol]: e.indicators || 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, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
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, freshness: e.freshness || null }
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
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,
|
||||
freshness: e.freshness || 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) {
|
||||
if (requestStockHistoryRef.current) requestStockHistoryRef.current(symbol);
|
||||
if (requestStockNewsTimelineRef.current) requestStockNewsTimelineRef.current(symbol);
|
||||
if (requestStockNewsCategoriesRef.current) requestStockNewsCategoriesRef.current(symbol);
|
||||
}
|
||||
},
|
||||
|
||||
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: new Date().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) };
|
||||
});
|
||||
|
||||
const normalizedSymbol = String(symbol).trim().toUpperCase();
|
||||
let shouldAnimateTicker = false;
|
||||
setTickers((prevTickers) => prevTickers.map((ticker) => {
|
||||
if (ticker.symbol === symbol) {
|
||||
const oldPrice = ticker.price;
|
||||
let newChange = ticker.change;
|
||||
if (ret !== null && ret !== undefined) {
|
||||
newChange = ret;
|
||||
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
|
||||
const priceChange = ((price - oldPrice) / oldPrice) * 100;
|
||||
newChange = (newChange !== null && newChange !== undefined) ? newChange + priceChange : priceChange;
|
||||
} else {
|
||||
newChange = 0;
|
||||
}
|
||||
if (oldPrice !== price) shouldAnimateTicker = true;
|
||||
return { ...ticker, price, change: newChange, open: open || ticker.open };
|
||||
}
|
||||
return ticker;
|
||||
}));
|
||||
|
||||
if (shouldAnimateTicker) {
|
||||
setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: true }));
|
||||
setTimeout(() => setRollingTickers((prev) => ({ ...prev, [normalizedSymbol]: false })), 500);
|
||||
}
|
||||
|
||||
if (realtime_prices) updateTickersFromPrices(realtime_prices);
|
||||
if (portfolio && portfolio.total_value) {
|
||||
setPortfolioData((prev) => ({
|
||||
...prev,
|
||||
netValue: portfolio.total_value,
|
||||
pnl: portfolio.pnl_percent || 0,
|
||||
equity: portfolio.equity || prev.equity
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Price Update] Error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
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) => {
|
||||
const result = e.result;
|
||||
if (result && typeof result === 'object') {
|
||||
if (result.portfolio_summary) {
|
||||
const summary = result.portfolio_summary;
|
||||
setPortfolioData((prev) => {
|
||||
const newEquity = [...prev.equity];
|
||||
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((item) => item.id === e.agentId);
|
||||
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((item) => item.id === e.agentId);
|
||||
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) => {
|
||||
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
|
||||
}));
|
||||
},
|
||||
|
||||
team_portfolio: (e) => {
|
||||
if (e.holdings) setHoldings(e.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);
|
||||
} 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);
|
||||
else if (e.stats) setStats(e.stats);
|
||||
},
|
||||
|
||||
team_leaderboard: (e) => {
|
||||
if (Array.isArray(e.data)) setLeaderboard(e.data);
|
||||
else if (Array.isArray(e.rows)) setLeaderboard(e.rows);
|
||||
else if (Array.isArray(e.leaderboard)) setLeaderboard(e.leaderboard);
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
if (isMockMode && e.beijing_time) {
|
||||
try { setVirtualTime(new Date(e.beijing_time)); } catch (error) { console.error('Error parsing virtual time:', error); }
|
||||
} else {
|
||||
setVirtualTime(null);
|
||||
}
|
||||
}
|
||||
if (e.market_status) setMarketStatus(e.market_status);
|
||||
},
|
||||
|
||||
time_fast_forwarded: (e) => {
|
||||
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str} → ${e.new_time_str}`);
|
||||
if (e.new_time) {
|
||||
try {
|
||||
setVirtualTime(new Date(e.new_time));
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
// Sync refs with store state
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
|
||||
return () => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
addSystemMessage, processFeedEvent,
|
||||
processHistoricalFeed, setAgentProfilesByAgent,
|
||||
setAgentSkillsByAgent, setAgentSkillsFeedback, setAgentSkillsSavingKey,
|
||||
setBubbles, setConnectionStatus, setCurrentDate, setDataSources,
|
||||
setExplainEventsByTicker, setHistorySourceByTicker, setHoldings,
|
||||
setInsiderTradesByTicker, setIsAgentSkillsLoading, setIsConnected,
|
||||
setIsRuntimeConfigSaving, setIsWatchlistSaving, setIsWorkspaceFileLoading,
|
||||
setLastDayHistory, setLeaderboard, setLocalSkillDraftsByKey,
|
||||
setMarketStatus, setNewsByTicker, setOhlcHistoryByTicker,
|
||||
setPortfolioData, setPriceHistoryByTicker, setProgress,
|
||||
setRollingTickers, setRuntimeConfig, setRuntimeConfigFeedback,
|
||||
setServerMode, setSkillDetailLoadingKey, setSkillDetailsByName,
|
||||
setStats, setSystemStatus, setTechnicalIndicatorsByTicker,
|
||||
setTickers, setTrades, setVirtualTime, setWatchlistFeedback,
|
||||
setWatchlistInputValue, setWorkspaceFileFeedback, setWorkspaceFileSavingKey,
|
||||
setWorkspaceFilesByAgent, updateTickersFromPrices
|
||||
]);
|
||||
|
||||
// Sync refs
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedSkillAgentIdRef.current = selectedSkillAgentId;
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
return { clientRef, setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories };
|
||||
}
|
||||
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* useWebsocketSessionSync - DEPRECATED
|
||||
*
|
||||
* This hook is deprecated. WebSocket connection and event handling is now managed
|
||||
* by useWebSocketConnection.js. This file is kept for backwards compatibility
|
||||
* but will be removed in a future version.
|
||||
*
|
||||
* All functionality has been consolidated into:
|
||||
* - useWebSocketConnection.js: WebSocket lifecycle and event handlers
|
||||
* - useStockDataRequests.js: Stock data request callbacks
|
||||
* - useAgentDataRequests.js: Agent operation callbacks
|
||||
*/
|
||||
|
||||
import { useWebSocketConnection } from './useWebSocketConnection';
|
||||
|
||||
/**
|
||||
* @deprecated Use useWebSocketConnection directly instead.
|
||||
* This hook is a thin wrapper that delegates to useWebSocketConnection
|
||||
* for backwards compatibility.
|
||||
*/
|
||||
export function useWebsocketSessionSync(props) {
|
||||
// Delegate to useWebSocketConnection
|
||||
const { clientRef } = useWebSocketConnection();
|
||||
|
||||
// Return clientRef so existing code can still access it
|
||||
return { clientRef };
|
||||
}
|
||||
|
||||
export default useWebsocketSessionSync;
|
||||
@@ -121,6 +121,10 @@ export function fetchCurrentRuntime() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/current');
|
||||
}
|
||||
|
||||
export function fetchRuntimeLogs() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/logs');
|
||||
}
|
||||
|
||||
export async function uploadAgentSkillZip({
|
||||
agentId,
|
||||
file,
|
||||
|
||||
81
frontend/src/services/runtimeControls.js
Normal file
81
frontend/src/services/runtimeControls.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const normalizeSymbol = (symbol) => {
|
||||
if (typeof symbol !== "string") {
|
||||
return "";
|
||||
}
|
||||
return symbol.trim().toUpperCase();
|
||||
};
|
||||
|
||||
export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
.reduce((acc, symbol) => {
|
||||
const existing = acc.find((ticker) => ticker.symbol === symbol);
|
||||
if (existing) {
|
||||
return acc;
|
||||
}
|
||||
const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
|
||||
acc.push(
|
||||
prior || {
|
||||
symbol,
|
||||
price: null,
|
||||
change: null
|
||||
}
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
|
||||
const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
|
||||
? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (runtimeSymbols.length > 0) {
|
||||
return runtimeSymbols;
|
||||
}
|
||||
|
||||
return fallbackTickers
|
||||
.map((ticker) => normalizeSymbol(ticker?.symbol))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseWatchlistInput = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildRuntimeSummaryLabel = (runtimeConfig) => {
|
||||
if (!runtimeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
|
||||
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
|
||||
const triggerTime = String(runtimeConfig.trigger_time || "now");
|
||||
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
|
||||
|
||||
if (scheduleMode === "intraday") {
|
||||
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
if (triggerTime.toLowerCase() === "now") {
|
||||
return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
|
||||
};
|
||||
59
frontend/src/services/runtimeControls.test.js
Normal file
59
frontend/src/services/runtimeControls.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
normalizeTickerSymbols,
|
||||
parseWatchlistInput
|
||||
} from "./runtimeControls";
|
||||
|
||||
describe("runtimeControls", () => {
|
||||
it("normalizes ticker symbols while preserving existing entries", () => {
|
||||
const previous = [
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
];
|
||||
|
||||
expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "NVDA", price: null, change: null },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
|
||||
const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
|
||||
const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
|
||||
|
||||
expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
|
||||
"TSLA",
|
||||
"META",
|
||||
"TSLA"
|
||||
]);
|
||||
expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
|
||||
"AAPL",
|
||||
"MSFT"
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses watchlist input tokens and removes duplicates", () => {
|
||||
expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
|
||||
"AAPL",
|
||||
"MSFT",
|
||||
"NVDA"
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds runtime summary labels", () => {
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "daily",
|
||||
trigger_time: "09:30",
|
||||
max_comm_cycles: 3
|
||||
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
|
||||
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "intraday",
|
||||
interval_minutes: 15,
|
||||
max_comm_cycles: 2
|
||||
})).toBe("调度 intraday / 15m / 讨论 2 轮");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Agent Store - Agent skills, profiles, workspaces
|
||||
*/
|
||||
export const useAgentStore = create((set) => ({
|
||||
// Selected agent for skill/workspace editing
|
||||
selectedSkillAgentId: null,
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set({ selectedSkillAgentId }),
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||
|
||||
// Agent profiles
|
||||
agentProfilesByAgent: {},
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set({ agentProfilesByAgent }),
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||
|
||||
// Agent skills
|
||||
agentSkillsByAgent: {},
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set({ agentSkillsByAgent }),
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||
|
||||
// Skill details
|
||||
skillDetailsByName: {},
|
||||
setSkillDetailsByName: (skillDetailsByName) => set({ skillDetailsByName }),
|
||||
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||
|
||||
// Local skill drafts
|
||||
localSkillDraftsByKey: {},
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set({ localSkillDraftsByKey }),
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||
|
||||
// Loading states
|
||||
isAgentSkillsLoading: false,
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set({ isAgentSkillsLoading }),
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||
|
||||
skillDetailLoadingKey: null,
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set({ skillDetailLoadingKey }),
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||
|
||||
agentSkillsSavingKey: null,
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set({ agentSkillsSavingKey }),
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||
|
||||
agentSkillsFeedback: null,
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set({ agentSkillsFeedback }),
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||
|
||||
// Workspace files
|
||||
selectedWorkspaceFile: null,
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set({ selectedWorkspaceFile }),
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||
|
||||
workspaceFilesByAgent: {},
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set({ workspaceFilesByAgent }),
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||
|
||||
workspaceDraftContent: '',
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set({ workspaceDraftContent }),
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||
|
||||
isWorkspaceFileLoading: false,
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set({ isWorkspaceFileLoading }),
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||
|
||||
workspaceFileSavingKey: null,
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set({ workspaceFileSavingKey }),
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||
|
||||
workspaceFileFeedback: null,
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set({ workspaceFileFeedback }),
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||
}));
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Market Store - Market data, stock prices, news
|
||||
*/
|
||||
export const useMarketStore = create((set) => ({
|
||||
// Ticker prices
|
||||
tickers: [],
|
||||
setTickers: (tickers) => set({ tickers }),
|
||||
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||
rollingTickers: {},
|
||||
setRollingTickers: (rollingTickers) => set({ rollingTickers }),
|
||||
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||
|
||||
// Price history
|
||||
priceHistoryByTicker: {},
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set({ priceHistoryByTicker }),
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||
|
||||
// OHLC history
|
||||
ohlcHistoryByTicker: {},
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set({ ohlcHistoryByTicker }),
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||
|
||||
// History source tracking
|
||||
historySourceByTicker: {},
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set({ historySourceByTicker }),
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||
|
||||
// Explain events
|
||||
explainEventsByTicker: {},
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set({ explainEventsByTicker }),
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||
|
||||
// Selected explain symbol
|
||||
selectedExplainSymbol: '',
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set({ selectedExplainSymbol }),
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||
|
||||
// News by ticker
|
||||
newsByTicker: {},
|
||||
setNewsByTicker: (newsByTicker) => set({ newsByTicker }),
|
||||
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||
|
||||
// Insider trades
|
||||
insiderTradesByTicker: {},
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set({ insiderTradesByTicker }),
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||
|
||||
// Technical indicators
|
||||
technicalIndicatorsByTicker: {},
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set({ technicalIndicatorsByTicker }),
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
||||
*/
|
||||
@@ -18,21 +22,21 @@ export const usePortfolioStore = create((set) => ({
|
||||
baseline_vw_return: 0,
|
||||
momentum_return: 0,
|
||||
},
|
||||
setPortfolioData: (portfolioData) => set({ portfolioData }),
|
||||
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||
|
||||
// Holdings
|
||||
holdings: [],
|
||||
setHoldings: (holdings) => set({ holdings }),
|
||||
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||
|
||||
// Trades
|
||||
trades: [],
|
||||
setTrades: (trades) => set({ trades }),
|
||||
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||
|
||||
// Statistics
|
||||
stats: null,
|
||||
setStats: (stats) => set({ stats }),
|
||||
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: [],
|
||||
setLeaderboard: (leaderboard) => set({ leaderboard }),
|
||||
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Runtime Store - Connection state and runtime configuration
|
||||
*/
|
||||
@@ -7,59 +11,59 @@ export const useRuntimeStore = create((set) => ({
|
||||
// Connection state
|
||||
isConnected: false,
|
||||
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
||||
setIsConnected: (isConnected) => set({ isConnected }),
|
||||
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
|
||||
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||
|
||||
// System state
|
||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||
currentDate: null,
|
||||
setSystemStatus: (systemStatus) => set({ systemStatus }),
|
||||
setCurrentDate: (currentDate) => set({ currentDate }),
|
||||
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||
|
||||
// Progress
|
||||
progress: { current: 0, total: 0 },
|
||||
setProgress: (progress) => set({ progress }),
|
||||
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||
|
||||
// Server mode
|
||||
serverMode: null, // 'live' | 'backtest' | null
|
||||
setServerMode: (serverMode) => set({ serverMode }),
|
||||
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||
|
||||
// Market status
|
||||
marketStatus: null,
|
||||
virtualTime: null,
|
||||
setMarketStatus: (marketStatus) => set({ marketStatus }),
|
||||
setVirtualTime: (virtualTime) => set({ virtualTime }),
|
||||
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||
|
||||
// Data sources
|
||||
dataSources: null,
|
||||
setDataSources: (dataSources) => set({ dataSources }),
|
||||
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||
|
||||
// Runtime config
|
||||
runtimeConfig: null,
|
||||
setRuntimeConfig: (runtimeConfig) => set({ runtimeConfig }),
|
||||
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||
|
||||
// Watchlist panel
|
||||
isWatchlistPanelOpen: false,
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set({ isWatchlistPanelOpen }),
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||
|
||||
// Watchlist draft
|
||||
watchlistDraftSymbols: [],
|
||||
watchlistInputValue: '',
|
||||
watchlistFeedback: null,
|
||||
isWatchlistSaving: false,
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set({ watchlistDraftSymbols }),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set({ watchlistInputValue }),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set({ watchlistFeedback }),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set({ isWatchlistSaving }),
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||
|
||||
// Runtime settings panel
|
||||
isRuntimeSettingsOpen: false,
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set({ isRuntimeSettingsOpen }),
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||
|
||||
// Runtime config drafts
|
||||
scheduleModeDraft: 'daily',
|
||||
intervalMinutesDraft: '60',
|
||||
triggerTimeDraft: '09:30',
|
||||
triggerTimeDraft: 'now',
|
||||
maxCommCyclesDraft: '2',
|
||||
initialCashDraft: '100000',
|
||||
marginRequirementDraft: '0',
|
||||
@@ -69,26 +73,26 @@ export const useRuntimeStore = create((set) => ({
|
||||
startDateDraft: '',
|
||||
endDateDraft: '',
|
||||
enableMockDraft: false,
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set({ scheduleModeDraft }),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set({ intervalMinutesDraft }),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set({ triggerTimeDraft }),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set({ maxCommCyclesDraft }),
|
||||
setInitialCashDraft: (initialCashDraft) => set({ initialCashDraft }),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set({ marginRequirementDraft }),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set({ enableMemoryDraft }),
|
||||
setModeDraft: (modeDraft) => set({ modeDraft }),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set({ pollIntervalDraft }),
|
||||
setStartDateDraft: (startDateDraft) => set({ startDateDraft }),
|
||||
setEndDateDraft: (endDateDraft) => set({ endDateDraft }),
|
||||
setEnableMockDraft: (enableMockDraft) => set({ enableMockDraft }),
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
|
||||
setEnableMockDraft: (enableMockDraft) => set((state) => ({ enableMockDraft: resolveValue(enableMockDraft, state.enableMockDraft) })),
|
||||
|
||||
// Runtime config feedback
|
||||
runtimeConfigFeedback: null,
|
||||
isRuntimeConfigSaving: false,
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set({ runtimeConfigFeedback }),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set({ isRuntimeConfigSaving }),
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||
|
||||
// Last day history (for replay)
|
||||
lastDayHistory: [],
|
||||
setLastDayHistory: (lastDayHistory) => set({ lastDayHistory }),
|
||||
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||
}));
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* UI Store - UI state, view management, layout
|
||||
*/
|
||||
export const useUIStore = create((set) => ({
|
||||
// Current view
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
setCurrentView: (currentView) => set({ currentView }),
|
||||
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||
|
||||
// Chart tab
|
||||
chartTab: 'all',
|
||||
setChartTab: (chartTab) => set({ chartTab }),
|
||||
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||
|
||||
// Initial animation
|
||||
isInitialAnimating: true,
|
||||
setIsInitialAnimating: (isInitialAnimating) => set({ isInitialAnimating }),
|
||||
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||
|
||||
// Last update timestamp
|
||||
lastUpdate: new Date(),
|
||||
setLastUpdate: (lastUpdate) => set({ lastUpdate }),
|
||||
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||
|
||||
// Is updating
|
||||
isUpdating: false,
|
||||
setIsUpdating: (isUpdating) => set({ isUpdating }),
|
||||
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||
|
||||
// Room bubbles
|
||||
bubbles: {},
|
||||
setBubbles: (bubbles) => set({ bubbles }),
|
||||
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||
|
||||
// Resizable panels
|
||||
leftWidth: 70,
|
||||
setLeftWidth: (leftWidth) => set({ leftWidth }),
|
||||
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||
isResizing: false,
|
||||
setIsResizing: (isResizing) => set({ isResizing }),
|
||||
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||
|
||||
// Now timestamp (for current time display)
|
||||
now: new Date(),
|
||||
setNow: (now) => set({ now }),
|
||||
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
|
||||
}));
|
||||
|
||||
@@ -478,7 +478,7 @@ export default function GlobalStyles() {
|
||||
background: #ffffff;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.agent-indicator {
|
||||
@@ -583,6 +583,7 @@ export default function GlobalStyles() {
|
||||
|
||||
.room-scene-wrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -646,7 +647,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -656,6 +657,7 @@ export default function GlobalStyles() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.room-canvas {
|
||||
@@ -666,7 +668,8 @@ export default function GlobalStyles() {
|
||||
|
||||
.room-bubble {
|
||||
position: absolute;
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
max-height: 260px;
|
||||
font-size: 11px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
@@ -676,6 +679,8 @@ export default function GlobalStyles() {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
line-height: 1.5;
|
||||
animation: bubbleAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
@keyframes bubbleAppear {
|
||||
@@ -786,6 +791,9 @@ export default function GlobalStyles() {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
position: relative;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.bubble-expand-btn {
|
||||
|
||||
Reference in New Issue
Block a user