Initial commit of integrated agent system

This commit is contained in:
cillin
2026-03-30 17:46:44 +08:00
commit 0fa413380c
337 changed files with 75268 additions and 0 deletions

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

500
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,500 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AppShell from './components/AppShell.jsx';
import RuntimeLogsModal from './components/RuntimeLogsModal.jsx';
import { AGENTS } from './config/constants';
import { useAgentDataRequests } from './hooks/useAgentDataRequests';
import { useFeedProcessor } from './hooks/useFeedProcessor';
import { useRuntimeControls } from './hooks/useRuntimeControls';
import { useStockDataRequests } from './hooks/useStockDataRequests';
import { useWebSocketConnection } from './hooks/useWebSocketConnection';
import { fetchRuntimeLogs } from './services/runtimeApi';
import { useAgentStore } from './store/agentStore';
import { useMarketStore } from './store/marketStore';
import { usePortfolioStore } from './store/portfolioStore';
import { useRuntimeStore } from './store/runtimeStore';
import { useUIStore } from './store/uiStore';
const EDITABLE_AGENT_WORKSPACE_FILES = [
'SOUL.md',
'PROFILE.md',
'AGENTS.md',
'MEMORY.md',
'POLICY.md'
];
export default function LiveTradingApp() {
const {
isConnected,
connectionStatus,
serverMode,
marketStatus,
virtualTime,
dataSources,
currentDate,
runtimeConfig,
} = useRuntimeStore();
const {
currentView,
chartTab,
isInitialAnimating,
lastUpdate,
isUpdating,
now,
setNow,
setLastUpdate,
setIsUpdating,
leftWidth,
isResizing,
bubbles,
} = useUIStore();
const {
tickers,
rollingTickers,
priceHistoryByTicker,
ohlcHistoryByTicker,
explainEventsByTicker,
newsByTicker,
insiderTradesByTicker,
technicalIndicatorsByTicker,
selectedExplainSymbol,
historySourceByTicker,
setSelectedExplainSymbol,
} = useMarketStore();
const {
portfolioData,
holdings,
trades,
stats,
leaderboard,
} = usePortfolioStore();
const {
selectedSkillAgentId,
agentProfilesByAgent,
agentSkillsByAgent,
skillDetailsByName,
localSkillDraftsByKey,
isAgentSkillsLoading,
skillDetailLoadingKey,
agentSkillsSavingKey,
agentSkillsFeedback,
selectedWorkspaceFile,
workspaceFilesByAgent,
workspaceDraftContent,
isWorkspaceFileLoading,
workspaceFileSavingKey,
workspaceFileFeedback,
setSelectedWorkspaceFile,
setSelectedSkillAgentId,
setWorkspaceDraftContent,
} = useAgentStore();
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage, clearFeed } = useFeedProcessor();
const resetRuntimeViewState = useCallback(() => {
clearFeed();
useMarketStore.getState().setPriceHistoryByTicker({});
useMarketStore.getState().setOhlcHistoryByTicker({});
useMarketStore.getState().setHistorySourceByTicker({});
useMarketStore.getState().setExplainEventsByTicker({});
useMarketStore.getState().setNewsByTicker({});
useMarketStore.getState().setInsiderTradesByTicker({});
useMarketStore.getState().setTechnicalIndicatorsByTicker({});
usePortfolioStore.getState().setHoldings([]);
usePortfolioStore.getState().setTrades([]);
usePortfolioStore.getState().setStats(null);
usePortfolioStore.getState().setLeaderboard([]);
usePortfolioStore.getState().setPortfolioData({
netValue: 10000,
pnl: 0,
equity: [],
baseline: [],
baseline_vw: [],
momentum: [],
strategies: [],
equity_return: 0,
baseline_return: 0,
baseline_vw_return: 0,
momentum_return: 0,
});
useRuntimeStore.getState().setLastDayHistory([]);
useUIStore.getState().setBubbles({});
}, [clearFeed]);
const {
clientRef,
setRequestStockHistory,
setRequestStockNewsTimeline,
setRequestStockNewsCategories,
} = useWebSocketConnection({
processHistoricalFeed,
processFeedEvent,
addSystemMessage,
});
const runtimeControls = useRuntimeControls({
clientRef,
currentTickers: tickers,
addSystemMessage,
onRuntimeStarted: resetRuntimeViewState,
});
const stockRequests = useStockDataRequests(clientRef, {
setRequestStockHistory,
setRequestStockNewsTimeline,
setRequestStockNewsCategories,
});
const {
requestAgentSkills,
requestAgentProfile,
requestSkillDetail,
handleCreateLocalSkill,
handleLocalSkillDraftChange,
handleLocalSkillSave,
handleLocalSkillDelete,
handleRemoveSharedSkill,
handleAgentSkillToggle,
handleSkillAgentChange,
requestWorkspaceFile,
handleWorkspaceFileChange,
handleWorkspaceFileSave,
handleUploadExternalSkill,
} = useAgentDataRequests(clientRef);
const [isRuntimeLogsOpen, setIsRuntimeLogsOpen] = useState(false);
const [isRuntimeLogsLoading, setIsRuntimeLogsLoading] = useState(false);
const [runtimeLogsPayload, setRuntimeLogsPayload] = useState(null);
const [runtimeLogsError, setRuntimeLogsError] = useState(null);
const agentFeedRef = useRef(null);
const isSocketReady = isConnected && connectionStatus === 'connected';
const selectedAgentId = selectedSkillAgentId || AGENTS[0]?.id || null;
const selectedAgentProfile = selectedAgentId ? (agentProfilesByAgent[selectedAgentId] || null) : null;
const selectedAgentSkills = selectedAgentId ? (agentSkillsByAgent[selectedAgentId] || []) : [];
const selectedWorkspaceContent = selectedAgentId && selectedWorkspaceFile
? (workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] || '')
: '';
useEffect(() => {
if (!selectedSkillAgentId && AGENTS.length > 0) {
setSelectedSkillAgentId(AGENTS[0].id);
}
}, [selectedSkillAgentId, setSelectedSkillAgentId]);
useEffect(() => {
if (!selectedWorkspaceFile) {
setSelectedWorkspaceFile('MEMORY.md');
}
}, [selectedWorkspaceFile, setSelectedWorkspaceFile]);
useEffect(() => {
if (!isSocketReady || !selectedAgentId || !clientRef.current) {
return;
}
if (!agentProfilesByAgent[selectedAgentId]) {
requestAgentProfile(selectedAgentId);
}
if (!Array.isArray(agentSkillsByAgent[selectedAgentId])) {
requestAgentSkills(selectedAgentId);
}
if (
selectedWorkspaceFile
&& workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] === undefined
) {
requestWorkspaceFile(selectedAgentId, selectedWorkspaceFile);
}
}, [
agentProfilesByAgent,
agentSkillsByAgent,
clientRef,
isSocketReady,
requestAgentProfile,
requestAgentSkills,
requestWorkspaceFile,
selectedAgentId,
selectedWorkspaceFile,
workspaceFilesByAgent,
]);
useEffect(() => {
if (!isSocketReady || !clientRef.current) {
return;
}
AGENTS.forEach((agent) => {
if (!agent?.id) {
return;
}
if (!agentProfilesByAgent[agent.id]) {
requestAgentProfile(agent.id);
}
});
}, [
agentProfilesByAgent,
clientRef,
isSocketReady,
requestAgentProfile,
]);
useEffect(() => {
const symbols = runtimeControls.displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
if (!symbols.length) {
setSelectedExplainSymbol('');
return;
}
if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) {
setSelectedExplainSymbol(symbols[0]);
}
}, [runtimeControls.displayTickers, selectedExplainSymbol, setSelectedExplainSymbol]);
useEffect(() => {
if (virtualTime) {
setNow(new Date(virtualTime));
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, [setNow, virtualTime]);
useEffect(() => {
setLastUpdate(new Date());
setIsUpdating(true);
const timer = setTimeout(() => setIsUpdating(false), 500);
return () => clearTimeout(timer);
}, [holdings, stats, trades, portfolioData.netValue, setIsUpdating, setLastUpdate]);
const marketStatusLabel = useMemo(() => {
if (!marketStatus) return null;
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
const normalized = raw.toLowerCase();
const byStatus = {
open: '开盘',
closed: '休市',
premarket: '盘前',
afterhours: '盘后',
};
const byText = {
'market closed (non-trading day)': '休市',
'market open': '开盘',
'market closed': '收盘',
'pre-market': '盘前',
'after-hours': '盘后',
'after hours': '盘后',
'backtest mode': '回测模式',
};
if (normalized && byText[normalized]) return byText[normalized];
if (marketStatus.status && byStatus[marketStatus.status]) return byStatus[marketStatus.status];
return raw || '状态未知';
}, [marketStatus]);
const providerLabelMap = useMemo(() => ({
yfinance: 'YFINANCE',
finnhub: 'FINNHUB',
financial_datasets: 'FINANCIAL DATASETS',
local_csv: 'CSV',
polygon: 'POLYGON',
backtest: 'BACKTEST',
}), []);
const dataSourceLabel = useMemo(() => {
const source = dataSources?.last_success?.prices
|| marketStatus?.live_quote_provider
|| (Array.isArray(dataSources?.preferred) ? dataSources.preferred[0] : null);
if (!source) return null;
const normalized = String(source).trim().toLowerCase();
return `数据源 ${providerLabelMap[normalized] || String(source).trim()}`;
}, [dataSources, marketStatus, providerLabelMap]);
const bubbleFor = useCallback((idOrName) => {
let bubble = bubbles[idOrName];
if (bubble) return bubble;
const agent = AGENTS.find((item) => item.name === idOrName || item.id === idOrName);
if (agent) {
bubble = bubbles[agent.id];
if (bubble) return bubble;
}
return null;
}, [bubbles]);
const handleManualTrigger = useCallback(() => {
if (!isSocketReady || !clientRef.current) {
addSystemMessage('连接未就绪,无法手动触发');
return;
}
const success = clientRef.current.send({ type: 'trigger_strategy' });
if (!success) {
addSystemMessage('手动触发发送失败,请检查连接状态');
return;
}
addSystemMessage('已发送手动触发请求');
}, [addSystemMessage, clientRef, isSocketReady]);
const loadRuntimeLogs = useCallback(async () => {
setIsRuntimeLogsLoading(true);
setRuntimeLogsError(null);
try {
const payload = await fetchRuntimeLogs();
setRuntimeLogsPayload(payload);
} catch (error) {
setRuntimeLogsError(error.message || '无法读取运行日志');
} finally {
setIsRuntimeLogsLoading(false);
}
}, []);
const agentRequests = {
agents: AGENTS,
agentProfilesByAgent,
agentSkillsByAgent,
workspaceFilesByAgent,
selectedAgentId,
selectedAgentProfile,
selectedAgentSkills,
skillDetailsByName,
localSkillDraftsByKey,
skillDetailLoadingKey,
editableFiles: EDITABLE_AGENT_WORKSPACE_FILES,
selectedWorkspaceFile,
workspaceFileContent: selectedWorkspaceContent,
workspaceDraftContent,
isConnected,
isAgentSkillsLoading,
agentSkillsSavingKey,
agentSkillsFeedback,
isWorkspaceFileLoading,
workspaceFileSavingKey,
workspaceFileFeedback,
onAgentChange: handleSkillAgentChange,
onCreateLocalSkill: handleCreateLocalSkill,
onSkillDetailRequest: requestSkillDetail,
onLocalSkillDraftChange: handleLocalSkillDraftChange,
onLocalSkillDelete: handleLocalSkillDelete,
onLocalSkillSave: handleLocalSkillSave,
onRemoveSharedSkill: handleRemoveSharedSkill,
onSkillToggle: handleAgentSkillToggle,
onWorkspaceFileChange: handleWorkspaceFileChange,
onWorkspaceDraftChange: setWorkspaceDraftContent,
onWorkspaceFileSave: handleWorkspaceFileSave,
onUploadExternalSkill: handleUploadExternalSkill,
clientRef,
};
return (
<>
<AppShell
isConnected={isConnected}
virtualTime={virtualTime}
now={now}
marketStatus={marketStatus}
serverMode={serverMode}
marketStatusLabel={marketStatusLabel}
dataSourceLabel={dataSourceLabel}
runtimeSummaryLabel={runtimeControls.runtimeSummaryLabel}
isUpdating={isUpdating}
onManualTrigger={handleManualTrigger}
onOpenRuntimeLogs={() => {
setIsRuntimeLogsOpen(true);
void loadRuntimeLogs();
}}
onRuntimeSettingsToggle={runtimeControls.handleRuntimeSettingsToggle}
isRuntimeSettingsOpen={runtimeControls.isRuntimeSettingsOpen}
isRuntimeConfigSaving={runtimeControls.isRuntimeConfigSaving}
isWatchlistSaving={runtimeControls.isWatchlistSaving}
runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback}
watchlistFeedback={runtimeControls.watchlistFeedback}
launchModeDraft={runtimeControls.launchModeDraft}
restoreRunIdDraft={runtimeControls.restoreRunIdDraft}
runtimeHistoryRuns={runtimeControls.runtimeHistoryRuns}
scheduleModeDraft={runtimeControls.scheduleModeDraft}
intervalMinutesDraft={runtimeControls.intervalMinutesDraft}
triggerTimeDraft={runtimeControls.triggerTimeDraft}
maxCommCyclesDraft={runtimeControls.maxCommCyclesDraft}
initialCashDraft={runtimeControls.initialCashDraft}
marginRequirementDraft={runtimeControls.marginRequirementDraft}
enableMemoryDraft={runtimeControls.enableMemoryDraft}
modeDraft={runtimeControls.modeDraft}
pollIntervalDraft={runtimeControls.pollIntervalDraft}
startDateDraft={runtimeControls.startDateDraft}
endDateDraft={runtimeControls.endDateDraft}
watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols}
watchlistInputValue={runtimeControls.watchlistInputValue}
watchlistSuggestions={runtimeControls.watchlistSuggestions}
onLaunchModeChange={runtimeControls.setLaunchModeDraft}
onRestoreRunIdChange={runtimeControls.setRestoreRunIdDraft}
onScheduleModeChange={runtimeControls.setScheduleModeDraft}
onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft}
onTriggerTimeChange={runtimeControls.setTriggerTimeDraft}
onMaxCommCyclesChange={runtimeControls.setMaxCommCyclesDraft}
onInitialCashChange={runtimeControls.setInitialCashDraft}
onMarginRequirementChange={runtimeControls.setMarginRequirementDraft}
onEnableMemoryChange={runtimeControls.setEnableMemoryDraft}
onModeChange={runtimeControls.setModeDraft}
onPollIntervalChange={runtimeControls.setPollIntervalDraft}
onStartDateChange={runtimeControls.setStartDateDraft}
onEndDateChange={runtimeControls.setEndDateDraft}
onWatchlistInputChange={runtimeControls.handleWatchlistInputChange}
onWatchlistInputKeyDown={runtimeControls.handleWatchlistInputKeyDown}
onWatchlistAdd={runtimeControls.handleWatchlistAdd}
onWatchlistRemove={runtimeControls.handleWatchlistRemove}
onWatchlistRestoreCurrent={runtimeControls.handleWatchlistRestoreCurrent}
onWatchlistRestoreDefault={runtimeControls.handleWatchlistRestoreDefault}
onWatchlistSuggestionClick={runtimeControls.handleWatchlistSuggestionClick}
onLaunchConfigSave={runtimeControls.handleLaunchConfigSave}
onRestoreDefaults={runtimeControls.handleRuntimeDefaultsRestore}
displayTickers={runtimeControls.displayTickers}
portfolioData={portfolioData}
rollingTickers={rollingTickers}
feed={feed}
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
currentView={currentView}
chartTab={chartTab}
holdings={holdings}
trades={trades}
stats={stats}
priceHistoryByTicker={priceHistoryByTicker}
ohlcHistoryByTicker={ohlcHistoryByTicker}
selectedExplainSymbol={selectedExplainSymbol}
onSelectedExplainSymbolChange={setSelectedExplainSymbol}
historySourceByTicker={historySourceByTicker}
explainEventsByTicker={explainEventsByTicker}
newsByTicker={newsByTicker}
insiderTradesByTicker={insiderTradesByTicker}
technicalIndicatorsByTicker={technicalIndicatorsByTicker}
currentDate={currentDate}
stockRequests={stockRequests}
agentRequests={agentRequests}
agentProfilesByAgent={agentProfilesByAgent}
leftWidth={leftWidth}
isResizing={isResizing}
onMouseDown={() => useUIStore.getState().setIsResizing(true)}
agentFeedRef={agentFeedRef}
/>
<RuntimeLogsModal
isOpen={isRuntimeLogsOpen}
isLoading={isRuntimeLogsLoading}
logPayload={runtimeLogsPayload}
error={runtimeLogsError}
onClose={() => setIsRuntimeLogsOpen(false)}
onRefresh={loadRuntimeLogs}
/>
</>
);
}

View File

@@ -0,0 +1,594 @@
import React from 'react';
import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Get rank medal/trophy
*/
function getRankMedal(rank) {
if (rank === 1) return { emoji: '🏆', color: '#FFD700', label: '金牌' };
if (rank === 2) return { emoji: '🥈', color: '#C0C0C0', label: '银牌' };
if (rank === 3) return { emoji: '🥉', color: '#CD7F32', label: '铜牌' };
return { emoji: `#${rank}`, color: '#333333', label: `#${rank}` };
}
/**
* Agent Performance Card Component
* Horizontal dropdown panel displayed below the agent indicator bar
*/
export default function AgentCard({ agent, onClose, isClosing }) {
if (!agent) return null;
const bullTotal = agent.bull?.n || 0;
const bullWins = agent.bull?.win || 0;
const bullUnknown = agent.bull?.unknown || 0;
const bearTotal = agent.bear?.n || 0;
const bearWins = agent.bear?.win || 0;
const bearUnknown = agent.bear?.unknown || 0;
const totalSignals = bullTotal + bearTotal;
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
const evaluatedTotal = evaluatedBull + evaluatedBear;
const bullWinRate = evaluatedBull > 0 ? (bullWins / evaluatedBull) : null;
const bearWinRate = evaluatedBear > 0 ? (bearWins / evaluatedBear) : null;
const overallWinRate = agent.winRate != null
? agent.winRate
: (evaluatedTotal > 0 ? ((bullWins + bearWins) / evaluatedTotal) : null);
const overallColor = overallWinRate != null
? (overallWinRate >= 0.5 ? '#00C853' : '#FF1744')
: '#555555';
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
const modelInfo = getModelIcon(agent.modelName, agent.modelProvider);
const shortModelName = getShortModelName(agent.modelName);
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
background: '#ffffff',
borderBottom: '2px solid #000000',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
zIndex: 800,
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
}}>
{/* Horizontal scrollable content */}
<div style={{
overflowX: 'auto',
overflowY: 'hidden',
padding: '12px',
/* Hide scrollbar for all browsers */
scrollbarWidth: 'none', /* Firefox */
msOverflowStyle: 'none', /* IE and Edge */
}}>
<style>
{`
div::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
`}
</style>
<div style={{
display: 'flex',
gap: '12px',
minWidth: 'max-content'
}}>
{/* Agent Info with Rank */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
background: '#fafafa',
border: '2px solid #000000',
minWidth: 200
}}>
{isPortfolioManager ? (
<img
src={ASSETS.teamLogo}
alt="Team"
style={{
height: 50,
width: 50,
objectFit: 'contain'
}}
/>
) : agent.avatar ? (
<img
src={agent.avatar}
alt={agent.name}
style={{
height: 50,
width: 50,
objectFit: 'contain'
}}
/>
) : null}
<div>
<div style={{
fontSize: 16,
fontWeight: 700,
color: '#000000',
marginBottom: 2
}}>
{displayName}
</div>
{rankMedal && !isPortfolioManager && (
<div style={{ fontSize: 18 }}>
{rankMedal.emoji} {agent.rank}
</div>
)}
</div>
</div>
{/* Risk Manager Note */}
{isRiskManager && (
<div style={{
padding: '8px 12px',
background: '#FFF9E6',
border: '2px solid #FFA726',
minWidth: 220,
maxWidth: 280,
display: 'flex',
alignItems: 'center'
}}>
<div style={{
fontSize: 12,
color: '#E65100',
fontStyle: 'italic',
lineHeight: 1.5,
whiteSpace: 'normal',
wordWrap: 'break-word'
}}>
风控经理专注于风险管理不参与预测准确率排名
</div>
</div>
)}
{/* Portfolio Manager Note */}
{isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#E8F5E9',
border: '2px solid #66BB6A',
minWidth: 220,
maxWidth: 280,
display: 'flex',
alignItems: 'center'
}}>
<div style={{
fontSize: 12,
color: '#2E7D32',
fontStyle: 'italic',
lineHeight: 1.5,
whiteSpace: 'normal',
wordWrap: 'break-word'
}}>
投资经理综合所有分析师建议提供团队最终交易信号不参与排名
</div>
</div>
)}
{/* Model Info Card */}
{agent.modelName && (
<div style={{
padding: '8px 12px',
background: '#ffffff',
border: `2px solid ${modelInfo.color}`,
minWidth: 140,
position: 'relative',
cursor: 'help'
}}
title={`模型:${agent.modelName}\n提供方${modelInfo.provider}`}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: modelInfo.color,
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
模型
</div>
<div style={{
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4
}}>
{agent.modelName || modelInfo.logoPath ? (
<LobeModelLogo
model={agent.modelName}
provider={agent.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={36}
type="color"
shape="square"
style={{
maxHeight: '100%',
maxWidth: '100%',
}}
/>
) : (
<div style={{
fontSize: 28,
lineHeight: 1
}}>
🤖
</div>
)}
</div>
<div style={{
fontSize: 11,
fontWeight: 600,
color: modelInfo.color,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{shortModelName}
</div>
<div style={{
fontSize: 8,
color: '#666666',
marginTop: 2
}}>
{modelInfo.provider}
</div>
</div>
)}
{/* Overall Win Rate */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 14px',
background: '#fafafa',
border: '2px solid #e0e0e0',
textAlign: 'center',
minWidth: 160
}}>
<div style={{
fontSize: 10,
color: '#333333',
fontWeight: 700,
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
胜率
</div>
<div style={{
fontSize: 36,
fontWeight: 700,
color: overallColor,
fontFamily: '"Courier New", monospace',
lineHeight: 1,
marginBottom: 2
}}>
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#555555'
}}>
{bullWins + bearWins} / {evaluatedTotal}
</div>
<div style={{
fontSize: 8,
color: '#888888',
marginTop: 4,
fontStyle: 'italic',
lineHeight: 1.2,
whiteSpace: 'pre-line'
}}>
评估: 总评估多空信号数{'\n'}胜率 = 正确信号 / 总评估信号
</div>
</div>
)}
{/* Bull Stats */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#F0FFF4',
border: '2px solid #00C853',
minWidth: 140
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#00C853',
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
牛市胜率
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
marginBottom: 2,
lineHeight: 1
}}>
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#333333'
}}>
{bullWins} / {evaluatedBull}
{bullUnknown > 0 && ` / ${bullUnknown}P`}
</div>
</div>
)}
{/* Bear Stats */}
{!isRiskManager && !isPortfolioManager && (
<div style={{
padding: '8px 12px',
background: '#FFF5F5',
border: '2px solid #FF1744',
minWidth: 140
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#FF1744',
letterSpacing: 1,
marginBottom: 4,
textTransform: 'uppercase'
}}>
熊市胜率
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
marginBottom: 2,
lineHeight: 1
}}>
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
</div>
<div style={{
fontSize: 9,
color: '#333333'
}}>
{bearWins} / {evaluatedBear}
{bearUnknown > 0 && ` / ${bearUnknown}P`}
</div>
</div>
)}
{/* Recent Signals - Horizontal scroll */}
{agent.signals && agent.signals.length > 0 && (
<div style={{
display: 'flex',
gap: 6,
padding: '8px 12px',
background: '#fafafa',
border: '2px solid #e0e0e0'
}}>
{[...agent.signals]
.filter(signal => signal && signal.signal)
.sort((a, b) => {
// Sort by date descending (newest first)
const dateA = a.date || '';
const dateB = b.date || '';
return dateB.localeCompare(dateA);
})
.slice(0, 35)
.map((signal, idx) => {
const signalType = signal.signal.toLowerCase();
const isBull = signalType.includes('bull') || signalType === 'long';
const isBear = signalType.includes('bear') || signalType === 'short';
const isNeutral = (!isBull && !isBear) || signalType.includes('neutral') || signalType === 'hold';
const isCorrect = signal.is_correct === true;
const isUnknown = signal.is_correct === 'unknown' || signal.is_correct === null;
// Determine result symbol/text and color: unknown has priority over neutral
let resultDisplay;
let resultColor = '#555555';
let resultFontSize = 18;
if (isNeutral) {
resultDisplay = '-';
resultColor = '#555555'; // Gray for neutral
} else if (isUnknown) {
resultDisplay = '?';
resultColor = '#FFA726'; // Orange for unknown
resultFontSize = 14; // Smaller font for text
} else {
resultDisplay = isCorrect ? '✓' : '✗';
resultColor = isCorrect ? '#00C853' : '#FF1744'; // Green for correct, Red for wrong
}
return (
<div key={idx} style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#ffffff',
border: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
minWidth: 70
}}>
<div style={{
fontWeight: 700,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
}}>
{signal.ticker}
</div>
<div style={{
fontSize: 16,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
}}>
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
</div>
<div style={{
fontSize: 8,
color: '#555555'
}}>
{signal.date?.substring(5, 10) || '暂无'}
</div>
<div style={{
fontSize: resultFontSize,
fontWeight: 700,
color: resultColor
}}>
{resultDisplay}
</div>
</div>
);
})}
{/* Info card explaining signal display */}
<div style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#E3F2FD',
border: '1px solid #90CAF9',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
minWidth: 70,
textAlign: 'center'
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#1976D2'
}}>
说明
</div>
<div style={{
fontSize: 8,
color: '#1976D2',
lineHeight: 1.2
}}>
仅显示最近5个交易日(1)的信号
</div>
</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>
<style>
{`
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
`}
</style>
</div>
);
}

View File

