feat(FE): AiAskPanel floating component — chat with Hermes from any dashboard page

This commit is contained in:
admin-valentin 2026-08-26 15:41:08 +00:00
parent cafa15fedc
commit ec5bd7d7dc

View file

@ -0,0 +1,146 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiFetch } from '../lib/api';
import { useSession } from './session-provider';
interface Message { role: 'user' | 'assistant'; content: string; }
export function AiAskPanel() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const askMut = useMutation({
mutationFn: (userMsg: string) =>
apiFetch<{ reply: string }>('/v1/ai/ask', {
tenantId,
method: 'POST',
body: {
messages: [...messages, { role: 'user', content: userMsg }],
context: JSON.stringify({ tenantId, ts: new Date().toISOString() }),
},
}),
onSuccess: (data, userMsg) => {
setMessages((prev) => [
...prev,
{ role: 'user', content: userMsg },
{ role: 'assistant', content: data.reply },
]);
},
});
const { data: brief } = useQuery({
queryKey: ['daily-brief', tenantId],
queryFn: () => apiFetch<{ brief: string }>('/v1/ai/daily-brief', { tenantId }),
enabled: Boolean(tenantId) && open && messages.length === 0,
staleTime: 600_000, // 10 min
});
function send() {
const msg = input.trim();
if (!msg || askMut.isPending) return;
setInput('');
askMut.mutate(msg);
}
const SUGGESTIONS = [
'Ce ar trebui să prioritizez azi?',
'Rezumă săptămâna trecută.',
'Care sunt obiectivele cu risc de nefinalizare?',
'Generează un brief pentru echipă.',
];
if (!tenantId) return null;
return (
<>
{/* Floating button */}
<button
onClick={() => setOpen((o) => !o)}
className="fixed bottom-6 right-6 z-50 h-13 w-13 rounded-full bg-primary text-white shadow-lg hover:bg-primary/90 transition-all flex items-center justify-center text-xl"
title="CEO OS AI Assistant"
>
{open ? '✕' : '✦'}
</button>
{/* Panel */}
{open && (
<div className="fixed bottom-22 right-6 z-50 flex flex-col w-[360px] max-h-[70vh] rounded-2xl border bg-background shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-3 bg-primary/5">
<span className="text-primary font-bold"></span>
<p className="text-sm font-semibold text-ink">CEO OS AI</p>
<span className="ml-auto text-[10px] text-ink-faint">via Hermes</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && brief?.brief && (
<div className="rounded-xl bg-primary/5 p-3 space-y-1">
<p className="text-[10px] font-semibold text-ink-faint uppercase">Brief zilnic</p>
<p className="text-xs text-ink whitespace-pre-line">{brief.brief}</p>
</div>
)}
{messages.length === 0 && !brief && (
<div className="space-y-2">
<p className="text-xs text-ink-faint">Întreabă- orice despre CEO OS:</p>
{SUGGESTIONS.map((s) => (
<button key={s} onClick={() => { setInput(s); }}
className="block w-full text-left text-xs text-ink-faint border rounded-lg px-3 py-2 hover:border-primary/40 hover:text-primary transition-colors">
{s}
</button>
))}
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`rounded-2xl px-3 py-2 text-xs max-w-[85%] whitespace-pre-line ${
m.role === 'user' ? 'bg-primary text-white' : 'bg-muted text-ink'
}`}>
{m.content}
</div>
</div>
))}
{askMut.isPending && (
<div className="flex justify-start">
<div className="bg-muted rounded-2xl px-3 py-2 text-xs text-ink-faint animate-pulse">
Hermes gândește
</div>
</div>
)}
{askMut.isError && (
<p className="text-xs text-signal-danger text-center">
Hermes indisponibil. Verifică HERMES_BASE_URL în Coolify.
</p>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="border-t p-3 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send()}
placeholder="Întreabă ceva… (Enter)"
className="flex-1 rounded-xl border bg-background px-3 py-2 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button onClick={send} disabled={!input.trim() || askMut.isPending}
className="rounded-xl bg-primary px-3 py-2 text-xs text-white disabled:opacity-50">
</button>
</div>
</div>
)}
</>
);
}