feat: add site assistant component for guided tool selection

- Introduced SiteAssistant component to assist users in selecting the right tools based on their queries.
- Integrated assistant into the main App component.
- Implemented message handling and storage for user-assistant interactions.
- Added quick prompts for common user queries related to tools.
- Enhanced ToolLandingPage and DownloadButton components with SharePanel for sharing tool results.
- Updated translations for new assistant features and sharing options.
- Added API methods for chat functionality with the assistant, including streaming responses.
This commit is contained in:
Your Name
2026-03-14 10:07:55 +02:00
parent e06e64f85f
commit 2b3367cdea
21 changed files with 1877 additions and 39 deletions

View File

@@ -0,0 +1,329 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bot, SendHorizontal, Sparkles, X } from 'lucide-react';
import { getToolSEO } from '@/config/seoData';
import { streamAssistantChat, type AssistantHistoryMessage } from '@/services/api';
import { trackEvent } from '@/services/analytics';
interface AssistantMessage {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: string;
}
interface AssistantStorageState {
sessionId: string;
fingerprint: string;
messages: AssistantMessage[];
}
const STORAGE_KEY = 'saaspdf:site-assistant:v1';
const MAX_STORED_MESSAGES = 20;
const ASSISTANT_ENABLED = import.meta.env.VITE_SITE_ASSISTANT_ENABLED !== 'false';
function createId(prefix: string): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function loadStoredState(): AssistantStorageState {
if (typeof window === 'undefined') {
return {
sessionId: createId('assistant-session'),
fingerprint: createId('assistant-visitor'),
messages: [],
};
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {
sessionId: createId('assistant-session'),
fingerprint: createId('assistant-visitor'),
messages: [],
};
}
const parsed = JSON.parse(raw) as Partial<AssistantStorageState>;
return {
sessionId: parsed.sessionId || createId('assistant-session'),
fingerprint: parsed.fingerprint || createId('assistant-visitor'),
messages: Array.isArray(parsed.messages) ? parsed.messages.slice(-MAX_STORED_MESSAGES) : [],
};
} catch {
return {
sessionId: createId('assistant-session'),
fingerprint: createId('assistant-visitor'),
messages: [],
};
}
}
export default function SiteAssistant() {
const location = useLocation();
const { t, i18n } = useTranslation();
const [storedState] = useState<AssistantStorageState>(() => loadStoredState());
const [open, setOpen] = useState(false);
const [sessionId, setSessionId] = useState(storedState.sessionId);
const [fingerprint] = useState(storedState.fingerprint);
const [messages, setMessages] = useState<AssistantMessage[]>(storedState.messages);
const [input, setInput] = useState('');
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const toolSlug = location.pathname.startsWith('/tools/')
? location.pathname.replace('/tools/', '').split('/')[0]
: '';
const toolSEO = toolSlug ? getToolSEO(toolSlug) : undefined;
const toolTitle = toolSEO ? t(`tools.${toolSEO.i18nKey}.title`) : '';
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
sessionId,
fingerprint,
messages: messages.slice(-MAX_STORED_MESSAGES),
})
);
}, [fingerprint, messages, sessionId]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, open]);
if (!ASSISTANT_ENABLED) {
return null;
}
const quickPrompts = toolSEO
? [
t('assistant.prompts.currentTool', { tool: toolTitle }),
t('assistant.prompts.alternativeTool'),
t('assistant.prompts.share'),
]
: [
t('assistant.prompts.findTool'),
t('assistant.prompts.pdfWorkflows'),
t('assistant.prompts.imageWorkflows'),
];
const sendMessage = async (content: string) => {
const trimmed = content.trim();
if (!trimmed || isSending) return;
const userMessage: AssistantMessage = {
id: createId('assistant-message'),
role: 'user',
content: trimmed,
createdAt: new Date().toISOString(),
};
const nextMessages = [...messages, userMessage].slice(-MAX_STORED_MESSAGES);
const assistantMessageId = createId('assistant-message');
const assistantPlaceholder: AssistantMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
createdAt: new Date().toISOString(),
};
const history: AssistantHistoryMessage[] = nextMessages.slice(-8).map((message) => ({
role: message.role,
content: message.content,
}));
setMessages([
...nextMessages,
assistantPlaceholder,
].slice(-MAX_STORED_MESSAGES));
setInput('');
setError(null);
setIsSending(true);
trackEvent('assistant_message_sent', { tool: toolSlug || 'global' });
try {
const response = await streamAssistantChat({
message: trimmed,
session_id: sessionId,
fingerprint,
tool_slug: toolSlug,
page_url: typeof window !== 'undefined' ? window.location.href : location.pathname,
locale: i18n.language,
history,
}, {
onSession: (nextSessionId) => {
setSessionId(nextSessionId);
},
onChunk: (chunk) => {
setMessages((currentMessages) => currentMessages.map((message) => (
message.id === assistantMessageId
? { ...message, content: `${message.content}${chunk}` }
: message
)));
},
});
setSessionId(response.session_id);
setMessages((currentMessages) => currentMessages.map((message) => (
message.id === assistantMessageId
? { ...message, content: response.reply }
: message
)));
} catch (requestError) {
const message = requestError instanceof Error
? requestError.message
: t('assistant.unavailable');
setError(message);
setMessages((currentMessages) => currentMessages.map((currentMessage) => (
currentMessage.id === assistantMessageId
? { ...currentMessage, content: t('assistant.unavailable') }
: currentMessage
)));
} finally {
setIsSending(false);
}
};
return (
<div className="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
<div className="pointer-events-auto w-full max-w-sm">
{open && (
<div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95">
<div className="bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.28),_transparent_40%),linear-gradient(135deg,rgba(15,23,42,1),rgba(30,41,59,0.96))] p-5 text-white">
<div className="flex items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-100">
<Sparkles className="h-3.5 w-3.5" />
{t('assistant.badge')}
</div>
<h2 className="mt-3 text-lg font-semibold">{t('assistant.title')}</h2>
<p className="mt-1 text-sm text-slate-200">{t('assistant.subtitle')}</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full bg-white/10 p-2 text-slate-200 transition-colors hover:bg-white/20 hover:text-white"
aria-label={t('assistant.close')}
>
<X className="h-4 w-4" />
</button>
</div>
<p className="mt-4 rounded-2xl bg-white/10 px-3 py-2 text-xs text-slate-100">
{t('assistant.dataNotice')}
</p>
</div>
<div ref={scrollRef} className="max-h-[26rem] space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200">
<p className="font-medium text-slate-900 dark:text-slate-100">
{toolTitle
? t('assistant.greetingWithTool', { tool: toolTitle })
: t('assistant.greeting')}
</p>
<p className="mt-2 text-slate-600 dark:text-slate-400">{t('assistant.emptyState')}</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={message.role === 'user'
? 'max-w-[85%] rounded-[24px] rounded-br-md bg-slate-900 px-4 py-3 text-sm text-white dark:bg-sky-500'
: 'max-w-[85%] rounded-[24px] rounded-bl-md bg-slate-100 px-4 py-3 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-200'}
>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))}
{isSending && (
<div className="flex justify-start">
<div className="rounded-[24px] rounded-bl-md bg-slate-100 px-4 py-3 text-sm text-slate-500 dark:bg-slate-800 dark:text-slate-300">
{t('assistant.thinking')}
</div>
</div>
)}
</div>
<div className="border-t border-slate-200 bg-slate-50/70 px-4 py-4 dark:border-slate-800 dark:bg-slate-950/70">
{error && (
<p className="mb-3 rounded-2xl bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
{error}
</p>
)}
<div className="mb-3 flex flex-wrap gap-2">
{quickPrompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => void sendMessage(prompt)}
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-sky-200 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-sky-800 dark:hover:text-sky-300"
>
{prompt}
</button>
))}
</div>
<div className="flex items-end gap-2 rounded-[24px] border border-slate-200 bg-white p-2 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void sendMessage(input);
}
}}
placeholder={t('assistant.inputPlaceholder')}
rows={1}
className="max-h-28 min-h-[2.75rem] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200 dark:placeholder:text-slate-500"
/>
<button
type="button"
onClick={() => void sendMessage(input)}
disabled={!input.trim() || isSending}
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-sky-500 text-white transition-colors hover:bg-sky-600 disabled:cursor-not-allowed disabled:bg-slate-300 dark:disabled:bg-slate-700"
aria-label={t('assistant.send')}
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
<button
type="button"
onClick={() => {
setOpen((value) => !value);
trackEvent('assistant_toggled', { open: !open, tool: toolSlug || 'global' });
}}
className="ml-auto flex items-center gap-3 rounded-full bg-[linear-gradient(135deg,#0f172a,#0369a1)] px-5 py-3 text-left text-white shadow-[0_18px_48px_rgba(2,132,199,0.35)] transition-transform hover:-translate-y-0.5"
>
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<Bot className="h-5 w-5" />
</span>
<span>
<span className="block text-sm font-semibold">{t('assistant.fabTitle')}</span>
<span className="block text-xs text-sky-100">{t('assistant.fabSubtitle')}</span>
</span>
</button>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { generateToolSchema, generateBreadcrumbs, generateFAQ } from '@/utils/se
import FAQSection from './FAQSection';
import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating';
import SharePanel from '@/components/shared/SharePanel';
import { useToolRating } from '@/hooks/useToolRating';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
@@ -85,7 +86,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* Tool Interface */}
{children}
<div className="mx-auto mt-6 flex max-w-3xl items-center justify-center px-4">
<div className="mx-auto mt-6 flex max-w-3xl flex-wrap items-start justify-center gap-3 px-4">
<SharePanel
variant="page"
title={toolTitle}
text={toolDesc}
url={canonicalUrl}
/>
<button
type="button"
onClick={() => dispatchRatingPrompt(slug, { forceOpen: true })}