@@ -0,0 +1,667 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from 'react';
import { formatTime } from '../utils/formatters';
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
import { getModelIcon } from '../utils/modelIcons';
import MarkdownModal from './MarkdownModal';
import LobeModelLogo from './LobeModelLogo.jsx';
const isAnalyst = (agentId, agentName) => {
if (agentId && agentId.includes('analyst')) return true;
if (agentName && agentName.toLowerCase().includes('analyst')) return true;
return false;
};
const isManager = (agentId, agentName) => {
if (agentId && agentId.includes('manager')) return true;
if (agentName && agentName.toLowerCase().includes('manager')) return true;
return false;
};
const stripMarkdown = (text) => {
return text
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/_(.+?)_/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.replace(/!\[.*?\]\(.+?\)/g, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/^\s*>\s+/gm, '')
.replace(/\|/g, ' ')
.replace(/^[-=]+$/gm, '');
};
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
const feedContentRef = useRef(null);
const [highlightedId, setHighlightedId] = useState(null);
const [selectedAgent, setSelectedAgent] = useState('all');
const [dropdownOpen, setDropdownOpen] = useState(false);
const getAgentModelInfo = (agentId) => {
if (!agentId) return { modelName: null, modelProvider: null };
const profile = agentProfilesByAgent?.[agentId];
if (profile?.model_name) {
return {
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return { modelName: null, modelProvider: null };
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
return {
modelName: agentData?.modelName,
modelProvider: agentData?.modelProvider
};
};
// Get agent info by name
const getAgentInfoByName = (agentName) => {
if (!agentName) return null;
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
if (agentConfig && profile?.model_name) {
return {
agentId: agentConfig.id,
modelName: profile.model_name,
modelProvider: profile.model_provider
};
}
if (!leaderboard) return null;
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
if (!agentData) return null;
return {
agentId: agentData.id || agentData.agentId,
modelName: agentData.modelName,
modelProvider: agentData.modelProvider
};
};
// Get unique agent names from feed (only registered agents in AGENTS)
const getUniqueAgents = () => {
const agentNamesInFeed = new Set();
// Collect all agent names that appear in the feed
feed.forEach(item => {
if (item.type === 'message' && item.data?.agent) {
agentNamesInFeed.add(item.data.agent);
} else if (item.type === 'conference' && item.data?.messages) {
item.data.messages.forEach(msg => {
if (msg.agent) {
agentNamesInFeed.add(msg.agent);
}
});
}
});
// Filter to only include registered agents and sort by AGENTS array order
const registeredAgentNames = AGENTS.map(a => a.name);
return registeredAgentNames.filter(name => agentNamesInFeed.has(name));
};
// Filter feed based on selected agent
const filteredFeed = selectedAgent === 'all'
? feed
: feed.filter(item => {
if (item.type === 'message') {
return item.data?.agent === selectedAgent;
} else if (item.type === 'conference') {
return item.data?.messages?.some(msg => msg.agent === selectedAgent);
}
return false;
});
useImperativeHandle(ref, () => ({
scrollToMessage: (bubble) => {
if (!bubble || !feedContentRef.current) return;
// Direct feedItemId match (used by replay mode)
if (bubble.feedItemId) {
const element = document.getElementById(`feed-item-${bubble.feedItemId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(bubble.feedItemId);
setTimeout(() => setHighlightedId(null), 2000);
return;
}
}
const bubbleTimestamp = bubble.ts || bubble.timestamp;
// Check if a message matches the bubble
const isMatch = (msg, checkTime = true) => {
const agentMatch = msg.agentId === bubble.agentId || msg.agent === bubble.agentName;
if (!agentMatch || !checkTime) return agentMatch;
return Math.abs(msg.timestamp - bubbleTimestamp) < 5000;
};
// Check if a feed item contains the target message
const itemContains = (item, checkTime = true) => {
if (item.type === 'message' && item.data) return isMatch(item.data, checkTime);
if (item.type === 'conference' && item.data?.messages) {
return item.data.messages.some(msg => isMatch(msg, checkTime));
}
return false;
};
// Find exact match first, then fallback to agent match
const targetItem = feed.find(item => itemContains(item, true))
|| feed.find(item => itemContains(item, false));
if (targetItem) {
const element = document.getElementById(`feed-item-${targetItem.id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(targetItem.id);
setTimeout(() => setHighlightedId(null), 2000);
}
}
}
}), [feed]);
const uniqueAgents = getUniqueAgents();
// Get current selection display info
const getCurrentSelectionInfo = () => {
if (selectedAgent === 'all') {
return { label: '全部角色', modelInfo: null, agentInfo: null };
}
const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
return { label: selectedAgent, modelInfo, agentInfo };
};
const currentSelection = getCurrentSelectionInfo();
return (
<div className="agent-feed">
<div className="agent-feed-header">
<h3 className="agent-feed-title">活动 feed</h3>
<div className="agent-filter-wrapper">
<label className="agent-filter-label">筛选:</label>
<div className="custom-select-wrapper">
<button
className="custom-select-trigger"
onClick={() => setDropdownOpen(!dropdownOpen)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
>
<div className="custom-select-value">
{(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
<LobeModelLogo
model={currentSelection.agentInfo?.modelName}
provider={currentSelection.agentInfo?.modelProvider}
fallbackSrc={currentSelection.modelInfo?.logoPath}
alt={currentSelection.modelInfo?.provider}
size={18}
className="select-model-icon"
shape="square"
type="color"
/>
)}
<span>{currentSelection.label}</span>
</div>
<span className="custom-select-arrow"></span>
</button>
{dropdownOpen && (
<div className="custom-select-dropdown">
<div
className={`custom-select-option ${selectedAgent === 'all' ? 'selected' : ''}`}
onClick={() => {
setSelectedAgent('all');
setDropdownOpen(false);
}}
>
<span>全部角色</span>
</div>
{uniqueAgents.map(agent => {
const agentInfo = getAgentInfoByName(agent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
return (
<div
key={agent}
className={`custom-select-option ${selectedAgent === agent ? 'selected' : ''}`}
onClick={() => {
setSelectedAgent(agent);
setDropdownOpen(false);
}}
>
{(agentInfo?.modelName || modelInfo?.logoPath) && (
<LobeModelLogo
model={agentInfo?.modelName}
provider={agentInfo?.modelProvider}
fallbackSrc={modelInfo?.logoPath}
alt={modelInfo?.provider}
size={18}
className="select-model-icon"
shape="square"
type="color"
/>
)}
<span>{agent}</span>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
<div className="feed-content" ref={feedContentRef}>
{filteredFeed.length === 0 && (
<div className="empty-state">
{selectedAgent === 'all'
? '等待系统更新...'
: `${selectedAgent} 没有消息`}
</div>
)}
{filteredFeed.map(item => {
const isHighlighted = item.id === highlightedId;
if (item.type === 'conference') {
return <ConferenceItem key={item.id} conference={item.data} itemId={item.id} isHighlighted={isHighlighted} getAgentModelInfo={getAgentModelInfo} />;
} else if (item.type === 'memory') {
return <MemoryItem key={item.id} memory={item.data} itemId={item.id} isHighlighted={isHighlighted} />;
} else if (item.data?.agent === 'System') {
return <SystemDivider key={item.id} message={item.data} itemId={item.id} />;
} else {
return <MessageItem key={item.id} message={item.data} itemId={item.id} isHighlighted={isHighlighted} getAgentModelInfo={getAgentModelInfo} />;
}
})}
</div>
</div>
);
});
AgentFeed.displayName = 'AgentFeed';
export default AgentFeed;
function SystemDivider({ message, itemId }) {
const content = String(message.content || '');
return (
<div
id={`feed-item-${itemId}`}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
gap: '12px',
}}
>
<div style={{ flex: 1, height: '1px', backgroundColor: '#d0d0d0' }} />
<span style={{
fontSize: '11px',
color: '#888',
whiteSpace: 'normal',
fontWeight: 500,
letterSpacing: '0.3px',
}}>
{content}
</span>
<div style={{ flex: 1, height: '1px', backgroundColor: '#d0d0d0' }} />
</div>
);
}
function ConferenceItem({ conference, itemId, isHighlighted, getAgentModelInfo }) {
const colors = MESSAGE_COLORS.conference;
return (
<div
id={`feed-item-${itemId}`}
className="feed-item"
style={{
backgroundColor: colors.bg,
outline: isHighlighted ? '2px solid #615CED' : 'none',
transition: 'outline 0.3s ease'
}}
>
<div className="feed-item-header">
<span className="feed-item-title" style={{ color: colors.text }}>
会议
</span>
{conference.isLive && <span className="feed-live-badge"> 实时</span>}
<span className="feed-item-time">{formatTime(conference.startTime)}</span>
</div>
<div className="feed-item-subtitle" style={{ color: colors.text }}>
{conference.title}
</div>
<div className="conference-messages">
{conference.messages.map((msg, idx) => (
<ConferenceMessage key={idx} message={msg} getAgentModelInfo={getAgentModelInfo} />
))}
</div>
</div>
);
}
function ConferenceMessage({ message, getAgentModelInfo }) {
const [expanded, setExpanded] = useState(false);
const agentColors = message.agent === 'System' ? MESSAGE_COLORS.system :
message.agent === 'Memory' ? MESSAGE_COLORS.memory :
getAgentColors(message.agentId, message.agent);
const agentModelData = message.agentId && getAgentModelInfo ?
getAgentModelInfo(message.agentId) :
{ modelName: null, modelProvider: null };
const modelInfo = getModelIcon(agentModelData.modelName, agentModelData.modelProvider);
let content = message.content || '';
if (typeof content === 'object') {
content = JSON.stringify(content, null, 2);
} else {
content = String(content);
}
const needsTruncation = content.length > 200;
const MAX_EXPANDED_LENGTH = 10000;
let displayContent = content;
if (!expanded && needsTruncation) {
displayContent = content.substring(0, 200) + '...';
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
}
return (
<div className="conf-message-item">
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{(agentModelData.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={20}
shape="circle"
type="color"
style={{ borderRadius: '50%' }}
/>
)}
{message.agent}
</div>
<div className="conf-message-content-wrapper">
<span className="conf-message-content">{stripMarkdown(displayContent)}</span>
{needsTruncation && (
<button
className="conf-expand-btn"
onClick={() => setExpanded(!expanded)}
>
{expanded ? '« 收起' : '更多 »'}
</button>
)}
</div>
</div>
);
}
function MemoryItem({ memory, itemId, isHighlighted }) {
const [expanded, setExpanded] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const colors = MESSAGE_COLORS.memory;
let content = memory.content || '';
if (typeof content === 'object') {
content = JSON.stringify(content, null, 2);
} else {
content = String(content);
}
const needsTruncation = content.length > 200;
const MAX_EXPANDED_LENGTH = 10000;
let displayContent = content;
if (!expanded && needsTruncation) {
displayContent = content.substring(0, 200) + '...';
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
}
const agentLabel = memory.agent && memory.agent !== 'Memory'
? `记忆 · ${memory.agent}`
: '记忆';
return (
<div
id={`feed-item-${itemId}`}
className="feed-item"
style={{
background: 'linear-gradient(180deg, #F0F9FF 0%, #F6F4FF 100%)',
border: '1px solid rgba(0, 194, 255, 0.15)',
outline: isHighlighted ? '2px solid #615CED' : 'none',
transition: 'outline 0.3s ease',
position: 'relative'
}}
>
<div className="feed-item-header">
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px' }}>
<div
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<span
style={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
>
<img
src={ASSETS.remeLogo}
alt="Memory"
style={{
cursor: 'default',
height: '12px',
width: 'auto',
objectFit: 'contain',
userSelect: 'none',
transition: 'all 0.2s ease',
opacity: showTooltip ? 1 : 0.9,
filter: showTooltip ? 'brightness(1.1)' : 'none'
}}
/>
<span style={{
fontSize: '11px',
marginLeft: '4px',
opacity: showTooltip ? 0.6 : 0,
transform: showTooltip ? 'translate(0, 0)' : 'translate(-4px, 2px)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: colors.text,
lineHeight: 1,
pointerEvents: 'none'
}}>
MEMORY
</span>
</span>
</div>
<span style={{
background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
color: 'transparent',
fontWeight: 700
}}>
{agentLabel}
</span>
</span>
<span className="feed-item-time">{formatTime(memory.timestamp)}</span>
</div>
<div style={{
position: 'absolute',
top: '34px',
left: '12px',
right: '12px',
background: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(4px)',
color: '#334155',
padding: '10px 14px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: '1.5',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0, 194, 255, 0.1)',
opacity: showTooltip ? 1 : 0,
visibility: showTooltip ? 'visible' : 'hidden',
transition: 'all 0.2s ease',
pointerEvents: 'none',
border: '1px solid rgba(0, 194, 255, 0.15)'
}}>
<div style={{
fontWeight: '700',
marginBottom: '3px',
background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
color: 'transparent',
display: 'inline-block'
}}>
Runtime Memory Layer
</div>
<div style={{ color: '#475569', opacity: 0.9 }}>
Retrieves relevant historical context and produces guidance for the current task based on the latest conversation state.
</div>
</div>
<div className="feed-item-content">{stripMarkdown(displayContent)}</div>
{needsTruncation && (
<button
className="feed-expand-btn"
onClick={() => setExpanded(!expanded)}
>
{expanded ? '« 收起' : '更多 »'}
</button>
)}
</div>
);
}
function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
const [expanded, setExpanded] = useState(false);
const [showModal, setShowModal] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const colors = message.agent === 'Memory' ? MESSAGE_COLORS.memory :
getAgentColors(message.agentId, message.agent);
const title = message.agent === 'Memory' ? '记忆' : message.agent || '智能体';
const agentModelData = message.agentId && getAgentModelInfo ?
getAgentModelInfo(message.agentId) :
{ modelName: null, modelProvider: null };
const modelInfo = getModelIcon(agentModelData.modelName, agentModelData.modelProvider);
const isAnalystAgent = isAnalyst(message.agentId, message.agent);
const isManagerAgent = isManager(message.agentId, message.agent);
const useModalView = isAnalystAgent || isManagerAgent;
let content = message.content || '';
if (typeof content === 'object') {
content = JSON.stringify(content, null, 2);
} else {
content = String(content);
}
let displayContent = content;
let showExpandButton = false;
if (useModalView) {
displayContent = content.length > 150 ? content.substring(0, 150) + '...' : content;
} else {
const needsTruncation = content.length > 200;
const MAX_EXPANDED_LENGTH = 8000;
if (!expanded && needsTruncation) {
displayContent = content.substring(0, 200) + '...';
showExpandButton = true;
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
showExpandButton = needsTruncation;
} else {
showExpandButton = needsTruncation;
}
}
return (
<>
<div
id={`feed-item-${itemId}`}
className="feed-item"
style={{
backgroundColor: colors.bg,
outline: isHighlighted ? '2px solid #615CED' : 'none',
transition: 'outline 0.3s ease'
}}
>
<div className="feed-item-header">
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
{message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentModelData.modelName}
provider={agentModelData.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={20}
shape="circle"
type="color"
style={{ borderRadius: '50%' }}
/>
)}
{title}
</span>
<span className="feed-item-time">{formatTime(message.timestamp)}</span>
</div>
<div className="feed-item-content">{stripMarkdown(displayContent)}</div>
{useModalView && (
<button
onClick={() => setShowModal(true)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{
marginTop: '8px',
fontSize: '12px',
color: isHovering ? '#000' : '#666',
fontWeight: '700',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 0',
textAlign: 'left',
width: '100%',
outline: 'none'
}}
>
📄 {isManagerAgent ? '查看决策日志 »' : '查看完整报告 »'}
</button>
)}
{showExpandButton && (
<button
className="feed-expand-btn"
onClick={() => setExpanded(!expanded)}
>
{expanded ? '« 收起' : '更多 »'}
</button>
)}
</div>
{useModalView && (
<MarkdownModal
isOpen={showModal}
onClose={() => setShowModal(false)}
content={content}
agentName={message.agent}
reportType={isManagerAgent ? 'decision' : 'analysis'}
/>
)}
</>
);
}

View File

@@ -0,0 +1,511 @@
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 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,
launchModeDraft,
restoreRunIdDraft,
runtimeHistoryRuns,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
watchlistDraftSymbols,
watchlistInputValue,
watchlistSuggestions,
onLaunchModeChange,
onRestoreRunIdChange,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
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,
agentProfilesByAgent,
// Layout
leftWidth,
isResizing,
onMouseDown,
agentFeedRef
}) {
const containerRef = useRef(null);
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
useEffect(() => {
if (currentView === 'openclaw') {
setCurrentView('statistics');
}
}, [currentView, setCurrentView]);
// 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 === 'chart' ? 'show-chart' :
'show-statistics'}`;
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 }}>
{/* 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}
launchMode={launchModeDraft}
restoreRunId={restoreRunIdDraft}
runtimeHistoryRuns={runtimeHistoryRuns}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
mode={modeDraft}
pollInterval={pollIntervalDraft}
startDate={startDateDraft}
endDate={endDateDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={onRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onLaunchModeChange={onLaunchModeChange}
onRestoreRunIdChange={onRestoreRunIdChange}
onScheduleModeChange={onScheduleModeChange}
onIntervalMinutesChange={onIntervalMinutesChange}
onTriggerTimeChange={onTriggerTimeChange}
onMaxCommCyclesChange={onMaxCommCyclesChange}
onInitialCashChange={onInitialCashChange}
onMarginRequirementChange={onMarginRequirementChange}
onEnableMemoryChange={onEnableMemoryChange}
onModeChange={onModeChange}
onPollIntervalChange={onPollIntervalChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
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">
<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}
agentProfilesByAgent={agentProfilesByAgent}
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}
onRequestHistory={stockRequests?.requestStockHistory}
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
onRequestNews={stockRequests?.requestStockNews}
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}
portfolioData={portfolioData}
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} agentProfilesByAgent={agentProfilesByAgent} />
</Suspense>
</div>
</div>
</>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
/**
* Header Component
* Reusable header brand for 大时代.
*/
export default function Header() {
return (
<div className="header-title" style={{ flex: '0 1 auto', minWidth: 0 }}>
<span
className="header-link"
style={{
padding: '4px 8px',
borderRadius: '3px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px'
}}
>
<img
src="/trading_logo.png"
alt="大时代 Logo"
style={{ height: '24px', width: 'auto' }}
/>
大时代
</span>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import React from 'react';
import ModelIcon from '@lobehub/icons/es/features/ModelIcon';
import ProviderIcon from '@lobehub/icons/es/features/ProviderIcon';
export default function LobeModelLogo({
model,
provider,
fallbackSrc = null,
alt = '',
size = 28,
shape = 'square',
type = 'color',
style = {},
className = '',
}) {
const hasModel = typeof model === 'string' && model.trim().length > 0;
const hasProvider = typeof provider === 'string' && provider.trim().length > 0;
try {
if (hasModel) {
return (
<ModelIcon
model={model}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
if (hasProvider) {
return (
<ProviderIcon
provider={provider.toLowerCase()}
size={size}
shape={shape}
type={type}
className={className}
style={style}
/>
);
}
} catch {
// Fall through to local fallback asset.
}
if (fallbackSrc) {
return (
<img
src={fallbackSrc}
alt={alt}
className={className}
style={{
width: size,
height: size,
objectFit: 'contain',
...style,
}}
/>
);
}
return (
<div
className={className}
style={{
width: size,
height: size,
borderRadius: shape === 'circle' ? '50%' : 8,
background: '#F3F4F6',
border: '1px solid #D1D5DB',
...style,
}}
/>
);
}

View File

@@ -0,0 +1,276 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function MarkdownModal({ isOpen, onClose, content, agentName, reportType = 'analysis' }) {
if (!isOpen) return null;
const subtitle = reportType === 'decision' ? 'Decision Log' : 'Financial Analysis Report';
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)',
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#ffffff',
borderRadius: '2px',
padding: '0',
maxWidth: '900px',
maxHeight: '85vh',
overflow: 'hidden',
width: '90%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
border: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '24px 32px',
borderBottom: '2px solid #000',
backgroundColor: '#fafafa',
}}>
<div>
<h2 style={{
margin: 0,
fontSize: '18px',
fontWeight: 700,
letterSpacing: '0.5px',
textTransform: 'uppercase',
color: '#000',
}}>
{agentName}
</h2>
<p style={{
margin: '4px 0 0 0',
fontSize: '12px',
color: '#666',
fontWeight: 500,
letterSpacing: '0.3px',
}}>
{subtitle}
</p>
</div>
<button
onClick={onClose}
style={{
background: '#000',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: '#fff',
width: '32px',
height: '32px',
borderRadius: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
outline: 'none',
}}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#333'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#000'}
>
×
</button>
</div>
{/* Content */}
<div style={{
padding: '32px 32px 24px 32px',
overflow: 'auto',
backgroundColor: '#fff',
flex: 1,
}}>
<style>{`
.markdown-content {
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.markdown-content h1 {
font-size: 24px;
font-weight: 700;
margin: 32px 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid #000;
color: #000;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.markdown-content h1:first-child {
margin-top: 0;
}
.markdown-content h2 {
font-size: 20px;
font-weight: 700;
margin: 28px 0 12px 0;
color: #000;
letter-spacing: 0.3px;
text-transform: uppercase;
padding-bottom: 8px;
border-bottom: 1px solid #d0d0d0;
}
.markdown-content h3 {
font-size: 16px;
font-weight: 700;
margin: 24px 0 10px 0;
color: #1a1a1a;
letter-spacing: 0.2px;
}
.markdown-content h4 {
font-size: 14px;
font-weight: 700;
margin: 20px 0 8px 0;
color: #2a2a2a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.markdown-content p {
margin: 12px 0;
line-height: 1.8;
font-size: 14px;
color: #2a2a2a;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 24px 0;
font-size: 13px;
border: 1px solid #000;
background: #fff;
}
.markdown-content th {
background-color: #000;
color: #fff;
padding: 12px 16px;
text-align: left;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
font-size: 12px;
border: 1px solid #000;
}
.markdown-content td {
border: 1px solid #d0d0d0;
padding: 12px 16px;
text-align: left;
color: #1a1a1a;
}
.markdown-content tr:nth-child(even) {
background-color: #fafafa;
}
.markdown-content tr:hover {
background-color: #f0f0f0;
}
.markdown-content ul,
.markdown-content ol {
margin: 16px 0;
padding-left: 28px;
line-height: 1.8;
}
.markdown-content li {
margin: 8px 0;
color: #2a2a2a;
font-size: 14px;
}
.markdown-content li::marker {
color: #000;
font-weight: 700;
}
.markdown-content strong {
font-weight: 700;
color: #000;
}
.markdown-content em {
font-style: italic;
color: #3a3a3a;
}
.markdown-content code {
background-color: #f5f5f5;
padding: 3px 8px;
border-radius: 2px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: #000;
border: 1px solid #e0e0e0;
}
.markdown-content pre {
background-color: #fafafa;
padding: 16px;
border-radius: 2px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid #d0d0d0;
border-left: 3px solid #000;
}
.markdown-content pre code {
background: none;
padding: 0;
border: none;
font-size: 13px;
}
.markdown-content blockquote {
border-left: 4px solid #000;
margin: 20px 0;
padding: 12px 20px;
background-color: #fafafa;
color: #2a2a2a;
font-style: italic;
}
.markdown-content hr {
border: none;
border-top: 1px solid #d0d0d0;
margin: 32px 0;
}
`}</style>
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
</div>
</div>
);
}
export default MarkdownModal;

View File

@@ -0,0 +1,830 @@
import React, { useMemo, useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { formatNumber, formatFullNumber } from '../utils/formatters';
/**
* Helper function to get the start time of the most recent trading session
* Trading session: 22:30 - next day 05:00
* @param {Date|null} virtualTime - Virtual time from server, or null to use real time
*/
function getRecentTradingSessionStart(virtualTime = null) {
// Use virtual time if provided, otherwise use real time
let now;
if (virtualTime) {
// Ensure virtualTime is a valid Date object
if (virtualTime instanceof Date && !isNaN(virtualTime.getTime())) {
now = virtualTime;
} else if (typeof virtualTime === 'string') {
now = new Date(virtualTime);
if (isNaN(now.getTime())) {
console.warn('Invalid virtualTime string, using current time:', virtualTime);
now = new Date();
}
} else {
console.warn('Invalid virtualTime type, using current time:', typeof virtualTime);
now = new Date();
}
} else {
now = new Date();
}
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
// Check if currently in trading session
const isInTradingSession = (currentHour === 22 && currentMinute >= 30) ||
currentHour >= 23 ||
(currentHour >= 0 && currentHour < 5) ||
(currentHour === 5 && currentMinute === 0);
let sessionStartTime;
if (isInTradingSession) {
// Currently in trading session, find today's 22:30
sessionStartTime = new Date(now);
sessionStartTime.setHours(22, 30, 0, 0);
// If current time is before 22:30, it means yesterday's 22:30
if (now < sessionStartTime) {
sessionStartTime.setDate(sessionStartTime.getDate() - 1);
}
} else {
// Not in trading session, find previous session start (yesterday 22:30)
sessionStartTime = new Date(now);
sessionStartTime.setDate(sessionStartTime.getDate() - 1);
sessionStartTime.setHours(22, 30, 0, 0);
}
return sessionStartTime;
}
/**
* Helper function to filter strategy data for live view
* NOTE: Live mode returns are now pre-processed by the backend, restricted to the
* latest trading session and already starting at 0% at session start. This helper
* is kept for potential future use but is no longer used in live mode.
*/
function filterStrategyDataForLive(strategyData, equity, sessionStartTime) {
if (!strategyData || strategyData.length === 0 || !equity || equity.length === 0) return [];
try {
if (!sessionStartTime || isNaN(sessionStartTime.getTime())) {
console.warn('Invalid sessionStartTime in filterStrategyDataForLive');
return [];
}
const sessionStartTimestamp = sessionStartTime.getTime();
// Find the last index before session
let lastDataBeforeSession = null;
for (let i = equity.length - 1; i >= 0; i--) {
if (equity[i] && typeof equity[i].t === 'number' && equity[i].t < sessionStartTimestamp) {
if (strategyData[i] && strategyData[i].v !== undefined && strategyData[i].v !== null) {
lastDataBeforeSession = strategyData[i];
}
break;
}
}
// Find data points in the session
const sessionData = [];
for (let i = 0; i < equity.length; i++) {
if (equity[i] && typeof equity[i].t === 'number' &&
equity[i].t >= sessionStartTimestamp &&
strategyData[i] &&
strategyData[i].v !== undefined && strategyData[i].v !== null) {
sessionData.push(strategyData[i]);
}
}
// If we have a value before session and session data, add the start point
// Create a start point with timestamp just before session start
if (lastDataBeforeSession && sessionData.length > 0) {
const startPoint = {
t: sessionStartTimestamp - 1,
v: lastDataBeforeSession.v
};
return [startPoint, ...sessionData];
}
return sessionData;
} catch (error) {
console.error('Error in filterStrategyDataForLive:', error);
return [];
}
}
/**
* Net Value Chart Component
* Displays portfolio value over time with multiple strategy comparisons
*/
export default function NetValueChart({ equity, baseline, baseline_vw, momentum, strategies, equity_return, baseline_return, baseline_vw_return, momentum_return, chartTab = 'all', virtualTime = null }) {
const [activePoint, setActivePoint] = useState(null);
const [stableYRange, setStableYRange] = useState(null);
const [legendTooltip, setLegendTooltip] = useState(null);
// Legend descriptions
const legendDescriptions = {
'大时代': '大时代 is our agents investment strategy',
'Buy & Hold (EW)': 'Equal Weight: Can be viewed as an equal-weighted index of all invested stocks',
'Buy & Hold (VW)': 'Value Weighted: Can be viewed as a market-cap weighted index of all invested stocks',
'Momentum': 'Momentum Strategy: Buy stocks that have performed well in the past',
};
// For live mode, use cumulative returns calculated by backend
// For all mode, use portfolio values directly
const dataSource = useMemo(() => {
if (chartTab === 'live') {
return {
equity: equity_return || equity,
baseline: baseline_return || baseline,
baseline_vw: baseline_vw_return || baseline_vw,
momentum: momentum_return || momentum
};
}
return {
equity: equity,
baseline: baseline,
baseline_vw: baseline_vw,
momentum: momentum
};
}, [chartTab, equity, baseline, baseline_vw, momentum, equity_return, baseline_return, baseline_vw_return, momentum_return]);
// Filter equity data based on chartTab
const filteredEquity = useMemo(() => {
if (chartTab === 'all') {
const sourceEquity = dataSource.equity;
if (!sourceEquity || sourceEquity.length === 0) return [];
// ALL chart: Show only the last point per day
// Logic: Keep the last equity value before 22:30 each day (the last equity value before US next trading day opens)
// Data after 22:30 belongs to the next trading day's session and is not shown in this chart
// Time handling: timestamp(ms) -> UTC -> Asia/Shanghai timezone, then group and filter based on Asia/Shanghai time
const dailyData = {};
sourceEquity.forEach((d) => {
// Timestamp is in milliseconds, first create UTC time, then convert to Asia/Shanghai timezone
// Equivalent to: pd.to_datetime(timestamp, unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
const utcDate = new Date(d.t); // timestamp(ms) -> UTC time
// Use Intl API to get date/time components in Asia/Shanghai timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const parts = formatter.formatToParts(utcDate);
const year = parts.find(p => p.type === 'year').value;
const month = parts.find(p => p.type === 'month').value;
const day = parts.find(p => p.type === 'day').value;
const hour = parseInt(parts.find(p => p.type === 'hour').value);
const minute = parseInt(parts.find(p => p.type === 'minute').value);
// Check if before 22:30 (Asia/Shanghai timezone)
const isBefore2230 = hour < 22 || (hour === 22 && minute < 30);
// Only process data before 22:30
if (isBefore2230) {
// Use Asia/Shanghai timezone date as key
const dateKey = `${year}-${month}-${day}`;
// Update if this day has no data yet, or if current data is later in time
if (!dailyData[dateKey] || new Date(d.t) > new Date(dailyData[dateKey].t)) {
dailyData[dateKey] = d;
}
}
});
// Convert to array and sort by time
return Object.values(dailyData).sort((a, b) => a.t - b.t);
} else if (chartTab === 'live') {
// LIVE chart: Show all updates from the most recent trading session (22:30-05:00)
// Live mode: Backend has already returned return curves for "current trading session + 0% starting point", frontend can use directly
const sourceEquity = dataSource.equity;
if (!sourceEquity || sourceEquity.length === 0) return [];
return sourceEquity;
}
return dataSource.equity || [];
}, [dataSource.equity, chartTab, virtualTime]);
// Helper function to get daily indices for 'all' view
const getDailyIndices = useMemo(() => {
if (!equity || equity.length === 0) return new Set();
const dailyIndices = new Set();
const dailyData = {};
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
equity.forEach((d, idx) => {
const utcDate = new Date(d.t);
const parts = formatter.formatToParts(utcDate);
const hour = parseInt(parts.find(p => p.type === 'hour').value);
const minute = parseInt(parts.find(p => p.type === 'minute').value);
// Check if before 22:30 (Asia/Shanghai timezone)
const isBefore2230 = hour < 22 || (hour === 22 && minute < 30);
// Only process data before 22:30
if (isBefore2230) {
const year = parts.find(p => p.type === 'year').value;
const month = parts.find(p => p.type === 'month').value;
const day = parts.find(p => p.type === 'day').value;
const dateKey = `${year}-${month}-${day}`;
if (!dailyData[dateKey] || new Date(d.t) > new Date(dailyData[dateKey].t)) {
dailyData[dateKey] = { data: d, index: idx };
}
}
});
Object.values(dailyData).forEach(({ index }) => dailyIndices.add(index));
return dailyIndices;
}, [equity]);
// Filter baseline, baseline_vw, momentum, strategies to match filteredEquity indices
const filteredBaseline = useMemo(() => {
const sourceBaseline = dataSource.baseline;
if (!sourceBaseline || sourceBaseline.length === 0 || !equity || equity.length === 0) return [];
if (chartTab === 'all') {
return sourceBaseline.filter((_, idx) => getDailyIndices.has(idx));
} else if (chartTab === 'live') {
// Live mode: Use backend pre-processed baseline return curves directly
return sourceBaseline;
}
return sourceBaseline;
}, [dataSource.baseline, equity, chartTab, getDailyIndices, virtualTime]);
const filteredBaselineVw = useMemo(() => {
const sourceBaselineVw = dataSource.baseline_vw;
if (!sourceBaselineVw || sourceBaselineVw.length === 0 || !equity || equity.length === 0) return [];
if (chartTab === 'all') {
return sourceBaselineVw.filter((_, idx) => getDailyIndices.has(idx));
} else if (chartTab === 'live') {
// Live mode: Use backend pre-processed baseline return curves directly
return sourceBaselineVw;
}
return sourceBaselineVw;
}, [dataSource.baseline_vw, equity, chartTab, getDailyIndices, virtualTime]);
const filteredMomentum = useMemo(() => {
const sourceMomentum = dataSource.momentum;
if (!sourceMomentum || sourceMomentum.length === 0 || !equity || equity.length === 0) return [];
if (chartTab === 'all') {
return sourceMomentum.filter((_, idx) => getDailyIndices.has(idx));
} else if (chartTab === 'live') {
// Live mode: Use backend pre-processed momentum return curves directly
return sourceMomentum;
}
return sourceMomentum;
}, [dataSource.momentum, equity, chartTab, getDailyIndices, virtualTime]);
const filteredStrategies = useMemo(() => {
if (!strategies || strategies.length === 0 || !equity || equity.length === 0) return [];
if (chartTab === 'all') {
return strategies.filter((_, idx) => getDailyIndices.has(idx));
} else if (chartTab === 'live') {
const sessionStartTime = getRecentTradingSessionStart(virtualTime);
return filterStrategyDataForLive(strategies, equity, sessionStartTime);
}
return strategies;
}, [strategies, equity, chartTab, getDailyIndices, virtualTime]);
const chartData = useMemo(() => {
if (!filteredEquity || filteredEquity.length === 0) return [];
try {
// LIVE mode: Align all curves by timestamp with forward filling to ensure consistent point counts and aligned starting points
if (chartTab === 'live') {
// Build timestamp -> value mapping
const toMap = (arr) => {
const m = new Map();
if (Array.isArray(arr)) {
arr.forEach((p) => {
if (p && typeof p.t === 'number' && typeof p.v === 'number') {
m.set(p.t, p.v);
}
});
}
return m;
};
const portfolioMap = toMap(filteredEquity);
const baselineMap = toMap(filteredBaseline);
const baselineVwMap = toMap(filteredBaselineVw);
const momentumMap = toMap(filteredMomentum);
const strategyMap = toMap(filteredStrategies);
// Collect all timestamps, sort by time
const timestampSet = new Set();
[filteredEquity, filteredBaseline, filteredBaselineVw, filteredMomentum, filteredStrategies].forEach(arr => {
if (Array.isArray(arr)) {
arr.forEach(p => {
if (p && typeof p.t === 'number') timestampSet.add(p.t);
});
}
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
if (timestamps.length === 0) return [];
// Current values for forward filling, initialized to 0% to ensure starting point alignment
let currentPortfolio = 0;
let currentBaseline = 0;
let currentBaselineVw = 0;
let currentMomentum = 0;
let currentStrategy = 0;
return timestamps.map((t, idx) => {
if (portfolioMap.has(t)) currentPortfolio = portfolioMap.get(t);
if (baselineMap.has(t)) currentBaseline = baselineMap.get(t);
if (baselineVwMap.has(t)) currentBaselineVw = baselineVwMap.get(t);
if (momentumMap.has(t)) currentMomentum = momentumMap.get(t);
if (strategyMap.has(t)) currentStrategy = strategyMap.get(t);
const date = new Date(t);
if (isNaN(date.getTime())) {
console.warn('Invalid timestamp in live chart data:', t);
return null;
}
return {
index: idx,
time:
date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}) +
' ' +
date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
timestamp: t,
portfolio: currentPortfolio,
baseline: currentBaseline,
baseline_vw: currentBaselineVw,
momentum: currentMomentum,
strategy: currentStrategy,
};
}).filter(item => item !== null);
}
// ALL mode: Keep the original index-based alignment logic
return filteredEquity.map((d, idx) => {
if (!d || typeof d.t !== 'number' || typeof d.v !== 'number') {
console.warn('Invalid equity data point:', d);
return null;
}
const date = new Date(d.t);
if (isNaN(date.getTime())) {
console.warn('Invalid timestamp:', d.t);
return null;
}
const baselineVal = filteredBaseline?.[idx]
? (typeof filteredBaseline[idx] === 'object' ? filteredBaseline[idx].v : filteredBaseline[idx])
: null;
const baselineVwVal = filteredBaselineVw?.[idx]
? (typeof filteredBaselineVw[idx] === 'object' ? filteredBaselineVw[idx].v : filteredBaselineVw[idx])
: null;
const momentumVal = filteredMomentum?.[idx]
? (typeof filteredMomentum[idx] === 'object' ? filteredMomentum[idx].v : filteredMomentum[idx])
: null;
const strategyVal = filteredStrategies?.[idx]
? (typeof filteredStrategies[idx] === 'object' ? filteredStrategies[idx].v : filteredStrategies[idx])
: null;
return {
index: idx,
time:
date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
timestamp: d.t,
portfolio: d.v,
baseline: baselineVal || null,
baseline_vw: baselineVwVal || null,
momentum: momentumVal || null,
strategy: strategyVal || null,
};
}).filter(item => item !== null); // Remove null entries
} catch (error) {
console.error('Error processing chart data:', error);
return [];
}
}, [filteredEquity, filteredBaseline, filteredBaselineVw, filteredMomentum, filteredStrategies, chartTab]);
const { yMin, yMax, xTickIndices } = useMemo(() => {
if (chartData.length === 0) return { yMin: 0, yMax: 1, xTickIndices: [] };
// Calculate min and max from all series
const allValues = chartData.flatMap(d =>
[d.portfolio, d.baseline, d.baseline_vw, d.momentum, d.strategy].filter(v => v !== null && isFinite(v))
);
if (allValues.length === 0) {
return { yMin: 0, yMax: 1000000, xTickIndices: [] };
}
const dataMin = Math.min(...allValues);
const dataMax = Math.max(...allValues);
const range = dataMax - dataMin || 1;
// For live mode (percentage data), use smaller padding and finer rounding
// For all mode (dollar amounts), use larger padding and coarser rounding
const isLiveMode = chartTab === 'live';
const paddingFactor = isLiveMode ? range * 0.15 : range * 0.03;
let yMinCalc = dataMin - paddingFactor;
let yMaxCalc = dataMax + paddingFactor;
// Smart rounding based on magnitude and mode
const magnitude = Math.max(Math.abs(yMinCalc), Math.abs(yMaxCalc));
let roundTo;
if (isLiveMode) {
// For percentage data, use much finer rounding
if (magnitude >= 100) {
roundTo = 10;
} else if (magnitude >= 10) {
roundTo = 1;
} else if (magnitude >= 1) {
roundTo = 0.1;
} else {
roundTo = 0.01;
}
} else {
// For dollar amounts, use coarser rounding
if (magnitude >= 1e6) {
roundTo = 10000;
} else if (magnitude >= 1e5) {
roundTo = 5000;
} else if (magnitude >= 1e4) {
roundTo = 1000;
} else {
roundTo = 100;
}
}
yMinCalc = Math.floor(yMinCalc / roundTo) * roundTo;
yMaxCalc = Math.ceil(yMaxCalc / roundTo) * roundTo;
// Stable range to prevent frequent updates
if (stableYRange) {
const { min: stableMin, max: stableMax } = stableYRange;
const stableRange = stableMax - stableMin;
const threshold = stableRange * 0.05;
const needsUpdate =
dataMin < (stableMin + threshold) ||
dataMax > (stableMax - threshold);
if (!needsUpdate) {
yMinCalc = stableMin;
yMaxCalc = stableMax;
}
}
// Calculate x-axis tick indices
const safeLength = Math.min(chartData.length, 10000);
const targetTicks = Math.min(8, Math.max(5, Math.floor(safeLength / 10)));
const step = Math.max(1, Math.floor(safeLength / (targetTicks - 1)));
const indices = [];
for (let i = 0; i < safeLength && indices.length < 100; i += step) {
indices.push(i);
}
if (safeLength > 0 && indices[indices.length - 1] !== safeLength - 1) {
indices.push(safeLength - 1);
}
return { yMin: yMinCalc, yMax: yMaxCalc, xTickIndices: indices };
}, [chartData, stableYRange]);
// Update stableYRange in useEffect to avoid infinite re-renders
// Use functional update to avoid dependency on stableYRange
useEffect(() => {
if (yMin !== undefined && yMax !== undefined && yMin !== null && yMax !== null && isFinite(yMin) && isFinite(yMax)) {
setStableYRange(prevRange => {
if (!prevRange) {
// Initialize stable range
return { min: yMin, max: yMax };
} else {
// Check if update is needed (5% threshold)
const stableRange = prevRange.max - prevRange.min;
const threshold = stableRange * 0.05;
const needsUpdate =
yMin < (prevRange.min + threshold) ||
yMax > (prevRange.max - threshold);
if (needsUpdate) {
return { min: yMin, max: yMax };
}
// No update needed, return previous range
return prevRange;
}
});
}
}, [yMin, yMax]);
if (!equity || equity.length === 0) {
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#cccccc',
fontFamily: '"Courier New", monospace',
fontSize: '12px'
}}>
暂无图表数据
</div>
);
}
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const isLiveMode = chartTab === 'live';
return (
<div style={{
background: '#000000',
border: '1px solid #333333',
padding: '10px 14px',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
color: '#ffffff'
}}>
<div style={{ fontWeight: 700, marginBottom: '6px', fontSize: '11px' }}>
{payload[0].payload.time}
</div>
{payload.map((entry, index) => (
<div key={index} style={{ color: entry.color, marginTop: '2px' }}>
<span style={{ fontWeight: 700 }}>{entry.name}:</span> {isLiveMode ? `${entry.value.toFixed(2)}%` : `$${formatNumber(entry.value)}`} </div>
))}
</div>
);
}
return null;
};
const CustomDot = ({ dataKey, ...props }) => {
const { cx, cy, payload, index } = props;
const isActive = activePoint === index;
const isLastPoint = index === chartData.length - 1;
// Only show dot for the last point
if (!isLastPoint) {
return null;
}
const colors = {
portfolio: '#00C853',
baseline: '#FF6B00',
baseline_vw: '#9C27B0',
momentum: '#2196F3',
strategy: '#795548'
};
return (
<circle
cx={cx}
cy={cy}
r={isActive ? 6 : 8}
fill={colors[dataKey]}
stroke="#ffffff"
strokeWidth={2}
style={{ cursor: 'pointer' }}
onMouseEnter={() => setActivePoint(index)}
onMouseLeave={() => setActivePoint(null)}
onClick={() => console.log('Clicked point:', { dataKey, ...payload })}
/>
);
};
const CustomXAxisTick = ({ x, y, payload }) => {
const shouldShow = xTickIndices.includes(payload.index);
if (!shouldShow) return null;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="middle"
fill="#666666"
fontSize="10px"
fontFamily='"Courier New", monospace'
fontWeight="700"
>
{payload.value}
</text>
</g>
);
};
const CustomLegend = ({ payload }) => {
if (!payload || payload.length === 0) return null;
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
padding: '10px 0',
position: 'relative',
fontFamily: '"Courier New", monospace',
fontSize: '11px',
fontWeight: 700,
justifyContent: 'center'
}}>
{payload.map((entry, index) => {
const description = legendDescriptions[entry.value] || '';
const isActive = legendTooltip === entry.value;
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
position: 'relative',
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: isActive ? '#f0f0f0' : 'transparent',
transition: 'background-color 0.2s',
userSelect: 'none'
}}
onMouseEnter={() => setLegendTooltip(entry.value)}
onMouseLeave={() => setLegendTooltip(null)}
onClick={(e) => {
e.stopPropagation();
setLegendTooltip(isActive ? null : entry.value);
}}
>
<div
style={{
width: '14px',
height: '3px',
backgroundColor: entry.color,
border: 'none'
}}
/>
<span
style={{
fontFamily: '"Courier New", monospace',
fontSize: '11px',
fontWeight: 700,
color: '#000000'
}}
>
{entry.value}
</span>
{isActive && description && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: '8px',
padding: '8px 12px',
background: '#000000',
color: '#ffffff',
fontSize: '10px',
fontFamily: '"Courier New", monospace',
whiteSpace: 'normal',
maxWidth: '300px',
zIndex: 1000,
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'none',
lineHeight: 1.4
}}
>
{description}
</div>
)}
</div>
);
})}
</div>
);
};
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 20, right: 30, bottom: 50, left: 60 }}
>
<XAxis
dataKey="time"
stroke="#666666"
tick={<CustomXAxisTick />}
interval={0}
/>
<YAxis
domain={[yMin, yMax]}
stroke="#000000"
style={{ fontFamily: '"Courier New", monospace', fontSize: '11px', fontWeight: 700 }}
tick={{ fill: '#000000' }}
tickFormatter={(value) => chartTab === 'live' ? `${value.toFixed(2)}%` : formatFullNumber(value)}
width={75}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
content={<CustomLegend />}
/>
{/* Portfolio line */}
<Line
type="linear"
dataKey="portfolio"
name="大时代"
stroke="#00C853"
strokeWidth={2.5}
dot={(props) => <CustomDot {...props} dataKey="portfolio" />}
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
isAnimationActive={false}
/>
{/* Baseline Equal Weight */}
{baseline && baseline.length > 0 && (
<Line
type="linear"
dataKey="baseline"
name="Buy & Hold (EW)"
stroke="#FF6B00"
strokeWidth={2}
strokeDasharray="5 5"
dot={(props) => <CustomDot {...props} dataKey="baseline" />}
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
isAnimationActive={false}
/>
)}
{/* Baseline Value Weighted */}
{baseline_vw && baseline_vw.length > 0 && (
<Line
type="linear"
dataKey="baseline_vw"
name="Buy & Hold (VW)"
stroke="#9C27B0"
strokeWidth={2}
strokeDasharray="8 4"
dot={(props) => <CustomDot {...props} dataKey="baseline_vw" />}
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
isAnimationActive={false}
/>
)}
{/* Momentum Strategy */}
{momentum && momentum.length > 0 && (
<Line
type="linear"
dataKey="momentum"
name="Momentum"
stroke="#2196F3"
strokeWidth={2}
strokeDasharray="3 3"
dot={(props) => <CustomDot {...props} dataKey="momentum" />}
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
isAnimationActive={false}
/>
)}
{/* Other Strategies */}
{strategies && strategies.length > 0 && (
<Line
type="linear"
dataKey="strategy"
name="Strategy"
stroke="#795548"
strokeWidth={2}
dot={(props) => <CustomDot {...props} dataKey="strategy" />}
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
isAnimationActive={false}
/>
)}
</LineChart>
</ResponsiveContainer>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import { OpenClawStatus } from './OpenClawStatus';
export default function OpenClawView() {
return <OpenClawStatus />;
}

View File

@@ -0,0 +1,235 @@
import React from 'react';
/**
* Performance View Component
* Displays agent performance leaderboard and signal history
*/
export default function PerformanceView({ leaderboard }) {
const rankedAgents = Array.isArray(leaderboard)
? leaderboard.filter(agent => agent.agentId !== 'risk_manager')
: [];
return (
<div>
{/* Agent Performance Section */}
<div className="section">
<div className="section-header">
<h2 className="section-title">分析师表现 - 信号准确率</h2>
</div>
{rankedAgents.length === 0 ? (
<div className="empty-state">暂无排行榜数据</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>排名</th>
<th>分析师</th>
<th>胜率</th>
<th>看涨信号</th>
<th>看涨胜率</th>
<th>看跌信号</th>
<th>看跌胜率</th>
<th>总信号数</th>
</tr>
</thead>
<tbody>
{rankedAgents.map(agent => {
const bullTotal = agent.bull?.n || 0;
const bullWins = agent.bull?.win || 0;
const bullUnknown = agent.bull?.unknown || 0;
const bearTotal = agent.bear?.n || 0;
const bearWins = agent.bear?.win || 0;
const bearUnknown = agent.bear?.unknown || 0;
const totalSignals = bullTotal + bearTotal;
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
const evaluatedTotal = evaluatedBull + evaluatedBear;
const bullWinRate = evaluatedBull > 0 ? (bullWins / evaluatedBull) : null;
const bearWinRate = evaluatedBear > 0 ? (bearWins / evaluatedBear) : null;
const overallWinRate = agent.winRate != null
? agent.winRate
: (evaluatedTotal > 0 ? ((bullWins + bearWins) / evaluatedTotal) : null);
const overallColor = overallWinRate != null
? (overallWinRate >= 0.5 ? '#00C853' : '#FF1744')
: '#999999';
return (
<tr key={agent.agentId}>
<td>
<span className={`rank-badge ${agent.rank === 1 ? 'first' : agent.rank === 2 ? 'second' : agent.rank === 3 ? 'third' : ''}`}>
{agent.rank === 1 ? '★ 1' : agent.rank}
</span>
</td>
<td>
<div style={{ fontWeight: 700, color: '#000000' }}>{agent.name}</div>
<div style={{ fontSize: 10, color: '#666666' }}>{agent.role}</div>
</td>
<td style={{ fontWeight: 700, color: overallColor }}>
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
</td>
<td>
<div style={{ fontSize: 12 }}>{bullTotal} 个信号</div>
<div style={{ fontSize: 10, color: '#666666' }}>{bullWins} 次命中</div>
{bullUnknown > 0 && (
<div style={{ fontSize: 10, color: '#999999' }}>{bullUnknown} 条未判定</div>
)}
</td>
<td style={{ color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
</td>
<td>
<div style={{ fontSize: 12 }}>{bearTotal} 个信号</div>
<div style={{ fontSize: 10, color: '#666666' }}>{bearWins} 次命中</div>
{bearUnknown > 0 && (
<div style={{ fontSize: 10, color: '#999999' }}>{bearUnknown} 条未判定</div>
)}
</td>
<td style={{ color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
</td>
<td style={{ fontWeight: 700 }}>{totalSignals}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Signal History with Dates */}
{rankedAgents.length > 0 && rankedAgents.some(agent => agent.signals && agent.signals.length > 0) && (
<div className="section" style={{ marginTop: 32 }}>
<div className="section-header">
<h2 className="section-title">信号历史</h2>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 20 }}>
{rankedAgents.map(agent => {
if (!agent.signals || agent.signals.length === 0) return null;
// Sort by date descending (newest first)
const sortedSignals = [...agent.signals].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
return (
<div key={agent.agentId} style={{
border: '1px solid #e0e0e0',
padding: 16,
background: '#fafafa'
}}>
<div style={{
fontWeight: 700,
fontSize: 12,
marginBottom: 12,
paddingBottom: 8,
borderBottom: '2px solid #000000',
letterSpacing: 1,
textTransform: 'uppercase'
}}>
{agent.name}
</div>
<div style={{
maxHeight: 500,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 8
}}>
{sortedSignals.map((signal, idx) => {
const signalType = signal.signal.toLowerCase();
const isBull = signalType.includes('bull') || signalType === 'long';
const isBear = signalType.includes('bear') || signalType === 'short';
const isNeutral = signalType.includes('neutral') || signalType === 'hold';
const resultStatus = signal.is_correct;
const isCorrect = resultStatus === true;
const isResultUnknown = resultStatus === 'unknown' || resultStatus === null || typeof resultStatus === 'undefined';
const realReturnValue = signal.real_return;
const hasRealReturn = typeof realReturnValue === 'number' && Number.isFinite(realReturnValue);
const realReturnDisplay = hasRealReturn
? `${realReturnValue >= 0 ? '+' : ''}${(realReturnValue * 100).toFixed(2)}%`
: '未判定';
const realReturnColor = hasRealReturn
? (realReturnValue >= 0 ? '#00C853' : '#FF1744')
: '#999999';
const statusColor = isResultUnknown ? '#999999' : (isCorrect ? '#00C853' : '#FF1744');
const statusSymbol = isResultUnknown ? '?' : (isCorrect ? '✓' : '✗');
return (
<div key={idx} style={{
fontSize: 11,
fontFamily: '"Courier New", monospace',
lineHeight: 1.4,
padding: '8px 10px',
background: '#ffffff',
border: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ flex: 1 }}>
<span style={{
color: '#666666',
fontSize: 10,
marginRight: 10,
fontWeight: 600
}}>
{signal.date}
</span>
<span style={{
fontWeight: 700,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999'
}}>
{signal.ticker}
</span>
<span style={{
marginLeft: 6,
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999',
fontSize: 12
}}>
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
</span>
{!isNeutral && (
<span style={{
marginLeft: 8,
fontSize: 10,
color: realReturnColor
}}>
{realReturnDisplay}
</span>
)}
</div>
{!isNeutral && (
<span style={{
fontSize: 14,
marginLeft: 10,
color: statusColor
}}>
{statusSymbol}
</span>
)}
</div>
);
})}
</div>
<div style={{
marginTop: 10,
paddingTop: 8,
borderTop: '1px solid #e0e0e0',
fontSize: 10,
color: '#666666',
textAlign: 'center'
}}>
{sortedSignals.length} 条信号
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,795 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants';
import AgentCard from './AgentCard';
import { getModelIcon } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
/**
* Custom hook to load an image
*/
function useImage(src) {
const [img, setImg] = useState(null);
useEffect(() => {
if (!src) {
setImg(null);
return;
}
// Reset image state when backend changes
setImg(null);
const image = new Image();
image.src = src;
image.onload = () => setImg(image);
image.onerror = () => {
console.error(`Failed to load image: ${src}`);
setImg(null);
};
// Cleanup: cancel loading if backend changes
return () => {
image.onload = null;
image.onerror = null;
};
}, [src]);
return img;
}
/**
* Get rank medal/trophy for display
*/
function getRankMedal(rank) {
if (rank === 1) return '🏆';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return null;
}
/**
* Room View Component
* Displays the conference room with agents, speech bubbles, and agent cards
* Supports click and hover (1.5s) to show agent performance cards
* Supports replay mode - completely independent from live mode
*/
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
// Agent selection and hover state
const [selectedAgent, setSelectedAgent] = useState(null);
const [hoveredAgent, setHoveredAgent] = useState(null);
const [isClosing, setIsClosing] = useState(false);
const hoverTimerRef = useRef(null);
const closeTimerRef = useRef(null);
// Bubble expansion state
const [expandedBubbles, setExpandedBubbles] = useState({});
// Hidden bubbles (locally dismissed)
const [hiddenBubbles, setHiddenBubbles] = useState({});
// Handle bubble close
const handleCloseBubble = (agentId, bubbleKey, e) => {
e.stopPropagation();
setHiddenBubbles(prev => ({
...prev,
[bubbleKey]: true
}));
};
// Replay state (must be defined before using in useMemo)
const [isReplaying, setIsReplaying] = useState(false);
const [replayBubbles, setReplayBubbles] = useState({});
const [modeTransition, setModeTransition] = useState(null); // 'entering-replay' | 'exiting-replay' | null
const [isPaused, setIsPaused] = useState(false);
const replayTimerRef = useRef(null);
const replayTimeoutsRef = useRef([]);
const replayStateRef = useRef({ messages: [], currentIndex: 0 });
// Background image
const roomBgSrc = ASSETS.roomBg;
const bgImg = useImage(roomBgSrc);
// Calculate scale to fit canvas in container (80% of available space)
const [scale, setScale] = useState(0.8);
useEffect(() => {
const updateScale = () => {
const container = containerRef.current;
if (!container) return;
const { clientWidth, clientHeight } = container;
if (clientWidth <= 0 || clientHeight <= 0) return;
const scaleX = clientWidth / SCENE_NATIVE.width;
const scaleY = clientHeight / SCENE_NATIVE.height;
const newScale = Math.min(scaleX, scaleY, 1.0) * 0.8; // Scale to 80% of original size
setScale(Math.max(0.3, newScale));
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
window.addEventListener('resize', updateScale);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateScale);
};
}, []);
// Set canvas size
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = SCENE_NATIVE.width;
canvas.height = SCENE_NATIVE.height;
const displayWidth = Math.round(SCENE_NATIVE.width * scale);
const displayHeight = Math.round(SCENE_NATIVE.height * scale);
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
}, [scale]);
// Draw room background
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
// Clear canvas first
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image if loaded
if (bgImg) {
ctx.drawImage(bgImg, 0, 0, SCENE_NATIVE.width, SCENE_NATIVE.height);
}
}, [bgImg, scale, roomBgSrc]);
// Determine which agents are speaking
const speakingAgents = useMemo(() => {
const speaking = {};
AGENTS.forEach(agent => {
const bubble = bubbleFor(agent.name);
speaking[agent.id] = !!bubble;
});
return speaking;
}, [bubbles, bubbleFor]);
// Find agent data from leaderboard
const getAgentData = (agentId) => {
const agent = AGENTS.find(a => a.id === agentId);
if (!agent) return null;
const profile = agentProfilesByAgent?.[agentId] || null;
// If no leaderboard data, return agent with default stats
if (!leaderboard || !Array.isArray(leaderboard)) {
return {
...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 },
winRate: null,
signals: [],
rank: null
};
}
const leaderboardData = leaderboard.find(lb => lb.agentId === agentId);
// If agent not in leaderboard, return agent with default stats
if (!leaderboardData) {
return {
...agent,
modelName: profile?.model_name || null,
modelProvider: profile?.model_provider || null,
bull: { n: 0, win: 0, unknown: 0 },
bear: { n: 0, win: 0, unknown: 0 },
winRate: null,
signals: [],
rank: null
};
}
// Merge data but preserve the correct avatar from AGENTS config
return {
...agent,
...leaderboardData,
modelName: profile?.model_name || leaderboardData.modelName || null,
modelProvider: profile?.model_provider || leaderboardData.modelProvider || null,
avatar: agent.avatar // Always use the frontend's avatar URL
};
};
// Get agent rank for display
const getAgentRank = (agentId) => {
const agentData = getAgentData(agentId);
return agentData?.rank || null;
};
// Handle agent click
const handleAgentClick = (agentId) => {
// Cancel any closing animation
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsClosing(false);
const agentData = getAgentData(agentId);
if (agentData) {
setSelectedAgent(agentData);
}
};
// Handle agent hover
const handleAgentMouseEnter = (agentId) => {
setHoveredAgent(agentId);
// Clear any existing timer
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
// Cancel any closing animation
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsClosing(false);
// If there's already a selected agent, switch immediately
// Otherwise, show after a short delay (0ms = immediate)
const agentData = getAgentData(agentId);
if (agentData) {
if (selectedAgent) {
// Already have a card open, switch immediately
setSelectedAgent(agentData);
} else {
// No card open, show after delay (currently 0ms = immediate)
hoverTimerRef.current = setTimeout(() => {
setSelectedAgent(agentData);
hoverTimerRef.current = null;
}, 0);
}
}
};
const handleAgentMouseLeave = () => {
setHoveredAgent(null);
// Clear timer if mouse leaves before 1.5 seconds
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
};
// Handle closing with animation
const handleClose = () => {
setIsClosing(true);
// Wait for animation to complete before removing
closeTimerRef.current = setTimeout(() => {
setSelectedAgent(null);
setIsClosing(false);
closeTimerRef.current = null;
}, 200); // Match the slideUp animation duration
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
// Clean up replay timers
if (replayTimerRef.current) {
clearTimeout(replayTimerRef.current);
}
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
replayTimeoutsRef.current = [];
};
}, []);
// Show replay button when not in replay mode and has feed history
const showReplayButton = !isReplaying && feed && feed.length > 0;
// Start replay with feed data
const handleReplayClick = useCallback(() => {
if (!feed || feed.length === 0) {
return;
}
startReplay(feed);
}, [feed]);
// Extract agent messages from feed items
const extractAgentMessages = useCallback((feedItems) => {
const messages = [];
feedItems.forEach((item, itemIndex) => {
if (item.type === 'message' && item.data) {
const msg = item.data;
// Skip system messages
if (msg.agent === 'System') return;
// Find matching agent
const agent = AGENTS.find(a =>
a.id === msg.agentId ||
a.name === msg.agent
);
if (agent) {
messages.push({
feedItemId: item.id,
agentId: agent.id,
agentName: agent.name,
content: msg.content,
timestamp: msg.timestamp
});
}
} else if (item.type === 'conference' && item.data?.messages) {
item.data.messages.forEach((msg, msgIndex) => {
if (msg.agent === 'System') return;
const agent = AGENTS.find(a =>
a.id === msg.agentId ||
a.name === msg.agent
);
if (agent) {
messages.push({
feedItemId: item.id,
agentId: agent.id,
agentName: agent.name,
content: msg.content,
timestamp: msg.timestamp
});
}
});
}
});
return messages;
}, []);
// Show next message in replay
const showNextMessage = useCallback(() => {
const { messages, currentIndex } = replayStateRef.current;
if (currentIndex >= messages.length) {
// End replay
setModeTransition('exiting-replay');
setTimeout(() => {
setModeTransition(null);
setIsReplaying(false);
setIsPaused(false);
setReplayBubbles({});
replayStateRef.current = { messages: [], currentIndex: 0 };
}, 500);
return;
}
const msg = messages[currentIndex];
const bubbleId = `replay_${msg.agentId}_${currentIndex}`;
setReplayBubbles(prev => ({
...prev,
[bubbleId]: {
id: bubbleId,
feedItemId: msg.feedItemId,
agentId: msg.agentId,
agentName: msg.agentName,
text: msg.content,
timestamp: msg.timestamp,
ts: msg.timestamp
}
}));
// Remove bubble after 10 seconds (previously 5s) to keep replay text visible longer
const hideTimeout = setTimeout(() => {
setReplayBubbles(prev => {
const newBubbles = { ...prev };
delete newBubbles[bubbleId];
return newBubbles;
});
}, 10000);
replayTimeoutsRef.current.push(hideTimeout);
// Schedule next message
replayStateRef.current.currentIndex = currentIndex + 1;
// Wait longer before next bubble to match extended visibility (was 3s)
const nextTimeout = setTimeout(() => {
showNextMessage();
}, 6000);
replayTimerRef.current = nextTimeout;
replayTimeoutsRef.current.push(nextTimeout);
}, []);
// Start replay with feed data
const startReplay = useCallback((feedItems) => {
if (!feedItems || feedItems.length === 0) {
return;
}
const agentMessages = extractAgentMessages(feedItems).reverse();
if (agentMessages.length === 0) {
return;
}
// Store messages for pause/resume
replayStateRef.current = { messages: agentMessages, currentIndex: 0 };
// Start transition animation
setModeTransition('entering-replay');
setIsReplaying(true);
setIsPaused(false);
setReplayBubbles({});
// Clear any existing timeouts
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
replayTimeoutsRef.current = [];
// Clear transition and start replay after animation completes
setTimeout(() => {
setModeTransition(null);
showNextMessage();
}, 500);
}, [extractAgentMessages, showNextMessage]);
// Pause replay
const pauseReplay = useCallback(() => {
if (replayTimerRef.current) {
clearTimeout(replayTimerRef.current);
replayTimerRef.current = null;
}
setIsPaused(true);
}, []);
// Resume replay
const resumeReplay = useCallback(() => {
setIsPaused(false);
showNextMessage();
}, [showNextMessage]);
// Stop replay
const stopReplay = useCallback(() => {
// Clear all timeouts
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
replayTimeoutsRef.current = [];
if (replayTimerRef.current) {
clearTimeout(replayTimerRef.current);
replayTimerRef.current = null;
}
// Transition out of replay mode
setModeTransition('exiting-replay');
// Clear transition and replay state after animation completes
setTimeout(() => {
setModeTransition(null);
setIsReplaying(false);
setIsPaused(false);
setReplayBubbles({});
replayStateRef.current = { messages: [], currentIndex: 0 };
}, 500);
}, []);
// Get bubble for specific agent (supports both live and replay mode)
const getBubbleForAgent = useCallback((agentName) => {
if (isReplaying) {
// Find replay bubble for this agent
const bubble = Object.values(replayBubbles).find(b => {
const agent = AGENTS.find(a => a.id === b.agentId);
return agent && agent.name === agentName;
});
return bubble || null;
} else {
// Use normal bubbleFor function
return bubbleFor(agentName);
}
}, [isReplaying, replayBubbles, bubbleFor]);
return (
<div className="room-view">
{/* Agents Indicator Bar */}
<div className="room-agents-indicator">
{AGENTS.map((agent, index) => {
const rank = getAgentRank(agent.id);
const medal = rank ? getRankMedal(rank) : null;
const agentData = getAgentData(agent.id);
const modelInfo = getModelIcon(agentData?.modelName, agentData?.modelProvider);
return (
<React.Fragment key={agent.id}>
<div
className={`agent-indicator ${speakingAgents[agent.id] ? 'speaking' : ''} ${hoveredAgent === agent.id ? 'hovered' : ''}`}
onClick={() => handleAgentClick(agent.id)}
onMouseEnter={() => handleAgentMouseEnter(agent.id)}
onMouseLeave={handleAgentMouseLeave}
>
<div className="agent-avatar-wrapper">
<img
src={agent.avatar}
alt={agent.name}
className="agent-avatar"
/>
<span className="agent-indicator-dot"></span>
{medal && (
<span className="agent-rank-medal">
{medal}
</span>
)}
{(agentData?.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={25}
shape="circle"
type="color"
className="agent-model-badge"
style={{
position: 'absolute',
top: -12,
right: -12,
borderRadius: '50%',
border: '2px solid #ffffff',
background: '#ffffff',
padding: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'none'
}}
/>
)}
</div>
<span className="agent-name">{agent.name}</span>
</div>
{/* Divider after Risk Manager (index 1) */}
{index === 1 && (
<div style={{
width: 2,
height: 60,
background: 'linear-gradient(to bottom, transparent, #333333, transparent)',
margin: '0 12px',
alignSelf: 'center'
}} />
)}
</React.Fragment>
);
})}
{/* Hint Text */}
<div className="agent-hint-text">
点击头像查看详情
</div>
</div>
{/* Room Canvas */}
<div className="room-canvas-container" ref={containerRef}>
<div className="room-scene">
<div className="room-scene-wrapper" style={{ width: Math.round(SCENE_NATIVE.width * scale), height: Math.round(SCENE_NATIVE.height * scale) }}>
<canvas ref={canvasRef} className="room-canvas" />
{/* Speech Bubbles */}
{AGENTS.map((agent, idx) => {
const bubble = getBubbleForAgent(agent.name);
if (!bubble) return null;
const bubbleKey = `${agent.id}_${bubble.timestamp || bubble.id || bubble.ts}`;
// Check if bubble is hidden
if (hiddenBubbles[bubbleKey]) return null;
const pos = AGENT_SEATS[idx];
const scaledWidth = SCENE_NATIVE.width * scale;
const scaledHeight = SCENE_NATIVE.height * scale;
// Bubble left-bottom corner aligns to agent position
const left = Math.round(pos.x * scaledWidth);
const bottom = Math.round(pos.y * scaledHeight);
// Get agent data for model info
const agentData = getAgentData(agent.id);
const modelInfo = getModelIcon(agentData?.modelName, agentData?.modelProvider);
// Truncate long text - 200 collapsed, 500 expanded max
const maxLength = 200;
const maxExpandedLength = 500;
const isTruncated = bubble.text.length > maxLength;
const isExpanded = expandedBubbles[bubbleKey];
const displayText = (!isExpanded && isTruncated)
? bubble.text.substring(0, maxLength) + '...'
: (isExpanded && bubble.text.length > maxExpandedLength)
? bubble.text.substring(0, maxExpandedLength) + '...'
: bubble.text;
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedBubbles(prev => ({
...prev,
[bubbleKey]: !prev[bubbleKey]
}));
};
const handleJumpToFeed = (e) => {
e.stopPropagation();
if (onJumpToMessage) {
onJumpToMessage(bubble);
}
};
return (
<div
key={agent.id}
className="room-bubble"
style={{ left, bottom }}
>
{/* Action buttons */}
<div className="bubble-action-buttons">
<button
className="bubble-jump-btn"
onClick={handleJumpToFeed}
title="跳转到消息"
>
</button>
<button
className="bubble-close-btn"
onClick={(e) => handleCloseBubble(agent.id, bubbleKey, e)}
title="关闭"
>
×
</button>
</div>
{/* Agent header with model icon */}
<div className="room-bubble-header">
{(agentData?.modelName || modelInfo.logoPath) && (
<LobeModelLogo
model={agentData?.modelName}
provider={agentData?.modelProvider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={18}
shape="circle"
type="color"
className="bubble-model-icon"
/>
)}
<div className="room-bubble-name">{bubble.agentName || agent.name}</div>
</div>
<div className="room-bubble-divider"></div>
{/* Message content */}
<div className="room-bubble-content">
{displayText}
{isTruncated && (
<button
className="bubble-expand-btn"
onClick={toggleExpand}
>
{isExpanded ? ' ↑' : ' ↓'}
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Agent Card - Dropdown style below indicator bar */}
{selectedAgent && (
<>
{/* Transparent overlay to close card */}
<div
className="agent-card-overlay"
onClick={handleClose}
/>
{/* Agent Card */}
<AgentCard
agent={selectedAgent}
isClosing={isClosing}
onClose={handleClose}
/>
</>
)}
{/* Mode Transition Overlay - sweeps in the dark gradient */}
{modeTransition === 'entering-replay' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
pointerEvents: 'none',
zIndex: 40,
clipPath: 'inset(0 100% 0 0)',
animation: 'clipReveal 0.5s ease-out forwards'
}}
/>
)}
{/* Mode Transition Overlay - sweeps out the dark gradient */}
{modeTransition === 'exiting-replay' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
pointerEvents: 'none',
zIndex: 40,
clipPath: 'inset(0 0 0 0)',
animation: 'clipHide 0.5s ease-out forwards'
}}
/>
)}
{/* Room Controls */}
{(showReplayButton || onOpenLaunchConfig) && (
<div className="replay-button-container">
{onOpenLaunchConfig && (
<button
className="replay-button"
onClick={onOpenLaunchConfig}
title="打开启动配置"
style={{ background: '#FFFFFF', color: '#000000' }}
>
<span>启动</span>
</button>
)}
<button
className="replay-button"
onClick={handleReplayClick}
title="Replay feed history"
disabled={!showReplayButton}
>
<span className="replay-icon">&#9654;&#9654;</span>
<span>回放</span>
</button>
</div>
)}
{/* Replay Mode Background + Indicator */}
{isReplaying && !modeTransition && (
<>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
pointerEvents: 'none',
zIndex: 40
}}
/>
<div className="replay-indicator">
<span className="replay-status">{isPaused ? '已暂停' : '回放模式'}</span>
<button
className="replay-button"
onClick={isPaused ? resumeReplay : pauseReplay}
style={{ padding: '6px 12px' }}
>
<span>{isPaused ? '▶' : '⏸'}</span>
</button>
<button className="replay-button" onClick={stopReplay} style={{ padding: '6px 12px' }}>
<span></span>
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
export default function RuntimeLogsModal({
isOpen,
isLoading,
logPayload,
error,
onClose,
onRefresh
}) {
const logRef = useRef(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [followTail, setFollowTail] = useState(true);
const refreshIntervalMs = useMemo(() => 2000, []);
useEffect(() => {
if (!isOpen || !autoRefresh) {
return undefined;
}
const timerId = window.setInterval(() => {
onRefresh();
}, refreshIntervalMs);
return () => window.clearInterval(timerId);
}, [autoRefresh, isOpen, onRefresh, refreshIntervalMs]);
useEffect(() => {
if (!isOpen || !followTail || !logRef.current) {
return;
}
logRef.current.scrollTop = logRef.current.scrollHeight;
}, [followTail, isOpen, logPayload?.content]);
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 12px',
display: 'flex',
gap: 16,
alignItems: 'center',
flexWrap: 'wrap'
}}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
实时刷新
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
<input
type="checkbox"
checked={followTail}
onChange={(event) => setFollowTail(event.target.checked)}
/>
自动滚底
</label>
</div>
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
<pre
ref={logRef}
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

@@ -0,0 +1,645 @@
import React from 'react';
import { createPortal } from 'react-dom';
const formatHistorySummary = (run) => {
const updatedAt = run?.updated_at ? String(run.updated_at).replace("T", " ").slice(0, 16) : "未知时间";
const mode = run?.bootstrap?.mode ? String(run.bootstrap.mode).toUpperCase() : "LIVE";
const tickers = Array.isArray(run?.bootstrap?.tickers) ? run.bootstrap.tickers.length : 0;
const assetValue = Number(run?.total_asset_value ?? 0).toFixed(2);
const trades = Number(run?.total_trades ?? 0);
return `${run.run_id} · ${updatedAt} · ${mode} · ${tickers}标的 · ${trades}笔交易 · $${assetValue}`;
};
export default function RuntimeSettingsPanel({
showTrigger = true,
isOpen,
isConnected,
isSaving,
feedback,
launchMode,
restoreRunId,
runtimeHistoryRuns,
scheduleMode,
intervalMinutes,
triggerTime,
maxCommCycles,
initialCash,
marginRequirement,
enableMemory,
mode,
pollInterval,
startDate,
endDate,
watchlistSymbols,
watchlistInputValue,
watchlistSuggestions,
onToggle,
onClose,
onScheduleModeChange,
onLaunchModeChange,
onRestoreRunIdChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
onWatchlistRemove,
onWatchlistRestoreCurrent,
onWatchlistRestoreDefault,
onWatchlistSuggestionClick,
onSave,
onRestoreDefaults
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
{showTrigger && (
<button
onClick={onToggle}
style={{
padding: '6px 10px',
borderRadius: 4,
border: '1px solid #333333',
background: isOpen ? '#1E1E1E' : '#111111',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.6px',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
启动配置
</button>
)}
{isOpen && createPortal((
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.28)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
zIndex: 9998
}}
>
<div
onClick={(event) => event.stopPropagation()}
style={{
width: 'min(760px, 92vw)',
maxHeight: '80vh',
overflowY: 'auto',
borderRadius: 16,
border: '1px solid #D9E0E7',
background: '#FFFFFF',
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
padding: 18,
paddingTop: 22,
display: 'grid',
gap: 16,
position: 'relative',
zIndex: 9999
}}
>
<button
onClick={onClose}
style={{
position: 'absolute',
top: 16,
right: 16,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
borderRadius: 999,
width: 40,
height: 40,
fontSize: 16,
lineHeight: 1,
color: '#111111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
}}
aria-label="关闭启动配置"
>
×
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>启动配置</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
配置本次任务的启动参数与调度方式
</div>
</div>
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 12
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>启动形式</div>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>任务模式</span>
<select
value={launchMode}
onChange={(e) => onLaunchModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="fresh">重新启动</option>
<option value="restore">从历史任务恢复</option>
</select>
</label>
{launchMode === 'restore' && (
<>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>历史任务</span>
<select
value={restoreRunId}
onChange={(e) => onRestoreRunIdChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="">请选择历史任务</option>
{runtimeHistoryRuns.map((run) => (
<option key={run.run_id} value={run.run_id}>
{formatHistorySummary(run)}
</option>
))}
</select>
</label>
<div style={{
fontSize: '11px',
color: '#6B7280',
lineHeight: 1.6,
padding: '10px 12px',
borderRadius: 8,
background: '#FFFFFF',
border: '1px dashed #D0D7DE'
}}>
恢复启动会从所选历史任务复制运行状态组合交易记录和 Agent 工作区资产并以新的任务 ID 继续运行
</div>
</>
)}
</div>
{launchMode === 'fresh' && (
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 12
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
minHeight: 40,
padding: '2px 0'
}}>
{watchlistSymbols.map((symbol) => (
<button
key={symbol}
onClick={() => onWatchlistRemove(symbol)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
<span>{symbol}</span>
<span style={{ color: '#777777' }}>×</span>
</button>
))}
{watchlistSymbols.length === 0 && (
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
还没有股票输入代码后回车添加
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={watchlistInputValue}
onChange={(e) => onWatchlistInputChange(e.target.value)}
onKeyDown={onWatchlistInputKeyDown}
placeholder="输入股票代码,回车添加"
style={{
flex: 1,
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
<button
onClick={onWatchlistAdd}
style={{
padding: '9px 12px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
添加
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{watchlistSuggestions.map((symbol) => {
const active = watchlistSymbols.includes(symbol);
return (
<button
key={symbol}
onClick={() => onWatchlistSuggestionClick(symbol)}
disabled={active}
style={{
padding: '5px 8px',
borderRadius: 999,
border: '1px solid',
borderColor: active ? '#B6E3C5' : '#D0D7DE',
background: active ? '#ECFDF3' : '#FFFFFF',
color: active ? '#157347' : '#4A5568',
fontSize: '10px',
fontWeight: 700,
cursor: active ? 'default' : 'pointer'
}}
>
{symbol}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={onWatchlistRestoreCurrent}
style={{
padding: '8px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复当前
</button>
<button
onClick={onWatchlistRestoreDefault}
style={{
padding: '8px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复默认
</button>
</div>
</div>
)}
{launchMode === 'fresh' && (
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 12
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
<select
value={scheduleMode}
onChange={(e) => onScheduleModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="daily">每日定时</option>
<option value="intraday">盘中轮询</option>
</select>
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>间隔(分钟)</span>
<input
type="number"
min="1"
value={intervalMinutes}
onChange={(e) => onIntervalMinutesChange(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 }}>每日定时时间 (NYSE)</span>
<input
type="time"
value={triggerTime}
onChange={(e) => onTriggerTimeChange(e.target.value)}
disabled={scheduleMode !== 'daily'}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
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="number"
min="1"
value={maxCommCycles}
onChange={(e) => onMaxCommCyclesChange(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="number"
min="1"
step="1000"
value={initialCash}
onChange={(e) => onInitialCashChange(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="number"
min="0"
step="0.01"
value={marginRequirement}
onChange={(e) => onMarginRequirementChange(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={enableMemory}
onChange={(e) => onEnableMemoryChange(e.target.checked)}
style={{
width: 16,
height: 16,
accentColor: '#0D47A1',
cursor: 'pointer'
}}
/>
<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>
</div>
)}
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>操作</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={onRestoreDefaults}
style={{
padding: '9px 12px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复默认
</button>
<button
onClick={onSave}
disabled={!isConnected || isSaving}
style={{
padding: '9px 14px',
borderRadius: 8,
border: '1px solid #1565C0',
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.4px',
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
}}
>
{isSaving ? '启动中' : '启动任务'}
</button>
</div>
</div>
{feedback && (
<span style={{
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: '11px',
fontFamily: '"Courier New", monospace'
}}>
{feedback.text}
</span>
)}
</div>
</div>
</div>
), document.body)}
</div>
);
}

View File

@@ -0,0 +1,806 @@
import React, { useEffect, useState } from 'react';
import {
approvePendingApproval,
denyPendingApproval,
loadAllRuntimeState
} from '../services/runtimeApi';
const AUTO_REFRESH_MS = 5000;
const STATUS_LABELS = {
idle: '空闲',
registered: '已注册',
initializing: '初始化中',
ready: '就绪',
running: '运行中',
analysis_in_progress: '分析中',
risk_review_in_progress: '风控处理中',
discussion_in_progress: '会商中',
decision_in_progress: '决策中',
execution_in_progress: '执行中',
settlement_in_progress: '结算中',
reflection_in_progress: '复盘中',
waiting_approval: '等待审批',
approved: '已批准',
denied: '已拒绝',
completed: '已完成',
error: '异常',
stopped: '已停止'
};
const EVENT_FILTER_OPTIONS = [
{ value: 'all', label: '全部事件' },
{ value: 'cycle', label: '运行周期' },
{ 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">
<div className="stat-card-label">
{label}
</div>
<div className="stat-card-value" style={{ color: accent }}>
{value}
</div>
{helper && (
<div style={{ marginTop: 8, fontSize: 11, color: '#666666', lineHeight: 1.5 }}>
{helper}
</div>
)}
</div>
);
}
function resolveApprovalTone(approval) {
const findings = Array.isArray(approval.findings) ? approval.findings : [];
const levels = findings.map((item) => item?.severity).filter(Boolean);
if (levels.includes('critical')) {
return { border: '#7F1D1D', bg: '#FEF2F2', text: '#991B1B', badgeBg: '#FECACA' };
}
if (levels.includes('high')) {
return { border: '#9A3412', bg: '#FFF7ED', text: '#C2410C', badgeBg: '#FED7AA' };
}
if (levels.includes('medium')) {
return { border: '#92400E', bg: '#FFFBEB', text: '#B45309', badgeBg: '#FDE68A' };
}
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 }}>
<div className="section-title" style={{ fontSize: 14 }}>
{label}
</div>
{action}
</div>
);
}
function formatStatusLabel(status) {
if (!status) {
return '-';
}
return STATUS_LABELS[status] || status.replace(/_/g, ' ');
}
function formatSessionLabel(sessionId) {
return sessionId || '无会话';
}
function formatEventLabel(eventName) {
if (!eventName) {
return '-';
}
const [group, action] = String(eventName).split(':');
if (group === 'cycle') {
if (action === 'start') return '周期开始';
if (action === 'complete') return '周期完成';
if (action === 'error') return '周期异常';
return '运行周期';
}
if (group === 'approval') {
if (action === 'created') return '创建审批';
if (action === 'approved') return '审批通过';
if (action === 'denied') return '审批拒绝';
if (action === 'expired') return '审批超时';
return '审批事件';
}
if (group === 'agent') {
if (action === 'status') return '状态更新';
if (action === 'registered') return '注册 Agent';
return 'Agent 事件';
}
return String(eventName).replace(/_/g, ' ');
}
export default function RuntimeView() {
const [runtimeState, setRuntimeState] = useState(null);
const [runtimeError, setRuntimeError] = useState(null);
const [isRuntimeLoading, setIsRuntimeLoading] = useState(false);
const [approvalActionId, setApprovalActionId] = useState(null);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
const [eventFilter, setEventFilter] = useState('all');
const refreshRuntimeState = () => {
setIsRuntimeLoading(true);
loadAllRuntimeState(
(state) => {
setRuntimeState(state);
setRuntimeError(null);
setIsRuntimeLoading(false);
},
(error) => {
setRuntimeError(error.message || '无法加载运行状态');
setIsRuntimeLoading(false);
}
);
};
useEffect(() => {
refreshRuntimeState();
}, []);
useEffect(() => {
if (!autoRefreshEnabled) {
return undefined;
}
const timer = window.setInterval(() => {
refreshRuntimeState();
}, AUTO_REFRESH_MS);
return () => window.clearInterval(timer);
}, [autoRefreshEnabled]);
const handleApprovalAction = async (approvalId, action) => {
setApprovalActionId(approvalId);
try {
if (action === 'approve') {
await approvePendingApproval(approvalId);
} else {
await denyPendingApproval(approvalId);
}
refreshRuntimeState();
} catch (error) {
setRuntimeError(error.message || '审批操作失败');
setIsRuntimeLoading(false);
} finally {
setApprovalActionId(null);
}
};
const agents = runtimeState?.agents || [];
const approvals = runtimeState?.approvals || [];
const events = runtimeState?.events || [];
const activeAgentsCount = agents.filter((agent) => agent.status && agent.status !== 'idle').length;
const visibleEvents = events
.filter((event) => eventFilter === 'all' || event.event.startsWith(eventFilter))
.slice()
.reverse();
return (
<div className="performance-page" style={{ height: '100%', minHeight: 0 }}>
<div className="section">
<div className="section-header">
<div>
<div className="section-title" style={{ fontSize: 18 }}>
运行态控制台
</div>
<div style={{
fontSize: 12,
color: '#666666',
marginTop: 4,
maxWidth: 760,
lineHeight: 1.5
}}>
查看当前运行上下文分析师状态待审批请求与近期事件这里是监控面板不再和运行设置挤在同一个小弹层里
</div>
</div>
<button
onClick={refreshRuntimeState}
disabled={isRuntimeLoading}
style={{
padding: '10px 14px',
borderRadius: 6,
border: '1px solid #111111',
background: isRuntimeLoading ? '#8A8A8A' : '#111111',
color: '#FFFFFF',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.4px',
cursor: isRuntimeLoading ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{isRuntimeLoading ? '刷新中' : '刷新运行态'}
</button>
</div>
</div>
<div className="section">
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
{metricCard('活跃 Agent', activeAgentsCount, '#2563EB', `${agents.length} 个 agent 已注册`)}
{metricCard('待审批', approvals.length, approvals.length > 0 ? '#C2410C' : '#059669', approvals.length > 0 ? '需要人工处理' : '当前无待处理审批')}
{metricCard('运行事件', events.length, '#111111', '最近运行阶段和状态变化')}
<div className="stat-card">
<div className="stat-card-label">
自动刷新
</div>
<button
onClick={() => setAutoRefreshEnabled((value) => !value)}
style={{
padding: '10px 12px',
border: '1px solid #000000',
background: autoRefreshEnabled ? '#000000' : '#FFFFFF',
color: autoRefreshEnabled ? '#FFFFFF' : '#000000',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.5px',
textTransform: 'uppercase',
cursor: 'pointer'
}}
>
{autoRefreshEnabled ? `开启 / ${AUTO_REFRESH_MS / 1000}` : '关闭'}
</button>
</div>
</div>
</div>
{runtimeError && (
<div className="section" style={{
borderColor: '#FF1744',
background: '#FFF5F7',
color: '#B91C1C',
fontSize: 12,
fontWeight: 700
}}>
{runtimeError}
</div>
)}
<div style={{
display: 'grid',
gap: 20,
alignContent: 'start'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(320px, 0.95fr) minmax(360px, 1.25fr)',
gap: 20,
alignItems: 'start'
}}>
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
{sectionTitle('运行上下文')}
{runtimeState?.context ? (
<div style={{
border: '1px solid #000000',
background: '#FAFAFA',
padding: 12,
display: 'grid',
gap: 10
}}>
<div>
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>配置名</div>
<div style={{ fontSize: 18, color: '#111111', fontWeight: 800, marginTop: 3 }}>
{runtimeState.context.config_name}
</div>
</div>
<div>
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>运行目录</div>
<div style={{ fontSize: 11, color: '#111111', lineHeight: 1.5, marginTop: 3, wordBreak: 'break-all' }}>
{runtimeState.context.run_dir}
</div>
</div>
<div>
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>启动参数</div>
<pre style={{
margin: '6px 0 0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 11,
lineHeight: 1.7,
color: '#111111',
fontFamily: '"Courier New", monospace'
}}>
{JSON.stringify(runtimeState.context.bootstrap_values || {}, null, 2)}
</pre>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无运行上下文</div>
)}
</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={{
display: 'grid',
gap: 10,
maxHeight: 640,
overflowY: 'auto',
paddingRight: 4
}}>
{approvals.length ? approvals.map((approval) => {
const tone = resolveApprovalTone(approval);
return (
<div
key={approval.approval_id}
style={{
border: `1px solid ${tone.border}`,
background: '#FFFFFF',
padding: 12,
display: 'grid',
gap: 8
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: '#111111' }}>
{approval.tool_name}
</div>
<div style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.5px',
padding: '4px 6px',
background: tone.badgeBg,
color: tone.text,
border: `1px solid ${tone.border}`,
textTransform: 'uppercase'
}}>
{formatStatusLabel(approval.status)}
</div>
</div>
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.5 }}>
{approval.agent_id} · {approval.workspace_id} · {formatSessionLabel(approval.session_id)}
</div>
{approval.tool_input && (
<pre style={{
margin: 0,
padding: 10,
background: '#FAFAFA',
border: '1px solid #000000',
fontSize: 11,
lineHeight: 1.6,
color: '#111111',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: '"Courier New", monospace'
}}>
{JSON.stringify(approval.tool_input, null, 2)}
</pre>
)}
{approval.findings?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{approval.findings.map((finding, index) => (
<span
key={`${approval.approval_id}-finding-${index}`}
style={{
padding: '4px 6px',
background: '#FFFFFF',
border: `1px solid ${tone.border}`,
color: tone.text,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase'
}}
>
{finding.severity}: {finding.message}
</span>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button
onClick={() => handleApprovalAction(approval.approval_id, 'deny')}
disabled={approvalActionId === approval.approval_id}
style={{
padding: '8px 10px',
border: '1px solid #000000',
background: '#FFFFFF',
color: '#000000',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
}}
>
拒绝
</button>
<button
onClick={() => handleApprovalAction(approval.approval_id, 'approve')}
disabled={approvalActionId === approval.approval_id}
style={{
padding: '8px 10px',
border: '1px solid #000000',
background: '#000000',
color: '#FFFFFF',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
}}
>
批准
</button>
</div>
</div>
)}) : (
<div style={{
border: '1px dashed #999999',
padding: 16,
fontSize: 12,
color: '#666666',
background: '#FAFAFA'
}}>
当前无待审批请求
</div>
)}
</div>
</section>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(320px, 1fr) minmax(360px, 1fr)',
gap: 20,
alignItems: 'start'
}}>
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
{sectionTitle('Agent 状态')}
<div style={{
display: 'grid',
gap: 8,
maxHeight: 420,
overflowY: 'auto',
paddingRight: 4
}}>
{runtimeState?.agents?.length ? runtimeState.agents.map((agent) => (
<div
key={agent.agent_id}
style={{
border: '1px solid #000000',
background: '#FAFAFA',
padding: 10,
display: 'grid',
gap: 4
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{agent.agent_id}</span>
<span style={{ fontSize: 11, color: '#2563EB', fontFamily: '"Courier New", monospace' }}>{formatStatusLabel(agent.status)}</span>
</div>
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
会话: {formatSessionLabel(agent.last_session)}
</div>
<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>
)}
</div>
</section>
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
{sectionTitle(
'近期事件',
<select
id="runtime-event-filter"
name="runtime_event_filter"
aria-label="筛选近期事件"
value={eventFilter}
onChange={(event) => setEventFilter(event.target.value)}
style={{
padding: '8px 10px',
border: '1px solid #000000',
background: '#FFFFFF',
color: '#000000',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase'
}}
>
{EVENT_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
筛选近期事件
</label>
<div style={{
display: 'grid',
gap: 8,
maxHeight: 420,
overflowY: 'auto',
paddingRight: 4
}}>
{visibleEvents.length ? visibleEvents.map((event, index) => (
<div
key={`${event.timestamp}-${event.event}-${index}`}
style={{
border: '1px solid #000000',
background: '#FAFAFA',
padding: 10,
display: 'grid',
gap: 4
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{formatEventLabel(event.event)}</span>
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>{formatSessionLabel(event.session)}</span>
</div>
<div style={{ fontSize: 10, color: '#6B7280' }}>{event.timestamp}</div>
{event.details && Object.keys(event.details).length > 0 && (
<pre style={{
margin: 0,
fontSize: 10,
lineHeight: 1.6,
color: '#374151',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: '"Courier New", monospace'
}}>
{JSON.stringify(event.details, null, 2)}
</pre>
)}
</div>
)) : (
<div style={{ fontSize: 12, color: '#9CA3AF' }}>当前筛选条件下暂无运行事件</div>
)}
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,685 @@
import React, { useState, useEffect } from 'react';
import { formatNumber, formatDateTime } from '../utils/formatters';
/**
* Statistics View Component
* Displays portfolio overview, holdings, and trade history in a side-by-side layout
* Left: Performance Overview (35%) | Right: Holdings + Trades (65%)
* No scrolling - content fits within viewport with pagination
*/
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) {
const [holdingsPage, setHoldingsPage] = useState(1);
const [tradesPage, setTradesPage] = useState(1);
const holdingsPerPage = 5;
const tradesPerPage = 8;
const effectiveStats = React.useMemo(() => {
const base = stats && typeof stats === 'object' ? stats : {};
const netValue = Number(portfolioData?.netValue ?? 0);
const pnl = Number(portfolioData?.pnl ?? 0);
const hasPortfolioValue = Number.isFinite(netValue) && netValue > 0;
const hasMeaningfulStats = Number(base?.totalAssetValue ?? 0) > 0;
if (hasMeaningfulStats || !hasPortfolioValue) {
return base;
}
const cashHolding = Array.isArray(holdings)
? holdings.find((item) => String(item?.ticker || '').toUpperCase() === 'CASH')
: null;
return {
...base,
totalAssetValue: netValue,
totalReturn: pnl,
cashPosition: Number(cashHolding?.marketValue ?? cashHolding?.currentPrice ?? 0),
totalTrades: Array.isArray(trades) ? trades.length : 0,
};
}, [holdings, portfolioData, stats, trades]);
// Calculate pagination for holdings
const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage);
const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage;
const holdingsEndIndex = holdingsStartIndex + holdingsPerPage;
const currentHoldings = holdings.slice(holdingsStartIndex, holdingsEndIndex);
// Calculate pagination for trades
const totalTradesPages = Math.ceil(trades.length / tradesPerPage);
const tradesStartIndex = (tradesPage - 1) * tradesPerPage;
const tradesEndIndex = tradesStartIndex + tradesPerPage;
const currentTrades = trades.slice(tradesStartIndex, tradesEndIndex);
// Calculate excess return (Evatraders return - benchmark value-weighted return)
const calculateExcessReturn = () => {
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) {
return null;
}
// Get Evatraders return from stats
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage
// Calculate benchmark return from baseline_vw
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
let benchmarkInitialValue, benchmarkCurrentValue;
if (baseline_vw.length > 0) {
const firstPoint = baseline_vw[0];
const lastPoint = baseline_vw[baseline_vw.length - 1];
benchmarkInitialValue = typeof firstPoint === 'object' ? firstPoint.v : firstPoint;
benchmarkCurrentValue = typeof lastPoint === 'object' ? lastPoint.v : lastPoint;
if (benchmarkInitialValue && benchmarkInitialValue > 0 && benchmarkCurrentValue) {
const benchmarkReturn = ((benchmarkCurrentValue - benchmarkInitialValue) / benchmarkInitialValue) * 100;
const excessReturn = evatradersReturn - benchmarkReturn;
return {
excessReturn: excessReturn,
benchmarkReturn: benchmarkReturn,
evatradersReturn: evatradersReturn
};
}
}
return null;
};
const excessReturnData = calculateExcessReturn();
// Calculate Portfolio Manager's win rate (similar logic to AgentCard)
const calculatePortfolioManagerWinRate = () => {
if (!leaderboard || !Array.isArray(leaderboard)) {
return null;
}
// Find portfolio_manager in leaderboard
const pmData = leaderboard.find(agent => agent.agentId === 'portfolio_manager');
if (!pmData) {
return null;
}
// Extract bull and bear data
const bullTotal = pmData.bull?.n || 0;
const bullWins = pmData.bull?.win || 0;
const bullUnknown = pmData.bull?.unknown || 0;
const bearTotal = pmData.bear?.n || 0;
const bearWins = pmData.bear?.win || 0;
const bearUnknown = pmData.bear?.unknown || 0;
// Calculate evaluated counts (exclude unknown)
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
const evaluatedTotal = evaluatedBull + evaluatedBear;
// Calculate win rate
const totalWins = bullWins + bearWins;
const winRate = evaluatedTotal > 0 ? (totalWins / evaluatedTotal) : null;
return {
winRate,
totalWins,
evaluatedTotal,
bullWins,
bearWins,
evaluatedBull,
evaluatedBear
};
};
const pmWinRateData = calculatePortfolioManagerWinRate();
// Reset to page 1 when data changes
useEffect(() => {
setHoldingsPage(1);
}, [holdings.length]);
useEffect(() => {
setTradesPage(1);
}, [trades.length]);
return (
<div style={{
display: 'flex',
height: '100%',
overflow: 'hidden',
background: '#f5f5f5'
}}>
{/* Left Panel: Performance Overview (35%) */}
<div style={{
width: '35%',
display: 'flex',
flexDirection: 'column',
background: '#ffffff',
borderRight: '2px solid #e0e0e0',
overflow: 'hidden'
}}>
{effectiveStats ? (
<div style={{
padding: '24px',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}>
{/* Header */}
<div style={{
marginBottom: 24,
paddingBottom: 16,
borderBottom: '3px solid #000000'
}}>
<h2 style={{
fontSize: 16,
fontWeight: 700,
letterSpacing: 2,
margin: 0,
color: '#000000',
textTransform: 'uppercase'
}}>
业绩表现
</h2>
</div>
{/* Main Stats - Hierarchical Layout */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Primary Metric - Total Asset Value */}
<div style={{
padding: '20px 0',
borderBottom: '1px solid #e0e0e0'
}}>
<div style={{
fontSize: 10,
color: '#666666',
fontWeight: 700,
letterSpacing: 1.5,
marginBottom: 12,
textTransform: 'uppercase'
}}>
总资产价值
</div>
<div style={{
fontSize: 36,
fontWeight: 700,
color: '#000000',
fontFamily: '"Courier New", monospace',
lineHeight: 1
}}>
${formatNumber(effectiveStats.totalAssetValue || 0)}
</div>
</div>
{/* Secondary Metrics - Grid: Excess Return, Win Rate, Absolute Return */}
<div style={{
display: 'grid',
gridTemplateColumns: excessReturnData ? '1fr 1fr 1fr' : '1fr 1fr',
gap: 16,
paddingBottom: 20,
borderBottom: '1px solid #e0e0e0'
}}>
{/* 1. Excess Return */}
{excessReturnData ? (
<div>
<div style={{
fontSize: 9,
color: '#999999',
fontWeight: 700,
letterSpacing: 1,
marginBottom: 8,
textTransform: 'uppercase'
}}>
超额收益
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: excessReturnData.excessReturn >= 0 ? '#00C853' : '#FF1744',
fontFamily: '"Courier New", monospace'
}}>
{excessReturnData.excessReturn >= 0 ? '+' : ''}{excessReturnData.excessReturn.toFixed(2)}%
</div>
<div style={{
fontSize: 7,
color: '#999999',
marginTop: 4,
fontFamily: '"Courier New", monospace'
}}>
vs 市值加权: {excessReturnData.benchmarkReturn >= 0 ? '+' : ''}{excessReturnData.benchmarkReturn.toFixed(2)}%
</div>
</div>
) : null}
{/* 2. Win Rate */}
<div>
<div style={{
fontSize: 9,
color: '#999999',
fontWeight: 700,
letterSpacing: 1,
marginBottom: 8,
textTransform: 'uppercase'
}}>
胜率
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: pmWinRateData?.winRate != null ? '#00C853' : '#000000',
fontFamily: '"Courier New", monospace'
}}>
{pmWinRateData?.winRate != null
? `${(pmWinRateData.winRate * 100).toFixed(1)}%`
: '暂无'}
</div>
{pmWinRateData && (
<div style={{
fontSize: 7,
color: '#999999',
marginTop: 4,
fontFamily: '"Courier New", monospace'
}}>
{pmWinRateData.totalWins} / {pmWinRateData.evaluatedTotal}
</div>
)}
</div>
{/* 3. Absolute Return */}
<div>
<div style={{
fontSize: 9,
color: '#999999',
fontWeight: 700,
letterSpacing: 1,
marginBottom: 8,
textTransform: 'uppercase'
}}>
绝对收益
</div>
<div style={{
fontSize: 28,
fontWeight: 700,
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
fontFamily: '"Courier New", monospace'
}}>
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
</div>
</div>
</div>
{/* Tertiary Metrics - Compact List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
padding: '8px 0',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{
fontSize: 10,
color: '#666666',
fontWeight: 600,
letterSpacing: 0.5,
textTransform: 'uppercase'
}}>
现金头寸
</div>
<div style={{
fontSize: 16,
fontWeight: 700,
color: '#000000',
fontFamily: '"Courier New", monospace'
}}>
${formatNumber(effectiveStats.cashPosition || 0)}
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
padding: '8px 0',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{
fontSize: 10,
color: '#666666',
fontWeight: 600,
letterSpacing: 0.5,
textTransform: 'uppercase'
}}>
总交易数
</div>
<div style={{
fontSize: 16,
fontWeight: 700,
color: '#000000',
fontFamily: '"Courier New", monospace'
}}>
{effectiveStats.totalTrades || 0}
</div>
</div>
</div>
{/* Ticker Weights - Compact */}
{effectiveStats?.tickerWeights && Object.keys(effectiveStats.tickerWeights).length > 0 && (
<div style={{
marginTop: 'auto',
paddingTop: 20,
borderTop: '1px solid #e0e0e0'
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
marginBottom: 12,
letterSpacing: 1,
textTransform: 'uppercase',
color: '#666666'
}}>
组合权重
</div>
<div className="statistics-table-container" style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 8,
maxHeight: 120
}}>
{Object.entries(effectiveStats.tickerWeights).map(([ticker, weight]) => {
const weightValue = Number(weight);
const isNegative = weightValue < 0;
const displayWeight = (weightValue * 100).toFixed(1);
return (
<div key={ticker} style={{
padding: '6px 10px',
background: '#fafafa',
border: '1px solid #e0e0e0',
fontSize: 10,
fontWeight: 700,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: '"Courier New", monospace'
}}>
<span style={{ color: '#000000' }}>{ticker}</span>
<span style={{ color: isNegative ? '#FF1744' : '#00C853' }}>
{displayWeight}%
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999999',
fontSize: 12,
letterSpacing: 0.5
}}>
暂无统计数据
</div>
)}
</div>
{/* Right Panel: Holdings + Trades (65%) */}
<div style={{
width: '65%',
display: 'flex',
flexDirection: 'column',
background: '#ffffff',
overflow: 'hidden'
}}>
{/* Portfolio Holdings - Top Half */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#ffffff',
margin: '16px 16px 8px 16px',
border: '1px solid #e0e0e0',
overflow: 'hidden'
}}>
<div style={{
padding: '16px 20px',
borderBottom: '2px solid #000000',
flexShrink: 0
}}>
<h2 style={{
fontSize: 13,
fontWeight: 700,
letterSpacing: 1.5,
margin: 0,
color: '#000000',
textTransform: 'uppercase'
}}>
持仓明细
</h2>
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{holdings.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999999',
fontSize: 11,
letterSpacing: 0.5
}}>
当前无持仓
</div>
) : (
<>
<div className="statistics-table-container" style={{ flex: 1 }}>
<table className="data-table">
<thead>
<tr>
<th>代码</th>
<th>数量</th>
<th>价格</th>
<th>市值</th>
<th>权重</th>
</tr>
</thead>
<tbody>
{currentHoldings.map(h => {
// For short positions, quantity should be negative and weight should also be negative
const isShort = h.ticker !== 'CASH' && Number(h.quantity) < 0;
const displayWeight = isShort ? -Math.abs(Number(h.weight)) : Number(h.weight);
return (
<tr key={h.ticker}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, color: '#000000' }}>{h.ticker}</span>
</div>
</td>
<td>{h.ticker === 'CASH' ? '-' : h.quantity}</td>
<td>{h.ticker === 'CASH' ? '-' : `$${Number(h.currentPrice).toFixed(2)}`}</td>
<td style={{ fontWeight: 700 }}>${formatNumber(h.marketValue)}</td>
<td style={{ color: isShort ? '#FF1744' : '#000000' }}>
{(displayWeight * 100).toFixed(2)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalHoldingsPages > 1 && (
<div style={{
padding: '12px 20px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
background: '#fafafa'
}}>
<button
className="pagination-btn"
onClick={() => setHoldingsPage(p => Math.max(1, p - 1))}
disabled={holdingsPage === 1}
>
上一页
</button>
<div className="pagination-info">
{holdingsPage} / {totalHoldingsPages}
</div>
<button
className="pagination-btn"
onClick={() => setHoldingsPage(p => Math.min(totalHoldingsPages, p + 1))}
disabled={holdingsPage === totalHoldingsPages}
>
下一页
</button>
</div>
)}
</>
)}
</div>
</div>
{/* Transaction History - Bottom Half */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#ffffff',
margin: '8px 16px 16px 16px',
border: '1px solid #e0e0e0',
overflow: 'hidden'
}}>
<div style={{
padding: '16px 20px',
borderBottom: '2px solid #000000',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0
}}>
<h2 style={{
fontSize: 13,
fontWeight: 700,
letterSpacing: 1.5,
margin: 0,
color: '#000000',
textTransform: 'uppercase'
}}>
交易历史
</h2>
{trades.length > 0 && (
<div style={{
fontSize: 10,
color: '#666666',
fontFamily: '"Courier New", monospace'
}}>
{trades.length}
</div>
)}
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{trades.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999999',
fontSize: 11,
letterSpacing: 0.5
}}>
暂无交易记录
</div>
) : (
<>
<div className="statistics-table-container" style={{ flex: 1 }}>
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>股票</th>
<th>方向</th>
<th>数量</th>
<th>价格</th>
</tr>
</thead>
<tbody>
{currentTrades.map((t, idx) => (
<tr key={t.id || `${t.ticker}-${t.timestamp}-${idx}`}>
<td style={{ fontSize: 10, color: '#666666', fontFamily: '"Courier New", monospace' }}>
{formatDateTime(t.timestamp)}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, color: '#000000' }}>{t.ticker}</span>
</div>
</td>
<td>
<span style={{
display: 'inline-block',
padding: '2px 6px',
fontSize: 9,
fontWeight: 700,
border: `1px solid ${t.side === 'LONG' ? '#00C853' : t.side === 'SHORT' ? '#FF1744' : '#666666'}`,
color: t.side === 'LONG' ? '#00C853' : t.side === 'SHORT' ? '#FF1744' : '#666666'
}}>
{t.side}
</span>
</td>
<td>{t.qty}</td>
<td>${Number(t.price).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalTradesPages > 1 && (
<div style={{
padding: '12px 20px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
background: '#fafafa'
}}>
<button
className="pagination-btn"
onClick={() => setTradesPage(p => Math.max(1, p - 1))}
disabled={tradesPage === 1}
>
上一页
</button>
<div className="pagination-info">
{tradesPage} / {totalTradesPages}
</div>
<button
className="pagination-btn"
onClick={() => setTradesPage(p => Math.min(totalTradesPages, p + 1))}
disabled={tradesPage === totalTradesPages}
>
下一页
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useEffect, useState } from 'react';
import ExplainNewsSection from './explain/ExplainNewsSection';
import ExplainPriceSection from './explain/ExplainPriceSection';
import ExplainInsiderSection from './explain/ExplainInsiderSection';
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
import useExplainModel from './explain/useExplainModel';
import { formatNumber, formatTickerPrice } from '../utils/formatters';
export default function StockExplainView({
tickers,
holdings,
trades,
leaderboard,
feed,
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedSymbol,
onSelectedSymbolChange,
selectedHistorySource,
newsSnapshot,
insiderTradesSnapshot,
technicalIndicatorsSnapshot,
onRequestHistory,
onRequestNews,
onRequestInsiderTrades,
onRequestTechnicalIndicators,
}) {
const [activeNewsCategory, setActiveNewsCategory] = useState('all');
const [activeNewsSentiment, setActiveNewsSentiment] = useState('all');
const [isPriceOpen, setIsPriceOpen] = useState(true);
const [isNewsOpen, setIsNewsOpen] = useState(true);
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
const {
availableSymbols,
selectedTicker,
holding,
tickerNews,
visibleNews,
newsCategories,
visibleNewsByCategory,
selectedNewsFreshness,
priceColor,
exposureWeight,
ohlcSeries,
priceSeries,
chartModel
} = useExplainModel({
tickers,
holdings,
trades,
leaderboard,
feed,
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedSymbol,
newsSnapshot,
selectedEventDate: '',
activeEventCategory: 'all',
activeNewsCategory,
activeNewsSentiment
});
useEffect(() => {
if (!availableSymbols.length) {
onSelectedSymbolChange?.('');
return;
}
if (!selectedSymbol || !availableSymbols.includes(selectedSymbol)) {
onSelectedSymbolChange?.(availableSymbols[0]);
}
}, [availableSymbols, onSelectedSymbolChange, selectedSymbol]);
useEffect(() => {
setActiveNewsCategory('all');
setActiveNewsSentiment('all');
}, [selectedSymbol]);
useEffect(() => {
if (!selectedSymbol) {
return;
}
if (onRequestHistory && (!Array.isArray(ohlcHistoryByTicker?.[selectedSymbol]) || ohlcHistoryByTicker[selectedSymbol].length === 0)) {
onRequestHistory(selectedSymbol);
}
if (onRequestNews && (!Array.isArray(newsSnapshot?.items) || newsSnapshot.items.length === 0)) {
onRequestNews(selectedSymbol);
}
}, [
newsSnapshot,
ohlcHistoryByTicker,
onRequestHistory,
onRequestNews,
selectedSymbol,
]);
useEffect(() => {
if (!selectedSymbol || !onRequestTechnicalIndicators) {
return;
}
if (technicalIndicatorsSnapshot) {
return;
}
onRequestTechnicalIndicators(selectedSymbol);
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
return (
<div className="performance-page">
<div className="section">
<div className="section-header">
<h2 className="section-title">个股分析</h2>
<div className="section-tabs" style={{ flexWrap: 'wrap', maxWidth: '100%' }}>
{availableSymbols.map((symbol) => (
<button
key={symbol}
className={`section-tab ${selectedSymbol === symbol ? 'active' : ''}`}
onClick={() => onSelectedSymbolChange?.(symbol)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<span>{symbol}</span>
</button>
))}
</div>
</div>
{!selectedSymbol ? (
<div className="empty-state">暂无可解释股票</div>
) : (
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<div className="stat-card">
<div className="stat-card-label">当前价格</div>
<div className="stat-card-value" style={{ color: priceColor }}>
{selectedTicker?.price != null ? `$${formatTickerPrice(selectedTicker.price)}` : '-'}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: priceColor, fontWeight: 700 }}>
{selectedTicker?.change != null ? `${selectedTicker.change >= 0 ? '+' : ''}${selectedTicker.change.toFixed(2)}%` : '暂无涨跌幅'}
</div>
</div>
<div className="stat-card">
<div className="stat-card-label">当前仓位</div>
<div className="stat-card-value">
{holding ? Number(holding.quantity) : 0}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
{holding ? `持仓市值 $${formatNumber(holding.marketValue || 0)}` : '当前无持仓'}
</div>
</div>
<div className="stat-card">
<div className="stat-card-label">组合权重</div>
<div className={`stat-card-value ${exposureWeight > 0 ? 'positive' : exposureWeight < 0 ? 'negative' : ''}`}>
{exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
{holding ? `最新价格 $${Number(holding.currentPrice || 0).toFixed(2)}` : '未进入投资组合'}
</div>
</div>
</div>
)}
</div>
{selectedSymbol && (
<>
<ExplainPriceSection
ohlcSeries={ohlcSeries}
priceSeries={priceSeries}
selectedHistorySource={selectedHistorySource}
chartModel={chartModel}
selectedTicker={selectedTicker}
isOpen={isPriceOpen}
onToggle={() => setIsPriceOpen((prev) => !prev)}
/>
<ExplainNewsSection
newsSnapshot={newsSnapshot}
visibleNewsByCategory={visibleNewsByCategory}
visibleNews={visibleNews}
selectedNewsFreshness={selectedNewsFreshness}
activeNewsCategory={activeNewsCategory}
onSelectNewsCategory={setActiveNewsCategory}
activeNewsSentiment={activeNewsSentiment}
onSelectNewsSentiment={setActiveNewsSentiment}
newsCategories={newsCategories}
tickerNews={tickerNews}
isOpen={isNewsOpen}
onToggle={() => setIsNewsOpen((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)}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,932 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import JSZip from 'jszip';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
import LobeModelLogo from './LobeModelLogo.jsx';
export default function TraderView({
agents,
agentProfilesByAgent,
agentSkillsByAgent,
workspaceFilesByAgent,
selectedAgentId,
selectedAgentProfile,
selectedAgentSkills,
skillDetailsByName,
localSkillDraftsByKey,
skillDetailLoadingKey,
editableFiles,
selectedWorkspaceFile,
workspaceFileContent,
workspaceDraftContent,
isConnected,
isAgentSkillsLoading,
agentSkillsSavingKey,
agentSkillsFeedback,
isWorkspaceFileLoading,
workspaceFileSavingKey,
workspaceFileFeedback,
onAgentChange,
onCreateLocalSkill,
onSkillDetailRequest,
onLocalSkillDraftChange,
onLocalSkillDelete,
onLocalSkillSave,
onRemoveSharedSkill,
onSkillToggle,
onWorkspaceFileChange,
onWorkspaceDraftChange,
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);
const [isExternalSkillChecking, setIsExternalSkillChecking] = useState(false);
const [externalSkillCheck, setExternalSkillCheck] = useState({ type: null, text: '' });
const [isSkillPickerOpen, setIsSkillPickerOpen] = useState(false);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.id === selectedAgentId) || agents[0] || null,
[agents, selectedAgentId]
);
useEffect(() => {
setExpandedSkillKey(null);
}, [selectedAgentId]);
if (!selectedAgent) {
return null;
}
const profile = selectedAgentProfile || {};
const modelInfo = getModelIcon(profile.model_name, profile.model_provider);
const activeSkills = selectedAgentSkills.filter((item) => item.status === 'enabled' || item.status === 'active');
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%',
overflow: 'hidden',
padding: '18px',
background: 'linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)',
display: 'grid',
gridTemplateRows: 'auto auto 1fr',
gap: 18
}}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.5px', color: '#111111' }}>
交易员档案
</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
聚焦查看每个 Agent 的模型工具组技能编排和工作区记忆不展示交易表现数据
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '120px minmax(0, 1fr)',
gap: 16,
alignItems: 'stretch',
minHeight: 0,
overflow: 'hidden'
}}>
{/* Left: agent avatar list */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 12,
display: 'grid',
gap: 10,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
{agents.map((agent) => {
const isSelected = agent.id === selectedAgentId;
return (
<button
key={agent.id}
type="button"
onClick={() => onAgentChange(agent.id)}
title={agent.name}
style={{
border: isSelected ? `2px solid ${agent.colors.accent}` : '1px solid #D9E0E7',
borderRadius: 16,
background: isSelected ? `${agent.colors.accent}10` : '#FFFFFF',
boxShadow: isSelected ? `0 10px 20px ${agent.colors.accent}18` : 'none',
padding: 8,
display: 'grid',
gap: 6,
justifyItems: 'center',
cursor: 'pointer'
}}
>
<img
src={agent.avatar}
alt={agent.name}
style={{
width: 56,
height: 56,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${agent.colors.accent}33`
}}
/>
<div style={{
fontSize: 10,
fontWeight: 800,
color: isSelected ? agent.colors.accent : '#374151',
textAlign: 'center',
lineHeight: 1.4
}}>
{agent.name}
</div>
</button>
);
})}
</div>
{/* Right: agent detail content */}
<div style={{
border: '1px solid #D9E0E7',
borderRadius: 14,
background: '#FFFFFF',
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
padding: 18,
display: 'grid',
gap: 16,
minHeight: 0,
overflowY: 'auto',
alignContent: 'start'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img
src={selectedAgent.avatar}
alt={selectedAgent.name}
style={{
width: 58,
height: 58,
borderRadius: 12,
objectFit: 'cover',
border: `1px solid ${selectedAgent.colors.accent}33`
}}
/>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: '#111111' }}>{selectedAgent.name}</div>
<div style={{ fontSize: 12, color: '#6B7280' }}>{selectedAgent.role}</div>
<div style={{ fontSize: 11, color: selectedAgent.colors.accent, fontWeight: 700 }}>
当前档案已展开
</div>
</div>
</div>
<div style={{
border: `1px solid ${modelInfo.color}2e`,
background: modelInfo.bgColor,
borderRadius: 12,
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
gap: 10
}}>
<LobeModelLogo
model={profile.model_name}
provider={profile.model_provider}
fallbackSrc={modelInfo.logoPath}
alt={modelInfo.provider}
size={26}
shape="circle"
type="color"
style={{ borderRadius: 999 }}
/>
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>
{getShortModelName(profile.model_name)}
</div>
</div>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)',
gap: 16,
alignItems: 'start',
minHeight: 0
}}>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
<div style={{ display: 'grid', gap: 2 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>技能</div>
<div style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
已启用: {activeSkills.length} / 已安装: {installedSkills.length}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
type="button"
onClick={() => setIsSkillPickerOpen(true)}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #1565C0',
background: '#EFF6FF',
color: '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
aria-label="管理技能"
>
技能管理
</button>
</div>
</div>
<div style={{
border: '1px solid #E5E7EB',
background: '#F8FAFC',
borderRadius: 8,
padding: '10px 12px',
display: 'grid',
gap: 10,
maxHeight: 520,
overflowY: 'auto'
}}>
{isAgentSkillsLoading ? (
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>加载技能中...</div>
) : installedSkills.length === 0 ? (
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>暂无技能</div>
) : installedSkills.map((skill) => {
const isEnabled = skill.status === 'enabled' || skill.status === 'active';
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:content` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:delete` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:remove`;
const isExpanded = expandedSkillKey === skill.skill_name;
const detailKey = `${selectedAgentId}:${skill.skill_name}`;
const skillDetail = skillDetailsByName?.[detailKey] || null;
const skillDraft = localSkillDraftsByKey?.[detailKey] ?? '';
const isDetailLoading = skillDetailLoadingKey === detailKey;
const isLocalSkill = skill.source === 'local';
return (
<div
key={skill.skill_name}
style={{
display: 'grid',
gap: 7,
paddingBottom: 10,
borderBottom: '1px dashed #D7DEE7'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
<button
type="button"
onClick={() => {
if (!isExpanded && !skillDetail && onSkillDetailRequest) {
onSkillDetailRequest(skill.skill_name);
}
setExpandedSkillKey((prev) => (prev === skill.skill_name ? null : skill.skill_name));
}}
style={{
flex: 1,
minWidth: 0,
border: 'none',
background: 'transparent',
padding: 0,
textAlign: 'left',
cursor: 'pointer',
display: 'grid',
gap: 4
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#6B7280', fontWeight: 700 }}>
{isExpanded ? '▾' : '▸'}
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
{skill.name || '未命名技能'}
</span>
<span style={{
padding: '2px 6px',
borderRadius: 999,
border: `1px solid ${isLocalSkill ? selectedAgent.colors.accent : '#D0D7DE'}`,
color: isLocalSkill ? selectedAgent.colors.accent : '#6B7280',
fontSize: 9,
fontWeight: 700
}}>
{isLocalSkill ? '本地' : '共享'}
</span>
</div>
<div style={{ fontSize: 11, color: '#4B5563', marginLeft: 20 }}>
{skill.description || '-'}
</div>
<div style={{ fontSize: 10, color: '#6B7280', marginLeft: 20 }}>
{isExpanded ? '点击收起详情' : '点击展开详情'}
</div>
</button>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
type="button"
onClick={() => onSkillToggle(skill.skill_name, !isEnabled)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: `1px solid ${isEnabled ? '#C62828' : '#1565C0'}`,
background: isConnected && !saving ? (isEnabled ? '#FFF5F5' : '#EFF6FF') : '#E5E7EB',
color: isEnabled ? '#C62828' : '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : isEnabled ? '禁用' : '启用'}
</button>
{isLocalSkill ? (
<button
type="button"
onClick={() => onLocalSkillDelete(skill.skill_name)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #C62828',
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
color: '#C62828',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '删除'}
</button>
) : (
<button
type="button"
onClick={() => onRemoveSharedSkill(skill.skill_name)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #C62828',
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
color: '#C62828',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '移除'}
</button>
)}
</div>
</div>
{isExpanded && (
<div style={{
marginLeft: 20,
borderRadius: 8,
border: '1px solid #E5E7EB',
background: '#FFFFFF',
padding: '10px 12px',
display: 'grid',
gap: 8
}}>
<div style={{
fontSize: 11,
color: '#1F2937',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
fontFamily: '"Courier New", monospace'
}}>
{isDetailLoading
? '加载技能说明中...'
: (skillDetail?.content || '暂无更详细的技能说明')}
</div>
{isLocalSkill && !isDetailLoading && (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 10, color: '#6B7280', fontWeight: 700 }}>
本地技能 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={{
minHeight: 220,
resize: 'vertical',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
padding: '10px 12px',
fontSize: 11,
lineHeight: 1.6,
fontFamily: '"Courier New", monospace'
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => onLocalSkillSave(skill.skill_name)}
disabled={!isConnected || saving || skillDraft === (skillDetail?.content || '')}
style={{
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? 'pointer' : 'not-allowed'
}}
>
{saving ? '保存中' : '保存本地技能'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{agentSkillsFeedback && (
<span style={{
color: agentSkillsFeedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}>
{agentSkillsFeedback.text}
</span>
)}
</div>
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>工作区文件编辑</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
直接调整该交易员的人设协作方式和长期记忆文件
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{editableFiles.map((filename) => {
const isActive = filename === selectedWorkspaceFile;
return (
<button
key={filename}
onClick={() => onWorkspaceFileChange(filename)}
style={{
padding: '7px 10px',
borderRadius: 999,
border: `1px solid ${isActive ? selectedAgent.colors.accent : '#D0D7DE'}`,
background: isActive ? `${selectedAgent.colors.accent}12` : '#FFFFFF',
color: isActive ? selectedAgent.colors.accent : '#4B5563',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer',
fontFamily: '"Courier New", monospace'
}}
>
{filename}
</button>
);
})}
</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 内容'}
style={{
minHeight: 280,
resize: 'vertical',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
padding: '12px 14px',
fontSize: 12,
lineHeight: 1.6,
fontFamily: '"Courier New", monospace'
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
当前文件: {selectedWorkspaceFile}
</span>
<button
onClick={onWorkspaceFileSave}
disabled={!isConnected || isWorkspaceFileLoading || workspaceFileSavingKey !== null || workspaceDraftContent === workspaceFileContent}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? 'pointer' : 'not-allowed'
}}
>
{workspaceFileSavingKey ? '保存中' : '保存文件'}
</button>
</div>
{workspaceFileFeedback && (
<span style={{
color: workspaceFileFeedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}>
{workspaceFileFeedback.text}
</span>
)}
</div>
</div>
</div>
</div>
</div>
{isSkillPickerOpen && createPortal((
<div
onClick={() => setIsSkillPickerOpen(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.28)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
zIndex: 9998
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(760px, 92vw)',
maxHeight: '80vh',
overflowY: 'auto',
borderRadius: 16,
border: '1px solid #D9E0E7',
background: '#FFFFFF',
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
padding: 18,
paddingTop: 22,
display: 'grid',
gap: 16,
position: 'relative',
zIndex: 9999
}}
>
<button
type="button"
onClick={() => setIsSkillPickerOpen(false)}
style={{
position: 'absolute',
top: 16,
right: 16,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
borderRadius: 999,
width: 40,
height: 40,
fontSize: 16,
lineHeight: 1,
color: '#111111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
}}
aria-label="关闭技能管理"
>
×
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>技能管理</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
{selectedAgent.name} 添加共享技能或创建本地技能
</div>
</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={{ 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"
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}
/>
<button
type="button"
onClick={() => {
if (onCreateLocalSkill) {
onCreateLocalSkill(newLocalSkillName);
setNewLocalSkillName('');
}
}}
disabled={!isConnected || !newLocalSkillName.trim()}
style={{
padding: '8px 12px',
borderRadius: 8,
border: '1px solid #1565C0',
background: isConnected && newLocalSkillName.trim() ? '#EFF6FF' : '#E5E7EB',
color: '#1565C0',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && newLocalSkillName.trim() ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
创建
</button>
</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' }}>
<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) => {
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,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>添加共享技能</div>
<div style={{
border: '1px solid #E5E7EB',
background: '#FFFFFF',
borderRadius: 8,
padding: '10px 12px',
display: 'grid',
gap: 10,
maxHeight: 360,
overflowY: 'auto'
}}>
{availableSkills.length === 0 ? (
<div style={{ fontSize: 11, color: '#6B7280' }}>没有可添加的共享技能</div>
) : availableSkills.map((skill) => {
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}`;
return (
<div
key={skill.skill_name}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 12,
alignItems: 'flex-start',
paddingBottom: 10,
borderBottom: '1px dashed #D7DEE7'
}}
>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
{skill.name || skill.skill_name}
</span>
<span style={{
padding: '2px 6px',
borderRadius: 999,
border: '1px solid #D0D7DE',
color: '#6B7280',
fontSize: 9,
fontWeight: 700
}}>
共享
</span>
</div>
<div style={{ fontSize: 11, color: '#4B5563' }}>
{skill.description || '-'}
</div>
</div>
<button
type="button"
onClick={() => onSkillToggle(skill.skill_name, true)}
disabled={!isConnected || saving}
style={{
padding: '7px 10px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !saving ? '#EFF6FF' : '#E5E7EB',
color: '#1565C0',
fontSize: 10,
fontWeight: 700,
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{saving ? '处理中' : '添加'}
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
), document.body)}
</div>
);
}

View File

@@ -0,0 +1,262 @@
import React from 'react';
export default function WatchlistPanel({
isOpen,
isConnected,
isSaving,
draftSymbols,
inputValue,
feedback,
suggestions,
onToggle,
onClose,
onInputChange,
onInputKeyDown,
onAdd,
onRemove,
onRestoreCurrent,
onRestoreDefault,
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
onClick={onToggle}
style={{
padding: '6px 10px',
borderRadius: 4,
border: '1px solid #333333',
background: isOpen ? '#1E1E1E' : '#111111',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.6px',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
自选股
</button>
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 10px)',
right: 0,
width: 360,
maxWidth: 'min(360px, 92vw)',
padding: '14px',
borderRadius: 8,
border: '1px solid #D9D9D9',
background: '#FFFFFF',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
zIndex: 40,
display: 'flex',
flexDirection: 'column',
gap: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
自选股管理
</div>
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
保存后会立即更新当前 run watchlist
</div>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: '#666666',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1
}}
>
×
</button>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
minHeight: 36,
padding: '2px 0'
}}>
{draftSymbols.map((symbol) => (
<button
key={symbol}
onClick={() => onRemove(symbol)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
border: '1px solid #D0D7DE',
background: '#F7F9FB',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
<span>{symbol}</span>
<span style={{ color: '#777777' }}>×</span>
</button>
))}
{draftSymbols.length === 0 && (
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
还没有股票输入代码后回车添加
</div>
)}
</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}
placeholder="输入股票代码,回车添加"
style={{
flex: 1,
padding: '9px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
<button
onClick={onAdd}
style={{
padding: '9px 12px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#F7F9FB',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
添加
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestions.map((symbol) => {
const active = draftSymbols.includes(symbol);
return (
<button
key={symbol}
onClick={() => onSuggestionClick(symbol)}
disabled={active}
style={{
padding: '5px 8px',
borderRadius: 999,
border: '1px solid',
borderColor: active ? '#B6E3C5' : '#D0D7DE',
background: active ? '#ECFDF3' : '#FFFFFF',
color: active ? '#157347' : '#4A5568',
fontSize: '10px',
fontWeight: 700,
cursor: active ? 'default' : 'pointer'
}}
>
{symbol}
</button>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={onRestoreCurrent}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复当前
</button>
<button
onClick={onRestoreDefault}
style={{
padding: '8px 10px',
borderRadius: 6,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '11px',
fontWeight: 700,
cursor: 'pointer'
}}
>
恢复默认
</button>
</div>
<button
onClick={onSave}
disabled={!isConnected || isSaving}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.4px',
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
}}
>
{isSaving ? '保存中' : '保存'}
</button>
</div>
{feedback && (
<span style={{
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: '11px',
fontFamily: '"Courier New", monospace'
}}>
{feedback.text}
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainEventsSection({
explainTimeline,
isOpen,
onToggle,
availableEventDates,
selectedEventDate,
onSelectEventDate,
eventCategoryCounts,
activeEventCategory,
onSelectEventCategory,
eventCategoryMeta,
visibleExplainEvents,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">关键事件时间线</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
图上点击事件点可切换对应日期
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起关键事件' : `展开关键事件 ${explainTimeline.length}`}
</button>
</div>
</div>
{explainTimeline.length === 0 ? (
<div className="empty-state">当前还没有可以串起来看的关键事件</div>
) : !isOpen ? (
<div className="empty-state">关键事件默认收起需要时再展开查看和筛选</div>
) : (
<div style={{ display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{availableEventDates.map((dateKey) => {
const isActive = dateKey === selectedEventDate;
return (
<button
key={dateKey}
onClick={() => onSelectEventDate(dateKey)}
style={{
border: '1px solid #111111',
background: isActive ? '#111111' : '#ffffff',
color: isActive ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{dateKey}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(eventCategoryMeta)
.filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all')
.map(([key, meta]) => {
const isActive = key === activeEventCategory;
return (
<button
key={key}
onClick={() => onSelectEventCategory(key)}
style={{
border: `1px solid ${meta.color}`,
background: isActive ? meta.color : '#ffffff',
color: isActive ? '#ffffff' : meta.color,
padding: '8px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{meta.label} {eventCategoryCounts[key] || 0}
</button>
);
})}
</div>
{visibleExplainEvents.length === 0 ? (
<div className="empty-state">当前日期下没有符合筛选条件的事件</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{visibleExplainEvents.map((event) => {
const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000';
const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other;
return (
<div
key={event.id}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
minHeight: 180
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: `1px solid ${categoryMeta.color}`,
color: categoryMeta.color,
fontSize: 10,
fontWeight: 700
}}>
{categoryMeta.label}
</span>
<strong style={{ fontSize: 13 }}>{event.title}</strong>
</div>
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(event.timestamp)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
background: accent
}} />
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
{event.meta}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{event.body}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { formatDateTime, formatNumber } from '../../utils/formatters';
export default function ExplainInsiderSection({
insiderTrades,
selectedSymbol,
isOpen,
onToggle,
onRequest,
}) {
const handleRefresh = () => {
if (onRequest) {
onRequest(selectedSymbol);
}
};
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">内部人交易</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{insiderTrades.length} 笔内部人交易记录
</div>
<button
onClick={handleRefresh}
style={{
border: '1px solid #111111',
background: '#ffffff',
color: '#111111',
padding: '5px 8px',
fontFamily: 'inherit',
fontSize: 10,
cursor: 'pointer'
}}
>
刷新
</button>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起' : `展开 ${insiderTrades.length}`}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">点击展开查看内部人交易详情</div>
) : insiderTrades.length === 0 ? (
<div className="empty-state">暂无内部人交易数据</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>交易日期</th>
<th>内部人</th>
<th>职位</th>
<th>方向</th>
<th>股份数</th>
<th>价格</th>
<th>持仓变化</th>
</tr>
</thead>
<tbody>
{insiderTrades.slice(0, 20).map((trade, index) => {
const isBuy = trade.is_buy;
const holdingChange = trade.holding_change;
return (
<tr key={trade.transaction_date + '-' + trade.name + '-' + index}>
<td>{trade.transaction_date || '-'}</td>
<td>{trade.name || '-'}</td>
<td>{trade.title || '-'}</td>
<td style={{
fontWeight: 700,
color: isBuy === true ? '#00C853' : isBuy === false ? '#FF1744' : '#666666'
}}>
{isBuy === true ? '买入' : isBuy === false ? '卖出' : '-'}
</td>
<td>{trade.transaction_shares != null ? formatNumber(trade.transaction_shares) : '-'}</td>
<td>${trade.transaction_price_per_share != null ? Number(trade.transaction_price_per_share).toFixed(2) : '-'}</td>
<td style={{
color: holdingChange != null ? (holdingChange > 0 ? '#00C853' : '#FF1744') : '#666666',
fontWeight: holdingChange != null ? 700 : 400
}}>
{holdingChange != null ? (holdingChange > 0 ? '+' : '') + formatNumber(holdingChange) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,249 @@
import React from 'react';
function toggleButtonStyle(active, accent = '#111111') {
return {
border: `1px solid ${accent}`,
background: active ? accent : '#ffffff',
color: active ? '#ffffff' : accent,
padding: '6px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
};
}
export default function ExplainMaintenanceSection({
selectedSymbol,
enrichStartDate,
enrichEndDate,
onChangeStartDate,
onChangeEndDate,
forceEnrich,
onToggleForce,
onlyLocalToLlm,
onToggleOnlyLocalToLlm,
rebuildStory,
onToggleRebuildStory,
rebuildSimilarDays,
onToggleRebuildSimilarDays,
isRunning,
onRunEnrich,
maintenanceStatus,
maintenanceHistory,
onSelectHistory,
onReplayHistory,
isOpen,
onToggle,
}) {
const stats = maintenanceStatus?.stats || null;
const summary = stats?.execution_summary || null;
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析数据维护</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
当前标的 {selectedSymbol || '-'}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起刷新工具' : '展开刷新工具'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">刷新工具默认收起需要时再展开重新分析数据或查看历史</div>
) : (
<div
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
display: 'grid',
gap: 14,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
开始日期
<input type="date" value={enrichStartDate} onChange={(e) => onChangeStartDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
</label>
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
结束日期
<input type="date" value={enrichEndDate} onChange={(e) => onChangeEndDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={onToggleForce} style={toggleButtonStyle(forceEnrich, '#b91c1c')}>
{forceEnrich ? '覆盖已有分析' : '仅补缺失'}
</button>
<button onClick={onToggleOnlyLocalToLlm} style={toggleButtonStyle(onlyLocalToLlm, '#7c3aed')}>
{onlyLocalToLlm ? '仅将规则分析升级为 LLM分析' : '不限制分析来源'}
</button>
<button onClick={onToggleRebuildStory} style={toggleButtonStyle(rebuildStory, '#2563eb')}>
{rebuildStory ? '重建主线叙事' : '跳过主线叙事'}
</button>
<button onClick={onToggleRebuildSimilarDays} style={toggleButtonStyle(rebuildSimilarDays, '#15803d')}>
{rebuildSimilarDays ? '重建相似交易日' : '跳过相似交易日'}
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onRunEnrich}
disabled={isRunning || !selectedSymbol || !enrichStartDate || !enrichEndDate}
style={{
border: '1px solid #111111',
background: isRunning ? '#d1d5db' : '#111111',
color: '#ffffff',
padding: '9px 14px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: isRunning ? 'wait' : 'pointer'
}}
>
{isRunning ? '执行中...' : '重新分析当前区间'}
</button>
{maintenanceStatus?.updatedAt ? (
<span style={{ fontSize: 11, color: '#666666' }}>
最近一次执行: {maintenanceStatus.updatedAt}
</span>
) : null}
</div>
{maintenanceStatus?.error ? (
<div style={{ fontSize: 11, color: '#991b1b', lineHeight: 1.7 }}>
执行失败: {maintenanceStatus.error}
</div>
) : null}
{stats ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
{[
['新闻总数', stats.news_count],
['待处理', stats.queued_count],
['已分析', stats.analyzed],
['已跳过', stats.skipped_existing_count],
['去重数', stats.deduped_count],
['LLM分析', stats.llm_count],
['规则分析', stats.local_count],
['升级数', stats.upgraded_local_to_llm_count],
].map(([label, value]) => (
<div key={label} style={{ border: '1px solid #111111', padding: 10 }}>
<div style={{ fontSize: 10, color: '#666666' }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{value ?? '-'}</div>
</div>
))}
</div>
{summary ? (
<div style={{ border: '1px solid #111111', padding: 12, fontSize: 11, lineHeight: 1.8 }}>
{summary.upgraded_dates?.length ? (
<div><strong>升级日期:</strong> {summary.upgraded_dates.join(', ')}</div>
) : null}
{summary.remaining_local_titles?.length ? (
<div><strong>仍为规则分析:</strong> {summary.remaining_local_titles.join(' / ')}</div>
) : null}
{typeof summary.skipped_non_local_count === 'number' ? (
<div><strong>跳过非规则分析:</strong> {summary.skipped_non_local_count}</div>
) : null}
{typeof summary.skipped_missing_analysis_count === 'number' ? (
<div><strong>跳过无历史分析:</strong> {summary.skipped_missing_analysis_count}</div>
) : null}
</div>
) : null}
</>
) : null}
{Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? (
<div style={{ border: '1px solid #111111', padding: 12, display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700 }}>最近刷新历史</div>
{maintenanceHistory.slice(0, 5).map((item, index) => (
<div
key={`${item.timestamp || 'history'}-${index}`}
style={{
borderTop: index === 0 ? 'none' : '1px solid #e5e7eb',
paddingTop: index === 0 ? 0 : 8,
fontSize: 11,
lineHeight: 1.8,
}}
>
<div>
<strong>{item.startDate || '-'}</strong> ~ <strong>{item.endDate || '-'}</strong>
{' · '}
{item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'}
{item.storyStatus ? ' · 主线叙事' : ''}
{item.similarStatus ? ' · 相似交易日' : ''}
</div>
<div style={{ color: item.error ? '#991b1b' : '#4b5563' }}>
{item.timestamp || '-'}
{item.error
? ` · 失败: ${item.error}`
: ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`}
</div>
<div style={{ marginTop: 4 }}>
<button
onClick={() => onSelectHistory?.(item)}
style={{
border: '1px solid #111111',
background: '#ffffff',
color: '#111111',
padding: '4px 8px',
fontFamily: 'inherit',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer'
}}
>
回填到表单
</button>
<button
onClick={() => onReplayHistory?.(item)}
style={{
marginLeft: 8,
border: '1px solid #111111',
background: '#111111',
color: '#ffffff',
padding: '4px 8px',
fontFamily: 'inherit',
fontSize: 10,
fontWeight: 700,
cursor: 'pointer'
}}
>
直接重跑
</button>
{!item.error ? (
<span style={{ marginLeft: 8, fontSize: 10, color: '#666666' }}>
{item.stats?.execution_summary?.upgraded_dates?.length
? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}`
: '无升级日期摘要'}
</span>
) : null}
</div>
</div>
))}
</div>
) : null}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainMentionsSection({
recentMentions,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">讨论提及</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
从交易讨论和分析 feed 提取
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起讨论摘录' : `展开讨论摘录 ${recentMentions.length}`}
</button>
</div>
</div>
{recentMentions.length === 0 ? (
<div className="empty-state">最近没有在讨论里提到这只股票</div>
) : !isOpen ? (
<div className="empty-state">讨论摘录默认收起需要时再展开查看</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{recentMentions.map((message, index) => (
<div
key={`${message.feedId || message.id}-${index}`}
style={{
border: '1px solid #000000',
background: '#fafafa',
padding: 14,
minHeight: 150
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 700, color: '#000000' }}>{message.agent || '未知角色'}</div>
<div style={{ fontSize: 10, color: '#666666' }}>
{message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')}
</div>
</div>
<div style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(message.timestamp)}
</div>
</div>
<div style={{
fontSize: 12,
lineHeight: 1.7,
color: '#000000',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{String(message.content || '')}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,320 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
function renderFreshness(freshness) {
if (!freshness || typeof freshness !== 'object') return null;
const lastFetch = freshness.last_news_fetch || '-';
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
}
function categoryLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
market: '市场交易',
policy: '政策监管',
earnings: '业绩财报',
product_tech: '产品技术',
competition: '竞争格局',
management: '管理层动态',
};
return labels[normalized] || value || '';
}
function relevanceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
high: '高相关',
medium: '中相关',
low: '低相关',
relevant: '高相关',
};
return labels[normalized] || value || '';
}
function analysisSourceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'llm') return 'LLM分析';
if (normalized === 'local') return '规则分析';
return value || '';
}
function sentimentStyle(sentiment) {
const normalized = String(sentiment || '').trim().toLowerCase();
if (normalized === 'positive') {
return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' };
}
if (normalized === 'negative') {
return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' };
}
return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' };
}
export default function ExplainNewsSection({
newsSnapshot,
visibleNewsByCategory,
visibleNews,
selectedNewsFreshness,
activeNewsCategory,
onSelectNewsCategory,
activeNewsSentiment,
onSelectNewsSentiment,
newsCategories,
tickerNews,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">新闻面板</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
</div>
{renderFreshness(selectedNewsFreshness) ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{renderFreshness(selectedNewsFreshness)}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起新闻面板' : '展开新闻面板'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">新闻面板已收起需要时再展开查看分类情绪和新闻卡片</div>
) : (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
<button
onClick={() => onSelectNewsCategory('all')}
style={{
border: '1px solid #111111',
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
全部 {visibleNews.length}
</button>
{Object.entries(newsCategories)
.filter(([, meta]) => Number(meta?.count || 0) > 0)
.map(([key, meta]) => {
const isActive = activeNewsCategory === key;
const pos = Number(meta?.positive_ids?.length || 0);
const neg = Number(meta?.negative_ids?.length || 0);
return (
<button
key={key}
onClick={() => onSelectNewsCategory(key)}
style={{
border: '1px solid #2563eb',
background: isActive ? '#2563eb' : '#ffffff',
color: isActive ? '#ffffff' : '#2563eb',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
{[
{ key: 'all', label: '全部情绪' },
{ key: 'positive', label: '利多' },
{ key: 'negative', label: '利空' },
{ key: 'neutral', label: '中性' }
].map((item) => {
const isActive = activeNewsSentiment === item.key;
return (
<button
key={item.key}
onClick={() => onSelectNewsSentiment(item.key)}
style={{
border: '1px solid #111111',
background: isActive ? '#111111' : '#ffffff',
color: isActive ? '#ffffff' : '#111111',
padding: '6px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{item.label}
</button>
);
})}
</div>
{tickerNews.length === 0 ? (
<div className="empty-state">当前数据源没有返回相关新闻</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{visibleNewsByCategory.map((item) => (
<div
key={item.id}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
minHeight: 180
}}
>
{(() => {
const sentimentMeta = sentimentStyle(item.sentiment);
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: `1px solid ${sentimentMeta.border}`,
background: sentimentMeta.background,
color: sentimentMeta.color,
fontSize: 10,
fontWeight: 700
}}>
{sentimentMeta.label}
</span>
{item.relevance ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{relevanceLabel(item.relevance)}
</span>
) : null}
{item.analysisSource ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #6b7280',
color: '#4b5563',
fontSize: 10,
fontWeight: 700
}}>
{analysisSourceLabel(item.analysisSource)}
</span>
) : null}
{item.analysisModelLabel ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #9ca3af',
color: '#374151',
fontSize: 10,
fontWeight: 700
}}>
{item.analysisModelLabel}
</span>
) : null}
{typeof item.retT0 === 'number' ? (
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
</span>
) : null}
</div>
);
})()}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{item.category ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{categoryLabel(item.category)}
</span>
) : null}
<strong style={{ fontSize: 13 }}>{item.title}</strong>
</div>
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(item.date)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
{item.source}
</span>
{item.related ? (
<span style={{ fontSize: 10, color: '#666666' }}>
关联: {item.related}
</span>
) : null}
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{item.summary || '该新闻没有可用摘要。'}
</div>
{item.keyDiscussion ? (
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
<strong>核心讨论:</strong> {item.keyDiscussion}
</div>
) : null}
{item.reasonGrowth ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
<strong>利多逻辑:</strong> {item.reasonGrowth}
</div>
) : null}
{item.reasonDecrease ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
<strong>利空逻辑:</strong> {item.reasonDecrease}
</div>
) : null}
{item.url ? (
<div style={{ marginTop: 12 }}>
<a
href={item.url}
target="_blank"
rel="noreferrer"
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
>
查看原文
</a>
</div>
) : null}
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,206 @@
import React from 'react';
import { formatTickerPrice } from '../../utils/formatters';
export default function ExplainPriceSection({
ohlcSeries,
priceSeries,
selectedHistorySource,
chartModel,
selectedTicker,
onSelectEventDate,
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">
<h2 className="section-title">价格与事件</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{ohlcSeries.length > 1
? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}`
: `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起价格区' : '展开价格区'}
</button>
</div>
</div>
{ohlcSeries.length === 0 && priceSeries.length === 0 ? (
<div className="empty-state">当前还没有可绘制的价格历史</div>
) : !isOpen ? (
<div className="empty-state">价格区已收起需要时再展开查看图表和事件点</div>
) : (
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<svg
viewBox={`0 0 ${chartModel.width} ${chartModel.height}`}
style={{ width: '100%', height: '220px', display: 'block', overflow: 'visible' }}
>
<defs>
<linearGradient id="stockExplainFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(0,0,0,0.18)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.02)" />
</linearGradient>
</defs>
<rect x="0" y="0" width={chartModel.width} height={chartModel.height} fill="#fafafa" />
<line
x1={chartModel.padding}
y1={chartModel.height - chartModel.padding}
x2={chartModel.width - chartModel.padding}
y2={chartModel.height - chartModel.padding}
stroke="#000000"
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}
x2={candle.centerX}
y2={candle.lowY}
stroke={stroke}
strokeWidth="1.4"
/>
<rect
x={candle.x}
y={candle.bodyY}
width={candle.width}
height={candle.bodyHeight}
fill={fill}
stroke={stroke}
strokeWidth="1.4"
/>
</g>
);
}) : chartModel.path && (
<>
<path d={`${chartModel.path} L${chartModel.width - chartModel.padding},${chartModel.height - chartModel.padding} L${chartModel.padding},${chartModel.height - chartModel.padding} Z`} fill="url(#stockExplainFill)" />
<path d={chartModel.path} fill="none" stroke="#000000" strokeWidth="2.5" />
</>
)}
{chartModel.markers.map((marker) => {
const fill = marker.tone === 'positive'
? '#00C853'
: marker.tone === 'negative'
? '#FF1744'
: marker.tone === 'news'
? '#2563eb'
: '#000000';
return (
<g
key={marker.id}
onClick={() => onSelectEventDate(marker.dateKey)}
style={{ cursor: 'pointer' }}
>
<line x1={marker.x} y1={marker.y} x2={marker.x} y2={chartModel.height - chartModel.padding} stroke={fill} strokeDasharray="3 3" strokeWidth="1" />
<circle
cx={marker.x}
cy={marker.y}
r={marker.markerType === 'news'
? (marker.isSelected ? '5.5' : '4')
: (marker.isSelected ? '6' : '4.5')}
fill={fill}
stroke={marker.isSelected ? '#111111' : '#ffffff'}
strokeWidth={marker.isSelected ? '2.5' : '2'}
/>
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
</g>
);
})}
<text x={chartModel.padding} y="14" fontSize="11" fill="#666666">
{chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''}
</text>
<text x={chartModel.padding} y={chartModel.height - 6} fontSize="11" fill="#666666">
{chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''}
</text>
<text x={chartModel.width - chartModel.padding} y="14" fontSize="11" fill="#666666" textAnchor="end">
{selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''}
</text>
</svg>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 12 }}>
<div style={{ fontSize: 11, color: '#666666' }}>
图表说明{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
</div>
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点新闻日期</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,231 @@
import React from 'react';
import { formatTickerPrice } from '../../utils/formatters';
function renderFreshness(freshness) {
if (!freshness || typeof freshness !== 'object') return null;
const lastFetch = freshness.last_news_fetch || '-';
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
}
function renderSentimentLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'positive') return '利多';
if (normalized === 'negative') return '利空';
if (normalized === 'neutral') return '中性';
return value || '';
}
function renderCategoryLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
market: '市场交易',
policy: '政策监管',
earnings: '业绩财报',
product_tech: '产品技术',
competition: '竞争格局',
management: '管理层动态',
};
return labels[normalized] || value || '';
}
function renderAnalysisSourceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'llm') return 'LLM分析';
if (normalized === 'local') return '规则分析';
return value || '';
}
function MetricRow({ label, value, valueColor = '#111111' }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, gap: 12 }}>
<span style={{ color: '#4b5563' }}>{label}</span>
<strong style={{ color: valueColor, textAlign: 'right' }}>{value}</strong>
</div>
);
}
function TagList({ items, tone = 'neutral', emptyText }) {
const palette = {
positive: { border: '#86efac', background: '#f0fdf4', color: '#166534' },
negative: { border: '#fca5a5', background: '#fef2f2', color: '#991b1b' },
neutral: { border: '#d1d5db', background: '#f9fafb', color: '#374151' },
};
const colors = palette[tone] || palette.neutral;
if (!Array.isArray(items) || items.length === 0) {
return <div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>{emptyText}</div>;
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{items.map((item, index) => (
<div
key={`${tone}-${index}-${item}`}
style={{
border: `1px solid ${colors.border}`,
background: colors.background,
color: colors.color,
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.6,
}}
>
{item}
</div>
))}
</div>
);
}
export default function ExplainRangeSection({
selectedRangeWindow,
selectedRangeExplain,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">区间涨跌分析</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedRangeWindow
? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}`
: '先在图上选择一个事件日期'}
</div>
{selectedRangeExplain?.analysis?.analysis_source ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedRangeExplain.analysis.analysis_source === 'llm'
? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}`
: `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`}
</div>
) : null}
{renderFreshness(selectedRangeExplain?.freshness) ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{renderFreshness(selectedRangeExplain?.freshness)}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起区间涨跌分析' : '展开区间涨跌分析'}
</button>
</div>
</div>
{!selectedRangeWindow ? (
<div className="empty-state">选择图上的日期后会自动生成最近 7 天的区间涨跌分析</div>
) : !isOpen ? (
<div className="empty-state">区间涨跌分析已收起需要时再展开查看摘要和快照</div>
) : !selectedRangeExplain ? (
<div className="empty-state">正在生成区间涨跌分析...</div>
) : selectedRangeExplain.error ? (
<div className="empty-state">{selectedRangeExplain.error}</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
区间摘要
</div>
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
{selectedRangeExplain.analysis?.summary || '暂无区间摘要'}
</div>
{selectedRangeExplain.analysis?.trend_analysis ? (
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
<strong>趋势拆解:</strong> {selectedRangeExplain.analysis.trend_analysis}
</div>
) : null}
<div style={{ marginTop: 14, display: 'grid', gap: 8 }}>
{(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => (
<div key={`${event.id || event.title}-${index}`} style={{ borderTop: index === 0 ? 'none' : '1px solid #e5e7eb', paddingTop: index === 0 ? 0 : 8 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 4 }}>
{event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''}
</div>
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 4 }}>{event.title}</div>
<div style={{ fontSize: 12, lineHeight: 1.6 }}>{event.summary || '暂无摘要'}</div>
</div>
))}
</div>
</div>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
区间快照
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>事实概览</div>
<MetricRow
label="区间涨跌"
value={`${Number(selectedRangeExplain.price_change_pct) >= 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`}
valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'}
/>
<MetricRow label="关联新闻" value={selectedRangeExplain.news_count || 0} />
<MetricRow label="区间高点" value={`$${formatTickerPrice(selectedRangeExplain.high_price)}`} />
<MetricRow label="区间低点" value={`$${formatTickerPrice(selectedRangeExplain.low_price)}`} />
<MetricRow label="交易日数" value={selectedRangeExplain.trading_days || 0} />
</div>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>主题分布</div>
{(selectedRangeExplain.dominant_categories || []).length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRangeExplain.dominant_categories.map((item) => (
<div
key={`${item.category}-${item.count}`}
style={{
border: '1px solid #d1d5db',
background: '#f9fafb',
color: '#374151',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.5,
}}
>
{renderCategoryLabel(item.category)} · {item.count}
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>
当前没有识别出明显的主题聚类
</div>
)}
</div>
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>驱动因素</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#166534' }}>利多因素</div>
<TagList
items={selectedRangeExplain.analysis?.bullish_factors || []}
tone="positive"
emptyText="当前区间内未提炼出明确的利多因素。"
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#991b1b' }}>利空因素</div>
<TagList
items={selectedRangeExplain.analysis?.bearish_factors || []}
tone="negative"
emptyText="当前区间内未提炼出明确的利空因素。"
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
export default function ExplainSignalsSection({
tickerSignals,
signalSummary,
latestSignal,
eventDateKey,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析师观点</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
最近 {tickerSignals.length} 条相关信号
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起分析师观点' : '展开分析师观点'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">分析师观点已收起需要时再展开查看信号统计和明细</div>
) : (
<>
<div className="stats-grid" style={{ marginBottom: 16 }}>
<div className="stat-card">
<div className="stat-card-label">看涨</div>
<div className="stat-card-value positive">{signalSummary.bullish}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">看跌</div>
<div className="stat-card-value negative">{signalSummary.bearish}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">中性</div>
<div className="stat-card-value">{signalSummary.neutral}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">最新结论</div>
<div className="stat-card-value" style={{ fontSize: 22 }}>
{latestSignal
? latestSignal.normalizedDirection === 'bullish'
? '偏多'
: latestSignal.normalizedDirection === 'bearish'
? '偏空'
: '观望'
: '暂无'}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
{latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'}
</div>
</div>
</div>
{tickerSignals.length === 0 ? (
<div className="empty-state">该股票还没有分析师信号记录</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>日期</th>
<th>分析师</th>
<th>方向</th>
<th>实际收益</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{tickerSignals.slice(0, 8).map((signal, index) => {
const realReturn = typeof signal.real_return === 'number'
? `${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
: '未判定';
const status = signal.is_correct === true ? '命中' : signal.is_correct === false ? '未命中' : '待判定';
const directionText = signal.normalizedDirection === 'bullish'
? '看涨'
: signal.normalizedDirection === 'bearish'
? '看跌'
: '中性';
const directionColor = signal.normalizedDirection === 'bullish'
? '#00C853'
: signal.normalizedDirection === 'bearish'
? '#FF1744'
: '#666666';
return (
<tr key={signal.id || `${signal.agentId}-${signal.date}-${index}`}>
<td>{signal.date || eventDateKey(signal.timestamp) || '-'}</td>
<td>
<div style={{ fontWeight: 700 }}>{signal.agentName}</div>
<div style={{ fontSize: 10, color: '#666666' }}>{signal.role}</div>
</td>
<td style={{ color: directionColor, fontWeight: 700 }}>{directionText}</td>
<td>{realReturn}</td>
<td>{status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import React from 'react';
function renderFreshness(freshness) {
if (!freshness || typeof freshness !== 'object') return null;
const lastFetch = freshness.last_news_fetch || '-';
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
}
export default function ExplainSimilarDaysSection({
selectedSimilarDays,
selectedEventDate,
onSelectSimilarDate,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">历史相似交易日</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedEventDate || '先选择一个事件日期'}
</div>
{renderFreshness(selectedSimilarDays?.freshness) ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{renderFreshness(selectedSimilarDays?.freshness)}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起相似交易日' : '展开相似交易日'}
</button>
</div>
</div>
{!selectedEventDate ? (
<div className="empty-state">选择图上的日期后会检索这只股票历史上的相似交易日</div>
) : !isOpen ? (
<div className="empty-state">相似交易日默认收起需要时再展开查看</div>
) : !selectedSimilarDays ? (
<div className="empty-state">正在检索相似交易日...</div>
) : selectedSimilarDays.error ? (
<div className="empty-state">{selectedSimilarDays.error}</div>
) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? (
<div className="empty-state">当前没有足够历史样本来计算相似交易日</div>
) : (
<div style={{ display: 'grid', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
目标日快照
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>新闻数量</div>
<strong>{selectedSimilarDays.target_features?.n_articles ?? 0}</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>情绪分数</div>
<strong>{Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)}</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>前一日涨跌</div>
<strong>{Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}%</strong>
</div>
<div style={{ fontSize: 12 }}>
<div style={{ color: '#666666', marginBottom: 4 }}>高相关新闻</div>
<strong>{selectedSimilarDays.target_features?.high_relevance_count ?? 0}</strong>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
{selectedSimilarDays.items.map((item) => (
<button
key={item.date}
onClick={() => onSelectSimilarDate?.(item.date)}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
textAlign: 'left',
cursor: 'pointer',
fontFamily: 'inherit'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>{item.date}</strong>
<span style={{ fontSize: 11, color: '#666666' }}>
相似度 {(Number(item.score || 0) * 100).toFixed(0)}%
</span>
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12, marginBottom: 10 }}>
<div>新闻数 {item.n_articles ?? 0}</div>
<div>情绪分数 {Number(item.sentiment_score ?? 0).toFixed(2)}</div>
<div>前一日涨跌 {Number(item.ret_1d ?? 0).toFixed(2)}%</div>
<div>次日表现 {item.ret_t1_after != null ? `${item.ret_t1_after >= 0 ? '+' : ''}${Number(item.ret_t1_after).toFixed(2)}%` : '-'}</div>
<div>三日表现 {item.ret_t3_after != null ? `${item.ret_t3_after >= 0 ? '+' : ''}${Number(item.ret_t3_after).toFixed(2)}%` : '-'}</div>
</div>
{(item.top_reasons || []).length > 0 ? (
<div style={{ fontSize: 11, lineHeight: 1.7, color: '#4b5563' }}>
<strong>主要线索:</strong> {item.top_reasons.join(' / ')}
</div>
) : null}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function renderFreshness(freshness) {
if (!freshness || typeof freshness !== 'object') return null;
const lastFetch = freshness.last_news_fetch || '-';
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
}
export default function ExplainStorySection({
selectedStory,
selectedSymbol,
currentDate,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">主线叙事</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'}
</div>
{renderFreshness(selectedStory?.freshness) ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{renderFreshness(selectedStory?.freshness)}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起主线叙事' : '展开主线叙事'}
</button>
</div>
</div>
{!selectedSymbol ? (
<div className="empty-state">先选择一只股票</div>
) : !isOpen ? (
<div className="empty-state">主线叙事默认收起需要时再展开查看完整叙事</div>
) : !selectedStory?.story ? (
<div className="empty-state">正在生成主线叙事...</div>
) : (
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 18 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
{selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'}
</div>
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedStory.story}
</ReactMarkdown>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
export default function ExplainSummarySection({
explainSummary,
tickerTrades,
tickerNews,
selectedSymbol,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">分析摘要</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
基于当前持仓成交和新闻自动汇总
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起分析摘要' : '展开分析摘要'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">分析摘要已收起需要时再展开查看概览和密度信息</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
当前解释
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{explainSummary.map((line, index) => (
<div key={`${selectedSymbol}-summary-${index}`} style={{ fontSize: 13, lineHeight: 1.7, color: '#000000' }}>
{line}
</div>
))}
</div>
</div>
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
分析概览
</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>成交记录</span>
<strong>{tickerTrades.length}</strong>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span>新闻条目</span>
<strong>{tickerNews.length}</strong>
</div>
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
当前分析综合读取信号成交新闻与已生成的解释结果
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,309 @@
import React from 'react';
import { formatNumber } from '../../utils/formatters';
export default function ExplainTechnicalSection({
technicalIndicators,
selectedSymbol,
isOpen,
onToggle,
}) {
const formatPct = (value) => {
if (value == null) return '-';
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
};
const formatPrice = (value) => {
if (value == null) return '-';
return `$${value.toFixed(2)}`;
};
const rsiStatusColor = (status) => {
if (status === 'oversold') return '#00C853';
if (status === 'overbought') return '#FF1744';
return '#666666';
};
const riskColor = (level) => {
if (level === 'HIGH RISK') return '#FF1744';
if (level === 'MODERATE RISK') return '#FF9800';
return '#00C853';
};
if (!technicalIndicators) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">技术指标</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 11, color: '#666666' }}>
加载中...
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起' : '展开'}
</button>
</div>
</div>
{isOpen && (
<div className="empty-state">正在加载技术指标数据...</div>
)}
</div>
);
}
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">技术指标</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{technicalIndicators.trend} · {technicalIndicators.mean_reversion}
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起技术指标' : '展开技术指标'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">点击展开查看技术指标详情</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
{/* MA Section */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
移动平均线
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>MA5</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma5)}</span>
<span style={{ color: technicalIndicators.ma?.distance?.ma5 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
{formatPct(technicalIndicators.ma?.distance?.ma5)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>MA10</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma10)}</span>
<span style={{ color: technicalIndicators.ma?.distance?.ma10 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
{formatPct(technicalIndicators.ma?.distance?.ma10)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>MA20</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma20)}</span>
<span style={{ color: technicalIndicators.ma?.distance?.ma20 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
{formatPct(technicalIndicators.ma?.distance?.ma20)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>MA50</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma50)}</span>
<span style={{ color: technicalIndicators.ma?.distance?.ma50 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
{formatPct(technicalIndicators.ma?.distance?.ma50)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>MA200</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma200)}</span>
<span style={{ color: technicalIndicators.ma?.distance?.ma200 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
{formatPct(technicalIndicators.ma?.distance?.ma200)}
</span>
</div>
</div>
</div>
{/* RSI Section */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
RSI (14)
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: rsiStatusColor(technicalIndicators.rsi?.status) }}>
{technicalIndicators.rsi?.rsi14?.toFixed(1) || '-'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{
padding: '2px 8px',
background: technicalIndicators.rsi?.status === 'oversold' ? '#E8F5E9' :
technicalIndicators.rsi?.status === 'overbought' ? '#FFEBEE' : '#F5F5F5',
color: rsiStatusColor(technicalIndicators.rsi?.status),
fontSize: 10,
fontWeight: 700,
borderRadius: 4
}}>
{technicalIndicators.rsi?.status === 'oversold' ? '超卖' :
technicalIndicators.rsi?.status === 'overbought' ? '超买' : '中性'}
</div>
<div style={{ fontSize: 10, color: '#666666' }}>
&lt;30 超卖 &gt;70 超买
</div>
</div>
</div>
{/* RSI Gauge */}
<div style={{ marginTop: 12, height: 8, background: '#E0E0E0', borderRadius: 4, position: 'relative' }}>
<div style={{
position: 'absolute',
left: 0,
width: `${Math.min(100, Math.max(0, technicalIndicators.rsi?.rsi14 || 0))}%`,
height: '100%',
background: rsiStatusColor(technicalIndicators.rsi?.status),
borderRadius: 4,
transition: 'width 0.3s'
}} />
<div style={{ position: 'absolute', left: '30%', top: -4, width: 1, height: 16, background: '#00C853' }} />
<div style={{ position: 'absolute', left: '70%', top: -4, width: 1, height: 16, background: '#FF1744' }} />
</div>
</div>
{/* MACD Section */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
MACD
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>MACD 线</span>
<span style={{ fontWeight: 600, color: technicalIndicators.macd?.macd > 0 ? '#00C853' : '#FF1744' }}>
{formatPrice(technicalIndicators.macd?.macd)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>Signal 线</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.macd?.signal)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>柱状图</span>
<span style={{ fontWeight: 600, color: technicalIndicators.macd?.histogram > 0 ? '#00C853' : '#FF1744' }}>
{formatPrice(technicalIndicators.macd?.histogram)}
</span>
</div>
</div>
</div>
{/* Bollinger Bands Section */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
布林带
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>上轨</span>
<span style={{ fontWeight: 600, color: '#FF1744' }}>
{formatPrice(technicalIndicators.bollinger?.upper)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>中轨</span>
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.bollinger?.mid)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>下轨</span>
<span style={{ fontWeight: 600, color: '#00C853' }}>
{formatPrice(technicalIndicators.bollinger?.lower)}
</span>
</div>
</div>
</div>
{/* Volatility Section */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
波动率
</div>
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>10</span>
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_10d)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>20</span>
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_20d)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>60</span>
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_60d)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, paddingTop: 8, borderTop: '1px solid #E0E0E0' }}>
<span style={{ color: '#666666' }}>年化波动率</span>
<span style={{ fontWeight: 700 }}>{formatPct(technicalIndicators.volatility?.annualized)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#666666' }}>风险等级</span>
<span style={{
fontWeight: 700,
color: riskColor(technicalIndicators.volatility?.risk_level)
}}>
{technicalIndicators.volatility?.risk_level || '-'}
</span>
</div>
</div>
</div>
{/* Trend Summary */}
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
趋势判断
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
padding: '4px 12px',
background: technicalIndicators.trend?.includes('BULLISH') ? '#E8F5E9' :
technicalIndicators.trend?.includes('BEARISH') ? '#FFEBEE' : '#F5F5F5',
color: technicalIndicators.trend?.includes('BULLISH') ? '#00C853' :
technicalIndicators.trend?.includes('BEARISH') ? '#FF1744' : '#666666',
fontSize: 12,
fontWeight: 700,
borderRadius: 4
}}>
{technicalIndicators.trend || '-'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
padding: '4px 12px',
background: technicalIndicators.mean_reversion?.includes('OVERBOUGHT') ? '#FFEBEE' :
technicalIndicators.mean_reversion?.includes('OVERSOLD') ? '#E8F5E9' : '#F5F5F5',
color: technicalIndicators.mean_reversion?.includes('OVERBOUGHT') ? '#FF1744' :
technicalIndicators.mean_reversion?.includes('OVERSOLD') ? '#00C853' : '#666666',
fontSize: 12,
fontWeight: 700,
borderRadius: 4
}}>
{technicalIndicators.mean_reversion || '-'}
</div>
</div>
<div style={{ fontSize: 10, color: '#666666', marginTop: 4 }}>
当前价格: {formatPrice(technicalIndicators.current_price)}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
export default function ExplainTradesSection({
tickerTrades,
selectedSymbol,
isOpen,
onToggle,
}) {
const sideLabel = (value) => {
if (value === 'LONG') return '做多';
if (value === 'SHORT') return '做空';
return value || '-';
};
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">成交记录</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{tickerTrades.length} 笔与 {selectedSymbol} 相关的交易
</div>
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起成交记录' : `展开成交记录 ${tickerTrades.length}`}
</button>
</div>
</div>
{tickerTrades.length === 0 ? (
<div className="empty-state">该股票暂无成交记录</div>
) : !isOpen ? (
<div className="empty-state">成交记录默认收起需要时再展开查看</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>方向</th>
<th>数量</th>
<th>价格</th>
</tr>
</thead>
<tbody>
{tickerTrades.slice(0, 10).map((trade, index) => (
<tr key={trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}>
<td>{formatDateTime(trade.timestamp)}</td>
<td style={{ fontWeight: 700, color: trade.side === 'LONG' ? '#00C853' : trade.side === 'SHORT' ? '#FF1744' : '#000000' }}>
{sideLabel(trade.side)}
</td>
<td>{trade.qty}</td>
<td>${Number(trade.price).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
export function normalizeSignalDirection(signal) {
const value = String(signal || '').trim().toLowerCase();
if (!value) return 'neutral';
if (value.includes('bull') || value === 'long' || value === 'buy') return 'bullish';
if (value.includes('bear') || value === 'short' || value === 'sell') return 'bearish';
return 'neutral';
}
export function includesTicker(content, ticker) {
if (!ticker || typeof content !== 'string') return false;
const normalized = ticker.trim().toUpperCase();
if (!normalized) return false;
return new RegExp(`\\b${normalized}\\b`, 'i').test(content);
}
export function flattenFeedMessages(feed) {
if (!Array.isArray(feed)) return [];
const items = [];
feed.forEach((item) => {
if (!item || !item.type || !item.data) return;
if (item.type === 'message' || item.type === 'memory') {
items.push({ ...item.data, feedType: item.type, feedId: item.id });
return;
}
if (item.type === 'conference' && Array.isArray(item.data.messages)) {
item.data.messages.forEach((message) => {
items.push({
...message,
feedType: 'conference',
feedId: item.id,
conferenceTitle: item.data.title
});
});
}
});
return items;
}
export function snippetText(content, ticker) {
const raw = String(content || '').replace(/\s+/g, ' ').trim();
if (!raw) return '';
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) {
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
}
const upper = raw.toUpperCase();
const idx = upper.indexOf(normalizedTicker);
if (idx === -1) {
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
}
const start = Math.max(0, idx - 90);
const end = Math.min(raw.length, idx + normalizedTicker.length + 130);
const snippet = raw.slice(start, end).trim();
return `${start > 0 ? '...' : ''}${snippet}${end < raw.length ? '...' : ''}`;
}
export function buildLinePath(points, width, height, padding) {
if (!Array.isArray(points) || points.length === 0) {
return '';
}
const prices = points.map((point) => Number(point.price)).filter(Number.isFinite);
if (!prices.length) {
return '';
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
return points.map((point, index) => {
const x = padding + (innerWidth * index) / Math.max(points.length - 1, 1);
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
}).join(' ');
}
export function parsePointTime(point) {
const raw = point?.timestamp ?? point?.label;
if (!raw) return NaN;
const direct = new Date(raw).getTime();
if (Number.isFinite(direct)) return direct;
return new Date(`${raw}T00:00:00`).getTime();
}
export function aggregatePriceSeriesToCandles(points) {
if (!Array.isArray(points) || points.length === 0) {
return [];
}
const bucketTarget = points.length >= 36 ? 12 : points.length >= 18 ? 8 : 4;
const bucketSize = Math.max(1, Math.ceil(points.length / bucketTarget));
const candles = [];
for (let index = 0; index < points.length; index += bucketSize) {
const bucket = points.slice(index, index + bucketSize);
const prices = bucket.map((point) => Number(point.price)).filter(Number.isFinite);
if (!prices.length) {
continue;
}
candles.push({
id: `${bucket[0]?.timestamp || index}-${bucket[bucket.length - 1]?.timestamp || index + bucket.length}`,
open: Number(bucket[0].price),
high: Math.max(...prices),
low: Math.min(...prices),
close: Number(bucket[bucket.length - 1].price),
startTimestamp: parsePointTime(bucket[0]),
endTimestamp: parsePointTime(bucket[bucket.length - 1]),
startLabel: bucket[0]?.label || bucket[0]?.timestamp || '',
endLabel: bucket[bucket.length - 1]?.label || bucket[bucket.length - 1]?.timestamp || ''
});
}
return candles;
}
export function eventDateKey(timestamp) {
if (!timestamp) return '';
const parsed = new Date(timestamp);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
return String(timestamp).slice(0, 10);
}
export function resolveEventCategory(event) {
if (!event) return 'other';
if (event.type === 'trade') return 'trade';
if (event.type === 'mention') return 'discussion';
if (event.type !== 'signal') return 'other';
const role = String(event.meta || '').toLowerCase();
if (role.includes('technical')) return 'technical';
if (role.includes('fundamental')) return 'fundamental';
if (role.includes('sentiment')) return 'sentiment';
if (role.includes('valuation')) return 'valuation';
if (role.includes('risk')) return 'risk';
if (role.includes('portfolio')) return 'portfolio';
return 'signal';
}
export function normalizeTradeRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const timestamp = row.timestamp || row.ts || row.created_at || null;
const ticker = row.ticker || '';
const side = row.side || '';
const qtyValue = Number(row.qty ?? row.quantity ?? 0);
const priceValue = Number(row.price ?? 0);
return {
id: row.id || `trade-${ticker}-${timestamp || fallbackIndex}-${fallbackIndex}`,
timestamp,
trading_date: row.trading_date || row.trade_date || null,
ticker,
side,
qty: Number.isFinite(qtyValue) ? qtyValue : 0,
price: Number.isFinite(priceValue) ? priceValue : 0
};
}
export function normalizeSignalRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const timestamp = row.timestamp || row.created_at || null;
const date = row.date || row.trade_date || eventDateKey(timestamp) || '';
const rawSignal = row.signal || row.title || '';
const normalizedDirection = normalizeSignalDirection(rawSignal);
const confidenceValue = Number(row.confidence);
const realReturnValue = Number(row.real_return);
const parsedCorrect = typeof row.is_correct === 'string'
? row.is_correct.toLowerCase() === 'true'
? true
: row.is_correct.toLowerCase() === 'false'
? false
: null
: typeof row.is_correct === 'boolean'
? row.is_correct
: null;
return {
id: row.id || `signal-${row.agent_id || row.agentId || 'agent'}-${date || fallbackIndex}-${fallbackIndex}`,
timestamp,
date,
ticker: row.ticker || '',
signal: rawSignal,
confidence: Number.isFinite(confidenceValue) ? confidenceValue : null,
real_return: Number.isFinite(realReturnValue) ? realReturnValue : null,
is_correct: parsedCorrect,
agentId: row.agent_id || row.agentId || '',
agentName: row.agent_name || row.agentName || row.meta || '未知分析师',
role: row.role || row.meta || '',
normalizedDirection
};
}
export function normalizeMentionRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
return {
id: row.id || `mention-${fallbackIndex}`,
feedId: row.id || `mention-${fallbackIndex}`,
timestamp: row.timestamp || null,
agent: row.agent || row.agentName || '未知角色',
content: row.body || row.content || '',
conferenceTitle: row.meta || '',
feedType: 'sqlite'
};
}
export function normalizeNewsRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const date = row.date || row.published_utc || row.timestamp || null;
const source = row.source || row.publisher || '新闻源';
const title = row.title || '未命名新闻';
const summary = row.summary || row.description || '';
return {
id: row.id || row.url || `news-${fallbackIndex}`,
date,
dateKey: eventDateKey(date),
ticker: row.ticker || '',
title,
source,
category: row.category || '',
related: row.related || '',
summary,
url: row.url || row.article_url || '',
tradeDate: row.trade_date || null,
relevance: row.relevance || '',
sentiment: row.sentiment || '',
keyDiscussion: row.key_discussion || '',
reasonGrowth: row.reason_growth || '',
reasonDecrease: row.reason_decrease || '',
retT0: Number.isFinite(Number(row.ret_t0)) ? Number(row.ret_t0) : null,
retT1: Number.isFinite(Number(row.ret_t1)) ? Number(row.ret_t1) : null,
retT3: Number.isFinite(Number(row.ret_t3)) ? Number(row.ret_t3) : null,
retT5: Number.isFinite(Number(row.ret_t5)) ? Number(row.ret_t5) : null,
retT10: Number.isFinite(Number(row.ret_t10)) ? Number(row.ret_t10) : null,
analysisSource: row.analysis_source || '',
analysisModelLabel: row.analysis_model_label || ''
};
}
export function normalizeNewsTimelineRow(row, fallbackIndex = 0) {
if (!row || typeof row !== 'object') return null;
const date = row.date || row.trade_date || null;
if (!date) return null;
const countValue = Number(row.count ?? 0);
const sourceCountValue = Number(row.source_count ?? 0);
return {
id: row.id || `news-timeline-${date}-${fallbackIndex}`,
date,
dateKey: eventDateKey(date),
count: Number.isFinite(countValue) ? countValue : 0,
sourceCount: Number.isFinite(sourceCountValue) ? sourceCountValue : 0,
topTitle: row.top_title || '',
positiveCount: Number.isFinite(Number(row.positive_count)) ? Number(row.positive_count) : 0,
negativeCount: Number.isFinite(Number(row.negative_count)) ? Number(row.negative_count) : 0,
neutralCount: Number.isFinite(Number(row.neutral_count)) ? Number(row.neutral_count) : 0,
highRelevanceCount: Number.isFinite(Number(row.high_relevance_count)) ? Number(row.high_relevance_count) : 0
};
}
export const EVENT_CATEGORY_META = {
all: { label: '全部事件', color: '#111111' },
discussion: { label: '讨论', color: '#555555' },
signal: { label: '信号', color: '#0f766e' },
technical: { label: '技术', color: '#2563eb' },
fundamental: { label: '基本面', color: '#059669' },
sentiment: { label: '情绪', color: '#7c3aed' },
valuation: { label: '估值', color: '#d97706' },
risk: { label: '风控', color: '#dc2626' },
portfolio: { label: '组合', color: '#111827' },
trade: { label: '成交', color: '#b91c1c' },
other: { label: '其他', color: '#6b7280' }
};

View File

@@ -0,0 +1,489 @@
import { useMemo } from 'react';
import {
aggregatePriceSeriesToCandles,
buildLinePath,
eventDateKey,
normalizeNewsRow,
normalizeNewsTimelineRow,
normalizeSignalDirection,
normalizeSignalRow,
parsePointTime,
resolveEventCategory
} from './explainUtils';
export default function useExplainModel({
tickers,
holdings,
trades,
leaderboard,
feed,
priceHistoryByTicker,
ohlcHistoryByTicker,
selectedSymbol,
explainEventsSnapshot,
newsSnapshot,
selectedEventDate,
activeEventCategory,
activeNewsCategory,
activeNewsSentiment = 'all'
}) {
const availableSymbols = useMemo(() => (
Array.isArray(tickers)
? tickers.map((ticker) => ticker?.symbol).filter((symbol) => typeof symbol === 'string' && symbol.trim())
: []
), [tickers]);
const selectedTicker = useMemo(
() => tickers.find((ticker) => ticker.symbol === selectedSymbol) || null,
[selectedSymbol, tickers]
);
const holding = useMemo(
() => holdings.find((item) => item.ticker === selectedSymbol) || null,
[holdings, selectedSymbol]
);
const tickerSignals = useMemo(() => {
const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals)
? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean)
: [];
if (snapshotSignals.length > 0) {
return snapshotSignals.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime());
}
if (!selectedSymbol) return [];
return (Array.isArray(leaderboard) ? leaderboard : []).flatMap((agent) => {
const signals = Array.isArray(agent.signals) ? agent.signals : [];
return signals
.filter((signal) => signal.ticker === selectedSymbol)
.map((signal) => ({
agentId: agent.agentId,
agentName: agent.name,
role: agent.role,
...signal,
normalizedDirection: normalizeSignalDirection(signal.signal)
}));
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [explainEventsSnapshot, leaderboard, selectedSymbol]);
const tickerNews = useMemo(() => {
const items = Array.isArray(newsSnapshot?.items)
? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean)
: [];
return items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [newsSnapshot]);
const dateScopedNews = useMemo(() => {
if (!selectedEventDate || !newsSnapshot?.byDate || typeof newsSnapshot.byDate !== 'object') {
return [];
}
const rows = Array.isArray(newsSnapshot.byDate[selectedEventDate])
? newsSnapshot.byDate[selectedEventDate]
: [];
return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean);
}, [newsSnapshot, selectedEventDate]);
const visibleNews = useMemo(() => tickerNews, [tickerNews]);
const tickerNewsTimeline = useMemo(() => {
const items = Array.isArray(newsSnapshot?.timeline)
? newsSnapshot.timeline.map((item, index) => normalizeNewsTimelineRow(item, index)).filter(Boolean)
: [];
return items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [newsSnapshot]);
const newsCategories = useMemo(() => (
newsSnapshot?.categories && typeof newsSnapshot.categories === 'object'
? newsSnapshot.categories
: {}
), [newsSnapshot]);
const visibleNewsByCategory = useMemo(() => {
let scopedNews = visibleNews;
if (activeNewsCategory !== 'all') {
const categoryMeta = newsCategories?.[activeNewsCategory];
const allowedIds = Array.isArray(categoryMeta?.article_ids)
? new Set(categoryMeta.article_ids)
: null;
if (allowedIds && allowedIds.size > 0) {
scopedNews = scopedNews.filter((item) => allowedIds.has(item.id));
}
}
if (activeNewsSentiment === 'all') {
return scopedNews;
}
return scopedNews.filter((item) => {
const sentiment = String(item.sentiment || '').trim().toLowerCase() || 'neutral';
return sentiment === activeNewsSentiment;
});
}, [activeNewsCategory, activeNewsSentiment, newsCategories, visibleNews]);
const selectedRangeWindow = useMemo(() => {
if (!selectedEventDate) return null;
const endDate = new Date(`${selectedEventDate}T00:00:00`);
if (Number.isNaN(endDate.getTime())) return null;
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6);
return {
startDate: startDate.toISOString().slice(0, 10),
endDate: selectedEventDate
};
}, [selectedEventDate]);
const selectedRangeExplain = useMemo(() => {
if (!selectedRangeWindow) return null;
const key = `${selectedRangeWindow.startDate}:${selectedRangeWindow.endDate}`;
return newsSnapshot?.rangeExplainCache?.[key] || null;
}, [newsSnapshot, selectedRangeWindow]);
const selectedStory = useMemo(() => {
const storyCache = newsSnapshot?.storyCache;
if (!storyCache || typeof storyCache !== 'object') {
return null;
}
const keys = Object.keys(storyCache).sort();
if (!keys.length) {
return null;
}
return storyCache[keys[keys.length - 1]] || null;
}, [newsSnapshot]);
const selectedNewsFreshness = useMemo(
() => newsSnapshot?.freshness || newsSnapshot?.categoriesFreshness || newsSnapshot?.timelineFreshness || null,
[newsSnapshot]
);
const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000';
const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null;
const ohlcSeries = useMemo(() => {
const raw = ohlcHistoryByTicker?.[selectedSymbol];
return Array.isArray(raw) ? raw.filter((candle) => Number.isFinite(Number(candle.close))).slice(-60) : [];
}, [ohlcHistoryByTicker, selectedSymbol]);
const priceSeries = useMemo(() => {
const raw = priceHistoryByTicker?.[selectedSymbol];
return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : [];
}, [priceHistoryByTicker, selectedSymbol]);
const explainTimeline = useMemo(() => {
const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({
id: `signal-${signal.agentId}-${signal.date}-${index}`,
type: 'signal',
timestamp: new Date(`${signal.date}T08:00:00`).toISOString(),
title: `${signal.agentName} 给出${signal.normalizedDirection === 'bullish' ? '看涨' : signal.normalizedDirection === 'bearish' ? '看跌' : '中性'}信号`,
meta: signal.role,
body: typeof signal.real_return === 'number'
? `后验收益 ${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
: '该信号暂未完成后验评估',
tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral'
}));
const fallbackTimeline = [...signalEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
...event,
dateKey: eventDateKey(event.timestamp),
category: resolveEventCategory(event)
}));
if (!explainEventsSnapshot) {
return fallbackTimeline;
}
const dbSignalEvents = (Array.isArray(explainEventsSnapshot.signals) ? explainEventsSnapshot.signals : [])
.map((signal, index) => {
if (signal?.type === 'signal' && signal?.timestamp) {
return signal;
}
const normalized = normalizeSignalRow(signal, index);
if (!normalized) return null;
return {
id: normalized.id,
type: 'signal',
timestamp: normalized.timestamp || (normalized.date ? new Date(`${normalized.date}T08:00:00`).toISOString() : null),
title: `${normalized.agentName} 给出${
normalized.normalizedDirection === 'bullish'
? '看涨'
: normalized.normalizedDirection === 'bearish'
? '看跌'
: '中性'
}信号`,
meta: normalized.role,
body: typeof normalized.real_return === 'number'
? `后验收益 ${normalized.real_return >= 0 ? '+' : ''}${(normalized.real_return * 100).toFixed(2)}%`
: '该信号暂未完成后验评估',
tone: normalized.normalizedDirection === 'bullish'
? 'positive'
: normalized.normalizedDirection === 'bearish'
? 'negative'
: 'neutral'
};
})
.filter(Boolean);
const dbEvents = [...dbSignalEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 24)
.map((event) => ({
...event,
dateKey: eventDateKey(event.timestamp),
category: resolveEventCategory(event)
}));
return dbEvents.length > 0 ? dbEvents : fallbackTimeline;
}, [explainEventsSnapshot, selectedSymbol, tickerSignals]);
const availableEventDates = useMemo(
() => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))),
[explainTimeline]
);
const eventCategoryCounts = useMemo(() => {
const scopedEvents = selectedEventDate
? explainTimeline.filter((event) => event.dateKey === selectedEventDate)
: explainTimeline;
const counts = { all: scopedEvents.length };
scopedEvents.forEach((event) => {
counts[event.category] = (counts[event.category] || 0) + 1;
});
return counts;
}, [explainTimeline, selectedEventDate]);
const visibleExplainEvents = useMemo(() => explainTimeline.filter((event) => {
if (selectedEventDate && event.dateKey !== selectedEventDate) {
return false;
}
if (activeEventCategory !== 'all' && event.category !== activeEventCategory) {
return false;
}
return true;
}), [activeEventCategory, explainTimeline, selectedEventDate]);
const chartModel = useMemo(() => {
const width = 720;
const height = 220;
const padding = 18;
if (!ohlcSeries.length && !priceSeries.length) {
return {
width,
height,
padding,
path: '',
minPrice: null,
maxPrice: null,
markers: [],
candles: [],
linePoints: [],
bucketCount: 0
};
}
if (ohlcSeries.length > 1) {
const prices = ohlcSeries.flatMap((candle) => [Number(candle.low), Number(candle.high)]);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const candleWidth = Math.max(8, Math.min(18, (innerWidth / ohlcSeries.length) * 0.55));
const startTime = parsePointTime({ timestamp: ohlcSeries[0]?.time });
const endTime = parsePointTime({ timestamp: ohlcSeries[ohlcSeries.length - 1]?.time });
const timeSpan = Math.max(endTime - startTime, 1);
const candles = ohlcSeries.map((candle, index) => {
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(ohlcSeries.length, 1);
const openY = height - padding - ((Number(candle.open) - minPrice) / span) * innerHeight;
const closeY = height - padding - ((Number(candle.close) - minPrice) / span) * innerHeight;
const highY = height - padding - ((Number(candle.high) - minPrice) / span) * innerHeight;
const lowY = height - padding - ((Number(candle.low) - minPrice) / span) * innerHeight;
return {
...candle,
id: `${candle.time || index}`,
centerX,
x: centerX - candleWidth / 2,
width: candleWidth,
openY,
closeY,
highY,
lowY,
bodyY: Math.min(openY, closeY),
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
};
});
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
const timestamp = new Date(event.timestamp).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = candles.length <= 1
? 0
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
const nearestCandle = candles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
}).filter(Boolean);
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = candles.length <= 1
? 0
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
const nearestCandle = candles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return {
id: item.id || `news-marker-${index}`,
title: item.topTitle || `当日 ${item.count} 条新闻`,
dateKey: item.dateKey,
tone: 'news',
x,
y,
isSelected: item.dateKey === selectedEventDate,
markerType: 'news',
count: item.count
};
}).filter(Boolean);
return {
width,
height,
padding,
path: '',
minPrice,
maxPrice,
markers: [...newsMarkers, ...explainMarkers],
candles,
linePoints: [],
bucketCount: candles.length
};
}
const prices = priceSeries.map((point) => Number(point.price));
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const span = maxPrice - minPrice || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const startTime = parsePointTime(priceSeries[0]);
const endTime = parsePointTime(priceSeries[priceSeries.length - 1]);
const timeSpan = Math.max(endTime - startTime, 1);
const candles = aggregatePriceSeriesToCandles(priceSeries);
const linePoints = priceSeries.map((point, index) => {
const x = padding + (innerWidth * index) / Math.max(priceSeries.length - 1, 1);
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
return { x, y };
});
const candleWidth = candles.length > 1
? Math.max(8, Math.min(24, (innerWidth / candles.length) * 0.58))
: 14;
const mappedCandles = candles.map((candle, index) => {
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(candles.length, 1);
const openY = height - padding - ((candle.open - minPrice) / span) * innerHeight;
const closeY = height - padding - ((candle.close - minPrice) / span) * innerHeight;
const highY = height - padding - ((candle.high - minPrice) / span) * innerHeight;
const lowY = height - padding - ((candle.low - minPrice) / span) * innerHeight;
return {
...candle,
centerX,
x: centerX - candleWidth / 2,
width: candleWidth,
openY,
closeY,
highY,
lowY,
bodyY: Math.min(openY, closeY),
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
};
});
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
const timestamp = new Date(event.timestamp).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = mappedCandles.length <= 1
? 0
: Math.min(
mappedCandles.length - 1,
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
);
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
}).filter(Boolean);
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
if (!Number.isFinite(timestamp)) return null;
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
const nearestCandleIndex = mappedCandles.length <= 1
? 0
: Math.min(
mappedCandles.length - 1,
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
);
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
const y = height - padding - ((price - minPrice) / span) * innerHeight;
return {
id: item.id || `news-marker-${index}`,
title: item.topTitle || `当日 ${item.count} 条新闻`,
dateKey: item.dateKey,
tone: 'news',
x,
y,
isSelected: item.dateKey === selectedEventDate,
markerType: 'news',
count: item.count
};
}).filter(Boolean);
return {
width,
height,
padding,
path: buildLinePath(priceSeries, width, height, padding),
minPrice,
maxPrice,
markers: [...newsMarkers, ...explainMarkers],
candles: mappedCandles,
linePoints,
bucketCount: mappedCandles.length
};
}, [explainTimeline, ohlcSeries, priceSeries, selectedEventDate, tickerNewsTimeline]);
return {
availableSymbols,
selectedTicker,
holding,
tickerSignals,
tickerNews,
visibleNews,
newsCategories,
visibleNewsByCategory,
selectedNewsFreshness,
selectedRangeWindow,
selectedRangeExplain,
selectedStory,
priceColor,
exposureWeight,
ohlcSeries,
priceSeries,
explainTimeline,
availableEventDates,
eventCategoryCounts,
visibleExplainEvents,
chartModel
};
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import useExplainModel from './useExplainModel';
function buildBaseProps() {
return {
tickers: [{ symbol: 'AAPL', price: 105.12, change: 1.34 }],
holdings: [{ ticker: 'AAPL', quantity: 10, weight: 0.2, marketValue: 1051.2, currentPrice: 105.12 }],
trades: [],
leaderboard: [],
feed: [],
priceHistoryByTicker: {
AAPL: [
{ timestamp: '2026-03-08T10:00:00Z', price: 100 },
{ timestamp: '2026-03-09T10:00:00Z', price: 103 },
{ timestamp: '2026-03-10T10:00:00Z', price: 105 }
]
},
ohlcHistoryByTicker: {},
selectedSymbol: 'AAPL',
explainEventsSnapshot: {
signals: [
{
id: 'sig-1',
ticker: 'AAPL',
date: '2026-03-10',
signal: 'bullish',
confidence: 0.88,
agent_id: 'agent-1',
agent_name: 'Alpha',
role: 'technical'
}
],
events: [
{
id: 'mention-1',
timestamp: '2026-03-10T12:00:00Z',
agent: 'Research',
body: 'AAPL momentum remains strong after earnings.',
meta: 'morning note'
}
],
trades: [
{
id: 'trade-1',
timestamp: '2026-03-10T15:00:00Z',
ticker: 'AAPL',
side: 'LONG',
qty: 5,
price: 104.5
}
]
},
newsSnapshot: {
items: [
{
id: 'news-1',
ticker: 'AAPL',
date: '2026-03-10T09:00:00Z',
title: 'Apple earnings beat expectations',
summary: 'Revenue topped consensus estimates.',
source: 'Polygon',
sentiment: 'positive'
},
{
id: 'news-2',
ticker: 'AAPL',
date: '2026-03-09T09:00:00Z',
title: 'Supplier update',
summary: 'Supply chain improves.',
source: 'Polygon',
sentiment: 'negative'
}
],
timeline: [
{ id: 'timeline-1', date: '2026-03-09', count: 1, source_count: 1, top_title: 'Supplier update' },
{ id: 'timeline-2', date: '2026-03-10', count: 1, source_count: 1, top_title: 'Apple earnings beat expectations' }
],
categories: {
earnings: {
count: 1,
article_ids: ['news-1']
}
},
rangeExplainCache: {
'2026-03-03:2026-03-10': {
summary: '区间内主要由财报催化推动。'
}
},
similarDaysCache: {
'2026-03-10': {
target_features: {
sentiment_score: 0.5,
n_articles: 2
},
items: [
{
date: '2026-02-18',
score: 0.92,
n_articles: 2,
sentiment_score: 0.4
}
]
}
}
},
selectedEventDate: '2026-03-10',
activeEventCategory: 'all',
activeNewsCategory: 'earnings',
activeNewsSentiment: 'all'
};
}
describe('useExplainModel', () => {
it('derives visible news and range explain data from snapshots', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableSymbols).toEqual(['AAPL']);
expect(result.current.visibleNews).toHaveLength(2);
expect(result.current.visibleNewsByCategory).toHaveLength(1);
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
expect(result.current.selectedRangeWindow).toEqual({
startDate: '2026-03-03',
endDate: '2026-03-10'
});
expect(result.current.selectedRangeExplain).toEqual({
summary: '区间内主要由财报催化推动。'
});
});
it('builds timeline, counts, and chart markers from explain data', () => {
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
expect(result.current.availableEventDates).toContain('2026-03-10');
expect(result.current.chartModel.markers.length).toBeGreaterThan(0);
expect(result.current.chartModel.path).toMatch(/^M/);
});
it('filters visible news by sentiment when requested', () => {
const props = buildBaseProps();
props.activeNewsCategory = 'all';
props.activeNewsSentiment = 'positive';
const { result } = renderHook(() => useExplainModel(props));
expect(result.current.visibleNewsByCategory).toHaveLength(1);
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
});
});

View File

@@ -0,0 +1,172 @@
/**
* Application Configuration Constants
*/
const trimTrailingSlash = (value) => value.replace(/\/+$/, "");
const isLocalDevHost = () => {
if (typeof window === "undefined") {
return false;
}
const host = String(window.location.hostname || "").trim().toLowerCase();
return host === "localhost" || host === "127.0.0.1";
};
// Centralized CDN asset URLs
export const CDN_ASSETS = {
companyRoom: {
agent_1: "https://img.alicdn.com/imgextra/i4/O1CN01Lr7SOl1lSExV0tOwv_!!6000000004817-2-tps-370-320.png",
agent_2: "https://img.alicdn.com/imgextra/i3/O1CN017Kb8cY1VQNUmuK47o_!!6000000002647-2-tps-368-312.png",
agent_3: "https://img.alicdn.com/imgextra/i3/O1CN010Fp55w1YqtGpVjgsS_!!6000000003111-2-tps-370-320.png",
agent_4: "https://img.alicdn.com/imgextra/i3/O1CN01VnUsML1Dkq6fHw3ks_!!6000000000255-2-tps-366-316.png",
agent_5: "https://img.alicdn.com/imgextra/i4/O1CN01o0kCQw1kyvbulBSl7_!!6000000004753-2-tps-370-314.png",
agent_6: "https://img.alicdn.com/imgextra/i2/O1CN01cLV0zl1FI6ULAunTp_!!6000000000463-2-tps-368-320.png",
team_logo: "https://img.alicdn.com/imgextra/i2/O1CN01n2S8aV25hcZhhNH95_!!6000000007558-2-tps-616-700.png",
reme_logo: "https://img.alicdn.com/imgextra/i2/O1CN01FhncuT1Tqp8LfCaft_!!6000000002434-2-tps-915-250.png",
full_room_dark: "https://img.alicdn.com/imgextra/i2/O1CN014sOgzK28re5haGC3X_!!6000000007986-2-tps-1248-832.png",
full_room_with_roles_tech_style: "https://img.alicdn.com/imgextra/i1/O1CN01qhupIj1KU4vF3yoT2_!!6000000001166-2-tps-1248-832.png",
},
llmModelLogos: {
"Zhipu AI": "https://img.alicdn.com/imgextra/i4/O1CN01PavE4h1SdFmbeUj6h_!!6000000002269-2-tps-92-92.png",
"Alibaba": "https://img.alicdn.com/imgextra/i4/O1CN01mTs8oZ1gsHOj0xy7O_!!6000000004197-0-tps-204-192.jpg",
"DeepSeek": "https://img.alicdn.com/imgextra/i3/O1CN01ocd9iO1D7S2qgEIXQ_!!6000000000169-2-tps-203-148.png",
"Moonshot": "https://img.alicdn.com/imgextra/i3/O1CN01rFzJg01wE0QFHNGLy_!!6000000006275-0-tps-182-148.jpg",
"Anthropic": "https://img.alicdn.com/imgextra/i4/O1CN01Sg8gbo1HKVnoU16rm_!!6000000000739-2-tps-148-148.png",
"Google": "https://img.alicdn.com/imgextra/i1/O1CN01fZwVYk1caBHdzh9qh_!!6000000003616-0-tps-148-148.jpg",
"OpenAI": "https://img.alicdn.com/imgextra/i3/O1CN01T1eaM8287qU0nZm91_!!6000000007886-2-tps-148-148.png",
"Groq": "https://img.alicdn.com/imgextra/i1/O1CN01WxASMc1QjXzhVl3eQ_!!6000000002012-2-tps-170-148.png",
"Ollama": "https://img.alicdn.com/imgextra/i1/O1CN01pN615e1i4vxLkQjVd_!!6000000004360-2-tps-204-192.png",
},
};
// Derived asset shortcuts
export const ASSETS = {
roomBg: CDN_ASSETS.companyRoom.full_room_with_roles_tech_style,
teamLogo: CDN_ASSETS.companyRoom.team_logo,
remeLogo: CDN_ASSETS.companyRoom.reme_logo,
};
// Scene dimensions (actual image size)
export const SCENE_NATIVE = { width: 1248, height: 832 };
// Agent seat positions (percentage relative to image, origin at bottom-left)
// Format: { x: horizontal %, y: vertical % from bottom }
export const AGENT_SEATS = [
{ x: 0.44, y: 0.58 }, // portfolio_manager
{ x: 0.55, y: 0.58 }, // risk_manager
{ x: 0.33, y: 0.52 }, // valuation_analyst
{ x: 0.42, y: 0.42 }, // sentiment_analyst
{ x: 0.56, y: 0.42 }, // fundamentals_analyst
{ x: 0.61, y: 0.49 }, // technical_analyst
];
// Agent definitions with subtle color schemes (very light backgrounds)
export const AGENTS = [
{
id: "portfolio_manager",
name: "投资经理",
role: "投资经理",
avatar: CDN_ASSETS.companyRoom.agent_1,
colors: { bg: "#F9FDFF", text: "#1565C0", accent: "#1565C0" }
},
{
id: "risk_manager",
name: "风控经理",
role: "风控经理",
avatar: CDN_ASSETS.companyRoom.agent_2,
colors: { bg: "#FFF8F8", text: "#C62828", accent: "#C62828" }
},
{
id: "valuation_analyst",
name: "估值分析师",
role: "估值分析师",
avatar: CDN_ASSETS.companyRoom.agent_3,
colors: { bg: "#FAFFFA", text: "#2E7D32", accent: "#2E7D32" }
},
{
id: "sentiment_analyst",
name: "情绪分析师",
role: "情绪分析师",
avatar: CDN_ASSETS.companyRoom.agent_4,
colors: { bg: "#FCFAFF", text: "#6A1B9A", accent: "#6A1B9A" }
},
{
id: "fundamentals_analyst",
name: "基本面分析师",
role: "基本面分析师",
avatar: CDN_ASSETS.companyRoom.agent_5,
colors: { bg: "#FFFCF7", text: "#E65100", accent: "#E65100" }
},
{
id: "technical_analyst",
name: "技术分析师",
role: "技术分析师",
avatar: CDN_ASSETS.companyRoom.agent_6,
colors: { bg: "#F9FEFF", text: "#00838F", accent: "#00838F" }
},
];
// LLM logo URLs for reuse
export const LLM_MODEL_LOGOS = { ...CDN_ASSETS.llmModelLogos };
// Message type colors (very subtle backgrounds)
export const MESSAGE_COLORS = {
system: { bg: "#FAFAFA", text: "#424242", accent: "#424242" },
memory: { bg: "#F2FDFF", text: "#00838F", accent: "#00838F" },
conference: { bg: "#F1F4FF", text: "#3949AB", accent: "#3949AB" }
};
// Helper function to get agent colors by ID or name
export const getAgentColors = (agentId, agentName) => {
const agent = AGENTS.find(a => a.id === agentId || a.name === agentName);
return agent?.colors || MESSAGE_COLORS.system;
};
// UI timing constants
export const BUBBLE_LIFETIME_MS = 8000;
export const CHART_MARGIN = { left: 60, right: 20, top: 20, bottom: 40 };
export const AXIS_TICKS = 5;
// WebSocket configuration
const DEFAULT_CONTROL_API_BASE = isLocalDevHost()
? "http://localhost:8000/api"
: "/api";
const DEFAULT_RUNTIME_API_BASE = isLocalDevHost()
? "http://localhost:8003/api/runtime"
: `${DEFAULT_CONTROL_API_BASE}/runtime`;
export const CONTROL_API_BASE =
trimTrailingSlash(import.meta.env.VITE_CONTROL_API_BASE_URL || "") || DEFAULT_CONTROL_API_BASE;
export const RUNTIME_API_BASE =
trimTrailingSlash(import.meta.env.VITE_RUNTIME_API_BASE_URL || "") ||
DEFAULT_RUNTIME_API_BASE;
const FALLBACK_WS_PROTOCOL =
typeof window !== "undefined" && window.location.protocol === "https:"
? "wss:"
: "ws:";
const FALLBACK_WS_HOST =
typeof window !== "undefined" ? window.location.hostname : "localhost";
const FALLBACK_WS_PORT =
typeof window !== "undefined" && window.location.port
? `:${window.location.port}`
: "";
export const WS_URL =
import.meta.env.VITE_WS_URL ||
(isLocalDevHost()
? `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}:8765`
: `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}${FALLBACK_WS_PORT}/ws`);
// Initial ticker symbols for the production watchlist
export const INITIAL_TICKERS = [
{ symbol: "AAPL", price: null, change: null },
{ symbol: "MSFT", price: null, change: null },
{ symbol: "GOOGL", price: null, change: null },
{ symbol: "AMZN", price: null, change: null },
{ symbol: "NVDA", price: null, change: null },
{ symbol: "META", price: null, change: null },
{ symbol: "TSLA", price: null, change: null },
{ symbol: "AMD", price: null, change: null },
{ symbol: "NFLX", price: null, change: null },
{ symbol: "AVGO", price: null, change: null },
{ symbol: "PLTR", price: null, change: null },
{ symbol: "COIN", price: null, change: null }
];

View File

@@ -0,0 +1,388 @@
import { useCallback } from 'react';
import {
createAgentLocalSkill,
deleteAgentLocalSkill,
disableAgentSkill,
enableAgentSkill,
fetchAgentProfile,
fetchAgentSkillDetail,
fetchAgentSkills,
fetchAgentWorkspaceFile,
fetchCurrentRuntime,
updateAgentLocalSkill,
updateAgentWorkspaceFile,
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,
setAgentProfilesByAgent,
setIsAgentSkillsLoading,
setAgentSkillsFeedback,
setAgentSkillsSavingKey,
setSkillDetailLoadingKey,
setAgentSkillsByAgent,
setSkillDetailsByName,
localSkillDraftsByKey,
selectedWorkspaceFile,
setWorkspaceFilesByAgent,
setWorkspaceDraftContent,
workspaceDraftContent,
setWorkspaceFileFeedback,
setWorkspaceFileSavingKey,
setIsWorkspaceFileLoading
} = useAgentStore();
const resolveWorkspaceId = useCallback(async () => {
const runtime = await fetchCurrentRuntime();
const workspaceId = runtime?.run_id;
if (!workspaceId) {
throw new Error('未检测到正在运行的任务');
}
return workspaceId;
}, []);
const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized) return false;
setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => fetchAgentSkills(workspaceId, normalized))
.then((payload) => {
setAgentSkillsByAgent((prev) => ({ ...prev, [normalized]: Array.isArray(payload?.skills) ? payload.skills : [] }));
setIsAgentSkillsLoading(false);
})
.catch(() => {
if (!clientRef.current) {
setIsAgentSkillsLoading(false);
return;
}
console.debug('REST agent skills request failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
if (!success) {
setIsAgentSkillsLoading(false);
}
});
return true;
}, [clientRef, resolveWorkspaceId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized) return false;
void resolveWorkspaceId()
.then((workspaceId) => fetchAgentProfile(workspaceId, normalized))
.then((payload) => {
setAgentProfilesByAgent((prev) => ({
...prev,
[normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {}
}));
})
.catch(() => {
if (clientRef.current) {
console.debug('REST agent profile request failed, falling back to websocket compatibility path');
clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
}
});
return true;
}, [clientRef, resolveWorkspaceId, setAgentProfilesByAgent]);
const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) return false;
const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey);
void resolveWorkspaceId()
.then((workspaceId) => fetchAgentSkillDetail(workspaceId, selectedSkillAgentId, normalized))
.then((payload) => {
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: payload?.skill || null }));
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({
...prev,
[detailKey]: typeof payload?.skill?.content === 'string' ? payload.skill.content : ''
}));
setSkillDetailLoadingKey(null);
})
.catch(() => {
if (!clientRef.current) {
setSkillDetailLoadingKey(null);
return;
}
console.debug('REST skill detail request failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
if (!success) {
setSkillDetailLoadingKey(null);
}
});
return true;
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]);
const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) {
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
return;
}
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => createAgentLocalSkill(workspaceId, selectedSkillAgentId, normalized))
.then(() => {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'success', text: `已创建本地技能 ${normalized}` });
requestAgentSkills(selectedSkillAgentId);
requestSkillDetail(normalized);
})
.catch(() => {
if (!clientRef.current) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill create failed, falling back to websocket compatibility path');
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, requestAgentSkills, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
}, [selectedSkillAgentId]);
const handleLocalSkillSave = useCallback((skillName) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`;
const content = localSkillDraftsByKey[detailKey];
if (typeof content !== 'string') return;
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => updateAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName, content))
.then(() => {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已保存` });
requestSkillDetail(skillName);
})
.catch(() => {
if (!clientRef.current) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill save failed, falling back to websocket compatibility path');
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, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDelete = useCallback((skillName) => {
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => deleteAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName))
.then(() => {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已删除` });
requestAgentSkills(selectedSkillAgentId);
})
.catch(() => {
if (!clientRef.current) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill delete failed, falling back to websocket compatibility path');
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, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleRemoveSharedSkill = useCallback((skillName) => {
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => disableAgentSkill(workspaceId, selectedSkillAgentId, skillName))
.then(() => {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 已移除共享技能 ${skillName}` });
requestAgentSkills(selectedSkillAgentId);
})
.catch(() => {
if (!clientRef.current) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST shared skill remove failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
});
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
const agentId = selectedSkillAgentId;
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
setAgentSkillsFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => enabled
? enableAgentSkill(workspaceId, agentId, skillName)
: disableAgentSkill(workspaceId, agentId, skillName))
.then(() => {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'success', text: `${agentId} ${enabled ? '已启用' : '已禁用'} ${skillName}` });
requestAgentSkills(agentId);
})
.catch(() => {
if (!clientRef.current) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST skill toggle failed, falling back to websocket compatibility path');
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, requestAgentSkills, resolveWorkspaceId, 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) return false;
setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => fetchAgentWorkspaceFile(workspaceId, normalizedAgentId, normalizedFilename))
.then((payload) => {
setWorkspaceFilesByAgent((prev) => ({
...prev,
[normalizedAgentId]: {
...(prev[normalizedAgentId] || {}),
[normalizedFilename]: typeof payload?.content === 'string' ? payload.content : ''
}
}));
setWorkspaceDraftContent(typeof payload?.content === 'string' ? payload.content : '');
setIsWorkspaceFileLoading(false);
})
.catch(() => {
if (!clientRef.current) {
setIsWorkspaceFileLoading(false);
return;
}
console.debug('REST workspace file read failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
if (!success) {
setIsWorkspaceFileLoading(false);
}
});
return true;
}, [clientRef, resolveWorkspaceId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]);
const handleWorkspaceFileChange = useCallback((filename) => {
useAgentStore.getState().setSelectedWorkspaceFile(filename);
requestWorkspaceFile(selectedSkillAgentId, filename);
}, [requestWorkspaceFile, selectedSkillAgentId]);
const handleWorkspaceFileSave = useCallback(() => {
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null);
void resolveWorkspaceId()
.then((workspaceId) => updateAgentWorkspaceFile(workspaceId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent))
.then((payload) => {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'success', text: `${selectedSkillAgentId}${selectedWorkspaceFile} 已保存` });
setWorkspaceFilesByAgent((prev) => ({
...prev,
[selectedSkillAgentId]: {
...(prev[selectedSkillAgentId] || {}),
[selectedWorkspaceFile]: typeof payload?.content === 'string' ? payload.content : workspaceDraftContent
}
}));
})
.catch(() => {
if (!clientRef.current) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST workspace file save failed, falling back to websocket compatibility path');
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, resolveWorkspaceId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, 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,445 @@
import { useState, useCallback, useRef } from "react";
import { AGENTS } from "../config/constants";
const MAX_FEED_ITEMS = 200;
const normalizeSystemContent = (content) => {
if (typeof content !== "string") {
return content;
}
const trimmed = content.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === "Runtime assets reloaded." || trimmed === "运行时配置已热更新") {
return "配置已刷新";
}
if (trimmed.startsWith("Watchlist updated:")) {
const symbols = trimmed.replace("Watchlist updated:", "").trim();
return symbols ? `自选已更新: ${symbols}` : "自选已更新";
}
if (trimmed === "已连接实时数据服务") {
return "已连接";
}
if (trimmed === "正在尝试连接数据服务...") {
return "连接中...";
}
if (trimmed.startsWith("day_start:")) {
const value = trimmed.replace("day_start:", "").trim();
return value ? `交易日开始:${value}` : "交易日开始";
}
if (trimmed.startsWith("day_complete:")) {
const value = trimmed.replace("day_complete:", "").trim();
return value ? `交易日结束:${value}` : "交易日结束";
}
if (trimmed.startsWith("day_error:")) {
const value = trimmed.replace("day_error:", "").trim();
return value ? `交易日异常:${value}` : "交易日异常";
}
return trimmed;
};
const normalizeConferenceTitle = (title) => {
if (typeof title !== "string") {
return "投资讨论";
}
const trimmed = title.trim();
if (!trimmed) {
return "投资讨论";
}
if (trimmed.startsWith("Investment Discussion -")) {
const date = trimmed.replace("Investment Discussion -", "").trim();
return date ? `投资讨论 · ${date}` : "投资讨论";
}
if (trimmed === "Team Conference") {
return "投资讨论";
}
return trimmed;
};
const normalizeAgentLabel = (agentName, agentId) => {
if (typeof agentName === "string") {
const trimmed = agentName.trim();
if (trimmed.toLowerCase() === "conference summary") {
return "会议总结";
}
}
if (typeof agentId === "string" && agentId.trim().toLowerCase() === "conference summary") {
return "会议总结";
}
return agentName;
};
/**
* Generate a unique ID for feed items
*/
const generateId = (prefix = "item") => `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
/**
* Convert raw event to a message object (for use within conferences or standalone)
*/
const eventToMessage = (evt) => {
if (!evt || !evt.type) {
return null;
}
const agent = AGENTS.find(a => a.id === evt.agentId);
const timestamp = evt.timestamp || evt.ts || Date.now();
switch (evt.type) {
case "agent_message":
case "conference_message":
return {
id: generateId("msg"),
timestamp,
agentId: evt.agentId,
agent: normalizeAgentLabel(agent?.name || evt.agentName || evt.agentId || "Agent", evt.agentId),
role: agent?.role || evt.role || "Agent",
content: evt.content
};
case "memory":
return {
id: generateId("memory"),
timestamp,
agentId: evt.agentId,
agent: agent?.name || evt.agentId || "Memory",
role: "Memory",
content: evt.content || evt.text || ""
};
case "system":
case "day_start":
case "day_complete":
case "day_error":
return {
id: generateId("sys"),
timestamp,
agent: "System",
role: "System",
content: normalizeSystemContent(evt.content || `${evt.type}: ${evt.date || ""}`)
};
default:
return null;
}
};
/**
* Convert raw event to a standalone feed item (non-conference)
*/
const eventToFeedItem = (evt) => {
if (!evt || !evt.type) {
return null;
}
const message = eventToMessage(evt);
if (!message) {
return null;
}
if (evt.type === "memory") {
return {
type: "memory",
id: message.id,
data: {
timestamp: message.timestamp,
agentId: message.agentId,
agent: message.agent,
content: message.content
}
};
}
return {
type: "message",
id: message.id,
data: message
};
};
/**
* Custom hook for processing feed events with conference aggregation
*/
export function useFeedProcessor() {
const [feed, setFeed] = useState([]);
// Active conference ref for real-time event handling
const activeConferenceRef = useRef(null);
/**
* Process historical events from server
* Events come in reverse chronological order (newest first)
* So conference_end appears BEFORE conference_start in the array
*/
const processHistoricalFeed = useCallback((events) => {
if (!Array.isArray(events)) {
console.warn("processHistoricalFeed: expected array, got", typeof events);
return;
}
console.log("📋 Processing historical events:", events.length);
const feedItems = [];
let currentConference = null;
// Process in chronological order (reverse the array)
const chronological = [...events].reverse();
for (const evt of chronological) {
if (!evt || !evt.type) {
continue;
}
try {
if (evt.type === "conference_start") {
// Start a new conference
currentConference = {
id: evt.conferenceId || generateId("conf"),
title: normalizeConferenceTitle(evt.title || "Team Conference"),
startTime: evt.timestamp || evt.ts || Date.now(),
endTime: null,
isLive: false,
participants: evt.participants || [],
messages: []
};
} else if (evt.type === "conference_end") {
// End current conference
if (currentConference) {
currentConference.endTime = evt.timestamp || evt.ts || Date.now();
currentConference.isLive = false;
feedItems.push({
type: "conference",
id: currentConference.id,
data: currentConference
});
currentConference = null;
}
} else if (evt.type === "conference_message") {
// Add to current conference if exists
const message = eventToMessage(evt);
if (message && currentConference) {
currentConference.messages.push(message);
} else if (message) {
// Fallback: show as standalone message if no active conference
feedItems.push({
type: "message",
id: message.id,
data: message
});
}
} else {
// Non-conference events
const feedItem = eventToFeedItem(evt);
if (feedItem) {
if (currentConference) {
// Add to conference messages
currentConference.messages.push(feedItem.data);
} else {
feedItems.push(feedItem);
}
}
}
} catch (error) {
console.error("Error processing historical event:", evt.type, error);
}
}
// If there's an unclosed conference, it's still live
if (currentConference) {
currentConference.isLive = true;
feedItems.push({
type: "conference",
id: currentConference.id,
data: currentConference
});
// Store as active for real-time updates
activeConferenceRef.current = currentConference;
console.log(`🔴 Restored active conference: ${currentConference.id} with ${currentConference.messages.length} messages`);
}
// Reverse back to newest-first order
setFeed(feedItems.reverse());
console.log(`✅ Processed ${feedItems.length} feed items from ${events.length} events`);
}, []);
/**
* Process a single real-time event
* Handles conference aggregation for live events
*/
const processFeedEvent = useCallback((evt) => {
if (!evt || !evt.type) {
return null;
}
// Handle conference start
if (evt.type === "conference_start") {
const conference = {
id: evt.conferenceId || generateId("conf"),
title: normalizeConferenceTitle(evt.title || "Team Conference"),
startTime: evt.timestamp || evt.ts || Date.now(),
endTime: null,
isLive: true,
participants: evt.participants || [],
messages: []
};
activeConferenceRef.current = conference;
setFeed(prev => [{ type: "conference", id: conference.id, data: conference }, ...prev].slice(0, MAX_FEED_ITEMS));
return conference;
}
// Handle conference end
if (evt.type === "conference_end") {
const activeConf = activeConferenceRef.current;
activeConferenceRef.current = null;
if (activeConf) {
const ended = {
...activeConf,
endTime: evt.timestamp || evt.ts || Date.now(),
isLive: false
};
setFeed(prev => prev.map(item =>
item.type === "conference" && item.id === activeConf.id
? { ...item, data: ended }
: item
));
return ended;
}
return null;
}
// Handle conference message
if (evt.type === "conference_message") {
const message = eventToMessage(evt);
if (!message) {
return null;
}
const activeConf = activeConferenceRef.current;
if (activeConf) {
// Add to active conference
const updated = {
...activeConf,
messages: [...activeConf.messages, message]
};
activeConferenceRef.current = updated;
setFeed(prev => prev.map(item =>
item.type === "conference" && item.id === activeConf.id
? { ...item, data: updated }
: item
));
return message;
} else {
// No active conference, show as standalone
const feedItem = { type: "message", id: message.id, data: message };
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
return feedItem;
}
}
// Handle other feed events (agent_message, memory, system, etc.)
const feedEventTypes = ["agent_message", "memory", "system", "day_start", "day_complete", "day_error"];
if (!feedEventTypes.includes(evt.type)) {
return null;
}
const feedItem = eventToFeedItem(evt);
if (!feedItem) {
return null;
}
const activeConf = activeConferenceRef.current;
if (activeConf) {
// Add to active conference
const updated = {
...activeConf,
messages: [...activeConf.messages, feedItem.data]
};
activeConferenceRef.current = updated;
setFeed(prev => prev.map(item =>
item.type === "conference" && item.id === activeConf.id
? { ...item, data: updated }
: item
));
return feedItem.data;
} else {
// No active conference, add as standalone
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
return feedItem;
}
}, []);
/**
* Add a system message to the feed
*/
const addSystemMessage = useCallback((content) => {
const message = {
id: generateId("sys"),
timestamp: Date.now(),
agent: "System",
role: "System",
content: normalizeSystemContent(content)
};
const activeConf = activeConferenceRef.current;
if (activeConf) {
const updated = {
...activeConf,
messages: [...activeConf.messages, message]
};
activeConferenceRef.current = updated;
setFeed(prev => prev.map(item =>
item.type === "conference" && item.id === activeConf.id
? { ...item, data: updated }
: item
));
} else {
const feedItem = { type: "message", id: message.id, data: message };
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
}
return message;
}, []);
/**
* Clear all feed items and reset active conference
*/
const clearFeed = useCallback(() => {
setFeed([]);
activeConferenceRef.current = null;
}, []);
/**
* Check if there's an active conference
*/
const hasActiveConference = useCallback(() => {
return activeConferenceRef.current !== null;
}, []);
return {
feed,
setFeed,
processHistoricalFeed,
processFeedEvent,
addSystemMessage,
clearFeed,
hasActiveConference
};
}
export default useFeedProcessor;

View File

@@ -0,0 +1,356 @@
import { useCallback } from "react";
import { useOpenClawStore } from "../store/openclawStore";
const RETRY_DELAY_MS = 250;
function sendWithRetry(clientRef, payload, retries = 3) {
const attemptSend = (remaining) => {
const client = clientRef.current;
if (!client) return false;
const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload));
if (sent || remaining <= 0) return sent;
window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS);
return false;
};
return attemptSend(retries);
}
export function useOpenClawPanel() {
// Access store state directly — do NOT destructure store as a useCallback dep
// or every store update will recreate all callbacks and trigger infinite loops.
const getStore = () => useOpenClawStore.getState();
const requestStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setStatusLoading(true);
store.setStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_status" });
}, []);
const requestSessions = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSessionsLoading(true);
store.setSessionsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_sessions" });
}, []);
const requestSessionDetail = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSelectedSessionKey(sessionKey);
store.setSessionDetailLoading(true);
store.setSessionDetailError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey });
}, []);
const requestSessionHistory = useCallback((sessionKey, limit = 20) => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, {
type: "get_openclaw_session_history",
session_key: sessionKey,
limit,
});
}, []);
const resolveSession = useCallback(({ agentId, label = null, channel = null, includeGlobal = true }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_resolve_session",
agent_id: agentId,
label,
channel,
include_global: includeGlobal,
});
}, []);
const createSession = useCallback(({ agentId, label = null, model = null, initialMessage = null }) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agentId) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_create_session",
agent_id: agentId,
label,
model,
initial_message: initialMessage,
});
}, []);
const subscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
}, []);
const unsubscribeSession = useCallback((sessionKey) => {
const client = getStore().clientRef?.current;
if (!client || !sessionKey) return;
sendWithRetry({ current: client }, {
type: "openclaw_unsubscribe_session",
session_key: sessionKey,
});
}, []);
const resetSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_reset_session",
session_key: sessionKey,
});
}, []);
const deleteSession = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey) return;
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_delete_session",
session_key: sessionKey,
});
}, []);
const sendSessionMessage = useCallback((sessionKey, message, thinking = null) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !sessionKey || !message?.trim()) return;
sendWithRetry({ current: client }, {
type: "openclaw_subscribe_session",
session_key: sessionKey,
});
store.setOpenclawChatSendingForSession?.(sessionKey, true);
store.setChatError?.(null);
sendWithRetry({ current: client }, {
type: "openclaw_send_message",
session_key: sessionKey,
message: message.trim(),
thinking,
});
}, []);
const requestCron = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setCronLoading(true);
store.setCronError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_cron" });
}, []);
const requestApprovals = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setApprovalsLoading(true);
store.setApprovalsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_approvals" });
}, []);
const requestAgents = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setAgentsLoading(true);
store.setAgentsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_agents" });
}, []);
const requestAgentsPresence = useCallback(() => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
}, []);
const requestSkills = useCallback((agentId = null) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillsLoading(true);
store.setSkillsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
}, []);
const requestModels = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsLoading(true);
store.setModelsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models" });
}, []);
const requestHooks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setHooksLoading(true);
store.setHooksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_hooks" });
}, []);
const requestPlugins = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPluginsLoading(true);
store.setPluginsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_plugins" });
}, []);
const requestSecretsAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecretsAuditLoading(true);
store.setSecretsAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" });
}, []);
const requestSecurityAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecurityAuditLoading(true);
store.setSecurityAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" });
}, []);
const requestDaemonStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setDaemonStatusLoading(true);
store.setDaemonStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" });
}, []);
const requestPairing = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPairingLoading(true);
store.setPairingError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_pairing" });
}, []);
const requestQrCode = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setQrCodeLoading(true);
store.setQrCodeError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_qr" });
}, []);
const requestUpdateStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setUpdateStatusLoading(true);
store.setUpdateStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_update_status" });
}, []);
const requestModelsAliases = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsAliasesLoading(true);
store.setModelsAliasesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" });
}, []);
const requestModelsFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsFallbacksLoading(true);
store.setModelsFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" });
}, []);
const requestModelsImageFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsImageFallbacksLoading(true);
store.setModelsImageFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" });
}, []);
const requestSkillUpdate = useCallback((slug = null, all = false) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillUpdateLoading(true);
store.setSkillUpdateError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all });
}, []);
const requestWorkspaceFiles = useCallback((workspace) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !workspace) return;
store.setWorkspaceFilesLoading(true);
store.setWorkspaceFilesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace });
}, []);
const requestWorkspaceFile = useCallback((agent_id, file_name) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agent_id || !file_name) return;
console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name });
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name });
}, []);
return {
requestStatus,
requestSessions,
requestSessionDetail,
requestSessionHistory,
resolveSession,
createSession,
subscribeSession,
unsubscribeSession,
resetSession,
deleteSession,
sendSessionMessage,
requestCron,
requestApprovals,
requestAgents,
requestAgentsPresence,
requestSkills,
requestModels,
requestHooks,
requestPlugins,
requestSecretsAudit,
requestSecurityAudit,
requestDaemonStatus,
requestPairing,
requestQrCode,
requestUpdateStatus,
requestModelsAliases,
requestModelsFallbacks,
requestModelsImageFallbacks,
requestSkillUpdate,
requestWorkspaceFiles,
requestWorkspaceFile,
};
}

View File

@@ -0,0 +1,581 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { INITIAL_TICKERS } from "../config/constants";
import { fetchRuntimeHistory, 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, onRuntimeStarted }) {
const {
runtimeConfig,
setRuntimeConfig,
isWatchlistPanelOpen,
setIsWatchlistPanelOpen,
isRuntimeSettingsOpen,
setIsRuntimeSettingsOpen,
watchlistDraftSymbols,
setWatchlistDraftSymbols,
watchlistInputValue,
setWatchlistInputValue,
watchlistFeedback,
setWatchlistFeedback,
isWatchlistSaving,
setIsWatchlistSaving,
launchModeDraft,
setLaunchModeDraft,
restoreRunIdDraft,
setRestoreRunIdDraft,
runtimeHistoryRuns,
setRuntimeHistoryRuns,
scheduleModeDraft,
setScheduleModeDraft,
intervalMinutesDraft,
setIntervalMinutesDraft,
triggerTimeDraft,
setTriggerTimeDraft,
maxCommCyclesDraft,
setMaxCommCyclesDraft,
initialCashDraft,
setInitialCashDraft,
marginRequirementDraft,
setMarginRequirementDraft,
enableMemoryDraft,
setEnableMemoryDraft,
modeDraft,
setModeDraft,
pollIntervalDraft,
setPollIntervalDraft,
startDateDraft,
setStartDateDraft,
endDateDraft,
setEndDateDraft,
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
]);
useEffect(() => {
if (!isRuntimeSettingsOpen) {
return;
}
let cancelled = false;
void fetchRuntimeHistory(20)
.then((payload) => {
if (cancelled) return;
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
setRuntimeHistoryRuns(runs);
if (!restoreRunIdDraft && runs.length > 0) {
setRestoreRunIdDraft(runs[0].run_id);
}
})
.catch(() => {
if (!cancelled) {
setRuntimeHistoryRuns([]);
}
});
return () => {
cancelled = true;
};
}, [isRuntimeSettingsOpen, restoreRunIdDraft, setRestoreRunIdDraft, setRuntimeHistoryRuns]);
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;
}
if (launchModeDraft === "restore" && !restoreRunIdDraft) {
setRuntimeConfigFeedback({ type: "error", text: "请选择一个历史任务用于恢复启动" });
return;
}
setIsRuntimeConfigSaving(true);
setIsWatchlistSaving(true);
setRuntimeConfigFeedback(null);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue("");
try {
const result = await startRuntime({
launch_mode: launchModeDraft,
restore_run_id: launchModeDraft === "restore" ? restoreRunIdDraft : null,
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,
});
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setIsRuntimeSettingsOpen(false);
setRuntimeConfigFeedback({
type: "success",
text: `任务已启动: ${result.run_id}`
});
addSystemMessage(`新任务已启动: ${result.run_id}`);
onRuntimeStarted?.(result);
} catch (error) {
setIsRuntimeConfigSaving(false);
setIsWatchlistSaving(false);
setRuntimeConfigFeedback({
type: "error",
text: `启动失败: ${error.message}`
});
}
}, [
addSystemMessage,
clientRef,
enableMemoryDraft,
endDateDraft,
initialCashDraft,
intervalMinutesDraft,
launchModeDraft,
marginRequirementDraft,
maxCommCyclesDraft,
modeDraft,
pollIntervalDraft,
restoreRunIdDraft,
scheduleModeDraft,
setIsRuntimeConfigSaving,
setIsRuntimeSettingsOpen,
setIsWatchlistSaving,
setRuntimeConfigFeedback,
setWatchlistDraftSymbols,
setWatchlistFeedback,
setWatchlistInputValue,
startDateDraft,
onRuntimeStarted,
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);
setLaunchModeDraft("fresh");
setRestoreRunIdDraft("");
setModeDraft(DEFAULT_MODE);
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
setStartDateDraft("");
setEndDateDraft("");
setRuntimeConfigFeedback(null);
}, [
setEnableMemoryDraft,
setEndDateDraft,
setInitialCashDraft,
setIntervalMinutesDraft,
setLaunchModeDraft,
setMarginRequirementDraft,
setMaxCommCyclesDraft,
setModeDraft,
setPollIntervalDraft,
setRestoreRunIdDraft,
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,
launchModeDraft,
restoreRunIdDraft,
runtimeHistoryRuns,
scheduleModeDraft,
intervalMinutesDraft,
triggerTimeDraft,
maxCommCyclesDraft,
initialCashDraft,
marginRequirementDraft,
enableMemoryDraft,
modeDraft,
pollIntervalDraft,
startDateDraft,
endDateDraft,
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,
setLaunchModeDraft,
setRestoreRunIdDraft,
setModeDraft,
setPollIntervalDraft,
setStartDateDraft,
setEndDateDraft,
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
};
}

File diff suppressed because it is too large Load Diff

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;

68
frontend/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

8
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,8 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import "./index.css";
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)

View File

@@ -0,0 +1,110 @@
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
const isLocalDevHost = () => {
if (typeof window === 'undefined') {
return false;
}
const host = String(window.location.hostname || '').trim().toLowerCase();
return host === 'localhost' || host === '127.0.0.1';
};
const NEWS_SERVICE_BASE = trimTrailingSlash(import.meta.env.VITE_NEWS_SERVICE_URL || '') || (
isLocalDevHost() ? 'http://localhost:8002' : ''
);
export function hasDirectNewsService() {
return Boolean(NEWS_SERVICE_BASE);
}
export async function fetchStockStoryDirect(ticker, asOfDate) {
if (!NEWS_SERVICE_BASE) {
throw new Error('Direct news service is not configured');
}
const params = new URLSearchParams();
if (asOfDate) {
params.set('as_of_date', asOfDate);
}
const query = params.toString();
const url = `${NEWS_SERVICE_BASE}/api/stories/${encodeURIComponent(ticker)}${query ? `?${query}` : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export async function fetchSimilarDaysDirect(ticker, date, topK = 8) {
if (!NEWS_SERVICE_BASE) {
throw new Error('Direct news service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('date', date);
params.set('n_similar', String(topK));
const response = await fetch(`${NEWS_SERVICE_BASE}/api/similar-days?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export async function fetchRangeExplainDirect(ticker, startDate, endDate, articleIds = []) {
if (!NEWS_SERVICE_BASE) {
throw new Error('Direct news service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('start_date', startDate);
params.set('end_date', endDate);
for (const articleId of Array.isArray(articleIds) ? articleIds : []) {
params.append('article_ids', articleId);
}
const response = await fetch(`${NEWS_SERVICE_BASE}/api/range-explain?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export async function fetchNewsForDateDirect(ticker, date, limit = 20) {
if (!NEWS_SERVICE_BASE) {
throw new Error('Direct news service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('date', date);
params.set('limit', String(limit));
const response = await fetch(`${NEWS_SERVICE_BASE}/api/news-for-date?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export async function fetchNewsCategoriesDirect(ticker, startDate, endDate, limit = 200) {
if (!NEWS_SERVICE_BASE) {
throw new Error('Direct news service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('limit', String(limit));
if (startDate) {
params.set('start_date', startDate);
}
if (endDate) {
params.set('end_date', endDate);
}
const response = await fetch(`${NEWS_SERVICE_BASE}/api/categories?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}

View File

@@ -0,0 +1,229 @@
import { startTransition } from 'react';
import { CONTROL_API_BASE, RUNTIME_API_BASE } from '../config/constants';
async function safeFetch(basePath, endpoint) {
const response = await fetch(`${basePath}${endpoint}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
async function safeRequest(basePath, endpoint, options = {}) {
const isFormData = options.body instanceof FormData;
const response = await fetch(`${basePath}${endpoint}`, {
headers: isFormData
? { ...(options.headers || {}) }
: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export function fetchRuntimeContext() {
return safeFetch(RUNTIME_API_BASE, '/context');
}
export function fetchRuntimeAgents() {
return safeFetch(RUNTIME_API_BASE, '/agents');
}
export function fetchRuntimeEvents() {
return safeFetch(RUNTIME_API_BASE, '/events');
}
export function fetchRuntimeHistory(limit = 20) {
return safeFetch(RUNTIME_API_BASE, `/history?limit=${limit}`);
}
export function fetchPendingApprovals() {
return safeFetch(CONTROL_API_BASE, '/guard/pending');
}
export function approvePendingApproval(approvalId) {
return safeRequest(CONTROL_API_BASE, '/guard/approve', {
method: 'POST',
body: JSON.stringify({
approval_id: approvalId,
one_time: true,
expires_in_minutes: 30
})
});
}
export function denyPendingApproval(approvalId, reason = 'Rejected from runtime panel') {
return safeRequest(CONTROL_API_BASE, '/guard/deny', {
method: 'POST',
body: JSON.stringify({
approval_id: approvalId,
reason
})
});
}
export function loadAllRuntimeState(onSuccess, onError) {
startTransition(async () => {
try {
const [context, agents, approvals, events] = await Promise.all([
fetchRuntimeContext(),
fetchRuntimeAgents(),
fetchPendingApprovals(),
fetchRuntimeEvents()
]);
onSuccess({
context,
agents: agents.agents,
approvals: approvals.approvals,
events: events.events
});
} catch (err) {
onError(err);
}
});
}
/**
* Start a new trading runtime with the given configuration.
* If a runtime is already running, it will be forcefully stopped first.
*/
export function startRuntime(config) {
return safeRequest(RUNTIME_API_BASE, '/start', {
method: 'POST',
body: JSON.stringify(config)
});
}
/**
* Stop the current running runtime.
*/
export function stopRuntime(force = true) {
return safeRequest(RUNTIME_API_BASE, `/stop?force=${force}`, {
method: 'POST'
});
}
/**
* Restart the runtime with a new configuration.
*/
export function restartRuntime(config) {
return safeRequest(RUNTIME_API_BASE, '/restart', {
method: 'POST',
body: JSON.stringify(config)
});
}
/**
* Get information about the currently running runtime.
*/
export function fetchCurrentRuntime() {
return safeFetch(RUNTIME_API_BASE, '/current');
}
export function fetchRuntimeLogs() {
return safeFetch(RUNTIME_API_BASE, '/logs');
}
export function fetchAgentProfile(workspaceId, agentId) {
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/profile`);
}
export function fetchAgentSkills(workspaceId, agentId) {
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills`);
}
export function fetchAgentSkillDetail(workspaceId, agentId, skillName) {
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}`);
}
export function fetchAgentWorkspaceFile(workspaceId, agentId, filename) {
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`);
}
export function createAgentLocalSkill(workspaceId, agentId, skillName) {
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local`, {
method: 'POST',
body: JSON.stringify({ skill_name: skillName })
});
}
export function updateAgentLocalSkill(workspaceId, agentId, skillName, content) {
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
method: 'PUT',
body: JSON.stringify({ content })
});
}
export function deleteAgentLocalSkill(workspaceId, agentId, skillName) {
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
method: 'DELETE'
});
}
export function enableAgentSkill(workspaceId, agentId, skillName) {
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/enable`, {
method: 'POST'
});
}
export function disableAgentSkill(workspaceId, agentId, skillName) {
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/disable`, {
method: 'POST'
});
}
export function updateAgentWorkspaceFile(workspaceId, agentId, filename, content) {
return fetch(`${CONTROL_API_BASE}/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain'
},
body: content
}).then(async (response) => {
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
});
}
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(
CONTROL_API_BASE,
`/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/upload`,
{
method: 'POST',
body: formData
}
);
}

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

@@ -0,0 +1,55 @@
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
const isLocalDevHost = () => {
if (typeof window === 'undefined') {
return false;
}
const host = String(window.location.hostname || '').trim().toLowerCase();
return host === 'localhost' || host === '127.0.0.1';
};
const TRADING_SERVICE_BASE = trimTrailingSlash(import.meta.env.VITE_TRADING_SERVICE_URL || '') || (
isLocalDevHost() ? 'http://localhost:8001' : ''
);
export function hasDirectTradingService() {
return Boolean(TRADING_SERVICE_BASE);
}
export async function fetchInsiderTradesDirect(ticker, startDate = null, endDate = null, limit = 50) {
if (!TRADING_SERVICE_BASE) {
throw new Error('Direct trading service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('limit', String(limit));
if (startDate) {
params.set('start_date', startDate);
}
if (endDate) {
params.set('end_date', endDate);
}
const response = await fetch(`${TRADING_SERVICE_BASE}/api/insider-trades?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
export async function fetchStockHistoryDirect(ticker, startDate, endDate) {
if (!TRADING_SERVICE_BASE) {
throw new Error('Direct trading service is not configured');
}
const params = new URLSearchParams();
params.set('ticker', ticker);
params.set('start_date', startDate);
params.set('end_date', endDate);
const response = await fetch(`${TRADING_SERVICE_BASE}/api/prices?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}

View File

@@ -0,0 +1,300 @@
/**
* WebSocket Client with Dynamic Port Resolution
* Handles connection, reconnection, and heartbeat
* Fetches Gateway port from API before connecting
*/
import { RUNTIME_API_BASE, 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(`${RUNTIME_API_BASE}/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 { status: "running", port: data.port, wsUrl: data.ws_url };
}
return { status: "stopped", port: data.port || null, wsUrl: data.ws_url || null };
} catch (error) {
console.warn('[Gateway] Failed to fetch port:', error);
return { status: "unavailable", port: null, wsUrl: 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 = null, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
this.onEvent = onEvent;
this.wsUrl = wsUrl; // null = auto-resolve from API
this.baseReconnectDelay = reconnectDelay;
this.reconnectDelay = reconnectDelay;
this.maxReconnectDelay = 30000;
this.heartbeatInterval = heartbeatInterval;
this.ws = null;
this.shouldReconnect = false;
this.reconnectTimer = null;
this.heartbeatTimer = null;
this.reconnectAttempts = 0;
this.lastPongTime = 0;
this.isConnecting = false;
}
async connect() {
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.reconnectDelay = this.baseReconnectDelay;
await this._connect();
}
async _connect() {
if (!this.shouldReconnect || this.isConnecting) {
return;
}
this.isConnecting = true;
// Resolve WebSocket URL if not set
let targetUrl = this.wsUrl;
if (!targetUrl) {
const gatewayInfo = await fetchGatewayPort();
if (gatewayInfo?.status === "running") {
// Always use the pre-configured WS_URL (which routes through the
// frontend reverse-proxy in production). The ws_url returned by
// the API points to localhost and is only useful server-side.
targetUrl = WS_URL;
console.log(`[WebSocket] Gateway is running, connecting via: ${targetUrl}`);
} else if (gatewayInfo?.status === "unavailable") {
targetUrl = WS_URL;
console.log(`[WebSocket] Using default URL: ${targetUrl}`);
} else {
this.isConnecting = false;
this._safeEmit({
type: "system",
content: "运行任务尚未启动,等待数据服务上线..."
});
if (this.shouldReconnect) {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
this._connect();
}, this.reconnectDelay);
}
return;
}
}
// Clear any existing connection
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
this.ws = null;
}
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 to", targetUrl);
this._startHeartbeat();
this.isConnecting = false;
};
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);
}
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.isConnecting = false;
};
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;
// 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;
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => {
this._connect();
}, this.reconnectDelay);
}
}
}
_safeEmit(msg) {
try {
this.onEvent(msg);
} catch (e) {
console.error("[WebSocket] Error in event handler:", e);
}
}
_startHeartbeat() {
this._stopHeartbeat();
this.lastPongTime = Date.now();
this.heartbeatTimer = setInterval(() => {
this._sendPing();
// Check for stale connection (no response in 60s)
const timeSinceLastPong = Date.now() - this.lastPongTime;
if (timeSinceLastPong > 60000 && this.ws) {
console.warn("[WebSocket] Connection appears stale, forcing reconnect");
this.ws.close();
}
}, this.heartbeatInterval);
}
_sendPing() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: "ping" }));
} catch (e) {
console.error("Heartbeat send error:", e);
}
}
}
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
this.ws.send(messageStr);
return true;
} catch (e) {
console.error("Send error:", e);
return false;
}
} else {
console.warn("WebSocket is not connected, cannot send message");
return false;
}
}
disconnect() {
this.shouldReconnect = false;
this._stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
try {
this.ws.close();
} catch (e) {
console.error("Close error:", e);
}
}
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();
}
}

