Fix runtime logging and frontend app regressions

This commit is contained in:
2026-03-24 10:58:41 +08:00
parent 032c37538f
commit c5eaf2b5ad
33 changed files with 4763 additions and 3131 deletions

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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}

View File

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

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

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

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

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

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

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

View 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;

View File

@@ -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,

View 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}`;
};

View 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 轮");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {