diff --git a/src/components/shell/assistant-drawer.tsx b/src/components/shell/assistant-drawer.tsx index 3d00676..9648c23 100644 --- a/src/components/shell/assistant-drawer.tsx +++ b/src/components/shell/assistant-drawer.tsx @@ -7,20 +7,24 @@ import { useAssistantSeed, useBriefing } from '@/hooks/use-queries'; import { Avatar } from '@/components/ui/avatar'; import { cn } from '@/lib/cn'; import { env, isMockMode } from '@/lib/env'; +import { motion, AnimatePresence } from 'framer-motion'; +import ReactMarkdown from 'react-markdown'; import type { ChatMessage } from '@/types/domain'; const defaultSuggestions = [ - { label: 'Financial summary', prompt: 'Summarize treasury health and any anomaly alerts.' }, - { label: 'Budget check', prompt: 'Check budget health and flag any risks before close.' }, + { label: 'Financial briefing', prompt: "Provide today's financial briefing and treasury health summary." }, + { label: 'Portfolio health', prompt: 'Summarize portfolio health and highlight any risk signals.' }, + { label: 'Agent activity log', prompt: 'What did agents do recently? Summarize the latest activity log.' }, { label: 'Stellar transfer explainer', prompt: 'Explain the most recent high-value Stellar transfer in plain English.' }, ]; /** * Slide-over AI assistant. Seeds from the mock conversation and the daily - * briefing's suggested prompts. Composer is local-only (no backend in mock - * mode) — sending appends an optimistic user turn plus a canned acknowledgement - * so the interaction reads end-to-end. + * briefing's suggested prompts. The live path streams from NVIDIA NIM. + * Composer is local-only (no backend in mock mode) — sending appends an + * optimistic user turn plus a canned acknowledgement so the interaction reads + * end-to-end. */ export function AssistantDrawer() { const open = useAssistantStore((s) => s.open); @@ -32,6 +36,7 @@ export function AssistantDrawer() { const [draft, setDraft] = useState(''); const [seeded, setSeeded] = useState(false); const scrollRef = useRef(null); + const composerRef = useRef(null); useEffect(() => { if (typeof window === 'undefined') return; @@ -68,6 +73,10 @@ export function AssistantDrawer() { if (open) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [open, messages]); + useEffect(() => { + if (open) composerRef.current?.focus(); + }, [open]); + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); @@ -95,23 +104,62 @@ export function AssistantDrawer() { return; } + const assistantId = `a-${Date.now()}`; + let accumulated = ''; + setMessages((prev) => [ + ...prev, + { id: assistantId, role: 'assistant', content: '', createdAt: new Date().toISOString() }, + ]); + try { const res = await fetch(`${env.apiUrl}${env.apiVersion}/ai/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: trimmed }), + body: JSON.stringify({ message: trimmed, stream: true }), }); - 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, createdAt: new Date().toISOString() }, - ]); + if (!res.ok || !res.body) throw new Error('AI request failed'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const event = line.trim(); + if (!event.startsWith('data:')) continue; + const data = event.slice(5).trim(); + if (data === '[DONE]') continue; + try { + const chunk = JSON.parse(data); + const delta = chunk?.choices?.[0]?.delta?.content; + if (delta) { + accumulated += delta; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: accumulated } : m)), + ); + } + } catch { + // Ignore partial JSON frames and keep reading the stream. + } + } + } + if (!accumulated) { + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: 'No response from AI.' } : m)), + ); + } } catch { - setMessages((prev) => [ - ...prev, - { id: `a-err-${Date.now()}`, role: 'assistant', content: 'Failed to reach the AI service. Check the API connection.', createdAt: new Date().toISOString() }, - ]); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: 'Failed to reach the AI service. Check the API connection.' } + : m, + ), + ); } }; @@ -122,17 +170,18 @@ export function AssistantDrawer() { ); return ( - <> - {/* Floating Bottom-Right AI Widget */} -
+ + {open && ( +
@@ -142,21 +191,21 @@ export function AssistantDrawer() {
-

Command Terminal

-

Autonomous AI Copilot

+

Financial Briefing

+

Powered by NVIDIA NIM

-
+
{messages.length === 0 && (
Ask for a treasury summary, a budget-risk review, or a plain-English explanation of a Stellar transfer. @@ -164,8 +213,11 @@ export function AssistantDrawer() { )} {messages.map((msg) => ( -
{msg.role === 'assistant' ? ( @@ -183,9 +235,13 @@ export function AssistantDrawer() { : 'border border-border bg-surface-secondary/60 text-foreground', )} > - {msg.content} + {msg.role === 'assistant' ? ( + {msg.content || '_Thinking…_'} + ) : ( + {msg.content} + )}
-
+ ))} {suggestions.length > 0 && ( @@ -193,13 +249,16 @@ export function AssistantDrawer() {

Suggested Actions

-
+
{suggestions.map((s) => (