feat: initial commit - EvoTraders project

量化交易多智能体系统,包含:
- 分析师、投资组合经理、风险经理等智能体
- 股票分析、投资组合管理、风险控制工具
- React 前端界面
- FastAPI 后端服务

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-03-13 04:34:06 +08:00
commit 12de93aa30
115 changed files with 29304 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;
}

1034
frontend/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import Header from './Header.jsx';
export default function AboutModal({ onClose }) {
const [isClosing, setIsClosing] = useState(false);
const [language, setLanguage] = useState('en'); // 'en' or 'zh'
const handleClose = () => {
setIsClosing(true);
// Wait for animation to complete before actually closing
setTimeout(() => {
onClose();
}, 600); // Match animation duration
};
const overlayStyle = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#ffffff',
zIndex: 9999,
animation: isClosing
? 'collapseUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'
: 'expandDown 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
transformOrigin: 'top center',
overflowY: 'auto'
};
const contentStyle = {
maxWidth: '900px',
width: '90%',
margin: '0 auto',
textAlign: 'left',
fontFamily: "'IBM Plex Mono', monospace",
color: '#000000',
lineHeight: 1.8,
fontSize: '14px',
letterSpacing: '0.01em',
padding: '60px 20px 80px',
animation: isClosing
? 'fadeOutContent 0.4s ease forwards'
: 'fadeInContent 0.8s ease 0.3s backwards'
};
const highlight = {
color: '#615CED',
fontWeight: 600
};
const linkStyle = {
color: '#615CED',
textDecoration: 'none',
borderBottom: '1px solid #615CED',
transition: 'all 0.2s'
};
const closeHintStyle = {
marginTop: '50px',
fontSize: '11px',
color: '#999',
cursor: 'pointer',
textAlign: 'center'
};
const languageSwitchStyle = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '25px',
marginTop: '10px',
gap: '0px',
fontSize: '11px',
fontFamily: "'IBM Plex Mono', monospace"
};
const getLangStyle = (isActive) => ({
padding: '3px 8px',
cursor: 'pointer',
transition: 'all 0.2s',
background: isActive ? '#000' : '#fff',
color: isActive ? '#fff' : '#000',
border: 'none'
});
const content = {
en: {
question: "What happens if AI models don't compete with each other, but instead trade like a ",
questionHighlight: "well-coordinated, high-performance team",
questionEnd: "?",
intro: "Not arena, but TEAM. We Hope that AI is no longer entering the financial markets as isolated models—it is stepping in as ",
introHighlight1: "teams",
introContinue: ", collaborating in one of the most challenging and noise-filled ",
introHighlight2: "real-time environments",
introContinue2: ".",
point1Highlight: "✦ Complementary skills",
point1: " - across multiple agents—data analysis, strategy generation, risk management—working together like a real trading desk, exchanging information through notifications and meetings.",
point2Highlight: "✦ An agent system that continually evolves",
point2: " — with memory modules that retain experience, learn from market feedback, reflect, and develop their own methodology over time.",
point3Highlight: "✦ AI teams interacting with live markets",
point3: " — learning from real-time data and making immediate decisions, not just theoretical simulations."
},
zh: {
intro: "如果不是让模型彼此竞争,而是像一支高效协作的团队一样进行实时交易,会发生什么?",
question: "这里不是竞技场而是团队。我们希望Agents不再单打独斗而是「组团」进入实时金融市场——这一十分困难且充满噪声的环境。",
trying: "我们正在探索多智能体协作在实时金融交易中的可能性。",
title1: "✦ 多智能体的技能互补",
point1: "不同模型、不同角色的智能体像真实的金融团队一样协作,各自承担数据分析、策略生成、风险控制等职责。",
point1Sub: "通过通知和会议机制进行信息交换,实现高效协作。",
title2: "✦ 能够持续进化的智能体系统",
point2: "依托「记忆」模块每个智能体都能跨回合保留经验不断学习、反思与调整。我们希望能看到在长期实时交易中Agent形成自己的独特方法论而不是一次性偶然的推理。",
point2Sub: "ReMe 记忆框架帮助 Agents 持续改进。",
title3: "✦ 实时参与市场的 AI Agents",
point3: "Agents从实时行情中学习并给予即时决策不是纸上谈兵而是面对市场的真实波动。"
}
};
return (
<>
<style>{`
@keyframes expandDown {
from {
transform: scaleY(0);
opacity: 0;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
@keyframes collapseUp {
from {
transform: scaleY(1);
opacity: 1;
}
to {
transform: scaleY(0);
opacity: 0;
}
}
@keyframes fadeInContent {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutContent {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
`}</style>
<div style={overlayStyle} onClick={handleClose}>
{/* Header */}
<div className="header" style={{
animation: isClosing
? 'fadeOutContent 0.4s ease forwards'
: 'fadeInContent 0.8s ease 0.3s backwards'
}} onClick={(e) => e.stopPropagation()}>
<Header
onEvoTradersClick={handleClose}
evoTradersLinkStyle="close"
/>
</div>
{/* Content */}
<div style={contentStyle} onClick={(e) => e.stopPropagation()}>
{/* Language Switch */}
<div style={languageSwitchStyle}>
<span
style={getLangStyle(language === 'zh')}
onClick={() => setLanguage('zh')}
>
中文
</span>
<span style={{ padding: '0 4px', color: '#999' }}></span>
<span
style={getLangStyle(language === 'en')}
onClick={() => setLanguage('en')}
>
EN
</span>
</div>
{language === 'en' ? (
// English Content
<>
<div style={{ marginBottom: '40px', fontSize: '15px', fontWeight: 600 }}>
{content.en.question}
<span style={highlight}>{content.en.questionHighlight}</span>
{content.en.questionEnd}
</div>
<div style={{ marginBottom: '30px' }}>
{content.en.intro}
<span style={highlight}>{content.en.introHighlight1}</span>
{content.en.introContinue}
<span style={highlight}>{content.en.introHighlight2}</span>
{content.en.introContinue2}
</div>
<div style={{ marginBottom: '25px' }}>
<span style={highlight}>{content.en.point1Highlight}</span>
{content.en.point1}
</div>
<div style={{ marginBottom: '25px' }}>
<span style={highlight}>{content.en.point2Highlight}</span>
{content.en.point2}
</div>
<div style={{ marginBottom: '40px' }}>
<span style={highlight}>{content.en.point3Highlight}</span>
{content.en.point3}
</div>
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
Everything is fully open-source. Built on{' '}
<a
href="https://github.com/agentscope-ai"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
AgentScope
</a>
, using{' '}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
ReMe
</a>
{' '}for memory management.
</div>
</>
) : (
// Chinese Content
<>
<div style={{ marginBottom: '30px' }}>
{content.zh.intro}
</div>
<div style={{ marginBottom: '40px', fontSize: '15px', fontWeight: 600 }}>
{content.zh.question}
</div>
<div style={{ marginBottom: '30px', fontSize: '14px', opacity: 0.8 }}>
{content.zh.trying}
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title1}
</div>
<div style={{ marginBottom: '10px' }}>
{content.zh.point1}
</div>
<div style={{ fontSize: '13px', opacity: 0.7 }}>
{content.zh.point1Sub}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title2}
</div>
<div style={{ marginBottom: '10px' }}>
{content.zh.point2}
</div>
<div style={{ fontSize: '13px', opacity: 0.7 }}>
{content.zh.point2Sub}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<div style={{ ...highlight, marginBottom: '10px' }}>
{content.zh.title3}
</div>
<div>
{content.zh.point3}
</div>
</div>
<div style={{ marginBottom: '10px', opacity: 0.7 }}>
我们已经在github上开源
</div>
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
EvoTraders 基于{' '}
<a
href="https://github.com/agentscope-ai"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
AgentScope
</a>
{' '}搭建并使用其中的{' '}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
ReMe
</a>
{' '}作为记忆管理核心
</div>
<div style={{ marginBottom: '10px', fontSize: '14px' }}>
你可以在此找到完整项目与示例
</div>
</>
)}
<div style={{ marginTop: '40px' }}>
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
github.com/agentscope-ai/agentscope-samples
</a>
</div>
<div style={closeHintStyle} onClick={handleClose}>
{language === 'en' ? 'Click here to close' : '点击此处关闭'}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,517 @@
import React from 'react';
import { ASSETS } from '../config/constants';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
/**
* 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 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: 1000,
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} Rank #{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={`Model: ${agent.modelName}\nProvider: ${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
}}>
{modelInfo.logoPath ? (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
style={{
maxHeight: '100%',
maxWidth: '100%',
objectFit: 'contain'
}}
/>
) : (
<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)}%` : 'N/A'}
</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)}%` : 'N/A'}
</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)}%` : 'N/A'}
</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) || 'N/A'}
</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>
)}
</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,641 @@
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';
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 }, ref) => {
const feedContentRef = useRef(null);
const [highlightedId, setHighlightedId] = useState(null);
const [selectedAgent, setSelectedAgent] = useState('all');
const [dropdownOpen, setDropdownOpen] = useState(false);
const getAgentModelInfo = (agentId) => {
if (!leaderboard || !agentId) 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 (!leaderboard || !agentName) 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: 'All Agents', modelInfo: null };
}
const agentInfo = getAgentInfoByName(selectedAgent);
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
return { label: selectedAgent, modelInfo };
};
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.modelInfo?.logoPath && (
<img
src={currentSelection.modelInfo.logoPath}
alt={currentSelection.modelInfo.provider}
className="select-model-icon"
/>
)}
<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>全部 Agents</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);
}}
>
{modelInfo?.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
className="select-model-icon"
/>
)}
<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' }}>
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
objectFit: 'contain'
}}
/>
)}
{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)}
>
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
>
<img
src={ASSETS.remeLogo}
alt="ReMe"
style={{
cursor: 'pointer',
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'
}}>
</span>
</a>
</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'
}}>
Memory powered by AgentScope-ReMe
</div>
<div style={{ color: '#475569', opacity: 0.9 }}>
Not only retrieves historical memories but also generates suggestions and hints for the current task based on latest context.
</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 || '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' }}>
{modelInfo.logoPath && message.agent !== 'Memory' && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
objectFit: 'contain'
}}
/>
)}
{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,253 @@
import React, { useState } from 'react';
/**
* Header Component
* Reusable header brand with EvoTraders logo, GitHub link, and Contact Us section
*
* @param {Function} onEvoTradersClick - Optional callback when EvoTraders is clicked
* @param {string} evoTradersLinkStyle - Optional style variant: 'default' | 'close'
*/
export default function Header({
onEvoTradersClick = null,
evoTradersLinkStyle = 'default' // 'default' shows ↗, 'close' shows ↙
}) {
const [activeContactCard, setActiveContactCard] = useState({ yue: false, jiaji: false });
const [clickedContactCard, setClickedContactCard] = useState(null);
const handleEvoTradersClick = () => {
if (onEvoTradersClick) {
onEvoTradersClick();
}
};
return (
<div className="header-title" style={{ flex: '0 1 auto', minWidth: 0 }}>
<span
className="header-link"
onClick={handleEvoTradersClick}
style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: '3px', display: 'inline-flex', alignItems: 'center', gap: '8px' }}
>
<img
src="/trading_logo.png"
alt="EvoTraders Logo"
style={{ height: '24px', width: 'auto' }}
/>
EvoTraders {evoTradersLinkStyle === 'close' ? (
<span className="link-arrow"></span>
) : (
<span className="link-arrow"></span>
)}
</span>
<span style={{
width: '2px',
height: '16px',
background: '#666',
margin: '0 16px',
display: 'inline-block',
verticalAlign: 'middle'
}} />
<span style={{
padding: '1px 5px',
fontSize: '9px',
fontWeight: 700,
color: '#00C853',
background: 'rgba(0, 200, 83, 0.1)',
border: '1px solid #00C853',
borderRadius: '3px',
letterSpacing: '0.5px',
marginRight: '0px'
}}>
开源
</span>
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
className="header-link"
style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: '6px' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'inline-block' }}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>agentscope-samples</span>
<span className="link-arrow"></span>
</a>
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
className="header-link"
style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: '6px', marginLeft: '0px' }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'inline-block' }}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>agentscope-ReMe</span>
<span className="link-arrow"></span>
</a>
<span style={{
width: '2px',
height: '16px',
background: '#666',
margin: '0 16px',
display: 'inline-block',
verticalAlign: 'middle'
}} />
<div
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
onClick={() => {
const bothActive = activeContactCard.yue && activeContactCard.jiaji;
if (!bothActive) {
setActiveContactCard({ yue: true, jiaji: true });
setClickedContactCard('both');
} else {
setActiveContactCard({ yue: false, jiaji: false });
setClickedContactCard(null);
}
}}
>
<span className="header-link">
联系我们
</span>
{/* Two contact buttons */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<div
onClick={(e) => {
e.stopPropagation();
if (activeContactCard.yue) {
setActiveContactCard(prev => ({ ...prev, yue: false }));
if (clickedContactCard === 'yue' || clickedContactCard === 'both') {
setClickedContactCard(null);
}
} else {
setActiveContactCard(prev => ({ ...prev, yue: true }));
setClickedContactCard('yue');
}
}}
onMouseEnter={() => {
if (!clickedContactCard || clickedContactCard === 'yue' || clickedContactCard === 'both') {
setActiveContactCard(prev => ({ ...prev, yue: true }));
}
}}
onMouseLeave={() => {
if (clickedContactCard !== 'yue' && clickedContactCard !== 'both') {
setActiveContactCard(prev => ({ ...prev, yue: false }));
}
}}
style={{
padding: '4px 8px',
background: activeContactCard.yue ? '#615CED' : '#f5f5f5',
color: activeContactCard.yue ? '#fff' : '#333',
border: '1px solid',
borderColor: activeContactCard.yue ? '#615CED' : '#e0e0e0',
borderRadius: '3px',
fontSize: '10px',
fontWeight: 700,
fontFamily: "'IBM Plex Mono', monospace",
cursor: 'pointer',
transition: 'all 0.2s',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: activeContactCard.yue ? '80px' : '32px',
minWidth: activeContactCard.yue ? '80px' : '32px'
}}
>
{activeContactCard.yue ? (
<a
href="https://1mycell.github.io/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()}
>
Yue Wu
</a>
) : 'YW'}
</div>
<div
onClick={(e) => {
e.stopPropagation();
if (activeContactCard.jiaji) {
setActiveContactCard(prev => ({ ...prev, jiaji: false }));
if (clickedContactCard === 'jiaji' || clickedContactCard === 'both') {
setClickedContactCard(null);
}
} else {
setActiveContactCard(prev => ({ ...prev, jiaji: true }));
setClickedContactCard('jiaji');
}
}}
onMouseEnter={() => {
if (!clickedContactCard || clickedContactCard === 'jiaji' || clickedContactCard === 'both') {
setActiveContactCard(prev => ({ ...prev, jiaji: true }));
}
}}
onMouseLeave={() => {
if (clickedContactCard !== 'jiaji' && clickedContactCard !== 'both') {
setActiveContactCard(prev => ({ ...prev, jiaji: false }));
}
}}
style={{
padding: '4px 8px',
background: activeContactCard.jiaji ? '#615CED' : '#f5f5f5',
color: activeContactCard.jiaji ? '#fff' : '#333',
border: '1px solid',
borderColor: activeContactCard.jiaji ? '#615CED' : '#e0e0e0',
borderRadius: '3px',
fontSize: '10px',
fontWeight: 700,
fontFamily: "'IBM Plex Mono', monospace",
cursor: 'pointer',
transition: 'all 0.2s',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: activeContactCard.jiaji ? '100px' : '32px',
minWidth: activeContactCard.jiaji ? '100px' : '32px'
}}
>
{activeContactCard.jiaji ? (
<a
href="https://dengjiaji.github.io/self/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()}
>
Jiaji Deng
</a>
) : 'JD'}
</div>
</div>
</div>
</div>
);
}

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,831 @@
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 (for mock mode), or null to use real time
*/
function getRecentTradingSessionStart(virtualTime = null) {
// Use virtual time if provided (for mock mode), 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 = {
'EvoTraders': 'EvoTraders 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'
}}>
NO DATA AVAILABLE
</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="EvoTraders"
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>
);
}

