Files
aramesh-backend/.backups-chatbot-10x/ChatWidget.tsx
T

252 lines
9.8 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
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 trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}