Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live; rate-limited public forms with honeypots; real client IP behind Traefik (--proxy-headers); TipTap URL sanitization on write (nh3); non-root containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1; HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code. Reliability: Alembic migrations (create_all baseline); graceful shutdown (exec uvicorn); hardened backups (checksums, sanity check, freshness gauge, optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring); .gitattributes LF for shell scripts. Orchestration: json-file log rotation, pinned images, per-service memory limits, edge/internal networks, Traefik ping/accesslog/metrics + flood limiter, conditional minio-setup, pgvector db image, one-shot db-init (umami + aramesh_chatbot databases). Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget). Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor, node/postgres/blackbox exporters, provisioned dashboards and 10 alert rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible). Analytics: Umami on shared Postgres, build-time tracker injection, custom events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts. Hygiene: CSV export for contact messages and subscribers; upload image optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh; gen-env.sh for strong credentials.
252 lines
9.8 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|