View File

@@ -0,0 +1,236 @@
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">Agent Performance - Signal Accuracy</h2>
</div>
{rankedAgents.length === 0 ? (
<div className="empty-state">No leaderboard data available</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>Rank</th>
<th>Agent</th>
<th>Win Rate</th>
<th>Bull Signals</th>
<th>Bull Win Rate</th>
<th>Bear Signals</th>
<th>Bear Win Rate</th>
<th>Total Signals</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)}%` : 'N/A'}
</td>
<td>
<div style={{ fontSize: 12 }}>{bullTotal} signals</div>
<div style={{ fontSize: 10, color: '#666666' }}>{bullWins} wins</div>
{bullUnknown > 0 && (
<div style={{ fontSize: 10, color: '#999999' }}>{bullUnknown} unknown</div>
)}
</td>
<td style={{ color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : 'N/A'}
</td>
<td>
<div style={{ fontSize: 12 }}>{bearTotal} signals</div>
<div style={{ fontSize: 10, color: '#666666' }}>{bearWins} wins</div>
{bearUnknown > 0 && (
<div style={{ fontSize: 10, color: '#999999' }}>{bearUnknown} unknown</div>
)}
</td>
<td style={{ color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : 'N/A'}
</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">Signal History</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)}%`
: 'Unknown';
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 ? 'Bull' : isBear ? 'Bear' : 'Neutral'}
</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'
}}>
Total: {sortedSignals.length} signals
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,770 @@
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';
/**
* 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, feed, onJumpToMessage }) {
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;
// If no leaderboard data, return agent with default stats
if (!leaderboard || !Array.isArray(leaderboard)) {
return {
...agent,
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,
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,
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>
)}
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
className="agent-model-badge"
style={{
position: 'absolute',
top: -12,
right: -12,
width: 25,
height: 25,
borderRadius: '50%',
border: '2px solid #ffffff',
background: '#ffffff',
objectFit: 'contain',
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">
{modelInfo.logoPath && (
<img
src={modelInfo.logoPath}
alt={modelInfo.provider}
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'
}}
/>
)}
{/* Replay Button */}
{showReplayButton && (
<div className="replay-button-container">
<button
className="replay-button"
onClick={handleReplayClick}
title="Replay feed history"
>
<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,483 @@
import React, { useState, useEffect, useRef } from 'react';
import { LLM_MODEL_LOGOS } from '../config/constants';
export default function RulesView() {
const [language, setLanguage] = useState('en'); // 'en' or 'zh'
const [scale, setScale] = useState(1);
const containerRef = useRef(null);
const contentRef = useRef(null);
// Auto-scale content to fit container without scrolling
useEffect(() => {
const handleResize = () => {
if (containerRef.current && contentRef.current) {
const containerHeight = containerRef.current.clientHeight;
const contentHeight = contentRef.current.scrollHeight;
if (contentHeight > containerHeight) {
const newScale = containerHeight / contentHeight;
setScale(Math.max(newScale * 0.95, 0.5)); // Min scale 0.5, with 95% of available space
} else {
setScale(1);
}
}
};
// Initial resize
handleResize();
// Listen to window resize
window.addEventListener('resize', handleResize);
// Observe content changes
const observer = new ResizeObserver(handleResize);
if (contentRef.current) {
observer.observe(contentRef.current);
}
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
};
}, [language]);
const containerStyle = {
width: '100%',
height: '100%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#FFFFFF',
padding: '10px'
};
const contentWrapperStyle = {
transform: `scale(${scale})`,
transformOrigin: 'center center',
transition: 'transform 0.3s ease',
width: '100%',
maxWidth: '900px'
};
const innerContentStyle = {
color: '#000000',
fontFamily: "'IBM Plex Mono', monospace",
fontSize: '13px',
lineHeight: '1.6',
letterSpacing: '0.01em',
padding: '0 10px'
};
const highlight = {
color: '#000000',
fontWeight: 700
};
const sectionTitleStyle = {
color: '#615CED',
fontSize: '16px',
fontWeight: 700,
marginBottom: '8px',
marginTop: '12px',
marginLeft: '-10px',
marginRight: '-10px',
width: 'calc(100% + 20px)',
padding: '8px 10px',
backgroundColor: '#FFFFFF',
letterSpacing: '0.5px',
boxSizing: 'border-box'
};
const subsectionStyle = {
marginBottom: '8px',
paddingLeft: '10px',
borderLeft: '2px solid #CCCCCC'
};
const linkStyle = {
color: '#615CED',
textDecoration: 'none',
borderBottom: '1px solid #615CED',
transition: 'all 0.2s'
};
const languageSwitchStyle = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '12px',
gap: '0px',
fontSize: '11px',
fontFamily: "'IBM Plex Mono', monospace"
};
const getLangStyle = (isActive) => ({
padding: '4px 10px',
cursor: 'pointer',
transition: 'all 0.2s',
background: isActive ? '#000000' : 'transparent',
color: isActive ? '#FFFFFF' : '#666666',
border: 'none',
borderRadius: '2px'
});
const llmLogos = [
{ name: 'Alibaba', file: 'Alibaba.jpeg', label: 'Qwen', url: LLM_MODEL_LOGOS['Alibaba'] },
{ name: 'DeepSeek', file: 'DeepSeek.png', label: 'DeepSeek', url: LLM_MODEL_LOGOS['DeepSeek'] },
{ name: 'Moonshot', file: 'Moonshot.jpeg', label: 'Moonshot', url: LLM_MODEL_LOGOS['Moonshot'] },
{ name: 'Zhipu AI', file: 'Zhipu AI.png', label: 'Zhipu AI', url: LLM_MODEL_LOGOS['Zhipu AI'] }
];
const content = {
en: {
section1Title: "Agent Setup",
pmRole: "Portfolio Manager",
pmDesc: "Makes final trading decisions and orchestrates team collaboration",
rmRole: "Risk Manager",
rmDesc: "Monitors portfolio risk and enforces risk limits",
analystsRole: "Analysts",
analystsDesc: "Conduct specialized research with different tools and AI models:",
analysts: [
{ name: "Valuation Analyst", model: "Moonshot", modelKey: "Moonshot" },
{ name: "Sentiment Analyst", model: "Qwen", modelKey: "Alibaba" },
{ name: "Fundamentals Analyst", model: "DeepSeek", modelKey: "DeepSeek" },
{ name: "Technical Analyst", model: "Zhipu AI", modelKey: "Zhipu AI" }
],
section2Title: "Agent Decision Mechanism",
tradingProcess: "Daily Trading Process",
tradingDesc: "Agents trade on a daily frequency while continuously tracking portfolio performance. Before each day's final trading decision, agents go through three key phases:",
analysisPhase: "• Analysis Phase",
analysisDesc: "All agents independently analyze information and form judgments based on their specialized tools.",
communicationPhase: "• Communication Phase",
commIntro: "Multiple communication channels enable effective collaboration: 1v1 Private Chat / 1vN Notification / NvN Conference",
decisionPhase: "• Decision Phase",
decisionDesc: "Portfolio Manager aggregates all information and makes the final team trading decision. The original trading signals from analysts are only used for individual-level ranking.",
reflectionTitle: "Learning & Evolution",
reflectionDesc: "Agents reflect on daily investment performance, summarize insights, and store them in ",
remeLink: "ReMe",
reflectionDesc2: " memory framework for continuous improvement.",
section3Title: "Performance Evaluation",
chartTitle: "• Performance Chart",
chartDesc: "Track portfolio equity curve vs. benchmarks (equal-weight, value-weighted, momentum). Use this to assess overall strategy effectiveness.",
rankingTitle: "• Analyst Rankings",
rankingDesc: "Click avatars in Trading Room to view analyst performance (Win Rate, Bull/Bear Win Rate). Use this to understand which analysts provide the most valuable insights.",
statsTitle: "• Statistics",
statsDesc: "Detailed holdings and trade history. Use this for in-depth analysis of position management and execution quality.",
callToAction: "Fork on ",
repoLink: "GitHub",
callToAction2: " to customize!"
},
zh: {
section1Title: "Agent 设定",
pmRole: "投资经理",
pmDesc: "负责最终交易决策和团队协作",
rmRole: "风控经理",
rmDesc: "监控组合风险并执行风险限制",
analystsRole: "分析师",
analystsDesc: "使用不同工具和 AI 模型进行专业研究:",
analysts: [
{ name: "估值分析师", model: "Moonshot", modelKey: "Moonshot" },
{ name: "情绪分析师", model: "Qwen", modelKey: "Alibaba" },
{ name: "基本面分析师", model: "DeepSeek", modelKey: "DeepSeek" },
{ name: "技术分析师", model: "Zhipu AI", modelKey: "Zhipu AI" }
],
section2Title: "Agent 决策机制",
tradingProcess: "交易流程",
tradingDesc: "Agents 进行日频交易并持续跟踪组合净值。每天最终交易决策前agents 经历三个关键阶段:",
analysisPhase: "• 分析阶段",
analysisDesc: "所有 agents 根据各自的工具和信息独立分析并形成判断。",
communicationPhase: "• 沟通阶段",
commIntro: "提供了多种沟通渠道1v1 私聊 / 1vN 通知 / NvN 会议",
decisionPhase: "• 决策阶段",
decisionDesc: "由 portfolio manager 汇总所有信息并给出最终的团队交易。analysts 给出的原始交易信号仅做个人维度的排名。",
reflectionTitle: "学习与进化",
reflectionDesc: "Agents 根据当日实际收益反思决策、总结经验,并存入 ",
remeLink: "ReMe",
reflectionDesc2: " 记忆框架以持续改进。",
section3Title: "收益评估",
chartTitle: "• 业绩图表",
chartDesc: "追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。",
rankingTitle: "• 分析师排名",
rankingDesc: "在 Trading Room 点击头像查看分析师表现(胜率、牛/熊市胜率)。用于了解哪些分析师提供最有价值的洞察。",
statsTitle: "• 统计数据",
statsDesc: "详细的持仓和交易历史。用于深入分析仓位管理和执行质量。",
callToAction: "在 ",
repoLink: "GitHub",
callToAction2: " 上 fork 并自定义!"
}
};
return (
<div ref={containerRef} style={containerStyle}>
<div ref={contentRef} style={contentWrapperStyle}>
<div style={innerContentStyle}>
{/* Language Switch */}
<div style={languageSwitchStyle}>
<span
style={getLangStyle(language === 'zh')}
onClick={() => setLanguage('zh')}
>
中文
</span>
<span style={{ padding: '0 4px', color: '#999' }}></span>
<span
style={getLangStyle(language === 'en')}
onClick={() => setLanguage('en')}
>
EN
</span>
</div>
{language === 'en' ? (
// English Content
<>
{/* Section 1: Agent Setup */}
<div style={sectionTitleStyle}>{content.en.section1Title}</div>
{/* Roles */}
<div style={{ marginBottom: '8px', fontSize: '12px' }}>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.en.pmRole}:</span> {content.en.pmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.en.rmRole}:</span> {content.en.rmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.en.analystsRole}:</span> {content.en.analystsDesc}
</div>
</div>
{/* Analysts with AI Models */}
<div style={{ marginLeft: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 14px', fontSize: '11px' }}>
{content.en.analysts.map(analyst => {
const logo = llmLogos.find(l => l.name === analyst.modelKey);
return (
<div key={analyst.name} style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{logo && (
<img
src={logo.url}
alt={logo.label}
style={{
height: '16px',
width: 'auto',
objectFit: 'contain'
}}
/>
)}
<span style={{ fontWeight: 600 }}>{analyst.name}</span>
<span style={{ color: '#666' }}>- {analyst.model}</span>
</div>
);
})}
</div>
<div style={{ marginBottom: '10px', fontSize: '11px', fontStyle: 'italic', opacity: 0.8 }}>
{content.en.callToAction}
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.en.repoLink}
</a>
{content.en.callToAction2}
</div>
{/* Section 2: Agent Decision Mechanism */}
<div style={sectionTitleStyle}>{content.en.section2Title}</div>
<div style={{ marginBottom: '6px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.en.tradingProcess}</div>
<div style={{ marginBottom: '6px', fontSize: '12px' }}>{content.en.tradingDesc}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.en.analysisPhase.replace('• ', '')}:</span> {content.en.analysisDesc}
</div>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.en.communicationPhase.replace('• ', '')}:</span> {content.en.commIntro}
</div>
<div style={{ fontSize: '12px' }}>
<span style={highlight}>{content.en.decisionPhase.replace('• ', '')}:</span> {content.en.decisionDesc}
</div>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.en.reflectionTitle}</div>
<div style={{ fontSize: '12px' }}>
{content.en.reflectionDesc}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.en.remeLink}
</a>
{content.en.reflectionDesc2}
</div>
</div>
{/* Section 3: Performance Evaluation */}
<div style={sectionTitleStyle}>{content.en.section3Title}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.en.chartTitle.replace('• ', '')}:</span> {content.en.chartDesc}
</div>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.en.rankingTitle.replace('• ', '')}:</span> {content.en.rankingDesc}
</div>
<div style={{ fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.en.statsTitle.replace('• ', '')}:</span> {content.en.statsDesc}
</div>
</div>
</>
) : (
// Chinese Content
<>
{/* 第一部分Agent 设定 */}
<div style={sectionTitleStyle}>{content.zh.section1Title}</div>
{/* 角色 */}
<div style={{ marginBottom: '8px', fontSize: '12px' }}>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.pmRole}:</span> {content.zh.pmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.rmRole}:</span> {content.zh.rmDesc}
</div>
<div style={{ marginBottom: '3px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.analystsRole}:</span> {content.zh.analystsDesc}
</div>
</div>
{/* Analysts 与 AI 模型 */}
<div style={{ marginLeft: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 14px', fontSize: '11px' }}>
{content.zh.analysts.map(analyst => {
const logo = llmLogos.find(l => l.name === analyst.modelKey);
return (
<div key={analyst.name} style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{logo && (
<img
src={logo.url}
alt={logo.label}
style={{
height: '16px',
width: 'auto',
objectFit: 'contain'
}}
/>
)}
<span style={{ fontWeight: 600 }}>{analyst.name}</span>
<span style={{ color: '#666' }}>- {analyst.model}</span>
</div>
);
})}
</div>
<div style={{ marginBottom: '10px', fontSize: '11px', fontStyle: 'italic', opacity: 0.8 }}>
{content.zh.callToAction}
<a
href="https://github.com/agentscope-ai/agentscope-samples"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.zh.repoLink}
</a>
{content.zh.callToAction2}
</div>
{/* 第二部分Agent 决策机制 */}
<div style={sectionTitleStyle}>{content.zh.section2Title}</div>
<div style={{ marginBottom: '6px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.zh.tradingProcess}</div>
<div style={{ marginBottom: '6px', fontSize: '12px' }}>{content.zh.tradingDesc}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.zh.analysisPhase.replace('• ', '')}:</span> {content.zh.analysisDesc}
</div>
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
<span style={highlight}>{content.zh.communicationPhase.replace('• ', '')}:</span> {content.zh.commIntro}
</div>
<div style={{ fontSize: '12px' }}>
<span style={highlight}>{content.zh.decisionPhase.replace('• ', '')}:</span> {content.zh.decisionDesc}
</div>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.zh.reflectionTitle}</div>
<div style={{ fontSize: '12px' }}>
{content.zh.reflectionDesc}
<a
href="https://github.com/agentscope-ai/ReMe"
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{content.zh.remeLink}
</a>
{content.zh.reflectionDesc2}
</div>
</div>
{/* 第三部分:收益评估 */}
<div style={sectionTitleStyle}>{content.zh.section3Title}</div>
<div style={subsectionStyle}>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.chartTitle.replace('• ', '')}:</span> {content.zh.chartDesc}
</div>
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.rankingTitle.replace('• ', '')}:</span> {content.zh.rankingDesc}
</div>
<div style={{ fontSize: '12px' }}>
<span style={{ fontWeight: 600 }}>{content.zh.statsTitle.replace('• ', '')}:</span> {content.zh.statsDesc}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,664 @@
import React, { useState, useEffect } from 'react';
import StockLogo from './StockLogo';
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 }) {
const [holdingsPage, setHoldingsPage] = useState(1);
const [tradesPage, setTradesPage] = useState(1);
const holdingsPerPage = 5;
const tradesPerPage = 8;
// 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 (!stats || !baseline_vw || baseline_vw.length === 0) {
return null;
}
// Get Evatraders return from stats
const evatradersReturn = stats.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'
}}>
{stats ? (
<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(stats.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)}%`
: 'N/A'}
</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: (stats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
fontFamily: '"Courier New", monospace'
}}>
{(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.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(stats.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'
}}>
{stats.totalTrades || 0}
</div>
</div>
</div>
{/* Ticker Weights - Compact */}
{stats.tickerWeights && Object.keys(stats.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(stats.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 }}>
{h.ticker !== 'CASH' && <StockLogo ticker={h.ticker} size={18} />}
<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 }}>
<StockLogo ticker={t.ticker} size={16} />
<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,28 @@
import React from 'react';
import { STOCK_LOGOS } from '../config/constants';
/**
* Stock Logo Component
* Displays company logo for a given ticker symbol
*/
export default function StockLogo({ ticker, size = 20 }) {
const logoUrl = STOCK_LOGOS[ticker];
if (!logoUrl) return null;
return (
<img
src={logoUrl}
alt={ticker}
style={{
width: size,
height: size,
borderRadius: '4px',
objectFit: 'contain',
marginRight: '8px',
verticalAlign: 'middle'
}}
onError={(e) => { e.target.style.display = 'none'; }}
/>
);
}

View File

@@ -0,0 +1,145 @@
/**
* Application Configuration Constants
*/
// 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",
},
stockLogos: {
"TSLA": "https://img.alicdn.com/imgextra/i4/O1CN01Pch4DD1DDrad8BQAQ_!!6000000000183-2-tps-128-128.png",
"AMZN": "https://img.alicdn.com/imgextra/i3/O1CN01KMsfnU25Wd4MGSgue_!!6000000007534-2-tps-128-128.png",
"NVDA": "https://img.alicdn.com/imgextra/i4/O1CN01Lq1eJr1mLeslgx6a0_!!6000000004938-2-tps-128-128.png",
"GOOGL": "https://img.alicdn.com/imgextra/i2/O1CN01kjJJbb25B6SESkOCn_!!6000000007487-2-tps-128-128.png",
"MSFT": "https://img.alicdn.com/imgextra/i4/O1CN01tdlNtQ1aFS7vHYfMG_!!6000000003300-2-tps-128-128.png",
"AAPL": "https://img.alicdn.com/imgextra/i4/O1CN01r0GH0q1diiHHOwxiO_!!6000000003770-2-tps-128-128.png",
"META": "https://img.alicdn.com/imgextra/i3/O1CN01pWAvHt1IkRqZoUG96_!!6000000000931-2-tps-130-96.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,
};
// Stock logos mapping
export const STOCK_LOGOS = { ...CDN_ASSETS.stockLogos };
// 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
export const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8765";
// Initial ticker symbols (MAG7 companies)
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 }
];

View File

@@ -0,0 +1,363 @@
import { useState, useCallback, useRef } from "react";
import { AGENTS } from "../config/constants";
const MAX_FEED_ITEMS = 200;
/**
* 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: agent?.name || evt.agentName || evt.agentId || "Agent",
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: 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: 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: 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
};
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;

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,192 @@
/**
* WebSocket Client for Read-Only Connection
* Handles connection, reconnection, and heartbeat
*/
import { WS_URL } from "../config/constants";
export class ReadOnlyClient {
constructor(onEvent, { wsUrl = WS_URL, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
this.onEvent = onEvent;
this.wsUrl = wsUrl;
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;
}
connect() {
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.reconnectDelay = this.baseReconnectDelay;
this._connect();
}
_connect() {
if (!this.shouldReconnect) {
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;
}
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.reconnectDelay = this.baseReconnectDelay;
this.lastPongTime = Date.now();
this._safeEmit({ type: "system", content: "Connected to live server" });
console.log("WebSocket connected");
this._startHeartbeat();
};
this.ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
// Update pong time for any message (server is alive)
this.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.ws.onclose = (event) => {
const code = event.code || "Unknown";
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
this._stopHeartbeat();
this.ws = null;
// Always attempt reconnect if shouldReconnect is true
if (this.shouldReconnect) {
this.reconnectAttempts++;
// Exponential backoff with cap
this.reconnectDelay = Math.min(
this.baseReconnectDelay * Math.pow(1.5, this.reconnectAttempts),
this.maxReconnectDelay
);
this._safeEmit({
type: "system",
content: "Try to connect to data server..."
});
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
console.log(`[WebSocket] Reconnect attempt ${this.reconnectAttempts}...`);
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;
}
}

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,396 @@
/**
* 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: "Default",
provider: "Default"
};
}
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 || "Unknown"
};
}
/**
* 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 "N/A";
}
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);
}