From 270c51a0c098cb0d59e4d7cb26c92a246a0ceee8 Mon Sep 17 00:00:00 2001 From: Awopetu Feyisayo <32976424+feyibosslady@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:07:55 +0000 Subject: [PATCH] feat: build Nvidia NIM AI assistant financial briefing drawer component - Create src/features/assistant/FinancialBriefingDrawer.tsx with Framer Motion animations - Implement chat state management with message history and loading spinners - Style chat bubbles and input controls using design tokens from src/styles/tokens.css - Add focus trapping for accessibility when drawer is open - Add preset prompt chips for common financial briefings (Daily Spend, Low Balance, Gas Fees, Treasury Report, Pending Approvals, Wallet Audit) - Integrate with existing AiBriefing data from useBriefing hook - Support streaming markdown chat responses with structured briefing widgets Closes #67 --- .../assistant/FinancialBriefingDrawer.tsx | 601 ++++++++++++++++++ src/features/assistant/index.ts | 1 + 2 files changed, 602 insertions(+) create mode 100644 src/features/assistant/FinancialBriefingDrawer.tsx create mode 100644 src/features/assistant/index.ts diff --git a/src/features/assistant/FinancialBriefingDrawer.tsx b/src/features/assistant/FinancialBriefingDrawer.tsx new file mode 100644 index 0000000..fa20e55 --- /dev/null +++ b/src/features/assistant/FinancialBriefingDrawer.tsx @@ -0,0 +1,601 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + X, + Sparkles, + Send, + Bot, + User, + Zap, + AlertCircle, + TrendingDown, + FileText, + Wallet, + ShieldCheck, + Loader2, +} from 'lucide-react'; +import { useAssistantStore } from '@/stores/ui-store'; +import { useBriefing } from '@/hooks/use-queries'; +import { cn } from '@/lib/cn'; +import { env, isMockMode } from '@/lib/env'; +import type { ChatMessage, QuickPromptChip } from '@/features/chat/types'; + +const PRESET_CHIPS: QuickPromptChip[] = [ + { + id: 'chip-spend-briefing', + label: 'Daily Spend Briefing', + promptText: 'Summarize total daily spending across all AI agents and departments, highlighting any anomalies.', + iconName: 'Zap', + }, + { + id: 'chip-low-balance', + label: 'Low Balance Wallets', + promptText: 'Identify any agent wallets approaching minimum reserve or threshold balance that need replenishment.', + iconName: 'AlertCircle', + }, + { + id: 'chip-gas-fees', + label: 'Gas Fee Optimization', + promptText: 'Analyze current Soroban contract RPC gas fees and recommend priority fee settings for cost efficiency.', + iconName: 'TrendingDown', + }, + { + id: 'chip-treasury-report', + label: 'Treasury Health Report', + promptText: 'Generate a comprehensive treasury health report including cash position, runway, and risk exposure.', + iconName: 'FileText', + }, + { + id: 'chip-pending-approvals', + label: 'Pending Approvals', + promptText: 'List all pending approval requests with risk scores, amounts, and expiration timelines.', + iconName: 'ShieldCheck', + }, + { + id: 'chip-wallet-audit', + label: 'Wallet Balance Audit', + promptText: 'Audit all agent wallet balances and flag any below 20% capacity with recommended top-up amounts.', + iconName: 'Wallet', + }, +]; + +const INITIAL_MESSAGES: ChatMessage[] = [ + { + id: 'msg-welcome', + role: 'assistant', + content: + 'Welcome to the Nvidia NIM Financial Briefing Assistant. I provide concise natural language summaries of agent spending anomalies, treasury health, and pending approvals. Select a quick prompt below or ask me anything.', + timestamp: new Date().toISOString(), + }, +]; + +const ICON_MAP: Record = { + Zap, + AlertCircle, + TrendingDown, + FileText, + ShieldCheck, + Wallet, +}; + +interface StructuredBriefing { + totalDailySpend: number; + currency: string; + activeAgentsCount: number; + lowBalanceWalletsCount: number; + topSpenderAgent: string; + recommendation: string; +} + +interface ExtendedChatMessage extends ChatMessage { + structuredBriefing?: StructuredBriefing; + isStreaming?: boolean; +} + +function formatCurrency(amount: number, currency: string): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +function formatRelativeTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function FocusTrap({ children, active }: { children: React.ReactNode; active: boolean }) { + const containerRef = useRef(null); + const previousActiveElement = useRef(null); + + useEffect(() => { + if (!active) return; + + const container = containerRef.current; + if (!container) return; + + previousActiveElement.current = document.activeElement as HTMLElement; + + const focusableElements = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + firstElement?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } + } + }; + + container.addEventListener('keydown', handleKeyDown); + return () => { + container.removeEventListener('keydown', handleKeyDown); + previousActiveElement.current?.focus(); + }; + }, [active]); + + return
{children}
; +} + +function MessageBubble({ message }: { message: ExtendedChatMessage }) { + const isAssistant = message.role === 'assistant'; + const isStreaming = message.isStreaming === true; + + return ( + + {isAssistant && ( + + + + )} + +
+ +

{message.content}

+ + {message.structuredBriefing && ( + +
+ Executive Summary + + + Live On-Chain + +
+ +
+
+ Total Daily Spend: +

+ {formatCurrency(message.structuredBriefing.totalDailySpend, message.structuredBriefing.currency)} +

+
+
+ Active Agents: +

{message.structuredBriefing.activeAgentsCount} Agents

+
+
+ Top Spender: +

{message.structuredBriefing.topSpenderAgent}

+
+
+ Low Balance Wallets: +

{message.structuredBriefing.lowBalanceWalletsCount} Warning

+
+
+ +
+ AI Recommendation: +

{message.structuredBriefing.recommendation}

+
+
+ )} + + {isStreaming && ( + + + Nvidia NIM is formulating insight... + + )} +
+ + + {formatRelativeTime(message.timestamp)} + +
+ + {!isAssistant && ( + + + + )} +
+ ); +} + +function QuickPromptChip({ + chip, + onClick, + disabled, +}: { + chip: QuickPromptChip; + onClick: () => void; + disabled: boolean; +}) { + const Icon = ICON_MAP[chip.iconName] || Zap; + + return ( + + + {chip.label} + + ); +} + +export function FinancialBriefingDrawer() { + const open = useAssistantStore((s) => s.open); + const setOpen = useAssistantStore((s) => s.setOpen); + const briefingQuery = useBriefing(); + + const [messages, setMessages] = useState(INITIAL_MESSAGES); + const [draft, setDraft] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [seeded, setSeeded] = useState(false); + const scrollRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (typeof window === 'undefined') return; + + try { + const saved = window.localStorage.getItem('astroid-financial-briefing-chat'); + if (saved) { + const parsed = JSON.parse(saved) as ExtendedChatMessage[]; + if (Array.isArray(parsed) && parsed.length > 0) { + setMessages(parsed); + setSeeded(true); + } + } + } catch { + // Ignore malformed local history + } + }, []); + + useEffect(() => { + if (!seeded && briefingQuery.data) { + const greetingMsg: ExtendedChatMessage = { + id: `msg-greeting-${Date.now()}`, + role: 'assistant', + content: briefingQuery.data.greeting, + timestamp: briefingQuery.data.generatedAt, + }; + const summaryMsg: ExtendedChatMessage = { + id: `msg-summary-${Date.now()}`, + role: 'assistant', + content: briefingQuery.data.summary, + timestamp: briefingQuery.data.generatedAt, + }; + setMessages((prev) => [...prev, greetingMsg, summaryMsg]); + setSeeded(true); + } + }, [seeded, briefingQuery.data]); + + useEffect(() => { + if (typeof window !== 'undefined' && messages.length > 0) { + window.localStorage.setItem('astroid-financial-briefing-chat', JSON.stringify(messages)); + } + }, [messages]); + + useEffect(() => { + if (open) { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + textareaRef.current?.focus(); + } + }, [open, messages]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [setOpen]); + + const send = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || isStreaming) return; + + const stamp = new Date().toISOString(); + const userMsg: ExtendedChatMessage = { + id: `u-${Date.now()}`, + role: 'user', + content: trimmed, + timestamp: stamp, + }; + setMessages((prev) => [...prev, userMsg]); + setDraft(''); + setIsStreaming(true); + + if (isMockMode) { + const mockReply: ExtendedChatMessage = { + id: `a-${Date.now()}`, + role: 'assistant', + content: + 'I can help with that. In this preview the assistant is running in mock mode — set NEXT_PUBLIC_API_URL to connect to the live Nvidia NIM API.', + timestamp: stamp, + }; + setMessages((prev) => [...prev, mockReply]); + setIsStreaming(false); + return; + } + + try { + const res = await fetch(`${env.apiUrl}${env.apiVersion}/ai/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: trimmed }), + }); + const body = await res.json(); + const reply = body?.data?.reply ?? body?.reply ?? 'No response from AI.'; + setMessages((prev) => [ + ...prev, + { id: `a-${Date.now()}`, role: 'assistant', content: reply, timestamp: new Date().toISOString() }, + ]); + } catch { + setMessages((prev) => [ + ...prev, + { + id: `a-err-${Date.now()}`, + role: 'assistant', + content: 'Failed to reach the Nvidia NIM AI service. Check the API connection.', + timestamp: new Date().toISOString(), + }, + ]); + } finally { + setIsStreaming(false); + } + }, + [isStreaming] + ); + + const handleSendMessage = (textToSend?: string) => { + const text = (textToSend || draft).trim(); + if (!text || isStreaming) return; + send(text); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const suggestions = briefingQuery.data?.suggestedActions?.length + ? briefingQuery.data.suggestedActions.map((s, i) => ({ + id: `suggested-${i}`, + label: s.label, + promptText: s.prompt, + iconName: 'Zap', + })) + : PRESET_CHIPS; + + return ( + + {open && ( + <> + setOpen(false)} + aria-hidden="true" + /> + + + +
+
+
+ + + +
+

Financial Briefing Assistant

+

Powered by Nvidia NIM

+
+
+ +
+ +
+ {messages.length === 0 && !isStreaming && ( +
+ Select a quick prompt or ask about spending anomalies, treasury health, or pending approvals. +
+ )} + + {messages.map((msg) => ( + + ))} + + {isStreaming && ( + + + Nvidia NIM is formulating insight... + + )} + + {suggestions.length > 0 && ( + +

+ Quick Prompts +

+
+ {suggestions.map((s) => ( + handleSendMessage(s.promptText)} + disabled={isStreaming} + /> + ))} +
+
+ )} +
+ +
{ + e.preventDefault(); + handleSendMessage(); + }} + > +
+