feat(FE): AiAskPanel floating component — chat with Hermes from any dashboard page
This commit is contained in:
parent
cafa15fedc
commit
ec5bd7d7dc
1 changed files with 146 additions and 0 deletions
146
src/components/ai-ask-panel.tsx
Normal file
146
src/components/ai-ask-panel.tsx
Normal 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ă-mă 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue