feat: Add evaluation hooks, skill adaptation and team pipeline config
- Add EvaluationHook for post-execution agent evaluation - Add SkillAdaptationHook for dynamic skill adaptation - Add team/ directory with team coordination logic - Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config - Update RuntimeView, TraderView and RuntimeSettingsPanel UI - Add runtimeApi and websocket services - Add runtime_state.json to smoke_fullstack state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.13",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AGENTS, INITIAL_TICKERS } from './config/constants';
|
||||
|
||||
// Services
|
||||
import { ReadOnlyClient } from './services/websocket';
|
||||
import { startRuntime } from './services/runtimeApi';
|
||||
import { startRuntime, uploadAgentSkillZip } from './services/runtimeApi';
|
||||
|
||||
// Hooks
|
||||
import { useFeedProcessor } from './hooks/useFeedProcessor';
|
||||
@@ -98,6 +98,8 @@ export default function LiveTradingApp() {
|
||||
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
|
||||
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
|
||||
const [newsByTicker, setNewsByTicker] = useState({});
|
||||
const [insiderTradesByTicker, setInsiderTradesByTicker] = useState({});
|
||||
const [technicalIndicatorsByTicker, setTechnicalIndicatorsByTicker] = useState({});
|
||||
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
|
||||
const [historySourceByTicker, setHistorySourceByTicker] = useState({});
|
||||
|
||||
@@ -127,6 +129,11 @@ export default function LiveTradingApp() {
|
||||
const [initialCashDraft, setInitialCashDraft] = useState('100000');
|
||||
const [marginRequirementDraft, setMarginRequirementDraft] = useState('0');
|
||||
const [enableMemoryDraft, setEnableMemoryDraft] = useState(false);
|
||||
const [modeDraft, setModeDraft] = useState('live');
|
||||
const [pollIntervalDraft, setPollIntervalDraft] = useState('10');
|
||||
const [startDateDraft, setStartDateDraft] = useState('');
|
||||
const [endDateDraft, setEndDateDraft] = useState('');
|
||||
const [enableMockDraft, setEnableMockDraft] = useState(false);
|
||||
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
|
||||
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
|
||||
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
|
||||
@@ -602,7 +609,11 @@ export default function LiveTradingApp() {
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft),
|
||||
mode: serverMode || 'live'
|
||||
mode: modeDraft || 'live',
|
||||
poll_interval: Number(pollIntervalDraft) || 10,
|
||||
start_date: startDateDraft || null,
|
||||
end_date: endDateDraft || null,
|
||||
enable_mock: Boolean(enableMockDraft)
|
||||
});
|
||||
|
||||
setIsRuntimeConfigSaving(false);
|
||||
@@ -630,9 +641,13 @@ export default function LiveTradingApp() {
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
enableMockDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
serverMode,
|
||||
addSystemMessage
|
||||
]);
|
||||
|
||||
@@ -644,6 +659,11 @@ export default function LiveTradingApp() {
|
||||
setInitialCashDraft('100000');
|
||||
setMarginRequirementDraft('0');
|
||||
setEnableMemoryDraft(false);
|
||||
setModeDraft('live');
|
||||
setPollIntervalDraft('10');
|
||||
setStartDateDraft('');
|
||||
setEndDateDraft('');
|
||||
setEnableMockDraft(false);
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, []);
|
||||
|
||||
@@ -862,6 +882,38 @@ export default function LiveTradingApp() {
|
||||
}
|
||||
}, [selectedSkillAgentId, selectedWorkspaceFile, 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]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||
}, [selectedWorkspaceContent]);
|
||||
@@ -967,6 +1019,31 @@ export default function LiveTradingApp() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: 'get_stock_insider_trades',
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}, []);
|
||||
|
||||
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
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !startDate || !endDate || !clientRef.current) {
|
||||
@@ -1050,13 +1127,15 @@ export default function LiveTradingApp() {
|
||||
}, [isLiveEnabled, chartTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWatchlistPanelOpen || !isWatchlistDraftDirty) {
|
||||
// Only reset when watchlist panel is closed AND runtime settings is also closed
|
||||
// This prevents reset when user is editing in RuntimeSettingsPanel
|
||||
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
if (!isWatchlistPanelOpen) {
|
||||
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||
setWatchlistInputValue('');
|
||||
}
|
||||
}
|
||||
}, [isWatchlistDraftDirty, isWatchlistPanelOpen, runtimeWatchlistSymbols]);
|
||||
}, [isWatchlistDraftDirty, isWatchlistPanelOpen, isRuntimeSettingsOpen, runtimeWatchlistSymbols]);
|
||||
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
@@ -1084,6 +1163,8 @@ export default function LiveTradingApp() {
|
||||
requestStockNews,
|
||||
requestStockNewsCategories,
|
||||
requestStockNewsTimeline,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockStory,
|
||||
selectedExplainSymbol
|
||||
]);
|
||||
@@ -1682,6 +1763,32 @@ export default function LiveTradingApp() {
|
||||
}));
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -2388,6 +2495,11 @@ export default function LiveTradingApp() {
|
||||
initialCash={initialCashDraft}
|
||||
marginRequirement={marginRequirementDraft}
|
||||
enableMemory={enableMemoryDraft}
|
||||
mode={modeDraft}
|
||||
pollInterval={pollIntervalDraft}
|
||||
startDate={startDateDraft}
|
||||
endDate={endDateDraft}
|
||||
enableMock={enableMockDraft}
|
||||
watchlistSymbols={watchlistDraftSymbols}
|
||||
watchlistInputValue={watchlistInputValue}
|
||||
watchlistSuggestions={watchlistSuggestions}
|
||||
@@ -2400,6 +2512,11 @@ export default function LiveTradingApp() {
|
||||
onInitialCashChange={setInitialCashDraft}
|
||||
onMarginRequirementChange={setMarginRequirementDraft}
|
||||
onEnableMemoryChange={setEnableMemoryDraft}
|
||||
onModeChange={setModeDraft}
|
||||
onPollIntervalChange={setPollIntervalDraft}
|
||||
onStartDateChange={setStartDateDraft}
|
||||
onEndDateChange={setEndDateDraft}
|
||||
onEnableMockChange={setEnableMockDraft}
|
||||
onWatchlistInputChange={handleWatchlistInputChange}
|
||||
onWatchlistInputKeyDown={handleWatchlistInputKeyDown}
|
||||
onWatchlistAdd={() => commitWatchlistInput(watchlistInputValue)}
|
||||
@@ -2539,6 +2656,7 @@ export default function LiveTradingApp() {
|
||||
onWorkspaceFileChange={handleWorkspaceFileChange}
|
||||
onWorkspaceDraftChange={setWorkspaceDraftContent}
|
||||
onWorkspaceFileSave={handleWorkspaceFileSave}
|
||||
onUploadExternalSkill={handleUploadExternalSkill}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -2573,9 +2691,13 @@ export default function LiveTradingApp() {
|
||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||
onRequestRangeExplain={requestStockRangeExplain}
|
||||
onRequestNewsForDate={requestStockNewsForDate}
|
||||
onRequestStory={requestStockStory}
|
||||
onRequestInsiderTrades={requestStockInsiderTrades}
|
||||
onRequestTechnicalIndicators={requestStockTechnicalIndicators}
|
||||
currentDate={currentDate}
|
||||
onRequestSimilarDays={requestStockSimilarDays}
|
||||
onRequestStockEnrich={requestStockEnrich}
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
const rankMedal = agent.rank ? getRankMedal(agent.rank) : null;
|
||||
const isPortfolioManager = agent.id === 'portfolio_manager';
|
||||
const isRiskManager = agent.id === 'risk_manager';
|
||||
const isValuationAnalyst = agent.id === 'valuation_analyst';
|
||||
const displayName = isPortfolioManager ? '团队' : agent.name;
|
||||
|
||||
// Get model icon configuration
|
||||
@@ -483,6 +484,78 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valuation Results Card - Only show for valuation_analyst */}
|
||||
{isValuationAnalyst && agent.signals && agent.signals.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
background: '#f5f5f5',
|
||||
border: '2px solid #7B1FA2'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#7B1FA2',
|
||||
minWidth: 80,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
估值分析
|
||||
</div>
|
||||
{agent.signals
|
||||
.filter(signal => signal && signal.intrinsic_value != null)
|
||||
.slice(0, 5)
|
||||
.map((signal, idx) => {
|
||||
const fairValue = signal.fair_value_range;
|
||||
const hasValuation = signal.intrinsic_value || fairValue;
|
||||
if (!hasValuation) return null;
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
fontSize: 9,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
padding: '6px 8px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #7B1FA2',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minWidth: 90
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color: '#333' }}>
|
||||
{signal.ticker}
|
||||
</div>
|
||||
{signal.intrinsic_value && (
|
||||
<div style={{ color: '#00C853', fontSize: 10 }}>
|
||||
内在 ${signal.intrinsic_value.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
{signal.value_gap_pct != null && (
|
||||
<div style={{
|
||||
color: signal.value_gap_pct > 0 ? '#00C853' : '#FF1744',
|
||||
fontSize: 9
|
||||
}}>
|
||||
{signal.value_gap_pct > 0 ? '+' : ''}{signal.value_gap_pct.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
{fairValue && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>
|
||||
区间 ${fairValue.bear?.toFixed(0) || '?'}-
|
||||
${fairValue.bull?.toFixed(0) || '?'}
|
||||
</div>
|
||||
)}
|
||||
{signal.valuation_methods && signal.valuation_methods.length > 0 && (
|
||||
<div style={{ fontSize: 7, color: '#999' }}>
|
||||
{signal.valuation_methods[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ export default function RuntimeSettingsPanel({
|
||||
initialCash,
|
||||
marginRequirement,
|
||||
enableMemory,
|
||||
mode,
|
||||
pollInterval,
|
||||
startDate,
|
||||
endDate,
|
||||
enableMock,
|
||||
watchlistSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
@@ -26,6 +31,11 @@ export default function RuntimeSettingsPanel({
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onEnableMockChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
@@ -405,6 +415,101 @@ export default function RuntimeSettingsPanel({
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用长期记忆</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>运行模式</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => onModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="live">实盘模式 (Live)</option>
|
||||
<option value="backtest">回测模式 (Backtest)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{mode === 'backtest' && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>轮询间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={pollInterval}
|
||||
onChange={(e) => onPollIntervalChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableMock}
|
||||
onChange={(e) => onEnableMockChange(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
accentColor: '#0D47A1',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
|
||||
@@ -67,6 +67,112 @@ function resolveApprovalTone(approval) {
|
||||
return { border: '#D1D5DB', bg: '#FCFCFC', text: '#374151', badgeBg: '#E5E7EB' };
|
||||
}
|
||||
|
||||
// 评估指标配置
|
||||
const METRICS_CONFIG = {
|
||||
hit_rate: {
|
||||
label: '命中率',
|
||||
icon: '◎',
|
||||
goodThreshold: 0.7,
|
||||
warnThreshold: 0.5
|
||||
},
|
||||
risk_violation: {
|
||||
label: '风控违例',
|
||||
icon: '⚠',
|
||||
goodThreshold: 0.1,
|
||||
warnThreshold: 0.3,
|
||||
inverted: true // 值越小越好
|
||||
},
|
||||
decision_latency: {
|
||||
label: '决策延迟',
|
||||
icon: '◷',
|
||||
goodThreshold: 5000,
|
||||
warnThreshold: 10000,
|
||||
inverted: true,
|
||||
unit: 'ms'
|
||||
},
|
||||
signal_consistency: {
|
||||
label: '信号一致性',
|
||||
icon: '≡',
|
||||
goodThreshold: 0.8,
|
||||
warnThreshold: 0.6
|
||||
}
|
||||
};
|
||||
|
||||
function getMetricColor(value, config) {
|
||||
if (value === null || value === undefined || isNaN(value)) {
|
||||
return { color: '#9CA3AF', bg: '#F9FAFB', arrow: '-' };
|
||||
}
|
||||
const isInverted = config.inverted;
|
||||
const effectiveValue = isInverted ? value : value;
|
||||
const effectiveGood = isInverted ? config.goodThreshold : config.goodThreshold;
|
||||
const effectiveWarn = isInverted ? config.warnThreshold : config.warnThreshold;
|
||||
|
||||
if (effectiveValue <= effectiveGood) {
|
||||
return { color: '#059669', bg: '#ECFDF5', arrow: '↑' };
|
||||
} else if (effectiveValue <= effectiveWarn) {
|
||||
return { color: '#D97706', bg: '#FFFBEB', arrow: '→' };
|
||||
} else {
|
||||
return { color: '#DC2626', bg: '#FEF2F2', arrow: '↓' };
|
||||
}
|
||||
}
|
||||
|
||||
function MetricBadge({ metricKey, value }) {
|
||||
const config = METRICS_CONFIG[metricKey];
|
||||
if (!config) return null;
|
||||
|
||||
const displayValue = value !== null && value !== undefined && !isNaN(value)
|
||||
? (config.unit === 'ms' ? `${Math.round(value)}${config.unit}` : `${(value * 100).toFixed(1)}%`)
|
||||
: '-';
|
||||
const { color, bg, arrow } = getMetricColor(value, config);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 6px',
|
||||
background: bg,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: color
|
||||
}}>
|
||||
<span>{config.icon}</span>
|
||||
<span>{displayValue}</span>
|
||||
<span style={{ marginLeft: 2 }}>{arrow}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMetricsPanel({ agent }) {
|
||||
const extensions = agent.extensions || {};
|
||||
const metrics = [
|
||||
{ key: 'hit_rate', value: extensions.hit_rate },
|
||||
{ key: 'risk_violation', value: extensions.risk_violation },
|
||||
{ key: 'decision_latency', value: extensions.decision_latency },
|
||||
{ key: 'signal_consistency', value: extensions.signal_consistency }
|
||||
];
|
||||
|
||||
const hasMetrics = metrics.some(m => m.value !== null && m.value !== undefined && !isNaN(m.value));
|
||||
if (!hasMetrics) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px dashed #E5E7EB'
|
||||
}}>
|
||||
{metrics.map(({ key, value }) => (
|
||||
<MetricBadge key={key} metricKey={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sectionTitle(label, action = null) {
|
||||
return (
|
||||
<div className="section-header" style={{ marginBottom: 0 }}>
|
||||
@@ -315,6 +421,131 @@ export default function RuntimeView() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('团队协作状态')}
|
||||
<div style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
{/* 自动广播状态 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>📣</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>自动广播</span>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
border: '1px solid #000000',
|
||||
background: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
|
||||
? '#000000'
|
||||
: '#FFFFFF',
|
||||
color: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
|
||||
? '#FFFFFF'
|
||||
: '#000000',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
{(runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast) ? '已启用' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fan-out Pipeline */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>👥</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>Fan-out Pipeline</span>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
border: '1px solid #2563EB',
|
||||
background: '#EFF6FF',
|
||||
color: '#2563EB',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
{runtimeState?.context?.fanout_pipeline?.length || 0} Agents
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 活跃分析师列表 */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>📈</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>活跃分析师</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const activeAnalysts = (runtimeState?.agents || []).filter(
|
||||
(agent) => agent.status && agent.status !== 'idle' && agent.status !== 'stopped'
|
||||
);
|
||||
if (activeAnalysts.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 10,
|
||||
border: '1px dashed #999999',
|
||||
background: '#FAFAFA',
|
||||
fontSize: 11,
|
||||
color: '#9CA3AF'
|
||||
}}>
|
||||
当前无活跃分析师
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeAnalysts.map((agent) => (
|
||||
<span
|
||||
key={agent.agent_id}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
border: '1px solid #059669',
|
||||
background: '#ECFDF5',
|
||||
color: '#059669',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3px'
|
||||
}}
|
||||
>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 团队配置详情 */}
|
||||
{runtimeState?.context?.team_config && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||
团队配置
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid #E5E7EB',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.5,
|
||||
color: '#374151',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(runtimeState.context.team_config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('待审批请求')}
|
||||
<div style={{
|
||||
@@ -479,6 +710,7 @@ export default function RuntimeView() {
|
||||
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
更新时间: {agent.last_updated}
|
||||
</div>
|
||||
<AgentMetricsPanel agent={agent} />
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无 agent 状态</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import ExplainSimilarDaysSection from './explain/ExplainSimilarDaysSection';
|
||||
import ExplainSignalsSection from './explain/ExplainSignalsSection';
|
||||
import ExplainSummarySection from './explain/ExplainSummarySection';
|
||||
import ExplainTradesSection from './explain/ExplainTradesSection';
|
||||
import ExplainInsiderSection from './explain/ExplainInsiderSection';
|
||||
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
|
||||
import { EVENT_CATEGORY_META, eventDateKey } from './explain/explainUtils';
|
||||
import useExplainModel from './explain/useExplainModel';
|
||||
import { formatDateTime, formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
@@ -28,9 +30,13 @@ export default function StockExplainView({
|
||||
selectedHistorySource,
|
||||
explainEventsSnapshot,
|
||||
newsSnapshot,
|
||||
insiderTradesSnapshot,
|
||||
technicalIndicatorsSnapshot,
|
||||
onRequestRangeExplain,
|
||||
onRequestNewsForDate,
|
||||
onRequestStory,
|
||||
onRequestInsiderTrades,
|
||||
onRequestTechnicalIndicators,
|
||||
currentDate,
|
||||
onRequestSimilarDays,
|
||||
onRequestStockEnrich
|
||||
@@ -49,6 +55,8 @@ export default function StockExplainView({
|
||||
const [isMaintenanceOpen, setIsMaintenanceOpen] = useState(false);
|
||||
const [isStoryOpen, setIsStoryOpen] = useState(false);
|
||||
const [isTradesOpen, setIsTradesOpen] = useState(false);
|
||||
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
|
||||
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
|
||||
const [isSimilarDaysOpen, setIsSimilarDaysOpen] = useState(false);
|
||||
const [enrichStartDate, setEnrichStartDate] = useState('');
|
||||
const [enrichEndDate, setEnrichEndDate] = useState('');
|
||||
@@ -163,6 +171,16 @@ export default function StockExplainView({
|
||||
onRequestSimilarDays(selectedSymbol, selectedEventDate);
|
||||
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
||||
return;
|
||||
}
|
||||
if (technicalIndicatorsSnapshot) {
|
||||
return;
|
||||
}
|
||||
onRequestTechnicalIndicators(selectedSymbol);
|
||||
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRangeWindow || !selectedSymbol || !onRequestRangeExplain) {
|
||||
return;
|
||||
@@ -368,6 +386,21 @@ export default function StockExplainView({
|
||||
onToggle={() => setIsTradesOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<ExplainInsiderSection
|
||||
insiderTrades={insiderTradesSnapshot?.trades || []}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isOpen={isInsiderOpen}
|
||||
onToggle={() => setIsInsiderOpen((prev) => !prev)}
|
||||
onRequest={onRequestInsiderTrades}
|
||||
/>
|
||||
|
||||
<ExplainTechnicalSection
|
||||
technicalIndicators={technicalIndicatorsSnapshot}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isOpen={isTechnicalOpen}
|
||||
onToggle={() => setIsTechnicalOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<ExplainMentionsSection
|
||||
recentMentions={recentMentions}
|
||||
isOpen={isMentionsPanelOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import JSZip from 'jszip';
|
||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||
|
||||
export default function TraderView({
|
||||
@@ -34,10 +35,14 @@ export default function TraderView({
|
||||
onSkillToggle,
|
||||
onWorkspaceFileChange,
|
||||
onWorkspaceDraftChange,
|
||||
onWorkspaceFileSave
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||
const [isExternalSkillChecking, setIsExternalSkillChecking] = useState(false);
|
||||
const [externalSkillCheck, setExternalSkillCheck] = useState({ type: null, text: '' });
|
||||
const [isSkillPickerOpen, setIsSkillPickerOpen] = useState(false);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
@@ -59,6 +64,50 @@ export default function TraderView({
|
||||
const installedSkills = selectedAgentSkills.filter((item) => item.status !== 'available');
|
||||
const availableSkills = selectedAgentSkills.filter((item) => item.status === 'available');
|
||||
|
||||
const validateExternalSkillZip = async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setExternalSkillCheck({ type: 'error', text: '请选择 zip 文件' });
|
||||
return false;
|
||||
}
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
setExternalSkillCheck({ type: 'error', text: '仅支持 .zip 文件' });
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsExternalSkillChecking(true);
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const entries = Object.keys(zip.files);
|
||||
const skillFilePath = entries.find((entry) => {
|
||||
const item = zip.files[entry];
|
||||
return !item.dir && /(^|\/)SKILL\.md$/i.test(entry);
|
||||
});
|
||||
|
||||
if (!skillFilePath) {
|
||||
setExternalSkillCheck({
|
||||
type: 'error',
|
||||
text: '压缩包中未检测到 SKILL.md,请检查目录结构'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setExternalSkillCheck({
|
||||
type: 'success',
|
||||
text: `预检通过,检测到: ${skillFilePath}`
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
setExternalSkillCheck({
|
||||
type: 'error',
|
||||
text: `无法解析 zip: ${error?.message || '未知错误'}`
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsExternalSkillChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
@@ -679,6 +728,85 @@ export default function TraderView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>上传外部技能包</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setExternalSkillFile(file);
|
||||
if (!file) {
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
return;
|
||||
}
|
||||
await validateExternalSkillZip(file);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!onUploadExternalSkill || !externalSkillFile) {
|
||||
return;
|
||||
}
|
||||
const valid = await validateExternalSkillZip(externalSkillFile);
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
await onUploadExternalSkill(externalSkillFile);
|
||||
setExternalSkillFile(null);
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
}}
|
||||
disabled={!isConnected || !externalSkillFile || isExternalSkillChecking || externalSkillCheck.type === 'error'}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? '#EFF6FF' : '#E5E7EB',
|
||||
color: '#1565C0',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{isExternalSkillChecking ? '预检中...' : '上传并安装'}
|
||||
</button>
|
||||
</div>
|
||||
{externalSkillCheck.text ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: externalSkillCheck.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
>
|
||||
{externalSkillCheck.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -11,11 +11,14 @@ async function safeFetch(endpoint) {
|
||||
}
|
||||
|
||||
async function safeRequest(endpoint, options = {}) {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const response = await fetch(`${BASE_PATH}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
headers: isFormData
|
||||
? { ...(options.headers || {}) }
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -118,3 +121,38 @@ export function restartRuntime(config) {
|
||||
export function fetchCurrentRuntime() {
|
||||
return safeFetch('/runtime/current');
|
||||
}
|
||||
|
||||
export async function uploadAgentSkillZip({
|
||||
agentId,
|
||||
file,
|
||||
activate = true,
|
||||
name,
|
||||
runId
|
||||
}) {
|
||||
if (!agentId) {
|
||||
throw new Error('agentId is required');
|
||||
}
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('valid zip file is required');
|
||||
}
|
||||
const runtime = runId ? { run_id: runId } : await fetchCurrentRuntime();
|
||||
const workspaceId = runtime?.run_id;
|
||||
if (!workspaceId) {
|
||||
throw new Error('未检测到正在运行的任务');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('activate', String(Boolean(activate)));
|
||||
if (name && String(name).trim()) {
|
||||
formData.append('name', String(name).trim());
|
||||
}
|
||||
|
||||
return safeRequest(
|
||||
`/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,61 @@
|
||||
/**
|
||||
* WebSocket Client for Read-Only Connection
|
||||
* WebSocket Client with Dynamic Port Resolution
|
||||
* Handles connection, reconnection, and heartbeat
|
||||
* Fetches Gateway port from API before connecting
|
||||
*/
|
||||
|
||||
import { WS_URL } from "../config/constants";
|
||||
|
||||
// Global port cache
|
||||
let cachedGatewayPort = null;
|
||||
let cachedWsUrl = null;
|
||||
|
||||
/**
|
||||
* Fetch Gateway WebSocket port from API
|
||||
*/
|
||||
export async function fetchGatewayPort() {
|
||||
try {
|
||||
const response = await fetch('/api/runtime/gateway/port');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.is_running && data.port) {
|
||||
cachedGatewayPort = data.port;
|
||||
cachedWsUrl = data.ws_url;
|
||||
return { port: data.port, wsUrl: data.ws_url };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('[Gateway] Failed to fetch port:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached or default WebSocket URL
|
||||
*/
|
||||
export function getWebSocketUrl() {
|
||||
if (cachedWsUrl) {
|
||||
return cachedWsUrl;
|
||||
}
|
||||
return WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached port (call when Gateway restarts)
|
||||
*/
|
||||
export function clearGatewayCache() {
|
||||
cachedGatewayPort = null;
|
||||
cachedWsUrl = null;
|
||||
}
|
||||
|
||||
export class ReadOnlyClient {
|
||||
constructor(onEvent, { wsUrl = WS_URL, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
|
||||
constructor(onEvent, { wsUrl = null, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
|
||||
this.onEvent = onEvent;
|
||||
this.wsUrl = wsUrl;
|
||||
this.wsUrl = wsUrl; // null = auto-resolve from API
|
||||
this.baseReconnectDelay = reconnectDelay;
|
||||
this.reconnectDelay = reconnectDelay;
|
||||
this.maxReconnectDelay = 30000;
|
||||
@@ -19,20 +66,38 @@ export class ReadOnlyClient {
|
||||
this.heartbeatTimer = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.lastPongTime = 0;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.baseReconnectDelay;
|
||||
this._connect();
|
||||
await this._connect();
|
||||
}
|
||||
|
||||
_connect() {
|
||||
if (!this.shouldReconnect) {
|
||||
async _connect() {
|
||||
if (!this.shouldReconnect || this.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// Resolve WebSocket URL if not set
|
||||
let targetUrl = this.wsUrl;
|
||||
if (!targetUrl) {
|
||||
// Try to fetch from API first
|
||||
const gatewayInfo = await fetchGatewayPort();
|
||||
if (gatewayInfo) {
|
||||
targetUrl = gatewayInfo.wsUrl;
|
||||
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
|
||||
} else {
|
||||
// Fallback to default
|
||||
targetUrl = WS_URL;
|
||||
console.log(`[WebSocket] Using default URL: ${targetUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any existing connection
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
@@ -45,70 +110,84 @@ export class ReadOnlyClient {
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
try {
|
||||
this.ws = new WebSocket(targetUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.baseReconnectDelay;
|
||||
this.lastPongTime = Date.now();
|
||||
this._safeEmit({ type: "system", content: "已连接实时数据服务" });
|
||||
console.log("WebSocket connected");
|
||||
this._startHeartbeat();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
// Update pong time for any message (server is alive)
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.baseReconnectDelay;
|
||||
this.lastPongTime = Date.now();
|
||||
this._safeEmit({ type: "system", content: "已连接实时数据服务" });
|
||||
console.log("WebSocket connected to", targetUrl);
|
||||
this._startHeartbeat();
|
||||
this.isConnecting = false;
|
||||
};
|
||||
|
||||
if (msg.type === "pong") {
|
||||
return;
|
||||
this.ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
// Update pong time for any message (server is alive)
|
||||
this.lastPongTime = Date.now();
|
||||
|
||||
if (msg.type === "pong") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[WebSocket] Message received:", msg.type || "unknown");
|
||||
this._safeEmit(msg);
|
||||
} catch (e) {
|
||||
console.error("[WebSocket] Parse error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[WebSocket] Message received:", msg.type || "unknown");
|
||||
this._safeEmit(msg);
|
||||
} catch (e) {
|
||||
console.error("[WebSocket] Parse error:", e);
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
this.ws.onclose = (event) => {
|
||||
const code = event.code || "未知";
|
||||
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
const code = event.code || "未知";
|
||||
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
||||
this._stopHeartbeat();
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
|
||||
this._stopHeartbeat();
|
||||
this.ws = null;
|
||||
// Always attempt reconnect if shouldReconnect is true
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectAttempts++;
|
||||
// Exponential backoff with cap
|
||||
this.reconnectDelay = Math.min(
|
||||
this.baseReconnectDelay * Math.pow(1.5, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
this._safeEmit({
|
||||
type: "system",
|
||||
content: "正在尝试连接数据服务..."
|
||||
});
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WebSocket] Reconnect attempt ${this.reconnectAttempts}...`);
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] Connection error:", error);
|
||||
this.isConnecting = false;
|
||||
|
||||
// Always attempt reconnect if shouldReconnect is true
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectAttempts++;
|
||||
// Exponential backoff with cap
|
||||
this.reconnectDelay = Math.min(
|
||||
this.baseReconnectDelay * Math.pow(1.5, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
this._safeEmit({
|
||||
type: "system",
|
||||
content: "正在尝试连接数据服务..."
|
||||
});
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WebSocket] Reconnect attempt ${this.reconnectAttempts}...`);
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_safeEmit(msg) {
|
||||
@@ -187,5 +266,17 @@ export class ReadOnlyClient {
|
||||
}
|
||||
}
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect with new port (call after Gateway restart)
|
||||
*/
|
||||
async reconnectWithNewPort() {
|
||||
console.log("[WebSocket] Reconnecting with new port...");
|
||||
clearGatewayCache();
|
||||
this.disconnect();
|
||||
this.shouldReconnect = true;
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"]
|
||||
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8765',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [react(), tsconfigPaths(),tailwindcss()],
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user