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:
2026-03-19 18:52:12 +08:00
parent f4a2b7f3af
commit 4b5ac86b83
87 changed files with 5042 additions and 744 deletions

View File

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

View File

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

View File

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

View File

@@ -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 }}>&#128227;</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 }}>&#128101;</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 }}>&#128200;</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>

View File

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

View File

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

View File

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

View File

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