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

@@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom';
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
import CookieConsent from '@/components/layout/CookieConsent';
import SiteAssistant from '@/components/layout/SiteAssistant';
import ErrorBoundary from '@/components/shared/ErrorBoundary';
import ToolLandingPage from '@/components/seo/ToolLandingPage';
import { useDirection } from '@/hooks/useDirection';
@@ -154,6 +155,7 @@ export default function App() {
</main>
<Footer />
<SiteAssistant />
<CookieConsent />
</div>
);

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>
);
}

View File

@@ -62,6 +62,50 @@
"noToken": "رابط غير صالح. يرجى طلب رابط جديد."
}
},
"assistant": {
"badge": "دليل ذكي",
"title": "هل تحتاج مساعدة لاختيار الأداة المناسبة؟",
"subtitle": "اسأل عن تدفقات PDF أو الصور أو الفيديو أو النصوص، وستحصل على إجابة موجهة فوراً.",
"dataNotice": "قد يتم حفظ الرسائل المرسلة هنا حتى نطوّر المساعد ونفهم احتياجات المستخدمين بشكل أفضل.",
"greeting": "مرحباً، أستطيع مساعدتك في اختيار الأداة المناسبة أو شرح طريقة عمل الموقع.",
"greetingWithTool": "مرحباً، أستطيع مساعدتك في استخدام {{tool}} أو ترشيح أداة أنسب إذا لزم الأمر.",
"emptyState": "اسأل عن أفضل أداة، أو سير العمل المناسب، أو كيفية مشاركة النتيجة وتنزيلها.",
"inputPlaceholder": "اسأل عن أي أداة أو سير عمل...",
"send": "إرسال الرسالة",
"thinking": "جارٍ التفكير...",
"unavailable": "المساعد غير متاح مؤقتاً. يرجى المحاولة بعد قليل.",
"close": "إغلاق المساعد",
"fabTitle": "اسأل SaaS-PDF",
"fabSubtitle": "مساعدة ذكية عبر جميع الأدوات",
"prompts": {
"currentTool": "كيف أستخدم {{tool}}؟",
"alternativeTool": "هل توجد أداة أفضل لهذه المهمة؟",
"share": "هل يمكنني مشاركة نتيجة هذه الأداة؟",
"findTool": "ما الأداة المناسبة لملفي؟",
"pdfWorkflows": "ما أفضل أدوات PDF هنا؟",
"imageWorkflows": "ما الأدوات التي تعمل مع الصور وOCR؟"
}
},
"share": {
"shareTool": "مشاركة هذه الأداة",
"shareResult": "مشاركة النتيجة",
"toolLabel": "رابط الأداة",
"resultLabel": "رابط التنزيل",
"native": "مشاركة",
"copyLink": "نسخ الرابط",
"copied": "تم النسخ",
"note": "شارك روابط التنزيل فقط عندما تكون مرتاحاً لمنح الآخرين إمكانية الوصول إلى الملف الناتج.",
"resultFallbackTitle": "الملف الناتج",
"resultDescription": "شارك وصولاً مباشراً إلى {{filename}}.",
"targets": {
"whatsapp": "واتساب",
"facebook": "فيسبوك",
"telegram": "تيليجرام",
"x": "X",
"linkedin": "لينكدإن",
"email": "البريد الإلكتروني"
}
},
"home": {
"hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
"heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
@@ -140,7 +184,7 @@
"title": "سياسة الخصوصية",
"lastUpdated": "آخر تحديث: {{date}}",
"dataCollectionTitle": "1. جمع البيانات",
"dataCollectionText": "نقوم فقط بمعالجة الملفات التي ترفعها عمداً. لا نطلب التسجيل ولا نجمع معلومات شخصية أثناء معالجة الملفات. إذا أنشأت حساباً، نخزن فقط بريدك الإلكتروني وكلمة المرور المشفرة.",
"dataCollectionText": "نقوم فقط بمعالجة الملفات التي ترفعها عمداً. لا نطلب التسجيل ولا نجمع معلومات شخصية أثناء معالجة الملفات. إذا أنشأت حساباً، نخزن فقط بريدك الإلكتروني وكلمة المرور المشفرة. وإذا استخدمت مساعد الموقع، فقد يتم حفظ رسائلك وردود المساعد لتحسين جودة الدعم، ودراسة استخدام المنتج، وتطوير تجربة المساعد.",
"fileHandlingTitle": "2. معالجة الملفات والتخزين",
"fileHandlingItems": [
"تتم معالجة الملفات المرفوعة على خوادمنا الآمنة.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [
"Google AdSense — لعرض الإعلانات",
"Google Analytics — لإحصائيات الاستخدام المجهولة",
"التخزين السحابي — لتخزين الملفات المؤقت والمشفر"
"التخزين السحابي — لتخزين الملفات المؤقت والمشفر",
"مزودو نماذج الذكاء الاصطناعي — لإنشاء ردود المساعد عند تفعيلها"
],
"securityTitle": "5. الأمان",
"securityText": "نستخدم إجراءات أمنية وفق معايير الصناعة تشمل تشفير HTTPS والتحقق من الملفات وتحديد المعدل وتعقيم المدخلات وتنظيف الملفات التلقائي. جميع البيانات أثناء النقل مشفرة وتتم معالجة الملفات في بيئات معزولة.",

View File

@@ -62,6 +62,50 @@
"noToken": "Invalid reset link. Please request a new one."
}
},
"assistant": {
"badge": "AI Guide",
"title": "Need help choosing the right tool?",
"subtitle": "Ask about PDF, image, video, or text workflows and get a guided answer right away.",
"dataNotice": "Messages sent here may be stored so we can improve the assistant and understand user needs better.",
"greeting": "Hi, I can help you find the right tool or explain how the site works.",
"greetingWithTool": "Hi, I can help you use {{tool}} or point you to a better fit if needed.",
"emptyState": "Ask for the best tool, the right workflow, or how to share and download your result.",
"inputPlaceholder": "Ask about any tool or workflow...",
"send": "Send message",
"thinking": "Thinking...",
"unavailable": "The assistant is temporarily unavailable. Please try again in a moment.",
"close": "Close assistant",
"fabTitle": "Ask SaaS-PDF",
"fabSubtitle": "Smart help across all tools",
"prompts": {
"currentTool": "How do I use {{tool}}?",
"alternativeTool": "Is there a better tool for this task?",
"share": "Can I share the result from this tool?",
"findTool": "Which tool should I use for my file?",
"pdfWorkflows": "What are the best PDF tools here?",
"imageWorkflows": "Which tools work for images and OCR?"
}
},
"share": {
"shareTool": "Share this tool",
"shareResult": "Share result",
"toolLabel": "Tool link",
"resultLabel": "Download link",
"native": "Share",
"copyLink": "Copy link",
"copied": "Copied",
"note": "Only share download links when you are comfortable giving others access to the processed file.",
"resultFallbackTitle": "Processed file",
"resultDescription": "Share direct access to {{filename}}.",
"targets": {
"whatsapp": "WhatsApp",
"facebook": "Facebook",
"telegram": "Telegram",
"x": "X",
"linkedin": "LinkedIn",
"email": "Email"
}
},
"home": {
"hero": "Everything You Need to Work with PDF Files — Instantly",
"heroSub": "Upload or drag & drop your file, and we'll auto-detect its type and suggest the right tools — edit, convert, compress, and more. No registration required.",
@@ -140,7 +184,7 @@
"title": "Privacy Policy",
"lastUpdated": "Last updated: {{date}}",
"dataCollectionTitle": "1. Data Collection",
"dataCollectionText": "We only process files you intentionally upload. We do not require registration, and no personal information is collected during file processing. If you create an account, we store only your email address and hashed password.",
"dataCollectionText": "We only process files you intentionally upload. We do not require registration, and no personal information is collected during file processing. If you create an account, we store only your email address and hashed password. If you use the site assistant, your messages and assistant replies may be stored to improve support quality, study product usage, and refine the assistant experience.",
"fileHandlingTitle": "2. File Processing & Storage",
"fileHandlingItems": [
"Uploaded files are processed on our secure servers.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [
"Google AdSense — for displaying advertisements",
"Google Analytics — for anonymous usage statistics",
"Cloud storage — for temporary encrypted file storage"
"Cloud storage — for temporary encrypted file storage",
"AI model providers — for generating assistant replies when enabled"
],
"securityTitle": "5. Security",
"securityText": "We employ industry-standard security measures including HTTPS encryption, file validation, rate limiting, input sanitization, and automatic file cleanup. All data in transit is encrypted, and files are processed in isolated environments.",

View File

@@ -62,6 +62,50 @@
"noToken": "Lien invalide. Veuillez en demander un nouveau."
}
},
"assistant": {
"badge": "Guide IA",
"title": "Besoin d'aide pour choisir le bon outil ?",
"subtitle": "Posez des questions sur les workflows PDF, image, vidéo ou texte et obtenez une réponse guidée immédiatement.",
"dataNotice": "Les messages envoyés ici peuvent être conservés afin d'améliorer l'assistant et de mieux comprendre les besoins des utilisateurs.",
"greeting": "Bonjour, je peux vous aider à trouver le bon outil ou à comprendre comment utiliser le site.",
"greetingWithTool": "Bonjour, je peux vous aider à utiliser {{tool}} ou vous orienter vers un meilleur choix si nécessaire.",
"emptyState": "Demandez le meilleur outil, le bon workflow, ou comment partager et télécharger votre résultat.",
"inputPlaceholder": "Posez une question sur un outil ou un workflow...",
"send": "Envoyer le message",
"thinking": "Réflexion en cours...",
"unavailable": "L'assistant est temporairement indisponible. Veuillez réessayer dans un instant.",
"close": "Fermer l'assistant",
"fabTitle": "Demander à SaaS-PDF",
"fabSubtitle": "Aide intelligente sur tous les outils",
"prompts": {
"currentTool": "Comment utiliser {{tool}} ?",
"alternativeTool": "Existe-t-il un meilleur outil pour cette tâche ?",
"share": "Puis-je partager le résultat de cet outil ?",
"findTool": "Quel outil convient à mon fichier ?",
"pdfWorkflows": "Quels sont les meilleurs outils PDF ici ?",
"imageWorkflows": "Quels outils fonctionnent avec les images et l'OCR ?"
}
},
"share": {
"shareTool": "Partager cet outil",
"shareResult": "Partager le résultat",
"toolLabel": "Lien de l'outil",
"resultLabel": "Lien de téléchargement",
"native": "Partager",
"copyLink": "Copier le lien",
"copied": "Copié",
"note": "Ne partagez les liens de téléchargement que si vous acceptez de donner à d'autres accès au fichier traité.",
"resultFallbackTitle": "Fichier traité",
"resultDescription": "Partager un accès direct à {{filename}}.",
"targets": {
"whatsapp": "WhatsApp",
"facebook": "Facebook",
"telegram": "Telegram",
"x": "X",
"linkedin": "LinkedIn",
"email": "E-mail"
}
},
"home": {
"hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément",
"heroSub": "Déposez votre fichier ici, nous détecterons automatiquement son type et proposerons les outils adaptés — édition, conversion, compression et plus. Aucune inscription requise.",
@@ -140,7 +184,7 @@
"title": "Politique de confidentialité",
"lastUpdated": "Dernière mise à jour : {{date}}",
"dataCollectionTitle": "1. Collecte de données",
"dataCollectionText": "Nous ne traitons que les fichiers que vous téléchargez intentionnellement. Nous n'exigeons pas d'inscription et aucune information personnelle n'est collectée lors du traitement des fichiers. Si vous créez un compte, nous ne stockons que votre adresse e-mail et votre mot de passe chiffré.",
"dataCollectionText": "Nous ne traitons que les fichiers que vous téléchargez intentionnellement. Nous n'exigeons pas d'inscription et aucune information personnelle n'est collectée lors du traitement des fichiers. Si vous créez un compte, nous ne stockons que votre adresse e-mail et votre mot de passe chiffré. Si vous utilisez l'assistant du site, vos messages et les réponses de l'assistant peuvent être conservés pour améliorer la qualité du support, étudier l'usage du produit et affiner l'expérience assistant.",
"fileHandlingTitle": "2. Traitement et stockage des fichiers",
"fileHandlingItems": [
"Les fichiers téléchargés sont traités sur nos serveurs sécurisés.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [
"Google AdSense — pour l'affichage de publicités",
"Google Analytics — pour les statistiques d'utilisation anonymes",
"Stockage cloud — pour le stockage temporaire chiffré des fichiers"
"Stockage cloud — pour le stockage temporaire chiffré des fichiers",
"Fournisseurs de modèles IA — pour générer les réponses de l'assistant lorsqu'elles sont activées"
],
"securityTitle": "5. Sécurité",
"securityText": "Nous employons des mesures de sécurité conformes aux normes de l'industrie, incluant le chiffrement HTTPS, la validation des fichiers, la limitation de débit, l'assainissement des entrées et le nettoyage automatique des fichiers. Toutes les données en transit sont chiffrées et les fichiers sont traités dans des environnements isolés.",

View File

@@ -122,6 +122,80 @@ export interface HistoryEntry {
created_at: string;
}
export interface AssistantHistoryMessage {
role: 'user' | 'assistant';
content: string;
}
export interface AssistantChatRequest {
message: string;
session_id?: string;
fingerprint: string;
tool_slug?: string;
page_url?: string;
locale?: string;
history?: AssistantHistoryMessage[];
}
export interface AssistantChatResponse {
session_id: string;
reply: string;
stored: boolean;
}
interface AssistantStreamHandlers {
onSession?: (sessionId: string) => void;
onChunk?: (chunk: string) => void;
}
interface AssistantStreamEvent {
event: string;
data: Record<string, unknown>;
}
function parseAssistantStreamEvent(rawEvent: string): AssistantStreamEvent | null {
const lines = rawEvent.split(/\r?\n/);
let event = 'message';
const dataLines: string[] = [];
for (const line of lines) {
if (!line) {
continue;
}
if (line.startsWith('event:')) {
event = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (!dataLines.length) {
return null;
}
return {
event,
data: JSON.parse(dataLines.join('\n')) as Record<string, unknown>,
};
}
function normalizeStreamError(status: number, bodyText: string): Error {
if (!bodyText.trim()) {
return new Error(`Request failed (${status}).`);
}
try {
const parsed = JSON.parse(bodyText) as { error?: string; message?: string };
return new Error(parsed.error || parsed.message || `Request failed (${status}).`);
} catch {
return new Error(bodyText.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
}
}
/**
* Upload a file and start a processing task.
*/
@@ -242,6 +316,104 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
return response.data;
}
/**
* Send one message to the site assistant.
*/
export async function chatWithAssistant(
payload: AssistantChatRequest
): Promise<AssistantChatResponse> {
const response = await api.post<AssistantChatResponse>('/assistant/chat', payload);
return response.data;
}
/**
* Stream one assistant response incrementally over SSE.
*/
export async function streamAssistantChat(
payload: AssistantChatRequest,
handlers: AssistantStreamHandlers = {}
): Promise<AssistantChatResponse> {
const response = await fetch('/api/assistant/chat/stream', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const bodyText = await response.text();
throw normalizeStreamError(response.status, bodyText);
}
if (!response.body) {
throw new Error('Streaming is not supported by this browser.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResponse: AssistantChatResponse | null = null;
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const rawEvent = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const parsedEvent = parseAssistantStreamEvent(rawEvent);
if (parsedEvent?.event === 'session') {
const sessionId = parsedEvent.data.session_id;
if (typeof sessionId === 'string') {
handlers.onSession?.(sessionId);
}
}
if (parsedEvent?.event === 'chunk') {
const chunk = parsedEvent.data.content;
if (typeof chunk === 'string' && chunk) {
handlers.onChunk?.(chunk);
}
}
if (parsedEvent?.event === 'done') {
const sessionId = parsedEvent.data.session_id;
const reply = parsedEvent.data.reply;
const stored = parsedEvent.data.stored;
if (
typeof sessionId === 'string' &&
typeof reply === 'string' &&
typeof stored === 'boolean'
) {
finalResponse = {
session_id: sessionId,
reply,
stored,
};
}
}
boundary = buffer.indexOf('\n\n');
}
if (done) {
break;
}
}
if (!finalResponse) {
throw new Error('Assistant stream ended unexpectedly.');
}
return finalResponse;
}
/**
* Check API health.
*/