View File

@@ -4,6 +4,7 @@ import type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
import SharePanel from '@/components/shared/SharePanel';
interface DownloadButtonProps {
/** Task result containing download URL */
@@ -77,6 +78,17 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
{t('common.download')} {result.filename}
</a>
<div className="mt-3 flex justify-center">
<SharePanel
variant="result"
title={result.filename || t('share.resultFallbackTitle')}
text={t('share.resultDescription', {
filename: result.filename || t('share.resultFallbackTitle'),
})}
url={result.download_url}
/>
</div>
{/* Expiry notice */}
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<Clock className="h-3.5 w-3.5" />

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Mail, MessageCircle, Send, Share2, Link as LinkIcon } from 'lucide-react';
import { trackEvent } from '@/services/analytics';
type ShareVariant = 'page' | 'result';
interface SharePanelProps {
title: string;
text: string;
url: string;
variant?: ShareVariant;
className?: string;
}
interface ShareTarget {
key: string;
label: string;
href: string;
}
function openShareWindow(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
}
export default function SharePanel({
title,
text,
url,
variant = 'page',
className = '',
}: SharePanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const canNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
if (!url) return null;
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedText = encodeURIComponent(text);
const targets: ShareTarget[] = [
{
key: 'whatsapp',
label: t('share.targets.whatsapp'),
href: `https://wa.me/?text=${encodeURIComponent(`${title}\n${url}`)}`,
},
{
key: 'facebook',
label: t('share.targets.facebook'),
href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
},
{
key: 'telegram',
label: t('share.targets.telegram'),
href: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
},
{
key: 'x',
label: t('share.targets.x'),
href: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
},
{
key: 'linkedin',
label: t('share.targets.linkedin'),
href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
},
{
key: 'email',
label: t('share.targets.email'),
href: `mailto:?subject=${encodedTitle}&body=${encodedText}%0A%0A${encodedUrl}`,
},
];
const handleNativeShare = async () => {
if (!canNativeShare) return;
try {
await navigator.share({ title, text, url });
trackEvent('share_clicked', { variant, target: 'native' });
} catch {
// Ignore cancelation and rely on the fallback actions below.
}
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
trackEvent('share_clicked', { variant, target: 'copy' });
} catch {
setCopied(false);
}
};
return (
<div className={className}>
<button
type="button"
onClick={() => {
setOpen((value) => !value);
trackEvent('share_panel_toggled', { variant, open: !open });
}}
className="inline-flex items-center gap-2 rounded-full border border-sky-200 bg-white px-4 py-2 text-sm font-medium text-sky-700 shadow-sm transition-colors hover:border-sky-300 hover:text-sky-800 dark:border-sky-900/70 dark:bg-slate-900 dark:text-sky-300 dark:hover:border-sky-700"
>
<Share2 className="h-4 w-4" />
{variant === 'result' ? t('share.shareResult') : t('share.shareTool')}
</button>
{open && (
<div className="mt-3 w-full max-w-md rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-slate-700 dark:bg-slate-900/95">
<div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300">
{variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')}
</p>
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{text}</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{canNativeShare && (
<button
type="button"
onClick={handleNativeShare}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
>
<Share2 className="h-4 w-4" />
{t('share.native')}
</button>
)}
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 rounded-full border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 hover:text-slate-900 dark:border-slate-600 dark:text-slate-200 dark:hover:border-slate-500"
>
{copied ? <LinkIcon className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied ? t('share.copied') : t('share.copyLink')}
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{targets.map((target) => (
<button
key={target.key}
type="button"
onClick={() => {
openShareWindow(target.href);
trackEvent('share_clicked', { variant, target: target.key });
}}
className="rounded-2xl border border-slate-200 px-3 py-3 text-left text-sm font-medium text-slate-700 transition-colors hover:border-sky-200 hover:bg-sky-50 hover:text-sky-800 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-800 dark:hover:bg-slate-800"
>
<span className="mb-2 block text-slate-400 dark:text-slate-500">
{target.key === 'whatsapp' && <MessageCircle className="h-4 w-4" />}
{target.key === 'telegram' && <Send className="h-4 w-4" />}
{target.key === 'email' && <Mail className="h-4 w-4" />}
{!['whatsapp', 'telegram', 'email'].includes(target.key) && <Share2 className="h-4 w-4" />}
</span>
<span>{target.label}</span>
</button>
))}
</div>
<p className="mt-4 text-xs text-slate-500 dark:text-slate-400">{t('share.note')}</p>
</div>
)}
</div>
);
}