feat: initial commit - EvoTraders project
量化交易多智能体系统,包含: - 分析师、投资组合经理、风险经理等智能体 - 股票分析、投资组合管理、风险控制工具 - React 前端界面 - FastAPI 后端服务 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal 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
1034
frontend/src/App.jsx
Normal file
File diff suppressed because it is too large
Load Diff
361
frontend/src/components/AboutModal.jsx
Normal file
361
frontend/src/components/AboutModal.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
517
frontend/src/components/AgentCard.jsx
Normal file
517
frontend/src/components/AgentCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
641
frontend/src/components/AgentFeed.jsx
Normal file
641
frontend/src/components/AgentFeed.jsx
Normal 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'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
253
frontend/src/components/Header.jsx
Normal file
253
frontend/src/components/Header.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
276
frontend/src/components/MarkdownModal.jsx
Normal file
276
frontend/src/components/MarkdownModal.jsx
Normal 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;
|
||||
|
||||
831
frontend/src/components/NetValueChart.jsx
Normal file
831
frontend/src/components/NetValueChart.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
236
frontend/src/components/PerformanceView.jsx
Normal file
236
frontend/src/components/PerformanceView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
770
frontend/src/components/RoomView.jsx
Normal file
770
frontend/src/components/RoomView.jsx
Normal 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">▶▶</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>
|
||||
);
|
||||
}
|
||||
|
||||
483
frontend/src/components/RulesView.jsx
Normal file
483
frontend/src/components/RulesView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
664
frontend/src/components/StatisticsView.jsx
Normal file
664
frontend/src/components/StatisticsView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/StockLogo.jsx
Normal file
28
frontend/src/components/StockLogo.jsx
Normal 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'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
145
frontend/src/config/constants.js
Normal file
145
frontend/src/config/constants.js
Normal 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 }
|
||||
];
|
||||
|
||||
363
frontend/src/hooks/useFeedProcessor.js
Normal file
363
frontend/src/hooks/useFeedProcessor.js
Normal 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
68
frontend/src/index.css
Normal 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
8
frontend/src/main.jsx
Normal 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 />
|
||||
)
|
||||
192
frontend/src/services/websocket.js
Normal file
192
frontend/src/services/websocket.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
1876
frontend/src/styles/GlobalStyles.jsx
Normal file
1876
frontend/src/styles/GlobalStyles.jsx
Normal file
File diff suppressed because it is too large
Load Diff
85
frontend/src/utils/formatters.js
Normal file
85
frontend/src/utils/formatters.js
Normal 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`;
|
||||
}
|
||||
|
||||
396
frontend/src/utils/modelIcons.js
Normal file
396
frontend/src/utils/modelIcons.js
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user