View File

@@ -0,0 +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((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
// Agent profiles
agentProfilesByAgent: {},
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
// Agent skills
agentSkillsByAgent: {},
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
// Skill details
skillDetailsByName: {},
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
// Local skill drafts
localSkillDraftsByKey: {},
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
// Loading states
isAgentSkillsLoading: false,
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
skillDetailLoadingKey: null,
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
agentSkillsSavingKey: null,
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
agentSkillsFeedback: null,
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
// Workspace files
selectedWorkspaceFile: null,
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
workspaceFilesByAgent: {},
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
workspaceDraftContent: '',
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
isWorkspaceFileLoading: false,
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
workspaceFileSavingKey: null,
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
workspaceFileFeedback: null,
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
}));

View File

@@ -0,0 +1,5 @@
export { useRuntimeStore } from './runtimeStore';
export { useMarketStore } from './marketStore';
export { usePortfolioStore } from './portfolioStore';
export { useAgentStore } from './agentStore';
export { useUIStore } from './uiStore';

View File

@@ -0,0 +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((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
rollingTickers: {},
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
// Price history
priceHistoryByTicker: {},
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
// OHLC history
ohlcHistoryByTicker: {},
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
// History source tracking
historySourceByTicker: {},
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
// Explain events
explainEventsByTicker: {},
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
// Selected explain symbol
selectedExplainSymbol: '',
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
// News by ticker
newsByTicker: {},
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
// Insider trades
insiderTradesByTicker: {},
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
// Technical indicators
technicalIndicatorsByTicker: {},
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
}));

View File

@@ -0,0 +1,354 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useOpenClawStore = create(
persist(
(set) => ({
// Raw data
openclawStatus: null,
openclawSessions: [],
openclawSessionsDefaults: null,
openclawSessionDetail: null,
openclawSessionHistory: [],
openclawCronJobs: [],
openclawApprovals: [],
openclawResolvedSessionKey: null,
openclawChatMessagesBySession: {},
openclawChatDraftBySession: {},
openclawChatSendingBySession: {},
openclawSessionSubscriptions: {},
// Loading states
isStatusLoading: false,
isSessionsLoading: false,
isSessionDetailLoading: false,
isCronLoading: false,
isApprovalsLoading: false,
isChatSending: false,
// Error states
statusError: null,
sessionsError: null,
sessionDetailError: null,
cronError: null,
approvalsError: null,
chatError: null,
// Agents state
agents: [],
agentsLoading: false,
agentsError: null,
agentsPresence: {},
// Skills state
skills: [],
skillsLoading: false,
skillsError: null,
// Models state
models: [],
modelsLoading: false,
modelsError: null,
// Hooks state
hooks: [],
hooksLoading: false,
hooksError: null,
// Plugins state
plugins: [],
pluginsLoading: false,
pluginsError: null,
// Secrets audit state
secretsAudit: null,
secretsAuditLoading: false,
secretsAuditError: null,
// Security audit state
securityAudit: null,
securityAuditLoading: false,
securityAuditError: null,
// Daemon status state
daemonStatus: null,
daemonStatusLoading: false,
daemonStatusError: null,
// Pairing state
pairing: null,
pairingLoading: false,
pairingError: null,
// QR code state
qrCode: null,
qrCodeLoading: false,
qrCodeError: null,
// Update status state
updateStatus: null,
updateStatusLoading: false,
updateStatusError: null,
// Models aliases state
modelsAliases: null,
modelsAliasesLoading: false,
modelsAliasesError: null,
// Models fallbacks state
modelsFallbacks: [],
modelsFallbacksLoading: false,
modelsFallbacksError: null,
// Models image fallbacks state
modelsImageFallbacks: [],
modelsImageFallbacksLoading: false,
modelsImageFallbacksError: null,
// Skill update state
skillUpdate: null,
skillUpdateLoading: false,
skillUpdateError: null,
// Workspace files state (per agent, keyed by workspace path)
workspaceFiles: {},
workspaceFilesLoading: false,
workspaceFilesError: null,
// Workspace file content (keyed by "agentId:filename")
workspaceFileContent: {},
// Selected session key for detail/history drill-down
selectedSessionKey: null,
// WebSocket client ref (set by App.jsx on connection)
clientRef: null,
setClientRef: (ref) => set({ clientRef: ref }),
// Setters
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
setOpenclawSessions: (data) => set({
openclawSessions: data?.sessions || [],
openclawSessionsDefaults: data?.defaults || null,
sessionsError: null,
}),
appendOpenclawSession: (session) => set((state) => {
const key = session?.key || session?.sessionKey;
if (!key) {
return {};
}
const existing = state.openclawSessions || [];
const deduped = existing.filter((item) => (item?.key || item?.sessionKey) !== key);
return { openclawSessions: [session, ...deduped] };
}),
removeOpenclawSession: (sessionKey) => set((state) => ({
openclawSessions: (state.openclawSessions || []).filter(
(item) => (item?.key || item?.sessionKey) !== sessionKey
),
selectedSessionKey:
state.selectedSessionKey === sessionKey ? null : state.selectedSessionKey,
})),
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: data?.error || null }),
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: data?.error || null }),
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
setOpenclawResolvedSessionKey: (key) => set({ openclawResolvedSessionKey: key || null }),
setOpenclawChatDraft: (sessionKey, value) => set((state) => ({
openclawChatDraftBySession: { ...state.openclawChatDraftBySession, [sessionKey]: value },
})),
appendOpenclawChatMessage: (sessionKey, message) => set((state) => {
const current = state.openclawChatMessagesBySession[sessionKey] || [];
const sameMessageIndex = current.findIndex((item) => {
const sameId = Boolean(message?.id && item?.id && message.id === item.id);
const sameMessageId = Boolean(
message?.messageId &&
item?.messageId &&
message.messageId === item.messageId
);
const sameSeq = Boolean(
message?.seq !== undefined &&
message?.seq !== null &&
item?.seq !== undefined &&
item?.seq !== null &&
message.seq === item.seq &&
message?.role === item?.role
);
const incomingText = String(message?.text || '').trim();
const existingText = String(item?.text || '').trim();
const incomingTs = Date.parse(message?.timestamp || '');
const existingTs = Date.parse(item?.timestamp || '');
const nearInTime =
Number.isFinite(incomingTs) &&
Number.isFinite(existingTs) &&
Math.abs(incomingTs - existingTs) < 1500;
const sameAssistantText =
message?.role === 'assistant' &&
item?.role === 'assistant' &&
incomingText &&
existingText &&
(
incomingText === existingText ||
incomingText.startsWith(existingText) ||
existingText.startsWith(incomingText)
) &&
nearInTime;
return sameId || sameMessageId || sameSeq || sameAssistantText;
});
if (sameMessageIndex >= 0) {
const next = [...current];
next[sameMessageIndex] = { ...next[sameMessageIndex], ...message };
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: next,
},
};
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: [...current, message],
},
};
}),
replaceOpenclawChatHistory: (sessionKey, messages) => set((state) => {
const incoming = Array.isArray(messages) ? messages : [];
const existing = state.openclawChatMessagesBySession[sessionKey] || [];
const merged = [];
const seen = new Set();
const signatureFor = (message) => {
if (!message) return "";
if (message.id) return `id:${message.id}`;
if (message.messageId) return `mid:${message.messageId}`;
if (message.seq !== undefined && message.seq !== null) return `seq:${message.seq}`;
return `txt:${message.role || ""}:${String(message.text || "").trim()}`;
};
for (const message of [...incoming, ...existing]) {
const signature = signatureFor(message);
if (!signature || seen.has(signature)) {
continue;
}
seen.add(signature);
merged.push(message);
}
return {
openclawChatMessagesBySession: {
...state.openclawChatMessagesBySession,
[sessionKey]: merged,
},
};
}),
setOpenclawChatSendingForSession: (sessionKey, value) => set((state) => ({
openclawChatSendingBySession: { ...state.openclawChatSendingBySession, [sessionKey]: Boolean(value) },
isChatSending: Boolean(value),
})),
setOpenclawSessionSubscribed: (sessionKey, value) => set((state) => ({
openclawSessionSubscriptions: { ...state.openclawSessionSubscriptions, [sessionKey]: Boolean(value) },
})),
setSelectedSessionKey: (key) => set({ selectedSessionKey: key }),
setStatusLoading: (v) => set({ isStatusLoading: v }),
setSessionsLoading: (v) => set({ isSessionsLoading: v }),
setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }),
setCronLoading: (v) => set({ isCronLoading: v }),
setApprovalsLoading: (v) => set({ isApprovalsLoading: v }),
setStatusError: (e) => set({ statusError: e }),
setSessionsError: (e) => set({ sessionsError: e }),
setSessionDetailError: (e) => set({ sessionDetailError: e }),
setCronError: (e) => set({ cronError: e }),
setApprovalsError: (e) => set({ approvalsError: e }),
setChatError: (e) => set({ chatError: e }),
setAgents: (agents) => set({ agents }),
setAgentsLoading: (loading) => set({ agentsLoading: loading }),
setAgentsError: (error) => set({ agentsError: error }),
setAgentsPresence: (presence) => set({ agentsPresence: presence }),
setSkills: (skills) => set({ skills }),
setSkillsLoading: (loading) => set({ skillsLoading: loading }),
setSkillsError: (error) => set({ skillsError: error }),
setModels: (models) => set({ models }),
setModelsLoading: (loading) => set({ modelsLoading: loading }),
setModelsError: (error) => set({ modelsError: error }),
setHooks: (hooks) => set({ hooks }),
setHooksLoading: (loading) => set({ hooksLoading: loading }),
setHooksError: (error) => set({ hooksError: error }),
setPlugins: (plugins) => set({ plugins }),
setPluginsLoading: (loading) => set({ pluginsLoading: loading }),
setPluginsError: (error) => set({ pluginsError: error }),
setSecretsAudit: (data) => set({ secretsAudit: data }),
setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }),
setSecretsAuditError: (error) => set({ secretsAuditError: error }),
setSecurityAudit: (data) => set({ securityAudit: data }),
setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }),
setSecurityAuditError: (error) => set({ securityAuditError: error }),
setDaemonStatus: (data) => set({ daemonStatus: data }),
setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }),
setDaemonStatusError: (error) => set({ daemonStatusError: error }),
setPairing: (data) => set({ pairing: data }),
setPairingLoading: (loading) => set({ pairingLoading: loading }),
setPairingError: (error) => set({ pairingError: error }),
setQrCode: (data) => set({ qrCode: data }),
setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }),
setQrCodeError: (error) => set({ qrCodeError: error }),
setUpdateStatus: (data) => set({ updateStatus: data }),
setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }),
setUpdateStatusError: (error) => set({ updateStatusError: error }),
setModelsAliases: (data) => set({ modelsAliases: data }),
setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }),
setModelsAliasesError: (error) => set({ modelsAliasesError: error }),
setModelsFallbacks: (data) => set({ modelsFallbacks: data }),
setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }),
setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }),
setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }),
setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }),
setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }),
setSkillUpdate: (data) => set({ skillUpdate: data }),
setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }),
setSkillUpdateError: (error) => set({ skillUpdateError: error }),
setWorkspaceFiles: (workspace, data) => set((state) => ({
workspaceFiles: { ...state.workspaceFiles, [workspace]: data },
})),
setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }),
setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }),
setWorkspaceFileContent: (key, content) => set((state) => ({
workspaceFileContent: { ...state.workspaceFileContent, [key]: content },
})),
}),
{
name: "openclaw-store",
// Skip persisting ephemeral UI state
partialize: (state) => ({
// Persist only data, not loading/error/UI states
openclawStatus: state.openclawStatus,
openclawSessions: state.openclawSessions,
openclawCronJobs: state.openclawCronJobs,
openclawApprovals: state.openclawApprovals,
agents: state.agents,
agentsPresence: state.agentsPresence,
skills: state.skills,
models: state.models,
hooks: state.hooks,
plugins: state.plugins,
secretsAudit: state.secretsAudit,
securityAudit: state.securityAudit,
daemonStatus: state.daemonStatus,
pairing: state.pairing,
qrCode: state.qrCode,
updateStatus: state.updateStatus,
modelsAliases: state.modelsAliases,
modelsFallbacks: state.modelsFallbacks,
modelsImageFallbacks: state.modelsImageFallbacks,
skillUpdate: state.skillUpdate,
}),
}
)
);

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/**
* Portfolio Store - Portfolio data, holdings, trades, statistics
*/
export const usePortfolioStore = create((set) => ({
// Portfolio data
portfolioData: {
netValue: 10000,
pnl: 0,
equity: [],
baseline: [],
baseline_vw: [],
momentum: [],
strategies: [],
equity_return: 0,
baseline_return: 0,
baseline_vw_return: 0,
momentum_return: 0,
},
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
// Holdings
holdings: [],
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
// Trades
trades: [],
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
// Statistics
stats: null,
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
// Leaderboard
leaderboard: [],
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
}));

