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

@@ -8,6 +8,7 @@ from app.extensions import cors, limiter, talisman, init_celery
from app.services.account_service import init_account_db from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
def create_app(config_name=None): def create_app(config_name=None):
@@ -77,6 +78,7 @@ def create_app(config_name=None):
init_account_db() init_account_db()
init_ratings_db() init_ratings_db()
init_ai_cost_db() init_ai_cost_db()
init_site_assistant_db()
# Register blueprints # Register blueprints
from app.routes.health import health_bp from app.routes.health import health_bp
@@ -103,6 +105,7 @@ def create_app(config_name=None):
from app.routes.html_to_pdf import html_to_pdf_bp from app.routes.html_to_pdf import html_to_pdf_bp
from app.routes.pdf_ai import pdf_ai_bp from app.routes.pdf_ai import pdf_ai_bp
from app.routes.rating import rating_bp from app.routes.rating import rating_bp
from app.routes.assistant import assistant_bp
app.register_blueprint(health_bp, url_prefix="/api") app.register_blueprint(health_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(auth_bp, url_prefix="/api/auth")
@@ -128,5 +131,6 @@ def create_app(config_name=None):
app.register_blueprint(html_to_pdf_bp, url_prefix="/api/convert") app.register_blueprint(html_to_pdf_bp, url_prefix="/api/convert")
app.register_blueprint(pdf_ai_bp, url_prefix="/api/pdf-ai") app.register_blueprint(pdf_ai_bp, url_prefix="/api/pdf-ai")
app.register_blueprint(rating_bp, url_prefix="/api/ratings") app.register_blueprint(rating_bp, url_prefix="/api/ratings")
app.register_blueprint(assistant_bp, url_prefix="/api/assistant")
return app return app

View File

@@ -0,0 +1,84 @@
"""Site assistant routes — global AI helper for the product UI."""
import json
from flask import Blueprint, Response, jsonify, request, stream_with_context
from app.extensions import limiter
from app.services.policy_service import resolve_web_actor
from app.services.site_assistant_service import chat_with_site_assistant, stream_site_assistant_chat
assistant_bp = Blueprint("assistant", __name__)
def _parse_chat_payload():
payload = request.get_json(silent=True) or {}
message = str(payload.get("message", "")).strip()
if not message:
return None, (jsonify({"error": "Message is required."}), 400)
if len(message) > 4000:
return None, (jsonify({"error": "Message is too long."}), 400)
actor = resolve_web_actor()
return {
"message": message,
"session_id": str(payload.get("session_id", "")).strip() or None,
"fingerprint": str(payload.get("fingerprint", "")).strip() or "anonymous",
"tool_slug": str(payload.get("tool_slug", "")).strip(),
"page_url": str(payload.get("page_url", "")).strip(),
"locale": str(payload.get("locale", "en")).strip() or "en",
"user_id": actor.user_id,
"history": payload.get("history") if isinstance(payload.get("history"), list) else None,
}, None
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=True)}\n\n"
@assistant_bp.route("/chat", methods=["POST"])
@limiter.limit("20/minute")
def assistant_chat_route():
"""Answer a product-help message and store the conversation for later analysis."""
chat_kwargs, error_response = _parse_chat_payload()
if error_response:
return error_response
try:
result = chat_with_site_assistant(**chat_kwargs)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception:
return jsonify({"error": "Assistant is temporarily unavailable."}), 500
return jsonify(result), 200
@assistant_bp.route("/chat/stream", methods=["POST"])
@limiter.limit("20/minute")
def assistant_chat_stream_route():
"""Stream assistant replies incrementally over SSE."""
chat_kwargs, error_response = _parse_chat_payload()
if error_response:
return error_response
try:
events = stream_site_assistant_chat(**chat_kwargs)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception:
return jsonify({"error": "Assistant is temporarily unavailable."}), 500
def generate():
for event in events:
yield _sse(event["event"], event["data"])
return Response(
stream_with_context(generate()),
content_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@@ -1,17 +1,11 @@
"""AI Chat Service — OpenRouter integration for flowchart improvement.""" """AI Chat Service — OpenRouter integration for flowchart improvement."""
import os
import json import json
import logging import logging
import requests import requests
logger = logging.getLogger(__name__) from app.services.openrouter_config_service import get_openrouter_settings
# Configuration logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free")
OPENROUTER_BASE_URL = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
)
SYSTEM_PROMPT = """You are a flowchart improvement assistant. You help users improve their flowcharts by: SYSTEM_PROMPT = """You are a flowchart improvement assistant. You help users improve their flowcharts by:
1. Suggesting better step titles and descriptions 1. Suggesting better step titles and descriptions
@@ -34,7 +28,9 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
Returns: Returns:
{"reply": "...", "updated_flow": {...} | None} {"reply": "...", "updated_flow": {...} | None}
""" """
if not OPENROUTER_API_KEY: settings = get_openrouter_settings()
if not settings.api_key:
return { return {
"reply": _fallback_response(message, flow_data), "reply": _fallback_response(message, flow_data),
"updated_flow": None, "updated_flow": None,
@@ -60,13 +56,13 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
try: try:
response = requests.post( response = requests.post(
OPENROUTER_BASE_URL, settings.base_url,
headers={ headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
json={ json={
"model": OPENROUTER_MODEL, "model": settings.model,
"messages": messages, "messages": messages,
"max_tokens": 500, "max_tokens": 500,
"temperature": 0.7, "temperature": 0.7,
@@ -92,7 +88,7 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
usage = data.get("usage", {}) usage = data.get("usage", {})
log_ai_usage( log_ai_usage(
tool="flowchart_chat", tool="flowchart_chat",
model=OPENROUTER_MODEL, model=settings.model,
input_tokens=usage.get("prompt_tokens", max(1, len(message) // 4)), input_tokens=usage.get("prompt_tokens", max(1, len(message) // 4)),
output_tokens=usage.get("completion_tokens", max(1, len(reply) // 4)), output_tokens=usage.get("completion_tokens", max(1, len(reply) // 4)),
) )
@@ -146,10 +142,10 @@ def _fallback_response(message: str, flow_data: dict | None) -> str:
return ( return (
f"Your flowchart '{title}' contains {step_count} steps " f"Your flowchart '{title}' contains {step_count} steps "
f"({decision_count} decisions). To get AI-powered suggestions, " f"({decision_count} decisions). To get AI-powered suggestions, "
f"please configure the OPENROUTER_API_KEY environment variable." f"please configure OPENROUTER_API_KEY for the application."
) )
return ( return (
"AI chat requires the OPENROUTER_API_KEY to be configured. " "AI chat requires OPENROUTER_API_KEY to be configured for the application. "
"Please set up the environment variable for full AI functionality." "Set it once in the app configuration for full AI functionality."
) )

View File

@@ -0,0 +1,37 @@
"""Shared OpenRouter configuration access for all AI-enabled services."""
from dataclasses import dataclass
import os
from flask import current_app, has_app_context
DEFAULT_OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
@dataclass(frozen=True)
class OpenRouterSettings:
api_key: str
model: str
base_url: str
def get_openrouter_settings() -> OpenRouterSettings:
"""Return the effective OpenRouter settings for the current execution context."""
if has_app_context():
api_key = str(current_app.config.get("OPENROUTER_API_KEY", "")).strip()
model = str(
current_app.config.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL)
).strip() or DEFAULT_OPENROUTER_MODEL
base_url = str(
current_app.config.get("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL)
).strip() or DEFAULT_OPENROUTER_BASE_URL
return OpenRouterSettings(api_key=api_key, model=model, base_url=base_url)
return OpenRouterSettings(
api_key=os.getenv("OPENROUTER_API_KEY", "").strip(),
model=os.getenv("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL).strip()
or DEFAULT_OPENROUTER_MODEL,
base_url=os.getenv("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL).strip()
or DEFAULT_OPENROUTER_BASE_URL,
)

View File

@@ -1,18 +1,12 @@
"""PDF AI services — Chat, Summarize, Translate, Table Extract.""" """PDF AI services — Chat, Summarize, Translate, Table Extract."""
import os
import json import json
import logging import logging
import requests import requests
logger = logging.getLogger(__name__) from app.services.openrouter_config_service import get_openrouter_settings
# Configuration logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free")
OPENROUTER_BASE_URL = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
)
class PdfAiError(Exception): class PdfAiError(Exception):
@@ -58,9 +52,11 @@ def _call_openrouter(
except Exception: except Exception:
pass # Don't block if cost service unavailable pass # Don't block if cost service unavailable
if not OPENROUTER_API_KEY: settings = get_openrouter_settings()
if not settings.api_key:
raise PdfAiError( raise PdfAiError(
"AI service is not configured. Set OPENROUTER_API_KEY environment variable." "AI service is not configured. Set OPENROUTER_API_KEY in the application configuration."
) )
messages = [ messages = [
@@ -70,13 +66,13 @@ def _call_openrouter(
try: try:
response = requests.post( response = requests.post(
OPENROUTER_BASE_URL, settings.base_url,
headers={ headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
json={ json={
"model": OPENROUTER_MODEL, "model": settings.model,
"messages": messages, "messages": messages,
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": 0.5, "temperature": 0.5,
@@ -102,7 +98,7 @@ def _call_openrouter(
usage = data.get("usage", {}) usage = data.get("usage", {})
log_ai_usage( log_ai_usage(
tool=tool_name, tool=tool_name,
model=OPENROUTER_MODEL, model=settings.model,
input_tokens=usage.get("prompt_tokens", _estimate_tokens(user_message)), input_tokens=usage.get("prompt_tokens", _estimate_tokens(user_message)),
output_tokens=usage.get("completion_tokens", _estimate_tokens(reply)), output_tokens=usage.get("completion_tokens", _estimate_tokens(reply)),
) )

View File

@@ -0,0 +1,594 @@
"""Site assistant service — page-aware AI help plus persistent conversation logging."""
import json
import logging
import os
import sqlite3
import uuid
from datetime import datetime, timezone
import requests
from flask import current_app
from app.services.openrouter_config_service import get_openrouter_settings
from app.services.ai_cost_service import AiBudgetExceededError, check_ai_budget, log_ai_usage
logger = logging.getLogger(__name__)
MAX_HISTORY_MESSAGES = 8
MAX_MESSAGE_LENGTH = 4000
TOOL_CATALOG = [
{"slug": "pdf-to-word", "label": "PDF to Word", "summary": "convert PDF files into editable Word documents"},
{"slug": "word-to-pdf", "label": "Word to PDF", "summary": "turn DOC or DOCX files into PDF documents"},
{"slug": "compress-pdf", "label": "Compress PDF", "summary": "reduce PDF file size while preserving readability"},
{"slug": "merge-pdf", "label": "Merge PDF", "summary": "combine multiple PDF files into one document"},
{"slug": "split-pdf", "label": "Split PDF", "summary": "extract ranges or split one PDF into separate pages"},
{"slug": "rotate-pdf", "label": "Rotate PDF", "summary": "rotate PDF pages to the correct orientation"},
{"slug": "pdf-to-images", "label": "PDF to Images", "summary": "convert each PDF page into PNG or JPG images"},
{"slug": "images-to-pdf", "label": "Images to PDF", "summary": "combine multiple images into one PDF"},
{"slug": "watermark-pdf", "label": "Watermark PDF", "summary": "add text watermarks to PDF pages"},
{"slug": "remove-watermark-pdf", "label": "Remove Watermark", "summary": "remove supported text and image-overlay watermarks from PDFs"},
{"slug": "protect-pdf", "label": "Protect PDF", "summary": "add password protection to PDF files"},
{"slug": "unlock-pdf", "label": "Unlock PDF", "summary": "remove PDF password protection when the password is known"},
{"slug": "page-numbers", "label": "Page Numbers", "summary": "add page numbers in different positions"},
{"slug": "pdf-editor", "label": "PDF Editor", "summary": "optimize and clean PDF copies"},
{"slug": "pdf-flowchart", "label": "PDF Flowchart", "summary": "analyze PDF procedures and turn them into flowcharts"},
{"slug": "pdf-to-excel", "label": "PDF to Excel", "summary": "extract structured table data into spreadsheet files"},
{"slug": "html-to-pdf", "label": "HTML to PDF", "summary": "convert HTML documents into PDF"},
{"slug": "reorder-pdf", "label": "Reorder PDF", "summary": "rearrange PDF pages using a full page order"},
{"slug": "extract-pages", "label": "Extract Pages", "summary": "create a PDF from selected pages"},
{"slug": "chat-pdf", "label": "Chat with PDF", "summary": "ask questions about one uploaded PDF"},
{"slug": "summarize-pdf", "label": "Summarize PDF", "summary": "generate a concise summary of one PDF"},
{"slug": "translate-pdf", "label": "Translate PDF", "summary": "translate PDF content into another language"},
{"slug": "extract-tables", "label": "Extract Tables", "summary": "find tables in a PDF and export them"},
{"slug": "image-converter", "label": "Image Converter", "summary": "convert images between common formats"},
{"slug": "image-resize", "label": "Image Resize", "summary": "resize images to exact dimensions"},
{"slug": "compress-image", "label": "Compress Image", "summary": "reduce image file size"},
{"slug": "ocr", "label": "OCR", "summary": "extract text from image or scanned PDF content"},
{"slug": "remove-background", "label": "Remove Background", "summary": "remove image backgrounds automatically"},
{"slug": "qr-code", "label": "QR Code", "summary": "generate QR codes from text or URLs"},
{"slug": "video-to-gif", "label": "Video to GIF", "summary": "convert short videos into GIF animations"},
{"slug": "word-counter", "label": "Word Counter", "summary": "count words, characters, and reading metrics"},
{"slug": "text-cleaner", "label": "Text Cleaner", "summary": "clean up text spacing and formatting"},
]
SYSTEM_PROMPT = """You are the SaaS-PDF site assistant.
You help users choose the right tool, understand how to use the current tool, and explain site capabilities.
Rules:
- Reply in the same language as the user.
- Keep answers practical and concise.
- Prefer recommending existing site tools over generic outside advice.
- If the user is already on a tool page, explain that tool first, then mention alternatives only when useful.
- Never claim to process or access a file unless the current tool explicitly supports file upload.
- When the user asks what tool to use, recommend 1-3 tools max and explain why.
- If the user asks about sharing or privacy, explain that download links may expire and users should avoid sharing sensitive files publicly.
"""
def _connect() -> sqlite3.Connection:
db_path = current_app.config["DATABASE_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_site_assistant_db() -> None:
"""Create assistant conversation tables if they do not exist."""
with _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS assistant_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
fingerprint TEXT NOT NULL,
tool_slug TEXT DEFAULT '',
page_url TEXT DEFAULT '',
locale TEXT DEFAULT 'en',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assistant_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
tool_slug TEXT DEFAULT '',
page_url TEXT DEFAULT '',
locale TEXT DEFAULT 'en',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES assistant_conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_assistant_conversations_user_id
ON assistant_conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_assistant_messages_conversation_id
ON assistant_messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_assistant_messages_created_at
ON assistant_messages(created_at);
"""
)
def chat_with_site_assistant(
message: str,
session_id: str | None,
fingerprint: str,
tool_slug: str = "",
page_url: str = "",
locale: str = "en",
user_id: int | None = None,
history: list[dict] | None = None,
) -> dict:
"""Generate an assistant reply and persist both sides of the conversation."""
prepared = _prepare_chat_request(
message=message,
session_id=session_id,
fingerprint=fingerprint,
tool_slug=tool_slug,
page_url=page_url,
locale=locale,
user_id=user_id,
history=history,
)
normalized_message = prepared["message"]
normalized_session_id = prepared["session_id"]
normalized_tool_slug = prepared["tool_slug"]
normalized_page_url = prepared["page_url"]
normalized_locale = prepared["locale"]
normalized_fingerprint = prepared["fingerprint"]
normalized_history = prepared["history"]
conversation_id = prepared["conversation_id"]
try:
check_ai_budget()
reply = _request_ai_reply(
message=normalized_message,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
history=normalized_history,
)
except AiBudgetExceededError:
reply = _fallback_reply(normalized_message, normalized_tool_slug)
except Exception as exc:
logger.warning("Site assistant fallback triggered: %s", exc)
reply = _fallback_reply(normalized_message, normalized_tool_slug)
_record_message(
conversation_id=conversation_id,
role="user",
content=normalized_message,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
metadata={"fingerprint": normalized_fingerprint},
)
_record_message(
conversation_id=conversation_id,
role="assistant",
content=reply,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
metadata={"model": _response_model_name()},
)
return {
"session_id": normalized_session_id,
"reply": reply,
"stored": True,
}
def stream_site_assistant_chat(
message: str,
session_id: str | None,
fingerprint: str,
tool_slug: str = "",
page_url: str = "",
locale: str = "en",
user_id: int | None = None,
history: list[dict] | None = None,
):
"""Yield assistant response events incrementally for SSE clients."""
prepared = _prepare_chat_request(
message=message,
session_id=session_id,
fingerprint=fingerprint,
tool_slug=tool_slug,
page_url=page_url,
locale=locale,
user_id=user_id,
history=history,
)
normalized_message = prepared["message"]
normalized_session_id = prepared["session_id"]
normalized_tool_slug = prepared["tool_slug"]
normalized_page_url = prepared["page_url"]
normalized_locale = prepared["locale"]
normalized_fingerprint = prepared["fingerprint"]
normalized_history = prepared["history"]
conversation_id = prepared["conversation_id"]
def generate_events():
yield {"event": "session", "data": {"session_id": normalized_session_id}}
_record_message(
conversation_id=conversation_id,
role="user",
content=normalized_message,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
metadata={"fingerprint": normalized_fingerprint},
)
reply = ""
response_model = "fallback"
try:
check_ai_budget()
settings = get_openrouter_settings()
if not settings.api_key:
raise RuntimeError("OPENROUTER_API_KEY is not configured for the application.")
response_model = settings.model
messages = _build_ai_messages(
message=normalized_message,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
history=normalized_history,
)
for chunk in _stream_ai_reply(messages=messages, settings=settings):
if not chunk:
continue
reply += chunk
yield {"event": "chunk", "data": {"content": chunk}}
if not reply.strip():
raise RuntimeError("Assistant returned an empty reply.")
log_ai_usage(
tool="site_assistant",
model=settings.model,
input_tokens=max(1, len(normalized_message) // 4),
output_tokens=max(1, len(reply) // 4),
)
except AiBudgetExceededError:
reply = _fallback_reply(normalized_message, normalized_tool_slug)
yield {"event": "chunk", "data": {"content": reply}}
except Exception as exc:
logger.warning("Site assistant streaming fallback triggered: %s", exc)
if not reply.strip():
reply = _fallback_reply(normalized_message, normalized_tool_slug)
yield {"event": "chunk", "data": {"content": reply}}
response_model = "fallback"
_record_message(
conversation_id=conversation_id,
role="assistant",
content=reply,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
metadata={"model": response_model},
)
yield {
"event": "done",
"data": {
"session_id": normalized_session_id,
"reply": reply,
"stored": True,
},
}
return generate_events()
def _ensure_conversation(
session_id: str,
user_id: int | None,
fingerprint: str,
tool_slug: str,
page_url: str,
locale: str,
) -> int:
now = _utc_now()
with _connect() as conn:
row = conn.execute(
"SELECT id FROM assistant_conversations WHERE session_id = ?",
(session_id,),
).fetchone()
if row is not None:
conn.execute(
"""
UPDATE assistant_conversations
SET user_id = ?, fingerprint = ?, tool_slug = ?, page_url = ?, locale = ?, updated_at = ?
WHERE id = ?
""",
(user_id, fingerprint, tool_slug, page_url, locale, now, row["id"]),
)
return int(row["id"])
cursor = conn.execute(
"""
INSERT INTO assistant_conversations (
session_id, user_id, fingerprint, tool_slug, page_url, locale, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(session_id, user_id, fingerprint, tool_slug, page_url, locale, now, now),
)
return int(cursor.lastrowid)
def _record_message(
conversation_id: int,
role: str,
content: str,
tool_slug: str,
page_url: str,
locale: str,
metadata: dict | None = None,
) -> None:
with _connect() as conn:
conn.execute(
"""
INSERT INTO assistant_messages (
conversation_id, role, content, tool_slug, page_url, locale, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
conversation_id,
role,
content,
tool_slug,
page_url,
locale,
json.dumps(metadata or {}, ensure_ascii=True),
_utc_now(),
),
)
def _prepare_chat_request(
message: str,
session_id: str | None,
fingerprint: str,
tool_slug: str,
page_url: str,
locale: str,
user_id: int | None,
history: list[dict] | None,
) -> dict:
normalized_message = (message or "").strip()[:MAX_MESSAGE_LENGTH]
if not normalized_message:
raise ValueError("Message is required.")
normalized_session_id = (session_id or "").strip() or str(uuid.uuid4())
normalized_tool_slug = (tool_slug or "").strip()[:120]
normalized_page_url = (page_url or "").strip()[:500]
normalized_locale = (locale or "en").strip()[:16] or "en"
normalized_fingerprint = (fingerprint or "anonymous").strip()[:120] or "anonymous"
normalized_history = _normalize_history(history)
conversation_id = _ensure_conversation(
session_id=normalized_session_id,
user_id=user_id,
fingerprint=normalized_fingerprint,
tool_slug=normalized_tool_slug,
page_url=normalized_page_url,
locale=normalized_locale,
)
return {
"message": normalized_message,
"session_id": normalized_session_id,
"tool_slug": normalized_tool_slug,
"page_url": normalized_page_url,
"locale": normalized_locale,
"fingerprint": normalized_fingerprint,
"history": normalized_history,
"conversation_id": conversation_id,
}
def _normalize_history(history: list[dict] | None) -> list[dict[str, str]]:
normalized: list[dict[str, str]] = []
for item in history or []:
if not isinstance(item, dict):
continue
role = str(item.get("role", "")).strip().lower()
content = str(item.get("content", "")).strip()[:MAX_MESSAGE_LENGTH]
if role not in {"user", "assistant"} or not content:
continue
normalized.append({"role": role, "content": content})
return normalized[-MAX_HISTORY_MESSAGES:]
def _request_ai_reply(
message: str,
tool_slug: str,
page_url: str,
locale: str,
history: list[dict[str, str]],
) -> str:
settings = get_openrouter_settings()
if not settings.api_key:
raise RuntimeError("OPENROUTER_API_KEY is not configured for the application.")
messages = _build_ai_messages(
message=message,
tool_slug=tool_slug,
page_url=page_url,
locale=locale,
history=history,
)
response = requests.post(
settings.base_url,
headers={
"Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json",
},
json={
"model": settings.model,
"messages": messages,
"max_tokens": 400,
"temperature": 0.3,
},
timeout=30,
)
response.raise_for_status()
data = response.json()
reply = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
if not reply:
raise RuntimeError("Assistant returned an empty reply.")
usage = data.get("usage", {})
log_ai_usage(
tool="site_assistant",
model=settings.model,
input_tokens=usage.get("prompt_tokens", max(1, len(message) // 4)),
output_tokens=usage.get("completion_tokens", max(1, len(reply) // 4)),
)
return reply
def _build_ai_messages(
message: str,
tool_slug: str,
page_url: str,
locale: str,
history: list[dict[str, str]],
) -> list[dict[str, str]]:
context_lines = [
f"Current locale: {locale or 'en'}",
f"Current tool slug: {tool_slug or 'none'}",
f"Current page URL: {page_url or 'unknown'}",
"Available tools:",
]
context_lines.extend(
f"- {tool['label']} ({tool['slug']}): {tool['summary']}"
for tool in TOOL_CATALOG
)
context = "\n".join(context_lines)
messages: list[dict[str, str]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": context},
]
messages.extend(history)
messages.append({"role": "user", "content": message})
return messages
def _stream_ai_reply(messages: list[dict[str, str]], settings):
response = requests.post(
settings.base_url,
headers={
"Authorization": f"Bearer {settings.api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json={
"model": settings.model,
"messages": messages,
"max_tokens": 400,
"temperature": 0.3,
"stream": True,
},
timeout=60,
stream=True,
)
response.raise_for_status()
try:
for raw_line in response.iter_lines(decode_unicode=True):
if not raw_line:
continue
line = raw_line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if not payload or payload == "[DONE]":
continue
try:
data = json.loads(payload)
except json.JSONDecodeError:
continue
choices = data.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if isinstance(content, str) and content:
yield content
finally:
response.close()
def _fallback_reply(message: str, tool_slug: str) -> str:
msg_lower = message.lower()
if any(keyword in msg_lower for keyword in ["merge", "combine", "دمج", "جمع"]):
return "إذا كنت تريد جمع أكثر من ملف PDF في ملف واحد فابدأ بأداة Merge PDF. إذا كنت تريد فقط إعادة ترتيب الصفحات داخل ملف واحد فاستخدم Reorder PDF."
if any(keyword in msg_lower for keyword in ["split", "extract", "قس", "استخراج"]):
return "لإخراج صفحات محددة في ملف جديد استخدم Extract Pages، أما إذا أردت تقسيم الملف إلى صفحات أو نطاقات متعددة فاستخدم Split PDF."
if any(keyword in msg_lower for keyword in ["watermark", "علامة", "filigrane"]):
return "إذا أردت إضافة علامة مائية فاستخدم Watermark PDF. إذا أردت إزالة علامة موجودة فاستخدم Remove Watermark، مع ملاحظة أن العلامات المسطحة أو المدمجة بعمق قد لا تكون قابلة للإزالة دائماً."
if tool_slug:
tool = next((item for item in TOOL_CATALOG if item["slug"] == tool_slug), None)
if tool:
return (
f"أنت الآن على أداة {tool['label']}. هذه الأداة تُستخدم لـ {tool['summary']}. "
"إذا وصفت لي ما تريد فعله بالتحديد فسأرشدك إلى الخطوات أو إلى أداة أنسب داخل الموقع."
)
return (
"أستطيع مساعدتك في اختيار الأداة المناسبة داخل الموقع أو شرح طريقة استخدامها. "
"اذكر لي الهدف الذي تريد الوصول إليه مثل ضغط PDF أو إزالة الخلفية أو ترجمة ملف PDF، وسأقترح الأداة المناسبة مباشرة."
)
def _response_model_name() -> str:
settings = get_openrouter_settings()
return settings.model if settings.api_key else "fallback"

Binary file not shown.

View File

@@ -80,7 +80,7 @@ class BaseConfig:
RATELIMIT_DEFAULT = "100/hour" RATELIMIT_DEFAULT = "100/hour"
# OpenRouter AI # OpenRouter AI
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "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") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free") OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free")
OPENROUTER_BASE_URL = os.getenv( OPENROUTER_BASE_URL = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions" "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"

View File

@@ -8,6 +8,7 @@ from app import create_app
from app.services.account_service import init_account_db from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db from app.services.ai_cost_service import init_ai_cost_db
from app.services.site_assistant_service import init_site_assistant_db
@pytest.fixture @pytest.fixture
@@ -33,6 +34,7 @@ def app():
init_account_db() init_account_db()
init_ratings_db() init_ratings_db()
init_ai_cost_db() init_ai_cost_db()
init_site_assistant_db()
# Create temp directories # Create temp directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

View File

@@ -0,0 +1,71 @@
"""Tests for the site assistant API route."""
import json
class TestAssistantRoute:
def test_requires_message(self, client):
response = client.post('/api/assistant/chat', json={})
assert response.status_code == 400
assert response.get_json()['error'] == 'Message is required.'
def test_success_returns_reply_and_session(self, client, monkeypatch):
monkeypatch.setattr(
'app.routes.assistant.chat_with_site_assistant',
lambda **kwargs: {
'session_id': kwargs['session_id'] or 'assistant-session-1',
'reply': 'Use Merge PDF for combining files.',
'stored': True,
},
)
response = client.post(
'/api/assistant/chat',
json={
'message': 'How do I combine files?',
'fingerprint': 'visitor-1',
'tool_slug': 'merge-pdf',
},
)
assert response.status_code == 200
body = response.get_json()
assert body['stored'] is True
assert body['reply'] == 'Use Merge PDF for combining files.'
assert body['session_id']
def test_stream_returns_sse_events(self, client, monkeypatch):
monkeypatch.setattr(
'app.routes.assistant.stream_site_assistant_chat',
lambda **kwargs: iter([
{'event': 'session', 'data': {'session_id': 'assistant-session-1'}},
{'event': 'chunk', 'data': {'content': 'Use Merge '}},
{'event': 'chunk', 'data': {'content': 'PDF.'}},
{
'event': 'done',
'data': {
'session_id': 'assistant-session-1',
'reply': 'Use Merge PDF.',
'stored': True,
},
},
]),
)
response = client.post(
'/api/assistant/chat/stream',
json={
'message': 'How do I combine files?',
'fingerprint': 'visitor-1',
'tool_slug': 'merge-pdf',
},
)
assert response.status_code == 200
assert response.headers['Content-Type'].startswith('text/event-stream')
body = response.get_data(as_text=True)
assert 'event: session' in body
assert f"data: {json.dumps({'session_id': 'assistant-session-1'})}" in body
assert 'event: chunk' in body
assert 'Use Merge PDF.' in body

View File

@@ -0,0 +1,119 @@
"""Tests for shared OpenRouter configuration resolution across AI services."""
from app.services.openrouter_config_service import get_openrouter_settings
from app.services.pdf_ai_service import _call_openrouter
from app.services.site_assistant_service import _request_ai_reply
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class TestOpenRouterConfigService:
def test_prefers_flask_config_when_app_context_exists(self, app, monkeypatch):
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
monkeypatch.setenv('OPENROUTER_BASE_URL', 'https://env.example/api')
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
'OPENROUTER_BASE_URL': 'https://config.example/api',
})
settings = get_openrouter_settings()
assert settings.api_key == 'config-key'
assert settings.model == 'config-model'
assert settings.base_url == 'https://config.example/api'
def test_falls_back_to_environment_without_app_context(self, monkeypatch):
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
monkeypatch.setenv('OPENROUTER_BASE_URL', 'https://env.example/api')
settings = get_openrouter_settings()
assert settings.api_key == 'env-key'
assert settings.model == 'env-model'
assert settings.base_url == 'https://env.example/api'
class TestAiServicesUseSharedConfig:
def test_pdf_ai_uses_flask_config(self, app, monkeypatch):
captured = {}
monkeypatch.setattr('app.services.ai_cost_service.check_ai_budget', lambda: None)
monkeypatch.setattr('app.services.ai_cost_service.log_ai_usage', lambda **kwargs: captured.setdefault('usage', kwargs))
def fake_post(url, headers, json, timeout):
captured['url'] = url
captured['headers'] = headers
captured['json'] = json
captured['timeout'] = timeout
return _FakeResponse({
'choices': [{'message': {'content': 'Configured PDF reply'}}],
'usage': {'prompt_tokens': 11, 'completion_tokens': 7},
})
monkeypatch.setattr('app.services.pdf_ai_service.requests.post', fake_post)
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
'OPENROUTER_BASE_URL': 'https://config.example/pdf-ai',
})
reply = _call_openrouter('system prompt', 'user question', max_tokens=321, tool_name='pdf_chat')
assert reply == 'Configured PDF reply'
assert captured['url'] == 'https://config.example/pdf-ai'
assert captured['headers']['Authorization'] == 'Bearer config-key'
assert captured['json']['model'] == 'config-model'
assert captured['json']['max_tokens'] == 321
assert captured['usage']['model'] == 'config-model'
def test_site_assistant_uses_flask_config(self, app, monkeypatch):
captured = {}
monkeypatch.setattr('app.services.site_assistant_service.log_ai_usage', lambda **kwargs: captured.setdefault('usage', kwargs))
def fake_post(url, headers, json, timeout):
captured['url'] = url
captured['headers'] = headers
captured['json'] = json
captured['timeout'] = timeout
return _FakeResponse({
'choices': [{'message': {'content': 'Configured assistant reply'}}],
'usage': {'prompt_tokens': 13, 'completion_tokens': 9},
})
monkeypatch.setattr('app.services.site_assistant_service.requests.post', fake_post)
with app.app_context():
app.config.update({
'OPENROUTER_API_KEY': 'assistant-key',
'OPENROUTER_MODEL': 'assistant-model',
'OPENROUTER_BASE_URL': 'https://config.example/assistant',
})
reply = _request_ai_reply(
message='How do I merge files?',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
history=[{'role': 'assistant', 'content': 'Previous reply'}],
)
assert reply == 'Configured assistant reply'
assert captured['url'] == 'https://config.example/assistant'
assert captured['headers']['Authorization'] == 'Bearer assistant-key'
assert captured['json']['model'] == 'assistant-model'
assert captured['json']['messages'][-1] == {'role': 'user', 'content': 'How do I merge files?'}
assert captured['usage']['model'] == 'assistant-model'

View File

@@ -0,0 +1,105 @@
"""Tests for site assistant persistence and fallback behavior."""
import json
import sqlite3
from app.services.site_assistant_service import chat_with_site_assistant, stream_site_assistant_chat
class TestSiteAssistantService:
def test_chat_persists_conversation_and_messages(self, app, monkeypatch):
with app.app_context():
monkeypatch.setattr(
'app.services.site_assistant_service._request_ai_reply',
lambda **kwargs: 'Use Merge PDF if you want one combined document.',
)
result = chat_with_site_assistant(
message='How can I combine PDF files?',
session_id='assistant-session-123',
fingerprint='visitor-123',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
user_id=None,
history=[{'role': 'user', 'content': 'Hello'}],
)
assert result['stored'] is True
assert result['session_id'] == 'assistant-session-123'
assert 'Merge PDF' in result['reply']
connection = sqlite3.connect(app.config['DATABASE_PATH'])
connection.row_factory = sqlite3.Row
conversation = connection.execute(
'SELECT session_id, fingerprint, tool_slug, locale FROM assistant_conversations WHERE session_id = ?',
('assistant-session-123',),
).fetchone()
messages = connection.execute(
'SELECT role, content FROM assistant_messages ORDER BY id ASC'
).fetchall()
assert conversation['fingerprint'] == 'visitor-123'
assert conversation['tool_slug'] == 'merge-pdf'
assert conversation['locale'] == 'en'
assert [row['role'] for row in messages] == ['user', 'assistant']
assert 'How can I combine PDF files?' in messages[0]['content']
assert 'Merge PDF' in messages[1]['content']
def test_stream_chat_persists_streamed_reply(self, app, monkeypatch):
class FakeStreamResponse:
def raise_for_status(self):
return None
def iter_lines(self, decode_unicode=True):
yield 'data: ' + json.dumps({
'choices': [{'delta': {'content': 'Use Merge '}}],
})
yield 'data: ' + json.dumps({
'choices': [{'delta': {'content': 'PDF for this.'}}],
})
yield 'data: [DONE]'
def close(self):
return None
with app.app_context():
monkeypatch.setattr(
'app.services.site_assistant_service.check_ai_budget',
lambda: None,
)
monkeypatch.setattr(
'app.services.site_assistant_service.requests.post',
lambda *args, **kwargs: FakeStreamResponse(),
)
app.config.update({
'OPENROUTER_API_KEY': 'config-key',
'OPENROUTER_MODEL': 'config-model',
})
events = list(stream_site_assistant_chat(
message='How can I combine PDF files?',
session_id='assistant-stream-123',
fingerprint='visitor-123',
tool_slug='merge-pdf',
page_url='https://example.com/tools/merge-pdf',
locale='en',
user_id=None,
history=[{'role': 'assistant', 'content': 'Hello'}],
))
assert events[0]['event'] == 'session'
assert events[1]['event'] == 'chunk'
assert events[2]['event'] == 'chunk'
assert events[-1]['event'] == 'done'
assert events[-1]['data']['reply'] == 'Use Merge PDF for this.'
connection = sqlite3.connect(app.config['DATABASE_PATH'])
connection.row_factory = sqlite3.Row
messages = connection.execute(
'SELECT role, content, metadata_json FROM assistant_messages ORDER BY id ASC'
).fetchall()
assert [row['role'] for row in messages] == ['user', 'assistant']
assert messages[1]['content'] == 'Use Merge PDF for this.'
assert 'config-model' in messages[1]['metadata_json']

View File

@@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom';
import Header from '@/components/layout/Header'; import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer'; import Footer from '@/components/layout/Footer';
import CookieConsent from '@/components/layout/CookieConsent'; import CookieConsent from '@/components/layout/CookieConsent';
import SiteAssistant from '@/components/layout/SiteAssistant';
import ErrorBoundary from '@/components/shared/ErrorBoundary'; import ErrorBoundary from '@/components/shared/ErrorBoundary';
import ToolLandingPage from '@/components/seo/ToolLandingPage'; import ToolLandingPage from '@/components/seo/ToolLandingPage';
import { useDirection } from '@/hooks/useDirection'; import { useDirection } from '@/hooks/useDirection';
@@ -154,6 +155,7 @@ export default function App() {
</main> </main>
<Footer /> <Footer />
<SiteAssistant />
<CookieConsent /> <CookieConsent />
</div> </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 FAQSection from './FAQSection';
import RelatedTools from './RelatedTools'; import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating'; import ToolRating from '@/components/shared/ToolRating';
import SharePanel from '@/components/shared/SharePanel';
import { useToolRating } from '@/hooks/useToolRating'; import { useToolRating } from '@/hooks/useToolRating';
import { dispatchRatingPrompt } from '@/utils/ratingPrompt'; import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
@@ -85,7 +86,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{/* Tool Interface */} {/* Tool Interface */}
{children} {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 <button
type="button" type="button"
onClick={() => dispatchRatingPrompt(slug, { forceOpen: true })} onClick={() => dispatchRatingPrompt(slug, { forceOpen: true })}

View File

@@ -4,6 +4,7 @@ import type { TaskResult } from '@/services/api';
import { formatFileSize } from '@/utils/textTools'; import { formatFileSize } from '@/utils/textTools';
import { trackEvent } from '@/services/analytics'; import { trackEvent } from '@/services/analytics';
import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt'; import { dispatchCurrentToolRatingPrompt } from '@/utils/ratingPrompt';
import SharePanel from '@/components/shared/SharePanel';
interface DownloadButtonProps { interface DownloadButtonProps {
/** Task result containing download URL */ /** Task result containing download URL */
@@ -77,6 +78,17 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
{t('common.download')} {result.filename} {t('common.download')} {result.filename}
</a> </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 */} {/* Expiry notice */}
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-500 dark:text-slate-400"> <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" /> <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": "رابط غير صالح. يرجى طلب رابط جديد." "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": { "home": {
"hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة", "hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
"heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.", "heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
@@ -140,7 +184,7 @@
"title": "سياسة الخصوصية", "title": "سياسة الخصوصية",
"lastUpdated": "آخر تحديث: {{date}}", "lastUpdated": "آخر تحديث: {{date}}",
"dataCollectionTitle": "1. جمع البيانات", "dataCollectionTitle": "1. جمع البيانات",
"dataCollectionText": "نقوم فقط بمعالجة الملفات التي ترفعها عمداً. لا نطلب التسجيل ولا نجمع معلومات شخصية أثناء معالجة الملفات. إذا أنشأت حساباً، نخزن فقط بريدك الإلكتروني وكلمة المرور المشفرة.", "dataCollectionText": "نقوم فقط بمعالجة الملفات التي ترفعها عمداً. لا نطلب التسجيل ولا نجمع معلومات شخصية أثناء معالجة الملفات. إذا أنشأت حساباً، نخزن فقط بريدك الإلكتروني وكلمة المرور المشفرة. وإذا استخدمت مساعد الموقع، فقد يتم حفظ رسائلك وردود المساعد لتحسين جودة الدعم، ودراسة استخدام المنتج، وتطوير تجربة المساعد.",
"fileHandlingTitle": "2. معالجة الملفات والتخزين", "fileHandlingTitle": "2. معالجة الملفات والتخزين",
"fileHandlingItems": [ "fileHandlingItems": [
"تتم معالجة الملفات المرفوعة على خوادمنا الآمنة.", "تتم معالجة الملفات المرفوعة على خوادمنا الآمنة.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [ "thirdPartyItems": [
"Google AdSense — لعرض الإعلانات", "Google AdSense — لعرض الإعلانات",
"Google Analytics — لإحصائيات الاستخدام المجهولة", "Google Analytics — لإحصائيات الاستخدام المجهولة",
"التخزين السحابي — لتخزين الملفات المؤقت والمشفر" "التخزين السحابي — لتخزين الملفات المؤقت والمشفر",
"مزودو نماذج الذكاء الاصطناعي — لإنشاء ردود المساعد عند تفعيلها"
], ],
"securityTitle": "5. الأمان", "securityTitle": "5. الأمان",
"securityText": "نستخدم إجراءات أمنية وفق معايير الصناعة تشمل تشفير HTTPS والتحقق من الملفات وتحديد المعدل وتعقيم المدخلات وتنظيف الملفات التلقائي. جميع البيانات أثناء النقل مشفرة وتتم معالجة الملفات في بيئات معزولة.", "securityText": "نستخدم إجراءات أمنية وفق معايير الصناعة تشمل تشفير HTTPS والتحقق من الملفات وتحديد المعدل وتعقيم المدخلات وتنظيف الملفات التلقائي. جميع البيانات أثناء النقل مشفرة وتتم معالجة الملفات في بيئات معزولة.",

View File

@@ -62,6 +62,50 @@
"noToken": "Invalid reset link. Please request a new one." "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": { "home": {
"hero": "Everything You Need to Work with PDF Files — Instantly", "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.", "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", "title": "Privacy Policy",
"lastUpdated": "Last updated: {{date}}", "lastUpdated": "Last updated: {{date}}",
"dataCollectionTitle": "1. Data Collection", "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", "fileHandlingTitle": "2. File Processing & Storage",
"fileHandlingItems": [ "fileHandlingItems": [
"Uploaded files are processed on our secure servers.", "Uploaded files are processed on our secure servers.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [ "thirdPartyItems": [
"Google AdSense — for displaying advertisements", "Google AdSense — for displaying advertisements",
"Google Analytics — for anonymous usage statistics", "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", "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.", "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." "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": { "home": {
"hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément", "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.", "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é", "title": "Politique de confidentialité",
"lastUpdated": "Dernière mise à jour : {{date}}", "lastUpdated": "Dernière mise à jour : {{date}}",
"dataCollectionTitle": "1. Collecte de données", "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", "fileHandlingTitle": "2. Traitement et stockage des fichiers",
"fileHandlingItems": [ "fileHandlingItems": [
"Les fichiers téléchargés sont traités sur nos serveurs sécurisés.", "Les fichiers téléchargés sont traités sur nos serveurs sécurisés.",
@@ -155,7 +199,8 @@
"thirdPartyItems": [ "thirdPartyItems": [
"Google AdSense — pour l'affichage de publicités", "Google AdSense — pour l'affichage de publicités",
"Google Analytics — pour les statistiques d'utilisation anonymes", "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é", "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.", "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; 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. * Upload a file and start a processing task.
*/ */
@@ -242,6 +316,104 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
return response.data; 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. * Check API health.
*/ */