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
|
Returns the concatenated content of every message so the user can
|
||||||
inspect the full prompt that was sent to the LLM.
|
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 ""
|
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] = []
|
parts: list[str] = []
|
||||||
items = messages
|
|
||||||
# Handle list-of-lists
|
|
||||||
if isinstance(items[0], list):
|
|
||||||
items = items[0]
|
|
||||||
for msg in items:
|
for msg in items:
|
||||||
|
# LangChain message objects have .content and .type
|
||||||
content = getattr(msg, "content", None)
|
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)
|
text = str(content) if content is not None else str(msg)
|
||||||
parts.append(f"[{role}] {text}")
|
parts.append(f"[{role}] {text}")
|
||||||
|
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
def _extract_model(self, event: Dict[str, Any]) -> str:
|
def _extract_model(self, event: Dict[str, Any]) -> str:
|
||||||
|
|
@ -288,13 +304,35 @@ class LangGraphEngine:
|
||||||
if kind == "on_chat_model_start":
|
if kind == "on_chat_model_start":
|
||||||
starts[node_name] = time.monotonic()
|
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 = ""
|
full_prompt = ""
|
||||||
prompt_snippet = ""
|
for source in (
|
||||||
messages = (event.get("data") or {}).get("messages")
|
data.get("messages"),
|
||||||
if messages:
|
(data.get("input") or {}).get("messages") if isinstance(data.get("input"), dict) else None,
|
||||||
full_prompt = self._extract_all_messages_content(messages)
|
data.get("input") if isinstance(data.get("input"), (list, tuple)) else None,
|
||||||
prompt_snippet = self._truncate(full_prompt.replace("\n", " "))
|
(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
|
# Remember the full prompt so we can attach it to the result event
|
||||||
prompts[node_name] = full_prompt
|
prompts[node_name] = full_prompt
|
||||||
|
|
@ -377,7 +415,16 @@ class LangGraphEngine:
|
||||||
usage = output.usage_metadata
|
usage = output.usage_metadata
|
||||||
if hasattr(output, "response_metadata") and output.response_metadata:
|
if hasattr(output, "response_metadata") and output.response_metadata:
|
||||||
model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model)
|
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)
|
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:
|
if raw:
|
||||||
full_response = raw[:_MAX_FULL_LEN]
|
full_response = raw[:_MAX_FULL_LEN]
|
||||||
response_snippet = self._truncate(raw)
|
response_snippet = self._truncate(raw)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
IconButton,
|
IconButton,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerOverlay,
|
DrawerOverlay,
|
||||||
|
|
@ -29,15 +30,43 @@ import {
|
||||||
TabPanels,
|
TabPanels,
|
||||||
Tab,
|
Tab,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
|
Tooltip,
|
||||||
|
Collapse,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} 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 { MetricHeader } from './components/MetricHeader';
|
||||||
import { AgentGraph } from './components/AgentGraph';
|
import { AgentGraph } from './components/AgentGraph';
|
||||||
|
import { PortfolioViewer } from './components/PortfolioViewer';
|
||||||
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
|
import { useAgentStream, AgentEvent } from './hooks/useAgentStream';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_BASE = 'http://127.0.0.1:8088/api';
|
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. */
|
/** Return the colour token for a given event type. */
|
||||||
const eventColor = (type: AgentEvent['type']): string => {
|
const eventColor = (type: AgentEvent['type']): string => {
|
||||||
switch (type) {
|
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 = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
|
const [activePage, setActivePage] = useState<Page>('dashboard');
|
||||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||||
|
const [activeRunType, setActiveRunType] = useState<RunType | null>(null);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
const [portfolioId, setPortfolioId] = useState<string>("main_portfolio");
|
|
||||||
const { events, status, clearEvents } = useAgentStream(activeRunId);
|
const { events, status, clearEvents } = useAgentStream(activeRunId);
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
// Event detail modal state
|
// Event detail modal state
|
||||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
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 [selectedEvent, setSelectedEvent] = useState<AgentEvent | null>(null);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | 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
|
// Auto-scroll the terminal to the bottom as new events arrive
|
||||||
const terminalEndRef = useRef<HTMLDivElement>(null);
|
const terminalEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -279,21 +321,47 @@ export const Dashboard: React.FC = () => {
|
||||||
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
|
// Clear activeRunType when run completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'completed' || status === 'error') {
|
||||||
|
setActiveRunType(null);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
|
const isRunning = isTriggering || status === 'streaming' || status === 'connecting';
|
||||||
|
|
||||||
const startRun = async (type: string) => {
|
const startRun = async (type: RunType) => {
|
||||||
if (isRunning) return;
|
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);
|
setIsTriggering(true);
|
||||||
|
setActiveRunType(type);
|
||||||
try {
|
try {
|
||||||
clearEvents();
|
clearEvents();
|
||||||
const res = await axios.post(`${API_BASE}/run/${type}`, {
|
const res = await axios.post(`${API_BASE}/run/${type}`, {
|
||||||
portfolio_id: portfolioId,
|
portfolio_id: params.portfolio_id,
|
||||||
date: new Date().toISOString().split('T')[0]
|
date: params.date,
|
||||||
|
ticker: params.ticker,
|
||||||
});
|
});
|
||||||
setActiveRunId(res.data.run_id);
|
setActiveRunId(res.data.run_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to start run:", err);
|
console.error("Failed to start run:", err);
|
||||||
|
setActiveRunType(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTriggering(false);
|
setIsTriggering(false);
|
||||||
}
|
}
|
||||||
|
|
@ -331,125 +399,196 @@ export const Dashboard: React.FC = () => {
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
|
<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>
|
<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" }} />
|
<Tooltip label="Dashboard" placement="right">
|
||||||
<IconButton aria-label="Portfolio" icon={<Wallet size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
<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" }} />
|
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* ─── Portfolio Page ────────────────────────────────────────── */}
|
||||||
<Flex flex="1" direction="column">
|
{activePage === 'portfolio' && (
|
||||||
{/* Top Metric Header */}
|
<Box flex="1">
|
||||||
<MetricHeader portfolioId={portfolioId} />
|
<PortfolioViewer defaultPortfolioId={params.portfolio_id} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dashboard Body */}
|
{/* ─── Dashboard Page ────────────────────────────────────────── */}
|
||||||
<Flex flex="1" overflow="hidden">
|
{activePage === 'dashboard' && (
|
||||||
{/* Left Side: Graph Area */}
|
<Flex flex="1" direction="column">
|
||||||
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
{/* Top Metric Header */}
|
||||||
<AgentGraph events={events} onNodeClick={openNodeDetail} />
|
<MetricHeader portfolioId={params.portfolio_id} />
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Right Side: Live Terminal */}
|
{/* Dashboard Body */}
|
||||||
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
|
<Flex flex="1" overflow="hidden">
|
||||||
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
{/* Left Side: Graph Area */}
|
||||||
<TerminalIcon size={16} color="#4fd1c5" />
|
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
||||||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
|
<AgentGraph events={events} onNodeClick={openNodeDetail} />
|
||||||
<Text fontSize="2xs" color="whiteAlpha.400" ml="auto">{events.length} events</Text>
|
|
||||||
</Flex>
|
{/* Floating Control Panel */}
|
||||||
|
<VStack position="absolute" top={4} left={4} spacing={2} align="stretch">
|
||||||
<Box flex="1" overflowY="auto" p={4} sx={{
|
{/* Run buttons row */}
|
||||||
'&::-webkit-scrollbar': { width: '4px' },
|
<HStack bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={2}>
|
||||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
{(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => {
|
||||||
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
|
const isThisRunning = isRunning && activeRunType === type;
|
||||||
}}>
|
const isOtherRunning = isRunning && activeRunType !== type;
|
||||||
{events.map((evt) => (
|
const icons: Record<RunType, React.ReactElement> = {
|
||||||
<Box
|
scan: <Search size={14} />,
|
||||||
key={evt.id}
|
pipeline: <BarChart3 size={14} />,
|
||||||
mb={2}
|
portfolio: <Wallet size={14} />,
|
||||||
fontSize="xs"
|
auto: <Bot size={14} />,
|
||||||
fontFamily="mono"
|
};
|
||||||
px={2}
|
const colors: Record<RunType, string> = {
|
||||||
py={1}
|
scan: 'cyan',
|
||||||
borderRadius="md"
|
pipeline: 'blue',
|
||||||
cursor="pointer"
|
portfolio: 'purple',
|
||||||
_hover={{ bg: 'whiteAlpha.100' }}
|
auto: 'green',
|
||||||
onClick={() => openEventDetail(evt)}
|
};
|
||||||
transition="background 0.15s"
|
return (
|
||||||
>
|
<Button
|
||||||
<Flex gap={2} align="center">
|
key={type}
|
||||||
<Text color="whiteAlpha.400" minW="52px" flexShrink={0}>[{evt.timestamp}]</Text>
|
size="sm"
|
||||||
<Text flexShrink={0}>{eventLabel(evt.type)}</Text>
|
leftIcon={icons[type]}
|
||||||
<Text color={eventColor(evt.type)} fontWeight="bold" flexShrink={0}>
|
colorScheme={colors[type]}
|
||||||
{evt.agent}
|
variant="solid"
|
||||||
</Text>
|
onClick={() => startRun(type)}
|
||||||
<ChevronRight size={10} style={{ flexShrink: 0, opacity: 0.4 }} />
|
isLoading={isThisRunning}
|
||||||
<Text color="whiteAlpha.700" isTruncated>{eventSummary(evt)}</Text>
|
loadingText="Running…"
|
||||||
<Eye size={12} style={{ flexShrink: 0, opacity: 0.3, marginLeft: 'auto' }} />
|
isDisabled={isOtherRunning}
|
||||||
</Flex>
|
>
|
||||||
</Box>
|
{RUN_TYPE_LABELS[type]}
|
||||||
))}
|
</Button>
|
||||||
{events.length === 0 && (
|
);
|
||||||
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
})}
|
||||||
<TerminalIcon size={48} />
|
<Divider orientation="vertical" h="20px" />
|
||||||
<Text fontSize="sm">Awaiting agent activation...</Text>
|
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : status === 'completed' ? 'blue' : status === 'error' ? 'red' : 'gray'}>
|
||||||
</Flex>
|
{status.toUpperCase()}
|
||||||
)}
|
</Tag>
|
||||||
<div ref={terminalEndRef} />
|
<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>
|
</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>
|
||||||
</Flex>
|
)}
|
||||||
|
|
||||||
{/* Unified Inspector Drawer (single event or all node events) */}
|
{/* Unified Inspector Drawer (single event or all node events) */}
|
||||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
|
<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