View File

@@ -0,0 +1,102 @@
import { create } from 'zustand';
const resolveValue = (updater, currentValue) => (
typeof updater === 'function' ? updater(currentValue) : updater
);
/**
* Runtime Store - Connection state and runtime configuration
*/
export const useRuntimeStore = create((set) => ({
// Connection state
isConnected: false,
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
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((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
// Progress
progress: { current: 0, total: 0 },
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
// Server mode
serverMode: null, // 'live' | 'backtest' | null
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
// Market status
marketStatus: null,
virtualTime: null,
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((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
// Runtime config
runtimeConfig: null,
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
// Watchlist panel
isWatchlistPanelOpen: false,
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
// Watchlist draft
watchlistDraftSymbols: [],
watchlistInputValue: '',
watchlistFeedback: null,
isWatchlistSaving: false,
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((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
// Runtime config drafts
launchModeDraft: 'fresh',
restoreRunIdDraft: '',
runtimeHistoryRuns: [],
scheduleModeDraft: 'daily',
intervalMinutesDraft: '60',
triggerTimeDraft: 'now',
maxCommCyclesDraft: '2',
initialCashDraft: '100000',
marginRequirementDraft: '0',
enableMemoryDraft: false,
modeDraft: 'live',
pollIntervalDraft: '10',
startDateDraft: '',
endDateDraft: '',
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })),
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })),
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })),
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) })),
// Runtime config feedback
runtimeConfigFeedback: null,
isRuntimeConfigSaving: false,
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((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
}));

View File

@@ -0,0 +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((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
// Chart tab
chartTab: 'all',
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
// Initial animation
isInitialAnimating: true,
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
// Last update timestamp
lastUpdate: new Date(),
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
// Is updating
isUpdating: false,
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
// Room bubbles
bubbles: {},
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
// Resizable panels
leftWidth: 70,
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
isResizing: false,
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
// Now timestamp (for current time display)
now: new Date(),
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
/**
* Formatting utility functions
*/
/**
* Format time from timestamp
*/
export function formatTime(ts) {
try {
const d = new Date(ts);
return d.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
} catch {
return "";
}
}
/**
* Format date and time from timestamp
*/
export function formatDateTime(ts) {
try {
const d = new Date(ts);
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
return `${date} ${time}`;
} catch {
return "";
}
}
/**
* Format number with commas (no decimals)
*/
export function formatNumber(num) {
if (!isFinite(num)) {
return "-";
}
return Math.abs(num).toLocaleString(undefined, { maximumFractionDigits: 0 });
}
/**
* Format full number with commas for Y-axis
*/
export function formatFullNumber(num) {
if (!isFinite(num)) {
return "-";
}
return num.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
/**
* Format ticker price with appropriate decimal places
*/
export function formatTickerPrice(price) {
if (!isFinite(price)) {
return "-";
}
if (price >= 1000) {
return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} else if (price >= 1) {
return price.toFixed(2);
} else {
return price.toFixed(4);
}
}
/**
* Calculate duration between two timestamps
*/
export function calculateDuration(start, end) {
const diff = end - start;
const minutes = Math.floor(diff / 60000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
}

View File

@@ -0,0 +1,395 @@
/**
* Model Icons and Styling Utilities
*
* Provides icon and styling configuration for different LLM models
*/
import { LLM_MODEL_LOGOS } from "../config/constants";
/**
* Get model icon and styling based on model name
* @param {string} modelName - The model name (e.g., "qwen-plus", "gpt-4o")
* @param {string} modelProvider - The model provider (e.g., "OPENAI", "ANTHROPIC")
* @returns {object} Icon configuration { logoPath, color, bgColor, label, provider }
*/
export function getModelIcon(modelName, modelProvider) {
if (!modelName) {
return {
logoPath: null,
color: "#666666",
bgColor: "#f5f5f5",
label: "默认",
provider: "默认"
};
}
const name = modelName.toLowerCase();
const provider = (modelProvider || "").toUpperCase();
// ========== Priority 1: Model Name Based Detection (Highest Priority) ==========
// This ensures we infer the correct logo from model name even if provider is OPENAI
// GLM Models (智谱AI)
if (name.includes("glm")) {
return {
logoPath: LLM_MODEL_LOGOS["Zhipu AI"],
color: "#4A90E2",
bgColor: "#E3F2FD",
label: "GLM-4.6",
provider: "Zhipu AI"
};
}
// Qwen Models (阿里云/通义千问)
if (name.includes("qwen")) {
return {
logoPath: LLM_MODEL_LOGOS["Alibaba"],
color: "#FF6A00",
bgColor: "#FFF3E0",
label: name.includes("max") ? "Qwen-Max" : name.includes("plus") ? "Qwen-Plus" : "Qwen",
provider: "Alibaba"
};
}
// DeepSeek Models
if (name.includes("deepseek")) {
return {
logoPath: LLM_MODEL_LOGOS["DeepSeek"],
color: "#1976D2",
bgColor: "#E3F2FD",
label: "DeepSeek-V3",
provider: "DeepSeek"
};
}
// Moonshot/Kimi Models (月之暗面)
if (name.includes("moonshot") || name.includes("kimi")) {
return {
logoPath: LLM_MODEL_LOGOS["Moonshot"],
color: "#7B68EE",
bgColor: "#F3E5F5",
label: "Kimi-K2",
provider: "Moonshot"
};
}
// Anthropic Claude Models (check model name first)
if (name.includes("claude")) {
return {
logoPath: LLM_MODEL_LOGOS["Anthropic"],
color: "#D97706",
bgColor: "#FEF3C7",
label: "Claude",
provider: "Anthropic"
};
}
// Google Gemini Models (check model name first)
if (name.includes("gemini")) {
return {
logoPath: LLM_MODEL_LOGOS["Google"],
color: "#4285F4",
bgColor: "#E8F0FE",
label: "Gemini",
provider: "Google"
};
}
// OpenAI GPT Models (check model name first)
if (name.includes("gpt") || name.includes("o1") || name.includes("o2") || name.includes("o3")) {
return {
logoPath: LLM_MODEL_LOGOS["OpenAI"],
color: "#10A37F",
bgColor: "#E8F5E9",
label: name.includes("4o") ? "GPT-4o" : name.includes("4.5") ? "GPT-4.5" : name.includes("4") ? "GPT-4" : name.includes("3.5") ? "GPT-3.5" : "OpenAI",
provider: "OpenAI"
};
}
// ========== Priority 2: Provider Based Detection (Fallback) ==========
// Only use provider if model name doesn't match any known patterns
// Anthropic Claude Models (provider fallback)
if (provider === "ANTHROPIC") {
return {
logoPath: LLM_MODEL_LOGOS["Anthropic"],
color: "#D97706",
bgColor: "#FEF3C7",
label: "Claude",
provider: "Anthropic"
};
}
// Google Gemini Models (provider fallback)
if (provider === "GOOGLE") {
return {
logoPath: LLM_MODEL_LOGOS["Google"],
color: "#4285F4",
bgColor: "#E8F0FE",
label: "Gemini",
provider: "Google"
};
}
// OpenAI Models (provider fallback - only if model name doesn't match)
if (provider === "OPENAI") {
return {
logoPath: LLM_MODEL_LOGOS["OpenAI"],
color: "#10A37F",
bgColor: "#E8F5E9",
label: "OpenAI",
provider: "OpenAI"
};
}
// Groq Models
if (provider === "GROQ") {
return {
logoPath: LLM_MODEL_LOGOS["Groq"],
color: "#DC2626",
bgColor: "#FEE2E2",
label: "Groq",
provider: "Groq"
};
}
// Ollama Models
if (provider === "OLLAMA") {
return {
logoPath: LLM_MODEL_LOGOS["Ollama"],
color: "#000000",
bgColor: "#F5F5F5",
label: "Ollama",
provider: "Ollama"
};
}
// OpenRouter Models
if (provider === "OPENROUTER") {
return {
logoPath: null,
color: "#8B5CF6",
bgColor: "#F5F3FF",
label: "OpenRouter",
provider: "OpenRouter"
};
}
// GigaChat Models
if (provider === "GIGACHAT") {
return {
logoPath: null,
color: "#9333EA",
bgColor: "#FAF5FF",
label: "GigaChat",
provider: "GigaChat"
};
}
// Default fallback
return {
logoPath: null,
color: "#666666",
bgColor: "#f5f5f5",
label: modelName.substring(0, 15),
provider: provider || "未知"
};
}
/**
* Get short model name for display
* @param {string} modelName - The full model name
* @returns {string} Short version of the model name (preserves full version numbers and suffixes)
*/
export function getShortModelName(modelName) {
if (!modelName) {
return "暂无";
}
const name = modelName.toLowerCase();
// Helper function to capitalize first letter of each word
const capitalizeWords = (str) => {
return str.split(/[-_\s]/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join("-");
};
// GLM - preserve version numbers
if (name.includes("glm")) {
// Extract version number if present (e.g., glm-4.6, glm-4.5)
const versionMatch = name.match(/glm[_-]?(\d+\.\d+)/);
if (versionMatch) {
return `GLM-${versionMatch[1]}`;
}
return "GLM-4.6"; // Default
}
// Qwen - preserve full version and suffixes
if (name.includes("qwen")) {
// Match patterns like: qwen3-max-preview, qwen-max, qwen-plus, qwen-flash
if (name.includes("qwen3-max")) {
// Extract suffix if present (e.g., -preview)
const fullMatch = name.match(/qwen3-max[_-]?([a-z0-9-]+)?/);
if (fullMatch && fullMatch[1]) {
return `Qwen3-Max-${capitalizeWords(fullMatch[1])}`;
}
return "Qwen3-Max";
}
if (name.includes("qwen-max")) {
const fullMatch = name.match(/qwen-max[_-]?([a-z0-9-]+)?/);
if (fullMatch && fullMatch[1]) {
return `Qwen-Max-${capitalizeWords(fullMatch[1])}`;
}
return "Qwen-Max";
}
if (name.includes("qwen-plus")) {
const fullMatch = name.match(/qwen-plus[_-]?([a-z0-9-]+)?/);
if (fullMatch && fullMatch[1]) {
return `Qwen-Plus-${capitalizeWords(fullMatch[1])}`;
}
return "Qwen-Plus";
}
if (name.includes("qwen-flash")) {
const fullMatch = name.match(/qwen-flash[_-]?([a-z0-9-]+)?/);
if (fullMatch && fullMatch[1]) {
return `Qwen-Flash-${capitalizeWords(fullMatch[1])}`;
}
return "Qwen-Flash";
}
// Generic qwen with version
const versionMatch = name.match(/qwen[_-]?(\d+[a-z0-9-]*)?/);
if (versionMatch && versionMatch[1]) {
return `Qwen-${capitalizeWords(versionMatch[1])}`;
}
return "Qwen";
}
// DeepSeek - preserve full version numbers and suffixes
if (name.includes("deepseek")) {
// Match patterns like: deepseek-v3.1, deepseek-v3.2-exp, deepseek-v3
// First try to match with version and suffix
const fullMatch = name.match(/deepseek[_-]?v?(\d+\.\d+[a-z0-9]*)[_-]?([a-z0-9-]+)?/);
if (fullMatch) {
const version = fullMatch[1];
const suffix = fullMatch[2];
if (suffix) {
return `DeepSeek-V${version}-${capitalizeWords(suffix)}`;
}
return `DeepSeek-V${version}`;
}
// Try to match just version
const versionMatch = name.match(/deepseek[_-]?v?(\d+\.\d+)/);
if (versionMatch) {
return `DeepSeek-V${versionMatch[1]}`;
}
// Fallback to generic DeepSeek
return "DeepSeek";
}
// Moonshot/Kimi - preserve full model names
if (name.includes("moonshot") || name.includes("kimi")) {
// Match patterns like: moonshot-kimi-k2-instruct, kimi-k2-instruct
// First check if it contains k2
if (name.includes("k2")) {
// Try to extract suffix after k2 (e.g., -instruct)
const k2Match = name.match(/k2[_-]?([a-z0-9-]+)?/);
if (k2Match && k2Match[1]) {
return `Moonshot-Kimi-K2-${capitalizeWords(k2Match[1])}`;
}
return "Moonshot-Kimi-K2";
}
if (name.includes("kimi")) {
return "Kimi";
}
return "Moonshot";
}
// OpenAI - preserve full version numbers
if (name.includes("gpt") || name.includes("o1") || name.includes("o2") || name.includes("o3")) {
// Match patterns like: gpt-4o, gpt-4.5, gpt-4, gpt-3.5-turbo
if (name.includes("gpt-4o")) {
const suffixMatch = name.match(/gpt-4o[_-]?([a-z0-9-]+)?/);
if (suffixMatch && suffixMatch[1]) {
return `GPT-4o-${capitalizeWords(suffixMatch[1])}`;
}
return "GPT-4o";
}
if (name.includes("gpt-4.5")) {
const suffixMatch = name.match(/gpt-4\.5[_-]?([a-z0-9-]+)?/);
if (suffixMatch && suffixMatch[1]) {
return `GPT-4.5-${capitalizeWords(suffixMatch[1])}`;
}
return "GPT-4.5";
}
if (name.includes("gpt-4")) {
const suffixMatch = name.match(/gpt-4[_-]?([a-z0-9-]+)?/);
if (suffixMatch && suffixMatch[1]) {
return `GPT-4-${capitalizeWords(suffixMatch[1])}`;
}
return "GPT-4";
}
if (name.includes("gpt-3.5")) {
const suffixMatch = name.match(/gpt-3\.5[_-]?([a-z0-9-]+)?/);
if (suffixMatch && suffixMatch[1]) {
return `GPT-3.5-${capitalizeWords(suffixMatch[1])}`;
}
return "GPT-3.5";
}
// O-series models
if (name.includes("o3")) {
return "O3";
}
if (name.includes("o2")) {
return "O2";
}
if (name.includes("o1")) {
return "O1";
}
return "OpenAI";
}
// Claude - preserve full model names
if (name.includes("claude")) {
if (name.includes("claude-opus")) {
const versionMatch = name.match(/claude-opus[_-]?(\d+[a-z0-9-]*)?/);
if (versionMatch && versionMatch[1]) {
return `Claude-Opus-${capitalizeWords(versionMatch[1])}`;
}
return "Claude-Opus";
}
if (name.includes("claude-sonnet")) {
const versionMatch = name.match(/claude-sonnet[_-]?(\d+[a-z0-9-]*)?/);
if (versionMatch && versionMatch[1]) {
return `Claude-Sonnet-${capitalizeWords(versionMatch[1])}`;
}
return "Claude-Sonnet";
}
if (name.includes("claude-haiku")) {
const versionMatch = name.match(/claude-haiku[_-]?(\d+[a-z0-9-]*)?/);
if (versionMatch && versionMatch[1]) {
return `Claude-Haiku-${capitalizeWords(versionMatch[1])}`;
}
return "Claude-Haiku";
}
return "Claude";
}
// Google Gemini
if (name.includes("gemini")) {
const versionMatch = name.match(/gemini[_-]?([a-z0-9.-]+)?/);
if (versionMatch && versionMatch[1]) {
return `Gemini-${capitalizeWords(versionMatch[1])}`;
}
return "Gemini";
}
// If no specific pattern matched, return formatted original name
// Truncate only if extremely long (over 30 chars)
if (modelName.length > 30) {
return capitalizeWords(modelName.substring(0, 27)) + "...";
}
// Return formatted original name
return capitalizeWords(modelName);
}