feat: button states, full prompt extraction, portfolio viewer, param inputs
1. Run buttons: only the triggered button shows spinner, others disabled 2. Backend: enhanced prompt extraction with multiple fallback paths (data.messages, data.input.messages, data.input, data.kwargs.messages) and raw dump fallback; improved response extraction for edge cases 3. Portfolio viewer: new PortfolioViewer component with holdings table, trade history, and summary tabs; portfolio dropdown with auto-load; Wallet sidebar icon now navigates to portfolio page 4. Parameter inputs: collapsible panel with date/ticker/portfolio_id; validation prevents running without required fields per run type Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/ffa268c8-e97c-4335-9bce-19bba583bea9
This commit is contained in:
parent
b08ce7199e
commit
6999da0827
|
|
@ -233,19 +233,35 @@ class LangGraphEngine:
|
|||
|
||||
Returns the concatenated content of every message so the user can
|
||||
inspect the full prompt that was sent to the LLM.
|
||||
|
||||
Handles several structures observed across LangChain / LangGraph versions:
|
||||
- flat list of message objects ``[SystemMessage, HumanMessage, ...]``
|
||||
- list-of-lists (batched) ``[[SystemMessage, HumanMessage, ...]]``
|
||||
- list of plain dicts ``[{"role": "system", "content": "..."}]``
|
||||
- tuple wrapper ``([SystemMessage, ...],)``
|
||||
"""
|
||||
if not isinstance(messages, list) or not messages:
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
# Unwrap single-element tuple / list-of-lists
|
||||
items: list = messages if isinstance(messages, list) else list(messages)
|
||||
if items and isinstance(items[0], (list, tuple)):
|
||||
items = list(items[0])
|
||||
|
||||
parts: list[str] = []
|
||||
items = messages
|
||||
# Handle list-of-lists
|
||||
if isinstance(items[0], list):
|
||||
items = items[0]
|
||||
for msg in items:
|
||||
# LangChain message objects have .content and .type
|
||||
content = getattr(msg, "content", None)
|
||||
role = getattr(msg, "type", "unknown")
|
||||
role = getattr(msg, "type", None)
|
||||
# Plain-dict messages (e.g. {"role": "user", "content": "..."})
|
||||
if content is None and isinstance(msg, dict):
|
||||
content = msg.get("content", "")
|
||||
role = msg.get("role") or msg.get("type") or "unknown"
|
||||
if role is None:
|
||||
role = "unknown"
|
||||
text = str(content) if content is not None else str(msg)
|
||||
parts.append(f"[{role}] {text}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _extract_model(self, event: Dict[str, Any]) -> str:
|
||||
|
|
@ -288,13 +304,35 @@ class LangGraphEngine:
|
|||
if kind == "on_chat_model_start":
|
||||
starts[node_name] = time.monotonic()
|
||||
|
||||
# Extract the full prompt being sent to the LLM
|
||||
data = event.get("data") or {}
|
||||
|
||||
# Extract the full prompt being sent to the LLM.
|
||||
# Try multiple paths observed in different LangChain versions:
|
||||
# 1. data.messages (most common)
|
||||
# 2. data.input.messages (newer LangGraph)
|
||||
# 3. data.input (if it's a list of messages itself)
|
||||
# 4. data.kwargs.messages (some providers)
|
||||
full_prompt = ""
|
||||
prompt_snippet = ""
|
||||
messages = (event.get("data") or {}).get("messages")
|
||||
if messages:
|
||||
full_prompt = self._extract_all_messages_content(messages)
|
||||
prompt_snippet = self._truncate(full_prompt.replace("\n", " "))
|
||||
for source in (
|
||||
data.get("messages"),
|
||||
(data.get("input") or {}).get("messages") if isinstance(data.get("input"), dict) else None,
|
||||
data.get("input") if isinstance(data.get("input"), (list, tuple)) else None,
|
||||
(data.get("kwargs") or {}).get("messages"),
|
||||
):
|
||||
if source:
|
||||
full_prompt = self._extract_all_messages_content(source)
|
||||
if full_prompt:
|
||||
break
|
||||
|
||||
# If all structured extractions failed, dump a raw preview
|
||||
if not full_prompt:
|
||||
raw_dump = str(data)[:_MAX_FULL_LEN]
|
||||
if raw_dump and raw_dump != "{}":
|
||||
full_prompt = f"[raw event data] {raw_dump}"
|
||||
|
||||
prompt_snippet = self._truncate(
|
||||
full_prompt.replace("\n", " "), _MAX_CONTENT_LEN
|
||||
) if full_prompt else ""
|
||||
|
||||
# Remember the full prompt so we can attach it to the result event
|
||||
prompts[node_name] = full_prompt
|
||||
|
|
@ -377,7 +415,16 @@ class LangGraphEngine:
|
|||
usage = output.usage_metadata
|
||||
if hasattr(output, "response_metadata") and output.response_metadata:
|
||||
model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model)
|
||||
|
||||
# Extract the response text – handle both message objects and plain dicts
|
||||
raw = self._extract_content(output)
|
||||
# If .content was empty or the repr of the whole object, try harder
|
||||
if not raw or raw.startswith("<") or raw == str(output):
|
||||
# Some providers wrap in .text or .message
|
||||
raw = (
|
||||
getattr(output, "text", "")
|
||||
or (output.get("content", "") if isinstance(output, dict) else "")
|
||||
)
|
||||
if raw:
|
||||
full_response = raw[:_MAX_FULL_LEN]
|
||||
response_snippet = self._truncate(raw)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Text,
|
||||
IconButton,
|
||||
Button,
|
||||
Input,
|
||||
useDisclosure,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
|
|
@ -29,15 +30,43 @@ import {
|
|||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot } from 'lucide-react';
|
||||
import { LayoutDashboard, Wallet, Settings, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { MetricHeader } from './components/MetricHeader';
|
||||
import { AgentGraph } from './components/AgentGraph';
|
||||
import { PortfolioViewer } from './components/PortfolioViewer';
|
||||
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = 'http://127.0.0.1:8088/api';
|
||||
|
||||
// ─── Run type definitions with required parameters ────────────────────
|
||||
type RunType = 'scan' | 'pipeline' | 'portfolio' | 'auto';
|
||||
|
||||
interface RunParams {
|
||||
date: string;
|
||||
ticker: string;
|
||||
portfolio_id: string;
|
||||
}
|
||||
|
||||
const RUN_TYPE_LABELS: Record<RunType, string> = {
|
||||
scan: 'Scan',
|
||||
pipeline: 'Pipeline',
|
||||
portfolio: 'Portfolio',
|
||||
auto: 'Auto',
|
||||
};
|
||||
|
||||
/** Which params each run type needs. */
|
||||
const REQUIRED_PARAMS: Record<RunType, (keyof RunParams)[]> = {
|
||||
scan: ['date'],
|
||||
pipeline: ['ticker', 'date'],
|
||||
portfolio: ['date', 'portfolio_id'],
|
||||
auto: ['date', 'ticker'],
|
||||
};
|
||||
|
||||
/** Return the colour token for a given event type. */
|
||||
const eventColor = (type: AgentEvent['type']): string => {
|
||||
switch (type) {
|
||||
|
|
@ -256,12 +285,17 @@ const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenM
|
|||
);
|
||||
};
|
||||
|
||||
// ─── Sidebar page type ────────────────────────────────────────────────
|
||||
type Page = 'dashboard' | 'portfolio';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<Page>('dashboard');
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
const [activeRunType, setActiveRunType] = useState<RunType | null>(null);
|
||||
const [isTriggering, setIsTriggering] = useState(false);
|
||||
const [portfolioId, setPortfolioId] = useState<string>("main_portfolio");
|
||||
const { events, status, clearEvents } = useAgentStream(activeRunId);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// Event detail modal state
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
|
|
@ -272,6 +306,14 @@ export const Dashboard: React.FC = () => {
|
|||
const [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
// Parameter inputs
|
||||
const [showParams, setShowParams] = useState(false);
|
||||
const [params, setParams] = useState<RunParams>({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
ticker: 'AAPL',
|
||||
portfolio_id: 'main_portfolio',
|
||||
});
|
||||
|
||||
// Auto-scroll the terminal to the bottom as new events arrive
|
||||
const terminalEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -279,21 +321,47 @@ export const Dashboard: React.FC = () => {
|
|||
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [events.length]);
|
||||
|
||||
// Clear activeRunType when run completes
|
||||
useEffect(() => {
|
||||
if (status === 'completed' || status === 'error') {
|
||||
setActiveRunType(null);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
|
||||
|
||||
const startRun = async (type: string) => {
|
||||
const startRun = async (type: RunType) => {
|
||||
if (isRunning) return;
|
||||
|
||||
// Validate required params
|
||||
const required = REQUIRED_PARAMS[type];
|
||||
const missing = required.filter((k) => !params[k]?.trim());
|
||||
if (missing.length > 0) {
|
||||
toast({
|
||||
title: `Missing required fields for ${RUN_TYPE_LABELS[type]}`,
|
||||
description: `Please fill in: ${missing.join(', ')}`,
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
setShowParams(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTriggering(true);
|
||||
setActiveRunType(type);
|
||||
try {
|
||||
clearEvents();
|
||||
const res = await axios.post(`${API_BASE}/run/${type}`, {
|
||||
portfolio_id: portfolioId,
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
portfolio_id: params.portfolio_id,
|
||||
date: params.date,
|
||||
ticker: params.ticker,
|
||||
});
|
||||
setActiveRunId(res.data.run_id);
|
||||
} catch (err) {
|
||||
console.error("Failed to start run:", err);
|
||||
setActiveRunType(null);
|
||||
} finally {
|
||||
setIsTriggering(false);
|
||||
}
|
||||
|
|
@ -331,125 +399,196 @@ export const Dashboard: React.FC = () => {
|
|||
{/* Sidebar */}
|
||||
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
|
||||
<Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box>
|
||||
<IconButton aria-label="Dashboard" icon={<LayoutDashboard size={20} />} variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} />
|
||||
<IconButton aria-label="Portfolio" icon={<Wallet size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||||
<Tooltip label="Dashboard" placement="right">
|
||||
<IconButton
|
||||
aria-label="Dashboard"
|
||||
icon={<LayoutDashboard size={20} />}
|
||||
variant="ghost"
|
||||
color={activePage === 'dashboard' ? 'cyan.400' : 'whiteAlpha.600'}
|
||||
bg={activePage === 'dashboard' ? 'whiteAlpha.100' : 'transparent'}
|
||||
_hover={{ bg: "whiteAlpha.100" }}
|
||||
onClick={() => setActivePage('dashboard')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Portfolio" placement="right">
|
||||
<IconButton
|
||||
aria-label="Portfolio"
|
||||
icon={<Wallet size={20} />}
|
||||
variant="ghost"
|
||||
color={activePage === 'portfolio' ? 'cyan.400' : 'whiteAlpha.600'}
|
||||
bg={activePage === 'portfolio' ? 'whiteAlpha.100' : 'transparent'}
|
||||
_hover={{ bg: "whiteAlpha.100" }}
|
||||
onClick={() => setActivePage('portfolio')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||||
</VStack>
|
||||
|
||||
{/* Main Content */}
|
||||
<Flex flex="1" direction="column">
|
||||
{/* Top Metric Header */}
|
||||
<MetricHeader portfolioId={portfolioId} />
|
||||
{/* ─── Portfolio Page ────────────────────────────────────────── */}
|
||||
{activePage === 'portfolio' && (
|
||||
<Box flex="1">
|
||||
<PortfolioViewer defaultPortfolioId={params.portfolio_id} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dashboard Body */}
|
||||
<Flex flex="1" overflow="hidden">
|
||||
{/* Left Side: Graph Area */}
|
||||
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
||||
<AgentGraph events={events} onNodeClick={openNodeDetail} />
|
||||
|
||||
{/* Floating Control Panel */}
|
||||
<HStack position="absolute" top={4} left={4} bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Search size={14} />}
|
||||
colorScheme="cyan"
|
||||
variant="solid"
|
||||
onClick={() => startRun('scan')}
|
||||
isLoading={isRunning}
|
||||
loadingText="Running…"
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<BarChart3 size={14} />}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => startRun('pipeline')}
|
||||
isLoading={isRunning}
|
||||
loadingText="Running…"
|
||||
>
|
||||
Pipeline
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Wallet size={14} />}
|
||||
colorScheme="purple"
|
||||
variant="solid"
|
||||
onClick={() => startRun('portfolio')}
|
||||
isLoading={isRunning}
|
||||
loadingText="Running…"
|
||||
>
|
||||
Portfolio
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Bot size={14} />}
|
||||
colorScheme="green"
|
||||
variant="solid"
|
||||
onClick={() => startRun('auto')}
|
||||
isLoading={isRunning}
|
||||
loadingText="Running…"
|
||||
>
|
||||
Auto
|
||||
</Button>
|
||||
<Divider orientation="vertical" h="20px" />
|
||||
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : 'gray'}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
</HStack>
|
||||
</Box>
|
||||
{/* ─── Dashboard Page ────────────────────────────────────────── */}
|
||||
{activePage === 'dashboard' && (
|
||||
<Flex flex="1" direction="column">
|
||||
{/* Top Metric Header */}
|
||||
<MetricHeader portfolioId={params.portfolio_id} />
|
||||
|
||||
{/* Right Side: Live Terminal */}
|
||||
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
|
||||
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||||
<TerminalIcon size={16} color="#4fd1c5" />
|
||||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
|
||||
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
|
||||
</Flex>
|
||||
|
||||
<Box flex="1" overflowY="auto" p={4} sx={{
|
||||
'&::-webkit-scrollbar': { width: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
|
||||
}}>
|
||||
{events.map((evt) => (
|
||||
<Box
|
||||
key={evt.id}
|
||||
mb={2}
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
onClick={() => openEventDetail(evt)}
|
||||
transition="background 0.15s"
|
||||
>
|
||||
<Flex gap={2} align="center">
|
||||
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
|
||||
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
|
||||
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
|
||||
{evt.agent}
|
||||
</Text>
|
||||
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
|
||||
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
|
||||
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
||||
<TerminalIcon size={48} />
|
||||
<Text fontSize="sm">Awaiting agent activation...</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={terminalEndRef} />
|
||||
{/* Dashboard Body */}
|
||||
<Flex flex="1" overflow="hidden">
|
||||
{/* Left Side: Graph Area */}
|
||||
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
||||
<AgentGraph events={events} onNodeClick={openNodeDetail} />
|
||||
|
||||
{/* Floating Control Panel */}
|
||||
<VStack position="absolute" top={4} left={4} spacing={2} align="stretch">
|
||||
{/* Run buttons row */}
|
||||
<HStack bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2}>
|
||||
{(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => {
|
||||
const isThisRunning = isRunning && activeRunType === type;
|
||||
const isOtherRunning = isRunning && activeRunType !== type;
|
||||
const icons: Record<RunType, React.ReactElement> = {
|
||||
scan: <Search size={14} />,
|
||||
pipeline: <BarChart3 size={14} />,
|
||||
portfolio: <Wallet size={14} />,
|
||||
auto: <Bot size={14} />,
|
||||
};
|
||||
const colors: Record<RunType, string> = {
|
||||
scan: 'cyan',
|
||||
pipeline: 'blue',
|
||||
portfolio: 'purple',
|
||||
auto: 'green',
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
size="sm"
|
||||
leftIcon={icons[type]}
|
||||
colorScheme={colors[type]}
|
||||
variant="solid"
|
||||
onClick={() => startRun(type)}
|
||||
isLoading={isThisRunning}
|
||||
loadingText="Running…"
|
||||
isDisabled={isOtherRunning}
|
||||
>
|
||||
{RUN_TYPE_LABELS[type]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Divider orientation="vertical" h="20px" />
|
||||
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : status === 'error' ? 'red' : 'gray'}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
<IconButton
|
||||
aria-label="Toggle params"
|
||||
icon={showParams ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="whiteAlpha.600"
|
||||
onClick={() => setShowParams(!showParams)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Collapsible parameter inputs */}
|
||||
<Collapse in={showParams} animateOpacity>
|
||||
<Box bg="blackAlpha.800" p={3} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200">
|
||||
<VStack spacing={2} align="stretch">
|
||||
<HStack>
|
||||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Date:</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
type="date"
|
||||
bg="whiteAlpha.100"
|
||||
borderColor="whiteAlpha.200"
|
||||
value={params.date}
|
||||
onChange={(e) => setParams((p) => ({ ...p, date: e.target.value }))}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Ticker:</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
placeholder="AAPL"
|
||||
bg="whiteAlpha.100"
|
||||
borderColor="whiteAlpha.200"
|
||||
value={params.ticker}
|
||||
onChange={(e) => setParams((p) => ({ ...p, ticker: e.target.value.toUpperCase() }))}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text fontSize="xs" color="whiteAlpha.600" minW="70px">Portfolio:</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
placeholder="main_portfolio"
|
||||
bg="whiteAlpha.100"
|
||||
borderColor="whiteAlpha.200"
|
||||
value={params.portfolio_id}
|
||||
onChange={(e) => setParams((p) => ({ ...p, portfolio_id: e.target.value }))}
|
||||
/>
|
||||
</HStack>
|
||||
<Text fontSize="2xs" color="whiteAlpha.400">
|
||||
Required: Scan → date · Pipeline → ticker, date · Portfolio → date, portfolio · Auto → date, ticker
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* Right Side: Live Terminal */}
|
||||
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
|
||||
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||||
<TerminalIcon size={16} color="#4fd1c5" />
|
||||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
|
||||
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
|
||||
</Flex>
|
||||
|
||||
<Box flex="1" overflowY="auto" p={4} sx={{
|
||||
'&::-webkit-scrollbar': { width: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
|
||||
}}>
|
||||
{events.map((evt) => (
|
||||
<Box
|
||||
key={evt.id}
|
||||
mb={2}
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
onClick={() => openEventDetail(evt)}
|
||||
transition="background 0.15s"
|
||||
>
|
||||
<Flex gap={2} align="center">
|
||||
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
|
||||
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
|
||||
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
|
||||
{evt.agent}
|
||||
</Text>
|
||||
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
|
||||
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
|
||||
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
||||
<TerminalIcon size={48} />
|
||||
<Text fontSize="sm">Awaiting agent activation...</Text>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={terminalEndRef} />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Unified Inspector Drawer (single event or all node events) */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Code,
|
||||
Spinner,
|
||||
Select,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { Wallet, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = 'http://127.0.0.1:8088/api';
|
||||
|
||||
interface Holding {
|
||||
ticker: string;
|
||||
quantity: number;
|
||||
avg_cost: number;
|
||||
current_price?: number;
|
||||
market_value?: number;
|
||||
unrealized_pnl?: number;
|
||||
sector?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Trade {
|
||||
id?: string;
|
||||
ticker: string;
|
||||
action: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
executed_at?: string;
|
||||
rationale?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PortfolioInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
cash_balance?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PortfolioState {
|
||||
portfolio: PortfolioInfo;
|
||||
snapshot: Record<string, unknown> | null;
|
||||
holdings: Holding[];
|
||||
recent_trades: Trade[];
|
||||
}
|
||||
|
||||
interface PortfolioViewerProps {
|
||||
defaultPortfolioId?: string;
|
||||
}
|
||||
|
||||
export const PortfolioViewer: React.FC<PortfolioViewerProps> = ({ defaultPortfolioId = 'main_portfolio' }) => {
|
||||
const [portfolios, setPortfolios] = useState<PortfolioInfo[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string>(defaultPortfolioId);
|
||||
const [state, setState] = useState<PortfolioState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch portfolio list
|
||||
useEffect(() => {
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/portfolios/`);
|
||||
const list = res.data as PortfolioInfo[];
|
||||
setPortfolios(list);
|
||||
if (list.length > 0 && !list.find((p) => p.id === selectedId)) {
|
||||
setSelectedId(list[0].id);
|
||||
}
|
||||
} catch {
|
||||
// Might fail if no DB — use fallback
|
||||
setPortfolios([{ id: defaultPortfolioId, name: defaultPortfolioId }]);
|
||||
}
|
||||
};
|
||||
fetchList();
|
||||
}, [defaultPortfolioId, selectedId]);
|
||||
|
||||
// Fetch portfolio state when selection changes
|
||||
const fetchState = useCallback(async () => {
|
||||
if (!selectedId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/portfolios/${selectedId}/latest`);
|
||||
setState(res.data as PortfolioState);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load portfolio';
|
||||
setError(msg);
|
||||
setState(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchState();
|
||||
}, [fetchState]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100%" bg="slate.950" color="white" overflow="hidden">
|
||||
{/* Header */}
|
||||
<Flex p={4} bg="slate.900" borderBottom="1px solid" borderColor="whiteAlpha.100" align="center" gap={3}>
|
||||
<Icon as={Wallet} color="cyan.400" boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="lg">Portfolio Viewer</Text>
|
||||
|
||||
<Select
|
||||
size="sm"
|
||||
maxW="220px"
|
||||
ml="auto"
|
||||
bg="whiteAlpha.100"
|
||||
borderColor="whiteAlpha.200"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
{portfolios.map((p) => (
|
||||
<option key={p.id} value={p.id} style={{ background: '#0f172a' }}>
|
||||
{p.name || p.id}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Box cursor="pointer" onClick={fetchState} opacity={0.6} _hover={{ opacity: 1 }}>
|
||||
<RefreshCw size={16} />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* Body */}
|
||||
{loading && (
|
||||
<Flex flex="1" align="center" justify="center"><Spinner color="cyan.400" /></Flex>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Flex flex="1" align="center" justify="center" direction="column" gap={2} opacity={0.5}>
|
||||
<Text fontSize="sm" color="red.300">{error}</Text>
|
||||
<Text fontSize="xs">Make sure the backend is running and the portfolio exists.</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!loading && !error && state && (
|
||||
<Tabs variant="soft-rounded" colorScheme="cyan" size="sm" flex="1" display="flex" flexDirection="column" overflow="hidden">
|
||||
<TabList px={4} pt={3}>
|
||||
<Tab>Holdings ({state.holdings.length})</Tab>
|
||||
<Tab>Trade History ({state.recent_trades.length})</Tab>
|
||||
<Tab>Summary</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels flex="1" overflow="auto">
|
||||
{/* Holdings */}
|
||||
<TabPanel px={4}>
|
||||
{state.holdings.length === 0 ? (
|
||||
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No holdings found.</Text>
|
||||
) : (
|
||||
<Box overflowX="auto">
|
||||
<Table size="sm" variant="unstyled">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Ticker</Th>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Qty</Th>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Avg Cost</Th>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>Mkt Value</Th>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100" isNumeric>P&L</Th>
|
||||
<Th color="whiteAlpha.500" borderBottom="1px solid" borderColor="whiteAlpha.100">Sector</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{state.holdings.map((h, i) => {
|
||||
const pnl = h.unrealized_pnl ?? 0;
|
||||
return (
|
||||
<Tr key={i} _hover={{ bg: 'whiteAlpha.50' }}>
|
||||
<Td fontWeight="bold"><Code colorScheme="cyan" fontSize="sm">{h.ticker}</Code></Td>
|
||||
<Td isNumeric>{h.quantity}</Td>
|
||||
<Td isNumeric>${(h.avg_cost ?? 0).toFixed(2)}</Td>
|
||||
<Td isNumeric>${(h.market_value ?? 0).toFixed(2)}</Td>
|
||||
<Td isNumeric color={pnl >= 0 ? 'green.400' : 'red.400'}>
|
||||
<HStack justify="flex-end" spacing={1}>
|
||||
<Icon as={pnl >= 0 ? ArrowUpRight : ArrowDownRight} boxSize={3} />
|
||||
<Text>${Math.abs(pnl).toFixed(2)}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td><Badge variant="outline" fontSize="2xs">{h.sector || '—'}</Badge></Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Trade History */}
|
||||
<TabPanel px={4}>
|
||||
{state.recent_trades.length === 0 ? (
|
||||
<Text color="whiteAlpha.500" fontSize="sm" textAlign="center" mt={8}>No trades recorded yet.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{state.recent_trades.map((t, i) => (
|
||||
<Flex
|
||||
key={i}
|
||||
bg="whiteAlpha.50"
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Badge colorScheme={t.action?.toUpperCase() === 'BUY' ? 'green' : t.action?.toUpperCase() === 'SELL' ? 'red' : 'gray'}>
|
||||
{t.action?.toUpperCase()}
|
||||
</Badge>
|
||||
<Code colorScheme="cyan" fontSize="sm">{t.ticker}</Code>
|
||||
<Text fontSize="sm">{t.quantity} @ ${(t.price ?? 0).toFixed(2)}</Text>
|
||||
</HStack>
|
||||
<VStack align="flex-end" spacing={0}>
|
||||
<Text fontSize="2xs" color="whiteAlpha.400">{t.executed_at || '—'}</Text>
|
||||
{t.rationale && (
|
||||
<Text fontSize="2xs" color="whiteAlpha.500" maxW="200px" isTruncated>{t.rationale}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Summary */}
|
||||
<TabPanel px={4}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Portfolio ID:</Text>
|
||||
<Code fontSize="sm">{state.portfolio.id}</Code>
|
||||
</HStack>
|
||||
{state.portfolio.cash_balance != null && (
|
||||
<HStack>
|
||||
<Text fontSize="sm" color="whiteAlpha.600" minW="100px">Cash Balance:</Text>
|
||||
<Code colorScheme="green" fontSize="sm">${state.portfolio.cash_balance.toFixed(2)}</Code>
|
||||
</HStack>
|
||||
)}
|
||||
{state.snapshot && (
|
||||
<Box mt={2}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" mb={1}>Latest Snapshot</Text>
|
||||
<Box bg="blackAlpha.500" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100" maxH="300px" overflowY="auto">
|
||||
<Text fontSize="xs" fontFamily="mono" whiteSpace="pre-wrap" wordBreak="break-word" color="whiteAlpha.900">
|
||||
{JSON.stringify(state.snapshot, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{!loading && !error && !state && (
|
||||
<Flex flex="1" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
||||
<Wallet size={48} />
|
||||
<Text fontSize="sm">Select a portfolio to view</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue