feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
No files matched your search
@@ -0,0 +1,704 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, Header, Query, status
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.widget import Widget
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.repositories.widget import WidgetRepository
|
||||||
|
from app.repositories.agent import AgentRepository
|
||||||
|
from app.core.security import create_conversation_token, verify_conversation_token
|
||||||
|
from app.services import conversation_token
|
||||||
|
from app.utils.client_ip import forwarded_client_ip
|
||||||
|
from app.repositories.customer import CustomerRepository
|
||||||
|
from app.models.schemas.widget import WidgetCreate, WidgetResponse
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.repositories.session_to_agent import SessionToAgentRepository, session_in_scope
|
||||||
|
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
|
||||||
|
from app.services.widget_identity import may_stand_for
|
||||||
|
from app.models.session_to_agent import SessionStatus
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.s3 import get_s3_signed_url
|
||||||
|
from app.utils.business_hours import is_within_business_hours
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# The enterprise module can record where each widget is live; without it,
|
||||||
|
# nothing is recorded.
|
||||||
|
try:
|
||||||
|
from app.enterprise.services import widget_install
|
||||||
|
except ImportError:
|
||||||
|
widget_install = None
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_expired() -> HTTPException:
|
||||||
|
"""401 telling the widget its visitor's identity lapsed, not that it never had one.
|
||||||
|
|
||||||
|
The embed loader answers this by asking the host page for a fresh token
|
||||||
|
(`tokenProvider`) and retrying, so an identified visitor is never silently
|
||||||
|
re-created as an anonymous customer.
|
||||||
|
"""
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"code": conversation_token.IDENTITY_EXPIRED_CODE,
|
||||||
|
"message": "Conversation token expired. Issue a new one with /generate-token.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ASSETS_DIR = Path(settings.WIDGET_ASSETS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_version() -> str:
|
||||||
|
"""Cache-buster for the widget bundle URLs.
|
||||||
|
|
||||||
|
/assets/widget.js is served without a Cache-Control header and its URL never
|
||||||
|
changes, so browsers fall back to heuristic caching and can keep serving an old
|
||||||
|
bundle for hours after a release — the widget silently runs last week's code.
|
||||||
|
Stamping the build's mtime+size onto the URL makes a new build a new URL.
|
||||||
|
Recomputed per request (a stat is microseconds) so a rebuild takes effect
|
||||||
|
without restarting the backend.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stat = (_ASSETS_DIR / "widget.js").stat()
|
||||||
|
return f"{int(stat.st_mtime)}-{stat.st_size}"
|
||||||
|
except OSError:
|
||||||
|
# Assets served from somewhere else (CDN, dev server) — nothing to stamp.
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _widget_html_response(html: str) -> HTMLResponse:
|
||||||
|
"""The widget HTML embeds a conversation token and the current customization,
|
||||||
|
and points at a versioned bundle URL. Caching it would hand a visitor someone
|
||||||
|
else's token from a shared cache, and would keep pinning them to a stale widget
|
||||||
|
build after a release."""
|
||||||
|
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
# BACKEND_URL defaults to http://localhost:8000 and neither compose file sets
|
||||||
|
# it, so a self-hosted install that never overrode it used to serve every visitor
|
||||||
|
# a widget whose socket dialled the visitor's own machine (#315). Loopback can
|
||||||
|
# never be right for a browser on someone else's computer, so we fall back to the
|
||||||
|
# origin the widget HTML was actually fetched over.
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"})
|
||||||
|
|
||||||
|
_warned_about_loopback_backend_url = False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loopback(url: str) -> bool:
|
||||||
|
host = urlparse(url).hostname
|
||||||
|
return host is not None and host.lower() in _LOOPBACK_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def _public_origin(request: Optional[Request]) -> Optional[str]:
|
||||||
|
"""Scheme and host this request reached us on, as the browser sees them.
|
||||||
|
|
||||||
|
Reads the forwarding headers our nginx sets rather than ``request.url``, which
|
||||||
|
only reflects them when the ASGI server is started with proxy headers enabled.
|
||||||
|
"""
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Several proxies in a chain append to these, and the browser's own value is
|
||||||
|
# the first entry.
|
||||||
|
forwarded_host = request.headers.get("x-forwarded-host")
|
||||||
|
host = (forwarded_host or request.headers.get("host") or "").split(",")[0].strip()
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||||
|
if forwarded_proto:
|
||||||
|
scheme = forwarded_proto.split(",")[0].strip()
|
||||||
|
elif forwarded_host:
|
||||||
|
# A proxy that rewrites the host but not the protocol (Traefik and some
|
||||||
|
# Caddy setups, unlike our nginx) leaves request.url.scheme reading "http"
|
||||||
|
# for the proxy's own hop. Trusting that would hand an https page a ws://
|
||||||
|
# socket the browser refuses as mixed content, so assume the hop was TLS.
|
||||||
|
scheme = "https"
|
||||||
|
else:
|
||||||
|
scheme = request.url.scheme or "https"
|
||||||
|
|
||||||
|
return f"{scheme}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def _widget_runtime_config(request: Optional[Request] = None) -> dict:
|
||||||
|
"""Runtime API/WS URLs to hand the widget iframe as ``window.APP_CONFIG``.
|
||||||
|
|
||||||
|
Derived from ``BACKEND_URL`` so a self-hosted widget talks to the configured
|
||||||
|
backend instead of the vendor cloud. On the hosted deployment BACKEND_URL is
|
||||||
|
``https://api.replypier.site``, which yields exactly the widget's baked-in
|
||||||
|
defaults, so injecting this is a no-op there.
|
||||||
|
"""
|
||||||
|
global _warned_about_loopback_backend_url
|
||||||
|
|
||||||
|
api_base = settings.BACKEND_URL.rstrip("/")
|
||||||
|
if _is_loopback(api_base):
|
||||||
|
derived = _public_origin(request)
|
||||||
|
if derived and not _is_loopback(derived):
|
||||||
|
if not _warned_about_loopback_backend_url:
|
||||||
|
_warned_about_loopback_backend_url = True
|
||||||
|
logger.warning(
|
||||||
|
"BACKEND_URL is %s, which no visitor's browser can reach. "
|
||||||
|
"Serving widgets against %s instead, taken from the request. "
|
||||||
|
"Set BACKEND_URL to this install's public API URL.",
|
||||||
|
api_base, derived,
|
||||||
|
)
|
||||||
|
api_base = derived
|
||||||
|
|
||||||
|
if api_base.startswith("https://"):
|
||||||
|
ws_url = "wss://" + api_base[len("https://"):]
|
||||||
|
elif api_base.startswith("http://"):
|
||||||
|
ws_url = "ws://" + api_base[len("http://"):]
|
||||||
|
else:
|
||||||
|
ws_url = api_base
|
||||||
|
return {"API_URL": f"{api_base}/api/v1", "WS_URL": ws_url}
|
||||||
|
|
||||||
|
@router.post("", response_model=WidgetResponse)
|
||||||
|
def create_new_widget(
|
||||||
|
widget: WidgetCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Create a new widget for the organization"""
|
||||||
|
widget_repo = WidgetRepository(db)
|
||||||
|
return widget_repo.create_widget(widget, current_user.organization_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _note_install(db: Session, widget: Widget, request: Request, background_tasks: BackgroundTasks) -> None:
|
||||||
|
"""Note where the widget is live (or blocked), after the response is
|
||||||
|
sent. Never lets a tracking error break the widget itself."""
|
||||||
|
if widget_install is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sighting = widget_install.claim_write(db, widget, request.headers.get("origin"), forwarded_client_ip(request))
|
||||||
|
if sighting:
|
||||||
|
background_tasks.add_task(widget_install.record, widget.id, sighting)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Install tracking failed for widget {widget.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{widget_id}/data", response_class=HTMLResponse)
|
||||||
|
async def get_widget_ui(
|
||||||
|
widget_id: str,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get widget UI and handle customer authentication"""
|
||||||
|
|
||||||
|
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found")
|
||||||
|
|
||||||
|
_note_install(db, widget, request, background_tasks)
|
||||||
|
|
||||||
|
# Get agent data
|
||||||
|
agent_repo = AgentRepository(db)
|
||||||
|
agent = agent_repo.get_by_id(widget.agent_id)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
|
||||||
|
# What the header may honestly claim. Only a human-handled agent needs the
|
||||||
|
# business-hours check: while the AI answers, it answers at 3am too.
|
||||||
|
presence = {
|
||||||
|
'mode': 'ai' if agent.ai_replies_enabled else 'human',
|
||||||
|
'available': True if agent.ai_replies_enabled
|
||||||
|
else is_within_business_hours(agent.organization),
|
||||||
|
}
|
||||||
|
|
||||||
|
require_token_auth = getattr(agent, 'require_token_auth', False)
|
||||||
|
customer_id = None
|
||||||
|
token = None
|
||||||
|
token_payload = None
|
||||||
|
|
||||||
|
# Try to validate existing token if provided
|
||||||
|
if authorization and authorization.startswith('Bearer '):
|
||||||
|
token = authorization.split(' ')[1]
|
||||||
|
state = conversation_token.inspect(token, widget_id)
|
||||||
|
if state.is_live:
|
||||||
|
token_payload = state.payload
|
||||||
|
customer_id = state.payload.get("sub")
|
||||||
|
elif state.identity_lost:
|
||||||
|
# A lapsed token that named a customer means a signed-in visitor whose
|
||||||
|
# identity just ran out. Say so instead of rendering the frame as a
|
||||||
|
# brand-new anonymous visitor - the loader re-identifies and retries.
|
||||||
|
raise _identity_expired()
|
||||||
|
else:
|
||||||
|
token = None # Invalid token
|
||||||
|
|
||||||
|
# SECURITY: If token auth is required, valid token MUST be provided
|
||||||
|
if require_token_auth:
|
||||||
|
if not token or customer_id is None:
|
||||||
|
logger.warning(f"Widget UI request denied: require_token_auth=true but no valid token for widget_id={widget_id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required. Token must be obtained from /api/v1/generate-token endpoint with valid API key."
|
||||||
|
)
|
||||||
|
# Has valid token - return widget with existing token
|
||||||
|
return _widget_html_response(await get_widget_html(
|
||||||
|
widget_id=widget_id,
|
||||||
|
agent_name=agent.display_name or agent.name,
|
||||||
|
agent_customization=agent.customization,
|
||||||
|
customer_id=customer_id,
|
||||||
|
initial_token=token,
|
||||||
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||||
|
allow_attachments=agent.allow_attachments,
|
||||||
|
ai_replies_enabled=agent.ai_replies_enabled,
|
||||||
|
presence=presence,
|
||||||
|
request=request
|
||||||
|
))
|
||||||
|
|
||||||
|
# Token auth NOT required - allow anonymous access
|
||||||
|
# If we have a valid token, use it; otherwise create a new one
|
||||||
|
if token and customer_id:
|
||||||
|
return _widget_html_response(await get_widget_html(
|
||||||
|
widget_id=widget_id,
|
||||||
|
agent_name=agent.display_name or agent.name,
|
||||||
|
agent_customization=agent.customization,
|
||||||
|
customer_id=customer_id,
|
||||||
|
initial_token=token,
|
||||||
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||||
|
allow_attachments=agent.allow_attachments,
|
||||||
|
ai_replies_enabled=agent.ai_replies_enabled,
|
||||||
|
presence=presence,
|
||||||
|
request=request
|
||||||
|
))
|
||||||
|
|
||||||
|
# No valid token - create new one for anonymous access. A live token that
|
||||||
|
# names no customer yet keeps its conversation key.
|
||||||
|
token = _mint_widget_token(widget_id, _conversation_key(token_payload), source=source)
|
||||||
|
|
||||||
|
return _widget_html_response(await get_widget_html(
|
||||||
|
widget_id=widget_id,
|
||||||
|
agent_name=agent.display_name or agent.name,
|
||||||
|
agent_customization=agent.customization,
|
||||||
|
customer_id=customer_id,
|
||||||
|
initial_token=token,
|
||||||
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
||||||
|
allow_attachments=agent.allow_attachments,
|
||||||
|
ai_replies_enabled=agent.ai_replies_enabled,
|
||||||
|
presence=presence,
|
||||||
|
request=request
|
||||||
|
))
|
||||||
|
|
||||||
|
async def get_widget_html(widget_id: str, agent_name: str, agent_customization: dict, customer_id: Optional[str] = None, initial_token: Optional[str] = None, agent_workflow: bool = False, allow_attachments: bool = False, ai_replies_enabled: bool = True,
|
||||||
|
presence: Optional[dict] = None, request: Optional[Request] = None) -> str:
|
||||||
|
"""Generate widget HTML with embedded data"""
|
||||||
|
import html
|
||||||
|
widget_url = settings.VITE_WIDGET_URL
|
||||||
|
version = _asset_version()
|
||||||
|
asset_query = f"?v={version}" if version else ""
|
||||||
|
|
||||||
|
# Convert AgentCustomization to dict if it's a model instance
|
||||||
|
customization_dict = {}
|
||||||
|
if agent_customization:
|
||||||
|
# Get signed URL for photo if using S3
|
||||||
|
photo_url = agent_customization.photo_url
|
||||||
|
if settings.S3_FILE_STORAGE and photo_url:
|
||||||
|
photo_url = await get_s3_signed_url(photo_url)
|
||||||
|
|
||||||
|
customization_dict = {
|
||||||
|
"chat_background_color": agent_customization.chat_background_color,
|
||||||
|
"chat_text_color": agent_customization.chat_text_color,
|
||||||
|
"chat_bubble_color": agent_customization.chat_bubble_color,
|
||||||
|
"accent_color": agent_customization.accent_color,
|
||||||
|
"font_family": agent_customization.font_family,
|
||||||
|
"photo_url": photo_url,
|
||||||
|
"chat_style": agent_customization.chat_style.value if agent_customization.chat_style else "CHATBOT",
|
||||||
|
"widget_position": agent_customization.widget_position.value if agent_customization.widget_position else "FLOATING",
|
||||||
|
"welcome_title": agent_customization.welcome_title,
|
||||||
|
"welcome_subtitle": agent_customization.welcome_subtitle,
|
||||||
|
"welcome_message": agent_customization.welcome_message,
|
||||||
|
"chat_initiation_messages": agent_customization.chat_initiation_messages or [],
|
||||||
|
"quick_actions": agent_customization.quick_actions or [],
|
||||||
|
"show_citations": agent_customization.show_citations,
|
||||||
|
"collect_email": agent_customization.collect_email,
|
||||||
|
# A human-only agent never produces an AI reply, so disclosing one
|
||||||
|
# would be untrue — same reason the widget hides it after takeover.
|
||||||
|
"show_ai_disclaimer": agent_customization.show_ai_disclaimer and ai_replies_enabled,
|
||||||
|
"allow_new_chat": agent_customization.allow_new_chat,
|
||||||
|
"customization_metadata": agent_customization.customization_metadata or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat Widget</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
// Runtime backend config so the widget connects to THIS install's
|
||||||
|
// backend instead of falling back to the baked cloud default.
|
||||||
|
// Declared before the widget module loads.
|
||||||
|
window.APP_CONFIG = {json.dumps(_widget_runtime_config(request))};
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="{widget_url}/assets/widget.js{asset_query}"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="{widget_url}/assets/widget.css{asset_query}">
|
||||||
|
<script>
|
||||||
|
window.__INITIAL_DATA__ = {{
|
||||||
|
widgetId: "{html.escape(widget_id)}",
|
||||||
|
agentName: "{html.escape(agent_name)}",
|
||||||
|
customization: {json.dumps(customization_dict)},
|
||||||
|
customerId: "{html.escape(customer_id or '')}",
|
||||||
|
initialToken: "{html.escape(initial_token or '')}",
|
||||||
|
customer: {{}},
|
||||||
|
workflow: {str(agent_workflow).lower()},
|
||||||
|
allowAttachments: {str(allow_attachments).lower()},
|
||||||
|
presence: {json.dumps(presence or {'mode': 'ai', 'available': True})}
|
||||||
|
}};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_human_agent_session_info(db: Session, customer_id: str, scope: str) -> dict:
|
||||||
|
"""Human agent assigned to the customer's open conversation this token may reach"""
|
||||||
|
human_agent_info = {}
|
||||||
|
session_repo = SessionToAgentRepository(db)
|
||||||
|
sessions = session_repo.get_customer_sessions(customer_id, SessionStatus.OPEN, scope=scope)
|
||||||
|
|
||||||
|
if sessions and len(sessions) > 0:
|
||||||
|
session_model,user_full_name, user_profile_pic = sessions[0]
|
||||||
|
|
||||||
|
if user_full_name: # If there's a human agent assigned
|
||||||
|
# HumanAgentResponse signs human_agent_profile_pic on serialization.
|
||||||
|
|
||||||
|
# Get human agent info from session
|
||||||
|
human_agent_info = {
|
||||||
|
"human_agent_name": user_full_name,
|
||||||
|
"human_agent_profile_pic": user_profile_pic,
|
||||||
|
}
|
||||||
|
|
||||||
|
return human_agent_info
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_key(token_payload: Optional[dict]) -> str:
|
||||||
|
"""Keep the browser's conversation key across re-mints, or start one.
|
||||||
|
|
||||||
|
Never an account key: only our backend mints those, for one signed-in
|
||||||
|
dashboard account, and every token the widget issues is unverified.
|
||||||
|
"""
|
||||||
|
key = (token_payload or {}).get(conversation_token.CONVERSATION_KEY_CLAIM)
|
||||||
|
if key and not conversation_token.is_account_key(key):
|
||||||
|
return key
|
||||||
|
return conversation_token.new_conversation_key()
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_widget_token(widget_id: str, conversation_key: str, customer_id: Optional[str] = None,
|
||||||
|
source: Optional[str] = None, token_payload: Optional[dict] = None) -> str:
|
||||||
|
"""A conversation token the widget issues itself - unverified, so always scoped.
|
||||||
|
|
||||||
|
Prefers a fresh `source` (first-visit Explore flow) over one the visitor's
|
||||||
|
earlier token carried.
|
||||||
|
"""
|
||||||
|
extra_data = {conversation_token.CONVERSATION_KEY_CLAIM: conversation_key}
|
||||||
|
source = source or (token_payload or {}).get("source")
|
||||||
|
if widget_id == settings.EXPLORE_WIDGET_ID and source:
|
||||||
|
extra_data["source"] = source
|
||||||
|
return create_conversation_token(widget_id=widget_id, customer_id=customer_id, **extra_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _anonymous_customer(customer_repo: CustomerRepository, widget: Widget, conversation_key: str,
|
||||||
|
claimed_email: Optional[str] = None) -> Customer:
|
||||||
|
"""A customer of the visitor's own. An address they gave that belongs to
|
||||||
|
someone they can't stand for is kept where the inbox shows it."""
|
||||||
|
meta_data = {CONTACT_EMAIL_META_KEY: claimed_email} if claimed_email else None
|
||||||
|
return customer_repo.create_customer(
|
||||||
|
CustomerRepository.anonymous_email(), widget.organization_id,
|
||||||
|
meta_data=meta_data, conversation_key=conversation_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _widget_data_response(widget: Widget, agent: Agent, customer_id: Optional[str], human_agent_info: dict,
|
||||||
|
token: Optional[str] = None) -> dict:
|
||||||
|
"""GET /widgets/{id}'s answer; `token` only when the visitor needs a new one."""
|
||||||
|
# photo_url is signed by AgentCustomizationResponse on serialization.
|
||||||
|
data = {
|
||||||
|
"id": widget.id,
|
||||||
|
"organization_id": widget.organization_id,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"human_agent": human_agent_info,
|
||||||
|
"agent": {
|
||||||
|
"id": agent.id,
|
||||||
|
"name": agent.name,
|
||||||
|
"display_name": agent.display_name,
|
||||||
|
"customization": agent.customization,
|
||||||
|
"workflow": bool(agent.use_workflow and agent.active_workflow_id),
|
||||||
|
"allow_attachments": agent.allow_attachments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
data["token"] = token
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{widget_id}", response_model=WidgetResponse)
|
||||||
|
async def get_widget_data(
|
||||||
|
widget_id: str,
|
||||||
|
response: Response,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get widget data including agent customization"""
|
||||||
|
logger.info(f"Getting widget data for widget_id {widget_id}, email {email}, has_token {bool(authorization)}")
|
||||||
|
# Binds this visitor to a customer: a stored copy would skip that and leave
|
||||||
|
# the widget holding the page's anonymous token.
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found")
|
||||||
|
|
||||||
|
agent_repo = AgentRepository(db)
|
||||||
|
agent = agent_repo.get_by_id(widget.agent_id)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
|
||||||
|
require_token_auth = getattr(agent, 'require_token_auth', False)
|
||||||
|
customer_id = None
|
||||||
|
token = None
|
||||||
|
token_payload = None
|
||||||
|
scope = None
|
||||||
|
|
||||||
|
# Try to validate existing token if provided
|
||||||
|
if authorization and authorization.startswith('Bearer '):
|
||||||
|
token = authorization.split(' ')[1]
|
||||||
|
state = conversation_token.inspect(token, widget_id)
|
||||||
|
if state.is_live:
|
||||||
|
try:
|
||||||
|
scope = conversation_token.conversation_scope(state.payload)
|
||||||
|
token_payload = state.payload
|
||||||
|
customer_id = state.payload.get("sub")
|
||||||
|
except ValueError:
|
||||||
|
token = None
|
||||||
|
elif state.identity_lost:
|
||||||
|
# Identity ran out rather than never existing: minting a fresh anonymous
|
||||||
|
# customer here is what used to turn a signed-in visitor into "Anonymous"
|
||||||
|
# in the inbox the moment they started a new chat.
|
||||||
|
raise _identity_expired()
|
||||||
|
else:
|
||||||
|
token = None
|
||||||
|
|
||||||
|
# SECURITY: If token auth required, must have valid token with customer_id
|
||||||
|
if require_token_auth:
|
||||||
|
if not token or customer_id is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
||||||
|
|
||||||
|
# Check if agent has workflow enabled
|
||||||
|
agent_has_workflow = bool(agent.use_workflow and agent.active_workflow_id)
|
||||||
|
|
||||||
|
# Check if agent has ASK_ANYTHING chat style
|
||||||
|
is_ask_anything_style = (agent.customization and
|
||||||
|
agent.customization.chat_style and
|
||||||
|
agent.customization.chat_style.value == "ASK_ANYTHING")
|
||||||
|
|
||||||
|
# Email is optional unless the agent explicitly opts into collecting it before chat.
|
||||||
|
# When not collecting email we bypass the gate (anonymous customer + token), like
|
||||||
|
# ASK_ANYTHING / workflow agents already do. NOTE: this runs AFTER the require_token_auth
|
||||||
|
# 401 check above, so token-protected widgets are unaffected.
|
||||||
|
email_bypass = not bool(agent.customization and getattr(agent.customization, 'collect_email', False))
|
||||||
|
|
||||||
|
customer_repo = CustomerRepository(db)
|
||||||
|
conversation_key = _conversation_key(token_payload)
|
||||||
|
# Judged on the customer the socket will act as - the merge survivor - or
|
||||||
|
# the widget would be told yes here and refused on every connect.
|
||||||
|
customer = customer_repo.resolve_merged(customer_id) if customer_id not in (None, "None") else None
|
||||||
|
|
||||||
|
if customer is not None:
|
||||||
|
# Once the embedding app signs this person in, a token the widget issued
|
||||||
|
# (from an email someone typed) may no longer name them - whoever claimed
|
||||||
|
# the address first would otherwise keep a token for the signed-in user.
|
||||||
|
# Hand that browser a customer of its own and keep the address it gave.
|
||||||
|
if not may_stand_for(customer, scope):
|
||||||
|
logger.info(f"Widget {widget_id}: unverified token names a signed-in customer; starting an anonymous one")
|
||||||
|
anonymous = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=customer.email)
|
||||||
|
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(anonymous.id),
|
||||||
|
source=source, token_payload=token_payload)
|
||||||
|
return _widget_data_response(widget, agent, str(anonymous.id), {}, new_token)
|
||||||
|
|
||||||
|
logger.debug(f"Using existing token for customer_id: {customer_id}")
|
||||||
|
human_agent_info = await get_human_agent_session_info(db, str(customer.id), scope)
|
||||||
|
return _widget_data_response(widget, agent, customer_id, human_agent_info)
|
||||||
|
|
||||||
|
# A token-protected widget only ever runs on tokens its site issued: one whose
|
||||||
|
# customer is gone is no reason to issue a widget token of our own.
|
||||||
|
if require_token_auth:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
||||||
|
|
||||||
|
# No customer yet - or the token's one no longer exists, which would otherwise
|
||||||
|
# refuse every connect while this answer kept the token. For workflow agents,
|
||||||
|
# ASK_ANYTHING style, or email-optional agents, create one with a blank email.
|
||||||
|
# Only email-collecting regular agents require email up front.
|
||||||
|
if not (email or agent_has_workflow or is_ask_anything_style or email_bypass):
|
||||||
|
# Regular agent without email/customer_id - return 401
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# The email is only what the visitor typed. It may still name the existing
|
||||||
|
# customer - one person in the inbox - because the token minted below is
|
||||||
|
# scoped to this browser's conversations, not to theirs. Never a person the
|
||||||
|
# embedding app signed in, though: only its own token may stand for them.
|
||||||
|
existing = customer_repo.get_customer_by_email(email, widget.organization_id) if email else None
|
||||||
|
if existing is not None and not existing.is_authenticated:
|
||||||
|
customer = existing
|
||||||
|
elif existing is not None:
|
||||||
|
customer = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=email)
|
||||||
|
elif email:
|
||||||
|
customer = customer_repo.create_customer(email, widget.organization_id, conversation_key=conversation_key)
|
||||||
|
else:
|
||||||
|
customer = _anonymous_customer(customer_repo, widget, conversation_key)
|
||||||
|
|
||||||
|
human_agent_info = await get_human_agent_session_info(db, str(customer.id), conversation_key)
|
||||||
|
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(customer.id),
|
||||||
|
source=source, token_payload=token_payload)
|
||||||
|
return _widget_data_response(widget, agent, str(customer.id), human_agent_info, new_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{widget_id}/end-chat")
|
||||||
|
async def end_chat_acknowledgment(
|
||||||
|
widget_id: str,
|
||||||
|
session_id: str,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Acknowledge end_chat event from widget and close session on backend"""
|
||||||
|
try:
|
||||||
|
logger.info(f"End chat request received: widget_id={widget_id}, session_id={session_id}, reason={reason}, has_token={bool(authorization)}")
|
||||||
|
|
||||||
|
# A conversation token is REQUIRED. This used to validate the token only
|
||||||
|
# when one was sent, so a request with no Authorization header fell
|
||||||
|
# straight through and closed any session by id, unauthenticated.
|
||||||
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_data = verify_conversation_token(authorization.split(' ')[1])
|
||||||
|
except Exception:
|
||||||
|
token_data = None
|
||||||
|
if not token_data:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
customer_id = token_data.get("sub")
|
||||||
|
if token_data.get("widget_id") != widget_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Widget mismatch")
|
||||||
|
try:
|
||||||
|
scope = conversation_token.conversation_scope(token_data)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
# The token proves who the caller is, not that this session is theirs —
|
||||||
|
# without this any visitor could close another customer's conversation,
|
||||||
|
# or another browser's conversation of a customer it only claimed to be.
|
||||||
|
session_repo = SessionToAgentRepository(db)
|
||||||
|
session = session_repo.get_session(session_id)
|
||||||
|
if not session or str(session.customer_id) != str(customer_id) or not session_in_scope(session, scope):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||||
|
|
||||||
|
success = session_repo.close_session(
|
||||||
|
session_id=session_id,
|
||||||
|
reason=reason,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"End chat acknowledged: widget_id={widget_id}, session_id={session_id}, reason={reason}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Chat session closed",
|
||||||
|
"session_id": session_id,
|
||||||
|
"closed_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to close session: widget_id={widget_id}, session_id={session_id}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in end_chat_acknowledgment: {str(e)}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to process end chat")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[WidgetResponse])
|
||||||
|
def list_widgets(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""List all widgets for the organization"""
|
||||||
|
widget_repo = WidgetRepository(db)
|
||||||
|
return widget_repo.get_widgets(current_user.organization_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent/{agent_id}", response_model=List[WidgetResponse])
|
||||||
|
def get_widgets_by_agent(
|
||||||
|
agent_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get all widgets for a specific agent"""
|
||||||
|
widget_repo = WidgetRepository(db)
|
||||||
|
widgets = widget_repo.get_widgets_by_agent(agent_id)
|
||||||
|
|
||||||
|
# Verify the agent belongs to the user's organization
|
||||||
|
agent_repo = AgentRepository(db)
|
||||||
|
agent = agent_repo.get_by_id(agent_id)
|
||||||
|
if not agent or str(agent.organization_id) != str(current_user.organization_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
|
||||||
|
return widgets
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{widget_id}")
|
||||||
|
def remove_widget(
|
||||||
|
widget_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Delete a widget"""
|
||||||
|
widget_repo = WidgetRepository(db)
|
||||||
|
widget = widget_repo.get_widget(widget_id)
|
||||||
|
if not widget or widget.organization_id != current_user.organization_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Widget not found")
|
||||||
|
widget_repo.delete_widget(widget_id)
|
||||||
|
return {"message": "Widget deleted"}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.auth import require_permissions
|
||||||
|
from app.repositories.widget_app import WidgetAppRepository
|
||||||
|
from app.models.schemas.widget_app import (
|
||||||
|
WidgetAppCreate,
|
||||||
|
WidgetAppUpdate,
|
||||||
|
WidgetAppResponse,
|
||||||
|
WidgetAppWithKeyResponse,
|
||||||
|
WidgetAppListResponse
|
||||||
|
)
|
||||||
|
from uuid import UUID
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
|
||||||
|
# Enterprise feature check
|
||||||
|
try:
|
||||||
|
from app.enterprise.repositories.plan import PlanRepository
|
||||||
|
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||||
|
HAS_ENTERPRISE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_ENTERPRISE = False
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_widget_app_feature_access(current_user: User, db: Session):
|
||||||
|
"""Check if user has access to widget apps feature (requires pro plan)"""
|
||||||
|
if not HAS_ENTERPRISE:
|
||||||
|
return # Allow access in non-enterprise mode
|
||||||
|
|
||||||
|
# Accessible subscription = active/trial/past-due-in-period OR cancelled-but-
|
||||||
|
# still-in-paid-period. Raises 403 when the org has no accessible plan.
|
||||||
|
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||||
|
plan_repo = PlanRepository(db)
|
||||||
|
|
||||||
|
# Check if api_access feature is available in the plan (required for widget apps)
|
||||||
|
if not plan_repo.check_feature_availability(str(subscription.plan_id), 'api_access'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Widget Apps feature is not available in your current plan. Please upgrade to Pro to access this feature."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=WidgetAppWithKeyResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_widget_app(
|
||||||
|
app_data: WidgetAppCreate,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new widget app with generated API key.
|
||||||
|
|
||||||
|
**IMPORTANT:** The API key is shown only once in this response.
|
||||||
|
Save it securely - it cannot be retrieved later.
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
|
||||||
|
# Create app (returns app and plain key)
|
||||||
|
app, plain_key = repo.create_app(
|
||||||
|
organization_id=current_user.organization_id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
name=app_data.name,
|
||||||
|
description=app_data.description
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return app with key (shown only once)
|
||||||
|
return WidgetAppWithKeyResponse(
|
||||||
|
id=app.id,
|
||||||
|
name=app.name,
|
||||||
|
description=app.description,
|
||||||
|
organization_id=app.organization_id,
|
||||||
|
created_by=app.created_by,
|
||||||
|
is_active=app.is_active,
|
||||||
|
created_at=app.created_at,
|
||||||
|
updated_at=app.updated_at,
|
||||||
|
api_key=plain_key # ONLY time key is exposed
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error creating widget app: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create widget app"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=WidgetAppListResponse)
|
||||||
|
async def list_widget_apps(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all widget apps for current organization.
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
apps = repo.get_apps_by_organization(
|
||||||
|
organization_id=current_user.organization_id,
|
||||||
|
include_inactive=include_inactive
|
||||||
|
)
|
||||||
|
|
||||||
|
return WidgetAppListResponse(
|
||||||
|
total=len(apps),
|
||||||
|
apps=[WidgetAppResponse.from_orm(app) for app in apps]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error listing widget apps: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to list widget apps"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{app_id}", response_model=WidgetAppResponse)
|
||||||
|
async def get_widget_app(
|
||||||
|
app_id: UUID,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get widget app details by ID.
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
app = repo.get_app_by_id(
|
||||||
|
app_id=app_id,
|
||||||
|
organization_id=current_user.organization_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Widget app {app_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return WidgetAppResponse.from_orm(app)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error getting widget app: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get widget app"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{app_id}", response_model=WidgetAppResponse)
|
||||||
|
async def update_widget_app(
|
||||||
|
app_id: UUID,
|
||||||
|
update_data: WidgetAppUpdate,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update widget app (name, description, active status).
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
app = repo.update_app(
|
||||||
|
app_id=app_id,
|
||||||
|
organization_id=current_user.organization_id,
|
||||||
|
name=update_data.name,
|
||||||
|
description=update_data.description,
|
||||||
|
is_active=update_data.is_active
|
||||||
|
)
|
||||||
|
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Widget app {app_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return WidgetAppResponse.from_orm(app)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error updating widget app: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update widget app"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{app_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_widget_app(
|
||||||
|
app_id: UUID,
|
||||||
|
hard_delete: bool = False,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete widget app (soft delete by default, hard delete if hard_delete=true).
|
||||||
|
|
||||||
|
Soft delete (default): Sets is_active=False, keeps record for audit trail.
|
||||||
|
Hard delete: Permanently removes record (use with caution).
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
|
||||||
|
if hard_delete:
|
||||||
|
success = repo.delete_app(app_id, current_user.organization_id)
|
||||||
|
else:
|
||||||
|
success = repo.deactivate_app(app_id, current_user.organization_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Widget app {app_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalidate cache if we have the key prefix (can't do this without key)
|
||||||
|
# Cache will naturally expire after TTL
|
||||||
|
|
||||||
|
return None # 204 No Content
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error deleting widget app: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete widget app"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{app_id}/regenerate-key", response_model=WidgetAppWithKeyResponse)
|
||||||
|
async def regenerate_api_key(
|
||||||
|
app_id: UUID,
|
||||||
|
current_user: User = Depends(require_permissions("manage_organization")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Regenerate API key for widget app.
|
||||||
|
|
||||||
|
**IMPORTANT:** The new API key is shown only once in this response.
|
||||||
|
Save it securely - it cannot be retrieved later.
|
||||||
|
The old key will be immediately invalidated.
|
||||||
|
|
||||||
|
Requires: manage_organization permission (admin only)
|
||||||
|
Requires: Pro plan with widget_apps feature
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check widget apps feature access
|
||||||
|
check_widget_app_feature_access(current_user, db)
|
||||||
|
|
||||||
|
repo = WidgetAppRepository(db)
|
||||||
|
|
||||||
|
result = repo.regenerate_api_key(
|
||||||
|
app_id=app_id,
|
||||||
|
organization_id=current_user.organization_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Widget app {app_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
app, new_plain_key = result
|
||||||
|
|
||||||
|
# Invalidate cache (old key is now invalid)
|
||||||
|
# Note: We don't have the old key here, so cache will expire naturally
|
||||||
|
|
||||||
|
return WidgetAppWithKeyResponse(
|
||||||
|
id=app.id,
|
||||||
|
name=app.name,
|
||||||
|
description=app.description,
|
||||||
|
organization_id=app.organization_id,
|
||||||
|
created_by=app.created_by,
|
||||||
|
is_active=app.is_active,
|
||||||
|
created_at=app.created_at,
|
||||||
|
updated_at=app.updated_at,
|
||||||
|
api_key=new_plain_key # New key shown only once
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error regenerating API key: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to regenerate API key"
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Keep crawlers out of the API origin.
|
||||||
|
|
||||||
|
The API host had no robots.txt at all, so search engines were free to crawl
|
||||||
|
(and then report as 404s) endpoints that were never pages: /api/v1/... routes
|
||||||
|
and the YOUR_WIDGET_ID placeholder our docs use in examples.
|
||||||
|
|
||||||
|
Two halves, because they do different jobs. robots.txt stops the fetch;
|
||||||
|
X-Robots-Tag stops a URL discovered elsewhere from being indexed anyway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
NOINDEX_HEADER = (b"x-robots-tag", b"noindex")
|
||||||
|
|
||||||
|
|
||||||
|
def api_robots_txt() -> str:
|
||||||
|
"""The API origin's robots.txt body.
|
||||||
|
|
||||||
|
Disallows everything — except, under path dispatch, the help center. That
|
||||||
|
mode (the self-host default) serves public help-center pages from
|
||||||
|
{BACKEND_URL}/help/{slug} on this very origin, and those are meant to be
|
||||||
|
indexed; a bare "Disallow: /" would quietly de-index every self-hosted
|
||||||
|
help center. Cloud runs subdomain mode, where nothing on this origin is a
|
||||||
|
page and the carve-out is absent.
|
||||||
|
|
||||||
|
The Allow goes FIRST. RFC 9309 crawlers take the most specific match and
|
||||||
|
would get this right either way, but ones that take the first match read
|
||||||
|
a leading "Disallow: /" and stop — skipping the very pages the carve-out
|
||||||
|
exists to keep crawlable.
|
||||||
|
"""
|
||||||
|
lines = ["User-agent: *"]
|
||||||
|
if settings.HELP_CENTER_PUBLIC_MODE == "path":
|
||||||
|
lines.append("Allow: /help/")
|
||||||
|
lines.append("Disallow: /")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
class NoIndexHeaderMiddleware:
|
||||||
|
"""Stamp ``X-Robots-Tag: noindex`` on every response under ``prefix``.
|
||||||
|
|
||||||
|
Pure ASGI rather than BaseHTTPMiddleware: this fronts every API request,
|
||||||
|
and BaseHTTPMiddleware would pull streaming responses through a queue for
|
||||||
|
the sake of one header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, prefix: str):
|
||||||
|
self.app = app
|
||||||
|
self.prefix = prefix
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or not scope.get("path", "").startswith(self.prefix):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def send_with_noindex(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
message["headers"] = [*(message.get("headers") or []), NOINDEX_HEADER]
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_with_noindex)
|
||||||
|
|
||||||
|
|
||||||
|
def install_crawler_policy(app: FastAPI) -> None:
|
||||||
|
"""Register the robots.txt route and the noindex header middleware."""
|
||||||
|
|
||||||
|
@app.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
|
||||||
|
async def robots_txt():
|
||||||
|
return PlainTextResponse(api_robots_txt())
|
||||||
|
|
||||||
|
app.add_middleware(NoIndexHeaderMiddleware, prefix=settings.API_V1_STR)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Custom-domain verification for the public help center.
|
||||||
|
|
||||||
|
The customer proves control of the domain with a TXT record
|
||||||
|
(_replypier.<domain> = rp-verify=<token>) and routes traffic with a CNAME
|
||||||
|
to HELP_CENTER_CNAME_TARGET (A/AAAA records matching HELP_CENTER_TARGET_IPS
|
||||||
|
are accepted for providers that flatten CNAMEs). Both must pass before the
|
||||||
|
host dispatcher will ever serve the domain. SSL turns active once an HTTPS
|
||||||
|
probe against /healthz succeeds with a valid certificate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logger import get_logger
|
||||||
|
from app.models.help_center import HelpCenterSettings, SSLStatus
|
||||||
|
from app.repositories.help_center import HelpCenterRepository
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DNS_LIFETIME_SECONDS = 5.0
|
||||||
|
SSL_PROBE_TIMEOUT_SECONDS = 10.0
|
||||||
|
TXT_RECORD_PREFIX = "_replypier"
|
||||||
|
# Public resolvers for verification lookups. The container's own resolver is
|
||||||
|
# Docker's embedded DNS (127.0.0.11) for service discovery — it negatively
|
||||||
|
# caches names that didn't exist when first queried (so a TXT added *after* the
|
||||||
|
# customer set up the domain keeps returning NXDOMAIN). Query public DNS
|
||||||
|
# directly, and let self-hosters override if their network blocks these.
|
||||||
|
_PUBLIC_DNS_RESOLVERS = [
|
||||||
|
ip.strip()
|
||||||
|
for ip in os.getenv("DOMAIN_VERIFY_DNS_RESOLVERS", "1.1.1.1,8.8.8.8,9.9.9.9").split(",")
|
||||||
|
if ip.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_verification_token() -> str:
|
||||||
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
def txt_record_host(domain: str) -> str:
|
||||||
|
return f"{TXT_RECORD_PREFIX}.{domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def expected_txt_value(row: HelpCenterSettings) -> str:
|
||||||
|
return f"rp-verify={row.domain_verification_token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver():
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
resolver = dns.resolver.Resolver()
|
||||||
|
if _PUBLIC_DNS_RESOLVERS:
|
||||||
|
resolver.nameservers = _PUBLIC_DNS_RESOLVERS
|
||||||
|
resolver.lifetime = DNS_LIFETIME_SECONDS
|
||||||
|
return resolver
|
||||||
|
|
||||||
|
|
||||||
|
def check_txt_record(row: HelpCenterSettings) -> bool:
|
||||||
|
"""True when the verification TXT record is present."""
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
expected = expected_txt_value(row)
|
||||||
|
try:
|
||||||
|
answers = _resolver().resolve(txt_record_host(row.custom_domain), "TXT")
|
||||||
|
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers,
|
||||||
|
dns.resolver.LifetimeTimeout):
|
||||||
|
return False
|
||||||
|
for record in answers:
|
||||||
|
value = b"".join(record.strings).decode("utf-8", errors="replace")
|
||||||
|
if value.strip() == expected:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_cname_record(domain: str) -> bool:
|
||||||
|
"""True when the domain points at us: CNAME to the target, or (for
|
||||||
|
CNAME-flattening providers) A/AAAA records matching the target IPs."""
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
target = settings.HELP_CENTER_CNAME_TARGET.rstrip(".").lower()
|
||||||
|
try:
|
||||||
|
answers = _resolver().resolve(domain, "CNAME")
|
||||||
|
return any(str(record.target).rstrip(".").lower() == target for record in answers)
|
||||||
|
except (dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout):
|
||||||
|
return False
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
pass # no CNAME — fall through to flattened A/AAAA comparison
|
||||||
|
if not settings.HELP_CENTER_TARGET_IPS:
|
||||||
|
return False
|
||||||
|
resolved = set()
|
||||||
|
for rtype in ("A", "AAAA"):
|
||||||
|
try:
|
||||||
|
resolved.update(str(r) for r in _resolver().resolve(domain, rtype))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return bool(resolved) and resolved <= settings.HELP_CENTER_TARGET_IPS
|
||||||
|
|
||||||
|
|
||||||
|
def probe_ssl(domain: str) -> bool:
|
||||||
|
"""HTTPS reachability + certificate validation, resolving via PUBLIC DNS.
|
||||||
|
The container's own resolver (Docker embedded DNS) negatively caches a
|
||||||
|
custom domain that didn't exist before its cert was provisioned — so httpx
|
||||||
|
(which uses the system resolver) would keep failing. Resolve the A record
|
||||||
|
ourselves and connect to that IP with SNI = the domain, which also verifies
|
||||||
|
the served cert is actually issued for this domain."""
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
try:
|
||||||
|
ip = str(_resolver().resolve(domain, "A")[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"SSL probe DNS for {domain} not ready: {e}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
with socket.create_connection((ip, 443), timeout=SSL_PROBE_TIMEOUT_SECONDS) as sock:
|
||||||
|
with ctx.wrap_socket(sock, server_hostname=domain) as tls:
|
||||||
|
tls.sendall(
|
||||||
|
f"GET /healthz HTTP/1.1\r\nHost: {domain}\r\nConnection: close\r\n\r\n".encode()
|
||||||
|
)
|
||||||
|
status_line = tls.recv(128).split(b"\r\n", 1)[0]
|
||||||
|
return b" 200 " in status_line
|
||||||
|
except (OSError, ssl.SSLError) as e:
|
||||||
|
logger.info(f"SSL probe for {domain} not ready: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_custom_domain(db: Session, row: HelpCenterSettings, domain: str) -> HelpCenterSettings:
|
||||||
|
"""Claim a (normalized) domain: fresh token, verification state reset."""
|
||||||
|
base_suffix = f".{settings.HELP_CENTER_BASE_DOMAIN}"
|
||||||
|
if domain == settings.HELP_CENTER_BASE_DOMAIN or domain.endswith(base_suffix):
|
||||||
|
raise ValueError(f"{settings.HELP_CENTER_BASE_DOMAIN} subdomains are assigned automatically and cannot be claimed.")
|
||||||
|
row.custom_domain = domain
|
||||||
|
row.domain_verification_token = generate_verification_token()
|
||||||
|
row.txt_record_verified = False
|
||||||
|
row.cname_record_verified = False
|
||||||
|
row.ssl_status = SSLStatus.NONE
|
||||||
|
row.domain_verified_at = None
|
||||||
|
return HelpCenterRepository(db).update(row)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
|
||||||
|
row.custom_domain = None
|
||||||
|
row.domain_verification_token = None
|
||||||
|
row.txt_record_verified = False
|
||||||
|
row.cname_record_verified = False
|
||||||
|
row.ssl_status = SSLStatus.NONE
|
||||||
|
row.domain_verified_at = None
|
||||||
|
return HelpCenterRepository(db).update(row)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_custom_domain(db: Session, row: HelpCenterSettings) -> HelpCenterSettings:
|
||||||
|
"""Run both DNS checks (and the SSL probe once verified) and persist the
|
||||||
|
outcome. Blocking (DNS/HTTP I/O) — callers run it off the event loop."""
|
||||||
|
if not row.custom_domain:
|
||||||
|
raise ValueError("No custom domain configured.")
|
||||||
|
row.txt_record_verified = check_txt_record(row)
|
||||||
|
row.cname_record_verified = check_cname_record(row.custom_domain)
|
||||||
|
if row.domain_verified:
|
||||||
|
if not row.domain_verified_at:
|
||||||
|
row.domain_verified_at = datetime.now(timezone.utc)
|
||||||
|
if row.ssl_status != SSLStatus.ACTIVE.value:
|
||||||
|
row.ssl_status = (
|
||||||
|
SSLStatus.ACTIVE if probe_ssl(row.custom_domain) else SSLStatus.PENDING
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.domain_verified_at = None
|
||||||
|
row.ssl_status = SSLStatus.NONE
|
||||||
|
return HelpCenterRepository(db).update(row)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
What a widget conversation token may do to the customer it names.
|
||||||
|
|
||||||
|
A widget visitor can be bound to a customer by an email they only typed, so the
|
||||||
|
token naming a customer is not proof of being them. Conversations are already
|
||||||
|
fenced by the token's scope (see conversation_token.conversation_scope); this
|
||||||
|
module fences the customer row itself - its contact details, merges, and whether
|
||||||
|
an unverified token may stand for a person the embedding app has signed in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.session_to_agent import SessionToAgent
|
||||||
|
from app.repositories.customer import CustomerRepository
|
||||||
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||||
|
from app.services.conversation_token import (
|
||||||
|
LEGACY_SCOPE_PREFIX,
|
||||||
|
is_verified_scope,
|
||||||
|
scope_for_session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_act_as(db: Session, customer: Optional[Customer], scope: Optional[str]) -> bool:
|
||||||
|
"""True when a token with `scope` may change `customer` as if it were them.
|
||||||
|
|
||||||
|
A verified token always may. An unverified one only for a customer its own
|
||||||
|
scope created - "no conversations elsewhere" is not ownership: a customer
|
||||||
|
made by a ticket or an import has none - and only while every conversation
|
||||||
|
of that customer is still its own.
|
||||||
|
"""
|
||||||
|
if customer is None or not scope:
|
||||||
|
return False
|
||||||
|
if is_verified_scope(scope):
|
||||||
|
return True
|
||||||
|
if customer.is_authenticated or not _created_by(customer, scope):
|
||||||
|
return False
|
||||||
|
return not SessionToAgentRepository(db).customer_has_sessions_outside_scope(customer.id, scope)
|
||||||
|
|
||||||
|
|
||||||
|
def _created_by(customer: Customer, scope: str) -> bool:
|
||||||
|
if customer.conversation_key:
|
||||||
|
return customer.conversation_key == scope
|
||||||
|
# Customers from before conversation keys: the anonymous row a legacy
|
||||||
|
# widget token created for itself is the only kind such a token can own.
|
||||||
|
return scope.startswith(LEGACY_SCOPE_PREFIX) and CustomerRepository.is_placeholder_email(customer.email)
|
||||||
|
|
||||||
|
|
||||||
|
def may_stand_for(customer: Optional[Customer], scope: Optional[str]) -> bool:
|
||||||
|
"""True when an unverified token may keep naming `customer` at all.
|
||||||
|
|
||||||
|
Once the embedding app has signed a person in, only a token our side vouched
|
||||||
|
for may name them - otherwise whoever claimed the address first would keep a
|
||||||
|
token for the signed-in user's row.
|
||||||
|
"""
|
||||||
|
if customer is None or not scope:
|
||||||
|
return False
|
||||||
|
return is_verified_scope(scope) or not customer.is_authenticated
|
||||||
|
|
||||||
|
|
||||||
|
def trusts_identity_in_conversation(
|
||||||
|
db: Session, customer: Optional[Customer], conversation: Optional[SessionToAgent]
|
||||||
|
) -> bool:
|
||||||
|
"""True when the visitor in `conversation` may be told, or change, what we
|
||||||
|
know of `customer`.
|
||||||
|
|
||||||
|
Reciting a stored name or email back is how a visitor who only typed someone
|
||||||
|
else's address would learn who they are. Unknown conversation: nothing.
|
||||||
|
"""
|
||||||
|
if conversation is None:
|
||||||
|
return False
|
||||||
|
return can_act_as(db, customer, scope_for_session_key(conversation.conversation_key))
|
||||||
|
|
||||||
|
|
||||||
|
def trusts_identity_in_session(db: Session, customer: Optional[Customer], session_id: UUID | str | None) -> bool:
|
||||||
|
"""trusts_identity_in_conversation, for a caller holding only the session id."""
|
||||||
|
conversation = SessionToAgentRepository(db).get_session(session_id) if session_id else None
|
||||||
|
return trusts_identity_in_conversation(db, customer, conversation)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.new-chat-confirm[data-v-6c78f353]{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--rp-hairline, rgba(0, 0, 0, .07));background:var(--rp-agent-bg, rgba(0, 0, 0, .03));font-family:var(--rp-body-font, inherit)}.new-chat-confirm__question[data-v-6c78f353]{margin:0;font-size:12.5px;line-height:1.35;color:var(--rp-text, inherit)}.new-chat-confirm__actions[data-v-6c78f353]{display:flex;gap:8px;margin-left:auto}.new-chat-confirm__button[data-v-6c78f353]{padding:5px 11px;border-radius:var(--rp-field-radius, 8px);border:1px solid var(--rp-border, rgba(0, 0, 0, .12));background:transparent;color:var(--rp-text, inherit);font-family:inherit;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s ease}.new-chat-confirm__button--primary[data-v-6c78f353]{border-color:transparent;background:var(--rp-accent, #f34611);color:var(--rp-on-accent, #ffffff)}.new-chat-confirm__button[data-v-6c78f353]:disabled{opacity:.5;cursor:default}.new-chat-confirm__button[data-v-6c78f353]:not(:disabled):hover{opacity:.85}@media (pointer: coarse){.new-chat-confirm[data-v-6c78f353]{padding:10px 12px}.new-chat-confirm__button[data-v-6c78f353]{min-height:40px;padding:8px 14px}}@media (prefers-reduced-motion: reduce){.new-chat-confirm__button[data-v-6c78f353]{transition:none}}.askai[data-v-edea9534]{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--rp-card);color:var(--rp-text);font-family:var(--rp-body-font, inherit)}.askai__bar[data-v-edea9534]{display:flex;align-items:center;gap:10px;padding:14px 14px 14px 16px;border-bottom:1px solid var(--rp-border);flex-shrink:0}.askai__bar-icon[data-v-edea9534]{color:var(--rp-accent);flex-shrink:0}.askai__input[data-v-edea9534]{flex:1;min-width:0;border:none;outline:none;background:transparent;color:var(--rp-text);font-size:16px;font-family:inherit;padding:2px 0}.askai__input[data-v-edea9534]::placeholder{color:var(--rp-muted)}.askai__input[data-v-edea9534]:disabled{opacity:.6}.askai__new[data-v-edea9534]{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;border:1px solid var(--rp-border);background:transparent;border-radius:6px;cursor:pointer;color:var(--rp-muted);font-family:inherit;flex-shrink:0;transition:color .15s ease,border-color .15s ease}.askai__new[data-v-edea9534]:hover:not(:disabled){color:var(--rp-text);border-color:var(--rp-accent)}.askai__new[data-v-edea9534]:disabled{opacity:.5;cursor:default}.askai__new--armed[data-v-edea9534]{color:var(--rp-text);border-color:var(--rp-accent)}.askai__close[data-v-edea9534]{border:1px solid var(--rp-border);background:transparent;border-radius:6px;padding:3px 7px;cursor:pointer;color:var(--rp-muted);flex-shrink:0;transition:color .15s ease,border-color .15s ease}.askai__close[data-v-edea9534]:hover{color:var(--rp-text);border-color:var(--rp-accent)}.askai__kbd[data-v-edea9534]{font-size:11px;font-weight:600;letter-spacing:.02em;font-family:inherit}.askai__body[data-v-edea9534]{flex:1;min-height:0;overflow-y:auto;scrollbar-gutter:stable;padding:18px 20px 22px}.askai__content[data-v-edea9534]{display:flow-root}.askai__intro[data-v-edea9534]{margin-bottom:18px}.askai__title[data-v-edea9534]{margin:0 0 4px;font-size:19px;font-weight:600;letter-spacing:-.01em}.askai__subtitle[data-v-edea9534]{margin:0;font-size:14px;line-height:1.5;color:var(--rp-muted)}.askai__label[data-v-edea9534]{display:block;margin:0 0 8px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--rp-muted)}.askai__suggestions[data-v-edea9534]{display:flex;flex-direction:column;gap:6px}.askai__suggestion[data-v-edea9534]{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;text-align:left;padding:11px 13px;border:1px solid var(--rp-border);border-radius:10px;background:var(--rp-agent-bg);color:var(--rp-text);font-size:14px;font-family:inherit;cursor:pointer;transition:border-color .15s ease,transform .15s ease}.askai__suggestion[data-v-edea9534]:hover:not(:disabled){border-color:var(--rp-accent);transform:translate(2px)}.askai__suggestion[data-v-edea9534]:disabled{opacity:.55;cursor:default}.askai__suggestion svg[data-v-edea9534]{color:var(--rp-muted);flex-shrink:0}.askai__turn+.askai__turn[data-v-edea9534]{margin-top:20px}.askai__question[data-v-edea9534]{margin:0 0 10px;font-size:16px;font-weight:600;line-height:1.4;letter-spacing:-.01em}.askai__answer[data-v-edea9534]{font-size:14.5px;line-height:1.65;color:var(--rp-text);overflow-wrap:break-word}.askai__answer--streaming[data-v-edea9534]>:last-child:after{content:"";display:inline-block;width:6px;height:1em;margin-left:2px;vertical-align:-.15em;background:var(--rp-accent, currentColor);animation:askai-blink-edea9534 1s steps(1) infinite}@keyframes askai-blink-edea9534{0%,50%{opacity:1}51%,to{opacity:0}}.askai__answer[data-v-edea9534] p{margin:0 0 10px}.askai__answer[data-v-edea9534] p:last-child{margiLine truncated
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="60" cy="60" r="60" fill="#FFF3E0"/>
|
||||||
|
<path d="M60 36C48.95 36 40 44.95 40 56C40 67.05 48.95 76 60 76C71.05 76 80 67.05 80 56C80 44.95 71.05 36 60 36ZM60 44C64.42 44 68 47.58 68 52C68 56.42 64.42 60 60 60C55.58 60 52 56.42 52 52C52 47.58 55.58 44 60 44ZM60 72C54.67 72 50 69.33 50 66C50 62.67 54.67 60 60 60C65.33 60 70 62.67 70 66C70 69.33 65.33 72 60 72Z" fill="#FF9800"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="60" cy="60" r="60" fill="#F5F5F5"/>
|
||||||
|
<path d="M60 36C48.95 36 40 44.95 40 56C40 67.05 48.95 76 60 76C71.05 76 80 67.05 80 56C80 44.95 71.05 36 60 36ZM66 64H54V60H66V64ZM66 56H54V52H66V56Z" fill="#9E9E9E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="64px" height="64px" fill-rule="evenodd" clip-rule="evenodd"><circle cx="198.287" cy="104.366" r="32.265" fill="#D7E057" transform="rotate(-76.526 198.291 104.361)"/><path fill="#3A312A" d="M198.287,138.131c-18.618,0-33.765-15.147-33.765-33.765s15.147-33.765,33.765-33.765 c18.618,0,33.765,15.146,33.765,33.765S216.905,138.131,198.287,138.131z M198.287,73.602c-16.964,0-30.765,13.801-30.765,30.765 s13.801,30.765,30.765,30.765s30.765-13.801,30.765-30.765S215.251,73.602,198.287,73.602z"/><path fill="#87796F" d="M196.097,126.921v-5.252c-3.704-0.166-7.297-1.161-9.398-2.377l1.658-6.468 c2.322,1.271,5.583,2.432,9.177,2.432c3.151,0,5.307-1.216,5.307-3.428c0-2.1-1.769-3.427-5.86-4.809 c-5.915-1.99-9.951-4.755-9.951-10.117c0-4.865,3.428-8.68,9.343-9.84v-5.252h5.418v4.865c3.704,0.165,6.191,0.94,8.016,1.825 l-1.603,6.246c-1.437-0.608-3.981-1.88-7.96-1.88c-3.594,0-4.755,1.549-4.755,3.096c0,1.825,1.935,2.986,6.634,4.754 c6.578,2.322,9.232,5.363,9.232,10.338c0,4.92-3.483,9.122-9.841,10.227v5.639H196.097z"/><path fill="#3A312A" d="M201.515,128.421h-5.418c-0.829,0-1.5-0.672-1.5-1.5v-3.855c-3.287-0.323-6.504-1.233-8.649-2.475 c-0.582-0.336-0.869-1.02-0.702-1.67l1.658-6.468c0.115-0.449,0.431-0.819,0.856-1.004c0.424-0.185,0.911-0.162,1.317,0.06 c1.535,0.84,4.673,2.248,8.458,2.248c0.636,0,3.807-0.093,3.807-1.928c0-0.91-0.586-1.95-4.84-3.388 c-5.107-1.718-10.971-4.583-10.971-11.538c0-5.338,3.546-9.479,9.343-11.032v-4.061c0-0.828,0.671-1.5,1.5-1.5h5.418 c0.829,0,1.5,0.672,1.5,1.5v3.464c2.737,0.252,5.094,0.87,7.17,1.877c0.642,0.312,0.976,1.031,0.798,1.723l-1.603,6.246 c-0.109,0.423-0.396,0.777-0.788,0.972c-0.392,0.194-0.848,0.207-1.25,0.037l-0.418-0.181c-1.45-0.63-3.64-1.581-6.958-1.581 c-1.216,0-3.255,0.207-3.255,1.597c0,0.866,1.641,1.836,5.663,3.35c7.144,2.521,10.204,6.035,10.204,11.742 c0,5.566-3.82,9.95-9.84,11.443v4.422C203.015,127.749,202.343,128.421,201.515,128.421z M197.597,125.421h2.418v-4.139 c0-0.73,0.525-1.353,1.243-1.478c5.303-0.922,8.597-4.273,8.597-8.749c0-3.612-1.4-6.513-8.231-8.924 c-4.339-1.632-7.635-3.115-7.635-6.168c0-2.223,1.643-4.597,6.255-4.597c3.063,0,5.339,0.692,6.94,1.321l0.852-3.321 c-1.807-0.692-3.885-1.085-6.311-1.193c-0.802-0.035-1.433-0.695-1.433-1.498v-3.365h-2.418v3.752c0,0.717-0.507,1.334-1.211,1.472 c-5.092,1-8.132,4.128-8.132,8.369c0,3.989,2.67,6.59,8.93,8.695c3.575,1.208,6.881,2.798,6.881,6.23 c0,2.993-2.672,4.928-6.807,4.928c-3.315,0-6.191-0.909-8.181-1.776l-0.902,3.52c2.052,0.911,4.94,1.547,7.713,1.672 c0.801,0.035,1.433,0.695,1.433,1.498V125.421z"/><path fill="#D7E057" d="M162.221,16.576c0,0,2.411,8.954,9.352,9.369c0,0-9.266,3.201-9.422,10.045c0,0-1.824-9.6-9.142-9.944 C153.009,26.046,161.532,24.755,162.221,16.576z"/><path fill="#3A312A" d="M162.15,37.49c-0.714,0-1.337-0.507-1.473-1.22c-0.016-0.082-1.704-8.441-7.739-8.726 c-0.77-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.295-0.047,7.363-1.243,7.942-8.113 c0.061-0.726,0.635-1.303,1.36-1.368c0.732-0.068,1.392,0.399,1.582,1.102l0,0c0.021,0.079,2.228,7.92,7.994,8.265 c0.715,0.042,1.299,0.584,1.396,1.293c0.098,0.709-0.319,1.388-0.996,1.622c-0.081,0.028-8.282,2.953-8.412,8.661 c-0.018,0.768-0.611,1.398-1.376,1.461C162.233,37.488,162.191,37.49,162.15,37.49z M157.631,25.963 c2.107,1.333,3.523,3.395,4.447,5.279c1.376-2.232,3.515-3.871,5.364-4.972c-2.287-1.27-3.884-3.35-4.941-5.224 C161.328,23.334,159.486,24.922,157.631,25.963z"/><path fill="#D7E057" d="M183.919,26.253c0,0,3.408,12.659,13.222,13.246c0,0-13.1,4.525-13.321,14.202 c0,0-2.578-13.572-12.925-14.059C170.895,39.642,182.946,37.817,183.919,26.253z"/><path fill="#3A312A" d="M183.819,55.2c-0.712,0-1.335-0.506-1.472-1.217c-0.024-0.125-2.495-12.418-11.522-12.843 c-0.771-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.441-0.068,10.894-1.817,11.754-12.032 c0.061-0.726,0.634-1.301,1.359-1.367c0.734-0.057,1.393,0.399,1.583,1.101c0.032,0.116,3.259,11.626,11.864,12.141 c0.714,0.042,1.298,0.583,1.396,1.292c0.098,0.708-0.319,1.388-0.994,1.622c-0.121,0.042-12.118,4.311-12.313,12.819 c-0.018,0.767-0.61,1.396-1.374,1.46C183.903,55.198,183.861,55.2,183.819,55.2z M176.078,39.471 c3.878,1.97,6.23,5.746,7.587,8.877c2.026-4.021,5.942-6.787,8.949-8.427c-4.134-1.843-6.783-5.651-8.334-8.723 C182.487,35.559,179.009,38.066,176.078,39.471z"/><path fill="#D7E057" d="M162.221,43.233c0,0,2.411,8.954,9.352,9.369c0,0-9.266,3.201-9.422,10.045c0,0-1.824-9.6-9.142-9.944 C153.009,52.704,161.532,51.412,162.221,43.233z"/><path fill="#3A312A" d="M162.149,64.148c-0.713,0-1.337-0.507-1.472-1.22c-0.016-0.082-1.704-8.442-7.739-8.727 c-0.77-0.036-1.388-0.65-1.428-1.421c-0.04-0.77,0.511-1.444,1.273-1.561c0.295-0.047,7.363-1.243,7.942-8.113 c0.061-0.726,0.635-1.303,1.36-1.368c0.732-0.067,1.392,0.399,1.582,1.102l0,0c0.021,0.079,2.228,7.92,7.994,8.265 c0.715,0.042,1.299,0.584,1.396,1.293c0.098,0.709-0.319,1.388-0.996,1.622c-0.081,0.028-8.282,2.953-8.412,8.662 c-0.018,0.768-0.611,1.398-1.376,1.461C162.233,64.146,162.191,64.148,162.149Line truncated
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="64px" height="64px"><path fill="#fff" d="M98.2,116.8l-22.2-9c-1.5,5.3-7.7,8.3-12.2,8.9v0.1c-0.7,0-1.9-0.1-2-0.1c-4.3-0.8-8.3-3.7-9.7-8.8 l-22.2,9l0,0C52,123.9,76,123.9,98.2,116.8L98.2,116.8z"/><path fill="#f7ebde" d="M76,102v6.5l0,0c-3.5,12-20.5,12-23.9,0l0,0V102"/><path fill="#f7ebde" d="M96.3,59.8c0-26.4-13.1-39.9-32.3-39.9S31.7,33.5,31.7,59.8c-6.2,0.5-4.8,14.6,2.2,13.8 c2,5.7,4.2,10.4,6.5,14.4c7.9,13.5,16.9,17.5,23.5,17.5c8.5,0,21.1-6.6,30-31.8C101.1,74.4,102.5,60.3,96.3,59.8z"/><path fill="#454b54" d="M30 120.5c-1.2 0-2.3-.7-2.8-1.9-.6-1.5.1-3.3 1.6-3.9l22.1-9c1.5-.6 3.3.1 3.9 1.6.6 1.5-.1 3.3-1.6 3.9l-22.1 9C30.7 120.4 30.3 120.5 30 120.5zM98.1 120.5c-.4 0-.8-.1-1.1-.2l-22.1-9c-1.5-.6-2.3-2.4-1.6-3.9.6-1.5 2.4-2.3 3.9-1.6l22.1 9c1.5.6 2.3 2.4 1.6 3.9C100.4 119.8 99.2 120.5 98.1 120.5z"/><path fill="#454b54" d="M64,120.5c-7.1,0-12.9-4.4-14.8-11.2c-0.5-1.6,0.5-3.3,2.1-3.7c1.6-0.5,3.3,0.5,3.7,2.1 c1.5,5.1,5.6,6.9,9.1,6.9s7.6-1.8,9.1-6.9c0.5-1.6,2.1-2.5,3.7-2.1c1.6,0.5,2.5,2.1,2.1,3.7C76.9,116.1,71.1,120.5,64,120.5z"/><path fill="#454b54" d="M64,108.4c-5.4,0-16-2.3-25.5-18c-0.9-1.4-0.4-3.3,1-4.1c1.4-0.9,3.3-0.4,4.1,1 c8,13.2,16.2,15.1,20.4,15.1c5,0,17.6-2.9,27.2-29.8c0.6-1.6,2.3-2.4,3.8-1.8c1.6,0.6,2.4,2.3,1.8,3.8C86,105.1,70.3,108.4,64,108.4 z"/><path fill="#c88b79" d="M96.7,59.9c-0.101,0-0.2,0-0.4,0C96.3,33.5,83.2,20,64,20c-14,0-24.8,7.2-29.6,21.4 c-1.7,4.9-2.6,10.6-2.7,17.1c7.712-18.368,17.85-11.435,31.374-4.503C72.521,58.84,83.621,63.684,96.7,59.9"/><path fill="#454b54" d="M87.1,64.3c-10.899,0-20.3-4.9-28.2-9c-6.8-3.6-12.7-6.6-16.9-5.1c-2.8,1-5.3,4.1-7.5,9.4 c-0.6,1.5-2.4,2.2-3.9,1.6c-1.5-0.6-2.2-2.4-1.6-3.9c2.9-7,6.5-11.1,11-12.8c6.7-2.4,13.7,1.2,21.7,5.4C71.5,55,82.6,60.8,95.8,57 c1.601-0.5,3.3,0.4,3.7,2c0.5,1.6-0.4,3.3-2,3.7C93.9,63.8,90.4,64.2,87.1,64.3"/><path fill="#fff" d="M59.864,55.8H36.34c-0.635,1.109-1.248,2.375-1.84,3.8c-0.455,1.137-1.599,1.814-2.78,1.814 c-0.179,0-0.358-0.015-0.536-0.047c0.204,5.363,0.505,13.239,0.515,13.239c0,0,0-0.002,0-0.006c0,2.601,2.1,4.7,4.7,4.7h17 c2.1,0,4-1.5,4.5-3.6l1-5.9c0.2-1.3,1.4-2.3,2.8-2.3c1.4,0,2.5,1,2.8,2.3l1,5.9c0.5,2.1,2.4,3.6,4.5,3.6h17c2.6,0,4.7-2.1,4.7-4.7 l0.414-10.714c-1.712,0.243-3.388,0.365-5.015,0.414C76.64,64.3,67.561,59.788,59.864,55.8"/><path fill="#faf4f2" d="M90.7,55.8H74.419c4.027,1.448,8.273,2.424,12.777,2.424c1.682,0,3.399-0.136,5.154-0.435L92.4,56.5 C92,56,91.4,55.8,90.7,55.8"/><path fill="#ededee" d="M74.419,55.8H59.864c7.697,3.988,16.776,8.5,27.236,8.5c1.627-0.049,3.303-0.171,5.015-0.414 l0.236-6.096c-1.756,0.299-3.473,0.435-5.154,0.435C82.693,58.224,78.447,57.247,74.419,55.8"/><path fill="#fff" d="M98.9,81.3l-8.1-1.4L96.3,48l8.1,1.4c3.5,0.6,5.8,4,5.2,7.5L106.3,76 C105.7,79.5,102.4,81.9,98.9,81.3z"/><path fill="#454b54" d="M100,84.4c-0.5,0-1.1,0-1.6-0.1L90.8,83c-1.6-0.3-2.7-1.8-2.5-3.4l5-32c0.1-0.8,0.6-1.5,1.2-2 s1.5-0.6,2.3-0.5l8.1,1.4c5.1,0.9,8.6,5.8,7.6,11l-3.4,19.1C108.5,81.1,104.6,84.4,100,84.4z M94.8,77.5l4.6,0.8 c0.2,0,0.4,0.1,0.6,0.1c1.7,0,3.1-1.2,3.4-2.9l3.4-19.1c0.3-1.9-0.9-3.7-2.8-4l-5.1-0.9L94.8,77.5z"/><path fill="#fff" d="M29.1,81.3l8.1-1.4L31.7,48l-8.1,1.4c-3.5,0.6-5.8,4-5.2,7.5L21.7,76C22.3,79.5,25.6,81.9,29.1,81.3z"/><path fill="#454b54" d="M28,84.4L28,84.4c-4.6,0-8.5-3.3-9.3-7.8l-3.4-19.1c-0.9-5.1,2.5-10,7.6-11l8.1-1.4 c0.8-0.1,1.6,0,2.2,0.5c0.7,0.5,1.1,1.2,1.2,1.9L40,79.3c0.3,1.6-0.8,3.2-2.4,3.5l-8.1,1.4C29.1,84.3,28.6,84.4,28,84.4z M29.2,51.5 L24,52.4c-1.9,0.3-3.1,2.1-2.8,4l3.4,19.1c0.3,1.7,1.7,2.9,3.4,2.9l0,0c0.2,0,0.4,0,0.6-0.1l5.2-0.9L29.2,51.5z"/><path fill="#454b54" d="M25.4,50.8h-0.1c-1.7-0.1-2.9-1.5-2.9-3.1C23.3,25.1,41.5,7.5,64,7.5c22.1,0,40.3,17.3,41.5,39.4 c0.1,1.7-1.2,3.1-2.8,3.2c-1.7,0.1-3.1-1.2-3.2-2.8c-1-18.9-16.6-33.7-35.5-33.7c-19.2,0-34.8,15.1-35.6,34.4 C28.4,49.5,27,50.8,25.4,50.8z"/><path fill="#454b54" d="M96.3,62.8c-1.7,0-3-1.3-3-3c0-23.4-10.7-36.9-29.3-36.9c-12.8,0-21.9,6.3-26.3,18.2 c-0.6,1.6-2.3,2.3-3.9,1.8c-1.6-0.6-2.3-2.3-1.8-3.9c5.4-14.2,16.7-22.1,32-22.1c22.1,0,35.3,16,35.3,42.9 C99.3,61.5,97.9,62.8,96.3,62.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="64px" height="64px" fill-rule="nonzero"><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><path d="M202.5,241.644h-149c-1.657,0 -3,-1.343 -3,-3v-8c0,-1.657 1.343,-3 3,-3h149c1.657,0 3,1.343 3,3v8c0,1.657 -1.343,3 -3,3z" fill="#4671c6"></path><path d="M202.5,243.644h-149c-2.757,0 -5,-2.243 -5,-5v-8c0,-2.757 2.243,-5 5,-5h149c2.757,0 5,2.243 5,5v8c0,2.757 -2.243,5 -5,5zM53.5,229.644c-0.551,0 -1,0.449 -1,1v8c0,0.551 0.449,1 1,1h149c0.552,0 1,-0.449 1,-1v-8c0,-0.551 -0.448,-1 -1,-1z" fill="#3762cc"></path><path d="M35.331,143.801c-1.657,0 -3,-1.343 -3,-3v-4c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v4c0,1.656 -1.344,3 -3,3z" fill="#a4c9ff"></path><path d="M215.615,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM198.879,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM182.143,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM165.406,199.536h-7.875c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.875c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM148.671,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM131.935,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM115.198,199.536h-7.875c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.875c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM98.462,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM81.727,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.344,3 -3,3zM64.99,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM48.254,199.536h-7.876c-1.657,0 -3,-1.343 -3,-3c0,-1.657 1.343,-3 3,-3h7.876c1.657,0 3,1.343 3,3c0,1.657 -1.343,3 -3,3zM220.686,194.032c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,194.009c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3zM220.686,177.296c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,177.273c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3zM220.686,160.56c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.343,3 -3,3zM35.331,160.537c-1.657,0 -3,-1.343 -3,-3v-7.876c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v7.876c0,1.657 -1.344,3 -3,3z" fill="#a4c9ff"></path><path d="M220.686,143.801c-1.657,0 -3,-1.343 -3,-3v-4c0,-1.657 1.343,-3 3,-3c1.657,0 3,1.343 3,3v4c0,1.656 -1.343,3 -3,3z" fill="#a4c9ff"></path><ellipse cx="220.686" cy="112.489" rx="32.314" ry="32.095" fill="#6bdddd"></ellipse><path d="M169.779,136.801h-23.373v0.671c0,9.976 -8.087,18.062 -18.062,18.062c-9.975,0 -18.062,-8.087 -18.062,-18.062v-0.671h-23.373c-8.97,0 -16.242,7.272 -16.242,16.242v51.889h115.355v-51.889c0,-8.97 -7.272,-16.242 -16.243,-16.242z" fill="#e0ebfc"></path><path d="M128.344,128.869c-6.43,0 -12.538,-1.352 -18.062,-3.772v12.375c0,9.976 8.087,18.062 18.062,18.062c9.975,0 18.062,-8.087 18.062,-18.062v-12.375c-5.524,2.421 -11.632,3.772 -18.062,3.772z" fill="#f9cfcf"></path><path d="M186.021,206.932h-115.354c-1.104,0 -2,-0.896 -2,-2v-51.889c0,-10.059 8.184,-18.242 18.242,-18.242h23.373c1.104,0 2,0.896 2,2v0.67c0,8.857 7.206,16.063 16.062,16.063c8.856,0 16.063,-7.206 16.063,-16.063v-0.67c0,-1.104 0.896,-2 2,-2h23.373c10.059,0 18.242,8.184 18.242,18.242v51.889c-0.001,1.105 -0.896,2 -2.001,2zM72.667,202.932h111.355v-49.889c0,-7.853 -6.389,-14.242 -14.242,-14.242h-21.417c-0.687,10.445 -9.403,18.733 -20.019,18.733c-10.616,0 -19.332,-8.288 -20.019,-18.733h-21.416c-7.853,0 -14.242,6.389 -14.242,14.242z" fill="#3762cc"></path><path d="M128.344,157.534c-11.062,0 -20.062,-9 -20.062,-20.063v-12.375c0,-0.675 0.341,-1.305 0.906,-1.674c0.565,-0.37 1.279,-0.428 1.897,-0.158c5.458,2.392 11.265,3.604 17.259,3.604c5.995,0 11.802,-1.213 17.26,-3.604c0.618,-0.27 1.332,-0.211 1.896,0.158c0.565,0.37 0.906,0.999 0.906,1.674v12.375c0,11.063 -9,20.063 -20.062,20.063zM112.282,128.052v9.419c0,8.857 7.206,16.063 16.062,16.063c8.856,0 16.063,-7.206 16.063,-16.063v-9.419c-5.138,1.871 -10.527,2.817 -16.063,2.817c-5.535,0 -10.925,-0.947 -16.062,-2.817z" fill="#3762cc"></path><path d="M173.087,73.675h-5.851v-5.907c0,-5.827 -2.859,-10.974 -7.239,-14.155c0.018,-0.027 0.032,-0.055 0.05,-0.082c-4.52,6.895 -12.313,11.45 -21.Line truncated
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||||
|
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Chat bubble background -->
|
||||||
|
<path
|
||||||
|
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Letter C -->
|
||||||
|
<path
|
||||||
|
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||||
|
fill="#f34611"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dots -->
|
||||||
|
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||||
|
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||||
|
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 728 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg width="1200" height="1200" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Chat bubble background with subtle outline -->
|
||||||
|
<path
|
||||||
|
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||||
|
fill="#f34611"
|
||||||
|
stroke="#EEEEEE"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Letter C -->
|
||||||
|
<path
|
||||||
|
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dots -->
|
||||||
|
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||||
|
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||||
|
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,32 @@
|
|||||||
|
// Runtime configuration - this file gets replaced at container startup
|
||||||
|
// Assigned to `self` so the service worker can importScripts() it too;
|
||||||
|
// in a page self === window, so window.APP_CONFIG still works.
|
||||||
|
self.APP_CONFIG = {
|
||||||
|
// API URLs
|
||||||
|
API_URL: "https://devapi.replypier.site/api/v1",
|
||||||
|
WS_URL: "wss://devapi.replypier.site",
|
||||||
|
WIDGET_URL: "https://devapp.replypier.site",
|
||||||
|
|
||||||
|
// Firebase Configuration
|
||||||
|
FIREBASE_API_KEY: "AIzaSyDTwDgANnBItYx39qm-6shq4cUws6QY4wI",
|
||||||
|
FIREBASE_AUTH_DOMAIN: "replypier-13e0a.firebaseapp.com",
|
||||||
|
FIREBASE_PROJECT_ID: "replypier-13e0a",
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID: "680125690355",
|
||||||
|
FIREBASE_APP_ID: "1:680125690355:web:466f50999a6c91935ae286",
|
||||||
|
FIREBASE_STORAGE_BUCKET: "replypier-13e0a.firebasestorage.app",
|
||||||
|
FIREBASE_MEASUREMENT_ID: "G-7LGB58R2Q7",
|
||||||
|
FIREBASE_VAPID_KEY: "BJKnggErez2qK3AxBhHyEV-EoS9lfcWO1GDXhRwUKIHbT_PH23vhY_8HRIz_KyuC-0gbuViSrtSMiFp7zgyYY4g",
|
||||||
|
|
||||||
|
// Razorpay Checkout
|
||||||
|
RAZORPAY_KEY_ID: "rzp_test_T92OR9gqPBht0N",
|
||||||
|
GTM_ID: "",
|
||||||
|
|
||||||
|
// Google Fonts API
|
||||||
|
GOOGLE_FONTS_API_KEY: "AIzaSyCTQCSK_ybLEglabeusbyekQGEyvFr2C5o",
|
||||||
|
|
||||||
|
DEMO_WIDGET_ID: "a6ee36e9-b70e-4b12-8f94-cc5a5b147f26",
|
||||||
|
// Node Environment
|
||||||
|
NODE_ENV: "development",
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Runtime configuration - this file gets replaced at container startup
|
||||||
|
// Assigned to `self` so the service worker can importScripts() it too;
|
||||||
|
// in a page self === window, so window.APP_CONFIG still works.
|
||||||
|
self.APP_CONFIG = {
|
||||||
|
// API URLs
|
||||||
|
API_URL: "http://localhost:8000/api/v1",
|
||||||
|
WS_URL: "ws://localhost:8000",
|
||||||
|
WIDGET_URL: "http://localhost:8000",
|
||||||
|
|
||||||
|
// Firebase Configuration
|
||||||
|
FIREBASE_API_KEY: "",
|
||||||
|
FIREBASE_AUTH_DOMAIN: "",
|
||||||
|
FIREBASE_PROJECT_ID: "",
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID: "680125690355",
|
||||||
|
FIREBASE_APP_ID: "",
|
||||||
|
FIREBASE_STORAGE_BUCKET: "",
|
||||||
|
FIREBASE_MEASUREMENT_ID: "",
|
||||||
|
FIREBASE_VAPID_KEY: "",
|
||||||
|
|
||||||
|
// Razorpay Checkout
|
||||||
|
RAZORPAY_KEY_ID: "rzp_test_T92OR9gqPBht0N",
|
||||||
|
GTM_ID: "",
|
||||||
|
|
||||||
|
// Google Fonts API
|
||||||
|
GOOGLE_FONTS_API_KEY: "",
|
||||||
|
|
||||||
|
// Node Environment
|
||||||
|
NODE_ENV: "development",
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||||
|
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="20" y="20" width="80" height="80" rx="22" fill="#C9F24E"/>
|
||||||
|
<circle cx="42" cy="60" r="6" fill="#0B0C10"/>
|
||||||
|
<circle cx="60" cy="60" r="6" fill="#0B0C10"/>
|
||||||
|
<circle cx="78" cy="60" r="6" fill="#0B0C10"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
OAuth redirect target for Facebook Login for Business (Messenger/Instagram).
|
||||||
|
|
||||||
|
Meta redirects the login popup here with ?code&state; this relays them to the
|
||||||
|
opener and closes. It's a static file (not the SPA) so the popup stays light,
|
||||||
|
and it must be registered as a Valid OAuth Redirect URI on the Meta app.
|
||||||
|
-->
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8" /><title>Connecting…</title></head>
|
||||||
|
<body>
|
||||||
|
Connecting…
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var params = new URLSearchParams(window.location.search)
|
||||||
|
var message = {
|
||||||
|
source: 'meta-oauth',
|
||||||
|
code: params.get('code'),
|
||||||
|
state: params.get('state'),
|
||||||
|
error: params.get('error_description') || params.get('error'),
|
||||||
|
}
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage(message, window.location.origin)
|
||||||
|
}
|
||||||
|
window.close()
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -0,0 +1,888 @@
|
|||||||
|
(()=>{var wt=(at,ot,e)=>new Promise((M,d)=>{var j=x=>{try{B(e.next(x))}catch(N){d(N)}},V=x=>{try{B(e.throw(x))}catch(N){d(N)}},B=x=>x.done?M(x.value):Promise.resolve(x.value).then(j,V);B((e=e.apply(at,ot)).next())});window.replypierId;window.replypierBaseUrl;window.ReplyPier;window.replypierConfig;(function(){function at(t){return/^#[0-9A-F]{6}$/i.test(t)}function ot(){return typeof window!="undefined"&&window.replypierBaseUrl?window.replypierBaseUrl:typeof window!="undefined"&&window.APP_CONFIG&&window.APP_CONFIG.API_URL?window.APP_CONFIG.API_URL:"https://api.replypier.site/api/v1"}let e={baseUrl:ot(),containerId:"replypier-container",backdropId:"replypier-backdrop",buttonId:"replypier-button",chatBubbleColor:"#f34611",loadingContainerId:"replypier-loading",tokenKey:"rpid",tokenProvider:null,containerBottom:100,containerRight:20,containerWidth:400,containerHeight:560,launcherBottom:20,launcherRight:20,side:"right",displayMode:"floating",launcher:!0,sidebarWidth:420,searchBarWidth:320,searchPlaceholder:"Ask anything...",zIndex:999999,trigger:null,hotkey:!0,surfaceIsPalette:null,chatStyle:null,chatInitiationMessages:[],initiationMessageId:"replypier-initiation",initiationShownKey:"rpim_shown",unreadCount:0,hasOpened:!1},M={};function d(t,n){e[t]=n,M[t]=!0}let j=["floating","sidebar-left","sidebar-right","search-bar"],V=["ASK_ANYTHING","AURORA"],B=680,x=620,N=140,S={width:!1,height:!1},P=0;function q(){return e.surfaceIsPalette===!1?!1:(V.indexOf(e.chatStyle)!==-1||e.displayMode==="search-bar")&&e.displayMode!=="sidebar-left"&&e.displayMode!=="sidebar-right"}function Z(){let t=document.getElementById(e.containerId);if(!t)return;if(!q()||!P){t.style.height="";return}let n=Math.min(rt().height,Math.round(window.innerHeight*.76));t.style.height=Math.max(N,Math.min(n,P))+"px"}function rt(){return{width:S.width?e.containerWidth:B,height:S.height?e.containerHeight:x}}function _t(t){if(!t||typeof t!="object")return;let n=function(i,o){M[i]||(e[i]=o)};j.indexOf(t.mode)!==-1&&n("displayMode",t.mode),(t.side==="left"||t.side==="right")&&n("side",t.side),typeof t.launcher=="boolean"&&n("launcher",t.launcher),m(t.width)&&(n("containerWidth",Math.max(280,t.width)),M.containerWidth||(S.width=!0)),m(t.height)&&(n("containerHeight",Math.max(400,t.height)),M.containerHeight||(S.height=!0)),m(t.sidebar_width)&&n("sidebarWidth",Math.max(280,t.sidebar_width)),typeof t.search_placeholder=="string"&&t.search_placeholder&&n("searchPlaceholder",t.search_placeholder.slice(0,80)),m(t.offset_bottom)&&n("launcherBottom",Math.max(0,t.offset_bottom)),m(t.offset_side)&&n("launcherRight",Math.max(0,t.offset_side)),m(t.z_index)&&n("zIndex",Math.max(1,Math.floor(t.z_index)))}function It(){let t=q(),n=t?rt():{width:e.containerWidth,height:e.containerHeight};return{type:"WIDGET_DISPLAY",mode:t?"ask-ai":e.displayMode,width:n.width,height:n.height,hotkey:e.hotkey!==!1}}let c=null,R=[],X=!1,g=null,T=null,y=!1;function J(t){c?t():y||R.push(t)}let Q={},st=!1;function _(t,n){let i=Q[t];!i||!i.length||i.slice().forEach(function(o){try{o(n)}catch(h){}})}function Lt(t){let n=/^#?([0-9a-f]{6})$/i.exec((t||"").trim());if(!n)return"#0B0C10";let i=parseInt(n[1],16);return(.299*(i>>16&255)+.587*(i>>8&255)+.114*(i&255))/255>.6?"#0B0C10":"#FFFFFF"}let ct=!1,E=null,lt=[];function F(){if(ct)return;ct=!0,E&&(clearTimeout(E),E=null);let t=document.getElementById(e.buttonId);t&&t.classList.remove("replypier-pending"),lt.splice(0).forEach(n=>{try{n()}catch(i){}})}function O(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-badge");if(!n)return;let i=e.hasOpened?e.unreadCount:(e.chatInitiationMessages||[]).length;n.textContent=i>99?"99+":String(i),n.style.display=i>0&&!t.classList.contains("active")?"flex":"none"}function W(){let t=e.displayMode,n=t==="sidebar-left"||t==="sidebar-right",i=t==="sidebar-left"?"left":t==="sidebar-right"?"right":e.side,o=t==="search-bar"?48:64,h=V.indexOf(e.chatStyle)===-1,r=q(),l=rt(),w=document.createElement("style");w.id="replypier-styles",w.textContent=`
|
||||||
|
/* ===== Rounded-square launcher (design comp) ===== */
|
||||||
|
#${e.buttonId} {
|
||||||
|
position: fixed;
|
||||||
|
bottom: ${e.launcherBottom}px;
|
||||||
|
${i}: ${e.launcherRight}px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 20px 20px 20px 6px;
|
||||||
|
background: ${e.chatBubbleColor};
|
||||||
|
color: ${Lt(e.chatBubbleColor)};
|
||||||
|
box-shadow: 0 16px 40px -8px ${e.chatBubbleColor}cc, inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: ${e.zIndex};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease;
|
||||||
|
animation: replypier-float 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#${e.buttonId}.active { animation: none; }
|
||||||
|
/* Stay fully invisible until the org's real brand color is known (or the
|
||||||
|
widget errors out / times out) \u2014 avoids ever flashing the wrong color. */
|
||||||
|
#${e.buttonId}.replypier-pending {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#${e.buttonId}:hover { transform: scale(1.06); }
|
||||||
|
|
||||||
|
/* Expanding rings (closed state only) */
|
||||||
|
#${e.buttonId} .rp-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 20px 20px 20px 6px;
|
||||||
|
border: 1.5px solid ${e.chatBubbleColor};
|
||||||
|
animation: replypier-ring 2.4s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-ring.r2 { animation-delay: 1.2s; }
|
||||||
|
#${e.buttonId}.active .rp-ring { display: none; }
|
||||||
|
|
||||||
|
/* Pulsing 3-dot mark (closed). Absolutely centered so it stays pinned to the
|
||||||
|
button's center regardless of the button's display mode (flex on desktop,
|
||||||
|
block when .mobile-closed) or viewport. */
|
||||||
|
#${e.buttonId} .rp-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
animation: replypier-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-dot:nth-child(2) { animation-delay: .18s; }
|
||||||
|
#${e.buttonId} .rp-dot:nth-child(3) { animation-delay: .36s; }
|
||||||
|
#${e.buttonId}.active .rp-dots { display: none; }
|
||||||
|
|
||||||
|
/* Chevron (open) \u2014 absolutely centered, pinned to the button center. */
|
||||||
|
#${e.buttonId} .rp-chevron {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
#${e.buttonId}.active .rp-chevron { display: block; }
|
||||||
|
|
||||||
|
/* Unread badge = number of nudges */
|
||||||
|
#${e.buttonId} .rp-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #FF8A73;
|
||||||
|
color: #0B0C10;
|
||||||
|
font: 700 11.5px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #F5F3EE;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#${e.buttonId}.active .rp-badge { display: none; }
|
||||||
|
|
||||||
|
#${e.buttonId}.loading .rp-dots,
|
||||||
|
#${e.buttonId}.loading .rp-chevron { visibility: hidden; }
|
||||||
|
#${e.buttonId}.loading:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin: -8px 0 0 -8px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: replypier-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search-bar trigger content: hidden unless search-bar mode (below) shows it. */
|
||||||
|
#${e.buttonId} .rp-search { display: none; }
|
||||||
|
|
||||||
|
${t==="search-bar"?`
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
/* ===== Search-bar mode: the launcher becomes a search-input-like bar.
|
||||||
|
Same element, same click/toggle/badge logic. Mobile keeps the bubble. ===== */
|
||||||
|
#${e.buttonId} {
|
||||||
|
width: ${e.searchBarWidth}px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #374151;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14), inset 0 0 0 1.5px ${e.chatBubbleColor}55;
|
||||||
|
animation: none;
|
||||||
|
justify-content: flex-start;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#${e.buttonId}:hover { transform: scale(1.02); }
|
||||||
|
#${e.buttonId} .rp-ring,
|
||||||
|
#${e.buttonId} .rp-dots,
|
||||||
|
#${e.buttonId} .rp-chevron,
|
||||||
|
#${e.buttonId}.active .rp-chevron { display: none; }
|
||||||
|
/* Dark spinner \u2014 the stock white one vanishes on the white bar. */
|
||||||
|
#${e.buttonId}.loading .rp-search { visibility: hidden; }
|
||||||
|
#${e.buttonId}.loading:after {
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-top-color: ${e.chatBubbleColor};
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-search svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: ${e.chatBubbleColor};
|
||||||
|
}
|
||||||
|
#${e.buttonId} .rp-search-placeholder {
|
||||||
|
font: 400 14px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`:""}
|
||||||
|
|
||||||
|
@keyframes replypier-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes replypier-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||||
|
@keyframes replypier-ring { 0% { transform: scale(.85); opacity: .5; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||||
|
@keyframes replypier-pulse { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#${e.buttonId},
|
||||||
|
#${e.buttonId} .rp-ring,
|
||||||
|
#${e.buttonId} .rp-dot,
|
||||||
|
.initiation-orb { animation: none !important; }
|
||||||
|
/* Open and close in place: no slide, scale or fade for people who asked for less motion. */
|
||||||
|
#${e.buttonId},
|
||||||
|
#${e.containerId},
|
||||||
|
#${e.backdropId} { transition: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.containerId} {
|
||||||
|
position: fixed;
|
||||||
|
/* The window sits above the launcher; when the launcher is moved (via
|
||||||
|
ReplyPier.init position / setPosition) the window shifts by the same
|
||||||
|
delta so the two stay together. Mobile is full-screen (media query below)
|
||||||
|
and is unaffected. */
|
||||||
|
bottom: ${(e.containerBottom||100)+((e.launcherBottom||20)-20)}px;
|
||||||
|
${i}: ${(e.containerRight||20)+((e.launcherRight||20)-20)}px;
|
||||||
|
width: ${e.containerWidth||384}px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
height: ${e.containerHeight}px;
|
||||||
|
max-height: calc(100vh - 132px);
|
||||||
|
background: transparent;
|
||||||
|
z-index: ${e.zIndex};
|
||||||
|
overflow: hidden;
|
||||||
|
/* Animated open/close with slide + fade */
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(16px) scale(0.98);
|
||||||
|
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1), transform 360ms cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear 360ms;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.containerId}.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1), transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backdrop element: always in the DOM, so keep it out of the host page's flow
|
||||||
|
even when it is not in use \u2014 an unstyled div in <body> adds a phantom row to
|
||||||
|
a flex/grid body layout. */
|
||||||
|
#${e.backdropId} {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
${r?`
|
||||||
|
/* ===== Ask AI: centered command palette (\u2318K), the pattern people expect from
|
||||||
|
an "Ask anything" surface \u2014 dimmed page, top-anchored so the eye lands on
|
||||||
|
the input rather than the middle of the screen. ===== */
|
||||||
|
#${e.containerId} {
|
||||||
|
top: 12vh;
|
||||||
|
bottom: auto;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
width: min(${l.width}px, calc(100vw - 32px));
|
||||||
|
max-width: none;
|
||||||
|
/* A cross-document iframe can't size its embedder, so this is a starting
|
||||||
|
height only: the palette measures its content and posts WIDGET_RESIZE,
|
||||||
|
which sets an inline height (clamped to this ceiling). height:100% against
|
||||||
|
an auto-height parent would collapse to the 150px replaced default. */
|
||||||
|
height: min(${l.height}px, 76vh);
|
||||||
|
max-height: 76vh;
|
||||||
|
transform: translate(-50%, -8px) scale(0.98);
|
||||||
|
z-index: ${e.zIndex+1};
|
||||||
|
transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 360ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
height 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
visibility 0s linear 360ms;
|
||||||
|
}
|
||||||
|
#${e.containerId}.active {
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
height 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
#${e.containerId} .replypier-iframe {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
#${e.backdropId} {
|
||||||
|
display: block;
|
||||||
|
background: rgba(9, 9, 14, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: ${e.zIndex};
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 220ms ease, visibility 0s linear 220ms;
|
||||||
|
}
|
||||||
|
#${e.backdropId}.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition: opacity 220ms ease;
|
||||||
|
}
|
||||||
|
/* The palette dims the page and closes on backdrop click / Esc, so the launcher
|
||||||
|
would just be a stray button floating over the dim. */
|
||||||
|
#${e.buttonId}.active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Full-screen on phones like every other mode. The main mobile block below
|
||||||
|
pins the edges but never resets transform \u2014 without this the centering
|
||||||
|
translate would shove the panel half a screen to the left. */
|
||||||
|
#${e.containerId},
|
||||||
|
#${e.containerId}.active { transform: none !important; }
|
||||||
|
#${e.backdropId} { display: none; }
|
||||||
|
}
|
||||||
|
`:""}
|
||||||
|
|
||||||
|
${n?`
|
||||||
|
/* ===== Sidebar mode: full-height drawer hugging the ${i} edge ===== */
|
||||||
|
#${e.containerId} {
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
${i}: 0;
|
||||||
|
${i==="left"?"right: auto;":"left: auto;"}
|
||||||
|
width: ${e.sidebarWidth}px;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
transform: translateX(${i==="left"?"-100%":"100%"});
|
||||||
|
box-shadow: ${i==="left"?"":"-"}12px 0 40px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
#${e.containerId}.active {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#${e.containerId} .replypier-iframe {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
${h?`
|
||||||
|
/* The drawer's own header chevron closes it, so keeping the launcher on screen
|
||||||
|
would just park a duplicate button beside the panel. Hide it while open. */
|
||||||
|
#${e.buttonId}.active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`:`
|
||||||
|
/* ASK_ANYTHING / AURORA draw no in-panel chevron, and the drawer would paint over
|
||||||
|
the launcher (later sibling, equal z-index) \u2014 leaving nothing to click. Keep it
|
||||||
|
reachable: slide it out beside the drawer edge, one layer up. */
|
||||||
|
#${e.buttonId} {
|
||||||
|
z-index: ${e.zIndex+1};
|
||||||
|
transition: transform 0.3s cubic-bezier(.34,1.3,.5,1), opacity 320ms ease,
|
||||||
|
left 360ms cubic-bezier(0.22, 1, 0.36, 1), right 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
#${e.buttonId}.active {
|
||||||
|
${i}: ${e.sidebarWidth+20}px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`:""}
|
||||||
|
|
||||||
|
/* Clean border around the widget container */
|
||||||
|
/* The widget panel (inside the iframe) draws its own theme-aware border, so the
|
||||||
|
container no longer adds one \u2014 avoids a double border (and a white line on dark themes). */
|
||||||
|
|
||||||
|
.replypier-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
border-radius: 24px;
|
||||||
|
/* Rely on corner shadows from container */
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replypier-mobile-close {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: ${e.zIndex+1};
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replypier-mobile-topbar {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: ${e.zIndex};
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replypier-mobile-topbar.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replypier-mobile-topbar .topbar-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only ASK_ANYTHING (which has no in-panel header chevron) uses the floating
|
||||||
|
mobile-close; other styles close via the header chevron, so don't double up. */
|
||||||
|
.ask-anything-mobile #replypier-mobile-close.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#${e.containerId} {
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
height: 100dvh !important; /* Dynamic viewport height for mobile browsers */
|
||||||
|
/* Reset the desktop max-width/height clamp \u2014 without this the desktop rule's
|
||||||
|
max-width: calc(100vw - 48px) / max-height: calc(100vh - 132px) keeps the
|
||||||
|
window a floating panel instead of going full-screen on mobile. */
|
||||||
|
max-width: 100vw !important;
|
||||||
|
max-height: 100vh !important;
|
||||||
|
max-height: 100dvh !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
position: fixed !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.containerId}.active {
|
||||||
|
bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-iframe {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
height: 100dvh !important; /* Dynamic viewport height for mobile browsers */
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.buttonId} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.buttonId}.mobile-closed {
|
||||||
|
/* flex (not block) keeps the launcher's centering context; the dots/chevron
|
||||||
|
are absolutely centered regardless, but this keeps it consistent. */
|
||||||
|
display: flex !important;
|
||||||
|
width: 56px !important;
|
||||||
|
height: 56px !important;
|
||||||
|
/* Respect the configured position on mobile too, so the closed launcher can
|
||||||
|
clear a fixed bottom nav bar (the main reason to move it up). */
|
||||||
|
bottom: ${e.launcherBottom}px !important;
|
||||||
|
${i}: ${e.launcherRight}px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-anything-mobile #replypier-mobile-close.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replypier-mobile-close:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ASK_ANYTHING style specific mobile topbar */
|
||||||
|
.ask-anything-mobile #replypier-mobile-topbar.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-anything-mobile #replypier-mobile-close.active {
|
||||||
|
top: 15px !important;
|
||||||
|
right: 15px !important;
|
||||||
|
z-index: ${e.zIndex+2} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When topbar is visible, push iframe down to avoid overlap */
|
||||||
|
.ask-anything-mobile .replypier-iframe {
|
||||||
|
top: 60px !important;
|
||||||
|
height: calc(100vh - 60px) !important;
|
||||||
|
height: calc(100dvh - 60px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
#replypier-mobile-close {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Initiation Message Styles */
|
||||||
|
#${e.initiationMessageId} {
|
||||||
|
position: fixed !important;
|
||||||
|
/* Sit above the launcher (+12px gap) and align to its edge on the active side,
|
||||||
|
so it never overlaps the icon regardless of the configured position/mode. */
|
||||||
|
bottom: ${(e.launcherBottom||20)+o+12}px !important;
|
||||||
|
${i}: ${e.launcherRight||20}px !important;
|
||||||
|
max-width: 260px !important;
|
||||||
|
background: white !important;
|
||||||
|
padding: 12px 36px 12px 14px !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
z-index: ${e.zIndex-1} !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(10px) scale(0.95);
|
||||||
|
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
display: block !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
text-align: left !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
/* Clamp the nudge text to 4 lines with an ellipsis so a long welcome/initiation
|
||||||
|
message can never balloon the bubble over the page or into the launcher.
|
||||||
|
Applied to the text node only, so the orb (left:-34px) is never clipped. */
|
||||||
|
#${e.initiationMessageId} .initiation-message-text {
|
||||||
|
margin: 0 !important;
|
||||||
|
display: -webkit-box !important;
|
||||||
|
-webkit-line-clamp: 4 !important;
|
||||||
|
-webkit-box-orient: vertical !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Siri-style orb avatar beside the nudge (design comp) */
|
||||||
|
.initiation-orb {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -34px !important;
|
||||||
|
bottom: 2px !important;
|
||||||
|
width: 26px !important;
|
||||||
|
height: 26px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: conic-gradient(from 0deg, #C9F24E, #9D8CFF, #5FE3D6, #FF8A73, #C9F24E) !important;
|
||||||
|
box-shadow: 0 4px 14px rgba(157, 140, 255, 0.4) !important;
|
||||||
|
animation: replypier-orb-spin 6s linear infinite !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
@keyframes replypier-orb-spin { to { transform: rotate(360deg); } }
|
||||||
|
${i==="left"?`
|
||||||
|
/* On the left edge the orb (which protrudes 34px past the bubble) would clip
|
||||||
|
off-screen \u2014 mirror it to the bubble's page-facing side. */
|
||||||
|
.initiation-orb {
|
||||||
|
left: auto !important;
|
||||||
|
right: -34px !important;
|
||||||
|
}
|
||||||
|
`:""}
|
||||||
|
|
||||||
|
#${e.initiationMessageId}.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
animation: replypier-bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes replypier-bounce-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-5px) scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.initiationMessageId}:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${e.initiationMessageId}::after {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: -7px !important;
|
||||||
|
${i}: 30px !important;
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
min-width: 14px !important;
|
||||||
|
min-height: 14px !important;
|
||||||
|
max-width: 14px !important;
|
||||||
|
max-height: 14px !important;
|
||||||
|
background: white !important;
|
||||||
|
transform: rotate(45deg) !important;
|
||||||
|
box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.06) !important;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
z-index: -1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-message-text {
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 4px 0 0 !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
min-height: 18px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
text-align: left !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: normal !important;
|
||||||
|
word-spacing: normal !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-message-text::after {
|
||||||
|
content: '|';
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: ${e.chatBubbleColor};
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-message-text.typing-complete::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-close {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 10px !important;
|
||||||
|
right: 10px !important;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 20px !important;
|
||||||
|
min-height: 20px !important;
|
||||||
|
max-width: 20px !important;
|
||||||
|
max-height: 20px !important;
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 5px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
opacity: 0.5 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
z-index: 2 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 0 !important;
|
||||||
|
line-height: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-close:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-close::before,
|
||||||
|
.initiation-close::after {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
width: 9px !important;
|
||||||
|
height: 1.5px !important;
|
||||||
|
min-width: 9px !important;
|
||||||
|
min-height: 1.5px !important;
|
||||||
|
max-width: 9px !important;
|
||||||
|
max-height: 1.5px !important;
|
||||||
|
background: #4a5568 !important;
|
||||||
|
border-radius: 1px !important;
|
||||||
|
top: 50% !important;
|
||||||
|
left: 50% !important;
|
||||||
|
margin: -0.75px 0 0 -4.5px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-close::before {
|
||||||
|
transform: rotate(45deg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiation-close::after {
|
||||||
|
transform: rotate(-45deg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#${e.initiationMessageId} {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error UI Styles */
|
||||||
|
.replypier-error-ui {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-ui::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(120, 120, 140, 0.12);
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.replypier-error-close:hover {
|
||||||
|
background: rgba(120, 120, 140, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.replypier-error-footer-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.replypier-error-footer-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.replypier-error-ui {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-card {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-icon-wrapper {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replypier-error-message {
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${e.launcher?"":`
|
||||||
|
/* Launcher hidden by config \u2014 the site opens the chat with its own trigger or
|
||||||
|
the JS API. Last in the sheet so it also beats the mobile .mobile-closed
|
||||||
|
re-show (display: flex !important at equal specificity). */
|
||||||
|
#${e.buttonId}, #${e.buttonId}.mobile-closed { display: none !important; }
|
||||||
|
`}
|
||||||
|
`;let f=document.getElementById("replypier-styles");f&&f.remove(),document.head.appendChild(w),vt()}function vt(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-search-placeholder");n&&n.textContent!==e.searchPlaceholder&&(n.textContent=e.searchPlaceholder)}function C(t){if(typeof t!="string")return null;let n=t.trim();return!n||n==="undefined"||n==="null"?null:n}function dt(){return C(window.replypierToken)||C(localStorage.getItem(e.tokenKey))}function ht(t){let n=C(t);if(!n)return null;let i=n.split(".")[1];if(!i)return null;try{let o=JSON.parse(atob(i.replace(/-/g,"+").replace(/_/g,"/")));return o.sub||o.customer_id||null}catch(o){return null}}function tt(t){t&&localStorage.setItem(e.tokenKey,t)}function H(){localStorage.removeItem(e.tokenKey)}function U(){return window.innerWidth<=768}function zt(t){return t?t.replace(/\uFFFD/g,"").replace(/[��]/g,"").replace(/[\x00-\x1F\x7F-\x9F]/g,"").trim():""}function Bt(){if(!e.chatInitiationMessages||e.chatInitiationMessages.length===0)return null;let t=Math.floor(Math.random()*e.chatInitiationMessages.length),n=e.chatInitiationMessages[t];return n?zt(n):null}function Nt(){return U()||!e.launcher||!e.chatInitiationMessages||e.chatInitiationMessages.length===0?!1:!sessionStorage.getItem(e.initiationShownKey)}function pt(){sessionStorage.setItem(e.initiationShownKey,"true")}function Pt(){let t=Bt();if(!t)return null;let n=document.createElement("div");return n.id=e.initiationMessageId,n.innerHTML=`
|
||||||
|
<span class="initiation-orb"></span>
|
||||||
|
<button class="initiation-close" aria-label="Close"></button>
|
||||||
|
<p class="initiation-message-text"></p>
|
||||||
|
`,n.dataset.fullMessage=t,n}function Rt(t,n,i=50){let o=t.querySelector(".initiation-message-text");if(!o)return;let h=0,r=setInterval(()=>{h<n.length?(o.textContent+=n.charAt(h),h++):(clearInterval(r),o.classList.add("typing-complete"),t.typeInterval=null)},i);t.typeInterval=r}function Ft(t,n){if(!t||!Nt())return;document.body.appendChild(t),setTimeout(()=>{t.classList.add("show"),setTimeout(()=>{let o=t.dataset.fullMessage;o&&Rt(t,o,40)},300)},1500),t.addEventListener("click",o=>{o.target.classList.contains("initiation-close")||(t.typeInterval&&clearInterval(t.typeInterval),mt(t),n(),pt())});let i=t.querySelector(".initiation-close");i&&i.addEventListener("click",o=>{o.stopPropagation(),t.typeInterval&&clearInterval(t.typeInterval),mt(t),pt()})}function mt(t){t&&(t.classList.remove("show"),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t)},400))}function ut(){y=!1,W();let t=document.createElement("div");t.id=e.buttonId,t.classList.add("replypier-pending"),t.innerHTML=`
|
||||||
|
<span class="rp-ring"></span>
|
||||||
|
<span class="rp-ring r2"></span>
|
||||||
|
<div class="rp-dots"><span class="rp-dot"></span><span class="rp-dot"></span><span class="rp-dot"></span></div>
|
||||||
|
<span class="rp-chevron">⌄</span>
|
||||||
|
<span class="rp-search">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.35-4.35"></path></svg>
|
||||||
|
<span class="rp-search-placeholder"></span>
|
||||||
|
</span>
|
||||||
|
<span class="rp-badge"></span>
|
||||||
|
`,E=setTimeout(F,8e3);let n=document.createElement("div");n.id=e.containerId;let i=document.createElement("div");i.id="replypier-mobile-close",i.innerHTML=`
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 12H19" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;let o=document.createElement("div");o.id="replypier-mobile-topbar",o.innerHTML=`
|
||||||
|
<h3 class="topbar-title">Chat</h3>
|
||||||
|
<div style="width: 44px;"></div>
|
||||||
|
`;let h=document.createElement("div");h.id=e.backdropId,document.body.appendChild(t),document.body.appendChild(h),document.body.appendChild(n),document.body.appendChild(i),document.body.appendChild(o),O(),vt();let r=!1,l=null,w=!1,f=null;function b(){r=!r,n.classList.toggle("active"),h.classList.toggle("active",r),t.classList.toggle("active"),i.classList.toggle("active"),r&&(e.hasOpened=!0,e.unreadCount=0),l&&l.contentWindow&&l.contentWindow.postMessage({type:"WIDGET_VISIBILITY",open:r},"*"),O(),r&&f&&(mt(f),f=null,pt()),U()?r?(t.classList.remove("mobile-closed"),t.style.animation=""):(t.classList.add("mobile-closed"),t.style.animation="replypier-float 4s ease-in-out infinite",o.classList.remove("active"),document.body.classList.remove("ask-anything-mobile")):r&&i.classList.remove("active"),r&&l&&l.contentWindow.postMessage({type:"SCROLL_TO_BOTTOM"},"*"),_(r?"open":"close")}let K=null,bt=!1;function Yt(a){let s=String(a||"").slice(0,2e3);s&&(bt&&l&&l.contentWindow?l.contentWindow.postMessage({type:"PREFILL_MESSAGE",text:s},"*"):K=s)}let D=0;function Ct(){for(D++;n.firstChild;)n.removeChild(n.firstChild);l=null,g=null,T=null,bt=!1,st=!1,K=null,w=!1,t.classList.remove("loading"),P=0,Z()}function Gt(){r&&b(),Ct(),e.unreadCount=0,e.hasOpened=!1,O(),et()}function jt(){r&&b(),Ct(),window.removeEventListener("resize",Tt),window.removeEventListener("message",St),f&&(f.typeInterval&&clearInterval(f.typeInterval),ft(f),f=null),[t,h,n,i,o].forEach(ft),ft(document.getElementById("replypier-styles")),document.body.classList.remove("ask-anything-mobile"),ct=!1,E&&(clearTimeout(E),E=null),lt.length=0,e.unreadCount=0,e.hasOpened=!1,c=null,R.length=0,X=!1,y=!0}function At(a){let s=document.createElement("div");s.className="replypier-error-ui";let I=document.createElement("div");I.className="replypier-error-card";let v=document.createElement("button");v.type="button",v.className="replypier-error-close",v.setAttribute("aria-label","Close chat"),v.textContent="\xD7",v.addEventListener("click",function(){r&&b()}),s.appendChild(v);let u=document.createElement("div");u.className="replypier-error-icon-wrapper";let p=document.createElementNS("http://www.w3.org/2000/svg","svg");p.setAttribute("class","replypier-error-icon"),p.setAttribute("viewBox","0 0 24 24"),p.setAttribute("fill","none"),p.setAttribute("stroke","currentColor"),p.setAttribute("stroke-width","1.5");let k=document.createElementNS("http://www.w3.org/2000/svg","path");k.setAttribute("d","M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z");let $=document.createElementNS("http://www.w3.org/2000/svg","path");$.setAttribute("d","M9 12l2 2 4-4"),p.appendChild(k),p.appendChild($),u.appendChild(p);let A=document.createElement("h2");A.className="replypier-error-title",A.textContent="Chat Unavailable";let yt=document.createElement("p");yt.className="replypier-error-message",yt.textContent=a;let it=document.createElement("div");it.className="replypier-error-footer";let L=document.createElementNS("http://www.w3.org/2000/svg","svg");L.setAttribute("width","14"),L.setAttribute("height","14"),L.setAttribute("viewBox","0 0 60 60"),L.setAttribute("fill","none");let G=document.createElementNS("http://www.w3.org/2000/svg","path");G.setAttribute("d","M18 12 H42 A6 6 0 0 1 48 18 V42 A6 6 0 0 1 42 48 H18 A6 6 0 0 1 12 42 V18 A6 6 0 0 1 18 12 Z M17 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M26 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M35 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z"),G.setAttribute("fill-rule","evenodd"),G.setAttribute("fill","currentColor"),G.setAttribute("opacity","0.6"),L.appendChild(G);let z=document.createElement("a");return z.href="https://replypier.site",z.target="_blank",z.rel="noopener noreferrer",z.className="replypier-error-footer-link",z.textContent="Powered by ReplyPier",it.appendChild(L),it.appendChild(z),I.appendChild(u),I.appendChild(A),I.appendChild(yt),I.appendChild(it),s.appendChild(I),s}function et(){return wt(this,null,function*(){if(w||l)return;let a=D;try{w=!0,t.classList.add("loading");let s=dt(),I=`${e.baseUrl}/widgets/${window.replypierId}/data?widget_id=${window.replypierId}`,v={method:"GET",mode:"cors",headers:s?{Authorization:`Bearer ${s}`}:{}};fetch(I,v).then(u=>wt(null,null,function*(){if(a!==D)return null;if(u.status===401)try{let k=(yield u.json()).detail;if(k&&k.code==="identity_expired")return t.classList.remove("loading"),_("identityExpired"),Vt(),null;let $=typeof k=="string"?k:"";if($.includes("generate-token")||$.includes("API key")||$.includes("Token must be obtained")){t.classList.remove("loading"),H();let A=At("This chat widget is not currently configured. Please contact the website administrator to enable chat support.");return n.appendChild(A),F(),null}}catch(p){}if(!u.ok)throw new Error("HTTP error! status: "+u.status);return u.text()})).then(u=>{u!==null&&a===D&&(l=document.createElement("iframe"),l.className="replypier-iframe",l.title="Chat",l.srcdoc=u,l.addEventListener("load",function(){bt=!0,l.contentWindow.postMessage(It(),"*"),l.contentWindow
Line truncated
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ClipboardIcon, CheckIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
apiKey: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.apiKey)
|
||||||
|
copied.value = true
|
||||||
|
toast.success('已复制!', {
|
||||||
|
description: 'API 密钥已复制到剪贴板',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('复制失败', {
|
||||||
|
description: '请手动复制',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal @close="emit('close')">
|
||||||
|
<template #title>API 密钥已创建</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="api-key-modal">
|
||||||
|
<div class="warning-banner">
|
||||||
|
<strong>⚠️ 请立即保存此密钥!</strong>
|
||||||
|
<p>这是唯一一次显示该 API 密钥,之后将无法再次查看。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-container">
|
||||||
|
<label>API 密钥:</label>
|
||||||
|
<div class="key-display">
|
||||||
|
<code class="api-key">{{ apiKey }}</code>
|
||||||
|
<button
|
||||||
|
class="copy-button"
|
||||||
|
@click="copyToClipboard"
|
||||||
|
:title="copied ? '已复制!' : '复制到剪贴板'"
|
||||||
|
>
|
||||||
|
<CheckIcon v-if="copied" class="icon success" />
|
||||||
|
<ClipboardIcon v-else class="icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<h4>后续步骤:</h4>
|
||||||
|
<ol>
|
||||||
|
<li>复制该 API 密钥并妥善保管</li>
|
||||||
|
<li>用它来验证组件 Token 生成请求</li>
|
||||||
|
<li>将其放入 Authorization 请求头:<code>Bearer {{ apiKey.substring(0, 20) }}...</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" @click="emit('close')">
|
||||||
|
我已保存密钥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.api-key-modal {
|
||||||
|
padding: var(--space-md);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-banner {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
border: 1px solid var(--warning-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-banner strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--warning-color);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-banner p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-container {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-container label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
padding: var(--space-xs);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
background: var(--info-bg);
|
||||||
|
border: 1px solid var(--info-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { WidgetApp, WidgetAppCreate } from '@/types/widget-app'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
app?: WidgetApp | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Always emit WidgetAppCreate since the form requires name
|
||||||
|
// The parent component uses this for both create and update operations
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [data: WidgetAppCreate]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref(props.app?.name || '')
|
||||||
|
const description = ref(props.app?.description || '')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
error.value = '请输入名称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.value.length > 100) {
|
||||||
|
error.value = '名称不能超过 100 个字符'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.value && description.value.length > 500) {
|
||||||
|
error.value = '描述不能超过 500 个字符'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
name: name.value.trim(),
|
||||||
|
description: description.value.trim() || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="widget-app-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">名称 <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如:市场官网"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="hint">便于识别的网站组件名称</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">描述</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
v-model="description"
|
||||||
|
placeholder="该组件将部署在哪里?"
|
||||||
|
maxlength="500"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<span class="hint">可选:说明该组件的用途</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{{ app ? '更新组件' : '创建组件' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.widget-app-form {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--c-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 13px 15px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--o12);
|
||||||
|
border-radius: var(--radius-input);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder,
|
||||||
|
.form-group textarea::placeholder {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-ink);
|
||||||
|
box-shadow: var(--ring-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 22px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), filter var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-solid);
|
||||||
|
color: var(--on-accent-solid);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--o05);
|
||||||
|
border: 1px solid var(--o14);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--o10);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,859 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, computed } from 'vue'
|
||||||
|
import { useWidgetApps } from '@/composables/useWidgetApps'
|
||||||
|
import { PlusIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
import WidgetAppForm from './WidgetAppForm.vue'
|
||||||
|
import ApiKeyModal from './ApiKeyModal.vue'
|
||||||
|
import { permissionChecks } from '@/utils/permissions'
|
||||||
|
import { useSubscriptionStorage } from '@/utils/storage'
|
||||||
|
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||||
|
|
||||||
|
const subscriptionStorage = useSubscriptionStorage()
|
||||||
|
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||||
|
|
||||||
|
const {
|
||||||
|
apps,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
showCreateModal,
|
||||||
|
showEditModal,
|
||||||
|
showDeleteModal,
|
||||||
|
showApiKeyModal,
|
||||||
|
selectedApp,
|
||||||
|
newApiKey,
|
||||||
|
showInactive,
|
||||||
|
hasAnyApps,
|
||||||
|
hasInactiveApps,
|
||||||
|
fetchApps,
|
||||||
|
handleCreateApp,
|
||||||
|
handleEditApp,
|
||||||
|
handleUpdateApp,
|
||||||
|
handleDeleteApp,
|
||||||
|
confirmDeleteApp,
|
||||||
|
handleRegenerateKey,
|
||||||
|
closeApiKeyModal
|
||||||
|
} = useWidgetApps()
|
||||||
|
|
||||||
|
const canManage = computed(() => permissionChecks.canManageOrganization())
|
||||||
|
|
||||||
|
// Subscription and widget apps feature checking
|
||||||
|
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||||
|
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
|
||||||
|
|
||||||
|
// Check if widget apps is locked (only if enterprise module exists)
|
||||||
|
const isWidgetAppsLocked = computed(() => {
|
||||||
|
// Only lock if enterprise module exists
|
||||||
|
if (!hasEnterpriseModule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSubscription.value || !isSubscriptionActive.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if api_access feature exists in subscription (required for widget apps)
|
||||||
|
const hasApiAccessFeature = subscriptionStorage.hasFeature('api_access')
|
||||||
|
if (!hasApiAccessFeature) {
|
||||||
|
return true // Lock widget apps if feature doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upgrade modal functions
|
||||||
|
const handleUpgrade = () => {
|
||||||
|
// Only redirect to subscription page if enterprise module exists
|
||||||
|
if (hasEnterpriseModule) {
|
||||||
|
window.location.href = '/settings/subscription'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (canManage.value && !isWidgetAppsLocked.value) {
|
||||||
|
await fetchApps()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!canManage" class="no-permission">
|
||||||
|
<p>你没有管理网站组件的权限。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="widget-app-list">
|
||||||
|
<!-- Widget Apps Locked Overlay -->
|
||||||
|
<div v-if="isWidgetAppsLocked" class="widget-apps-locked-overlay">
|
||||||
|
<div class="locked-content">
|
||||||
|
<div class="locked-header">
|
||||||
|
<div class="locked-icon-wrapper">
|
||||||
|
<div class="locked-icon-bg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="locked-icon">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>网站组件</h2>
|
||||||
|
<div class="locked-badge">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="badge-icon">
|
||||||
|
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>付费功能</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="locked-description">
|
||||||
|
解锁网站组件,即可创建安全的 API 密钥,用于在多个平台与应用中验证聊天组件。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="locked-features">
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<span class="feature-title">安全 API 密钥</span>
|
||||||
|
<span class="feature-desc">为每个集成生成独立密钥</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<span class="feature-title">访问控制</span>
|
||||||
|
<span class="feature-desc">随时管理与吊销访问权限</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||||
|
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-content">
|
||||||
|
<span class="feature-title">多平台</span>
|
||||||
|
<span class="feature-desc">在多个站点部署组件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="upgrade-btn" @click="handleUpgrade">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="upgrade-icon">
|
||||||
|
<path fill-rule="evenodd" d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
升级到 Pro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal content when not locked -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-header-text">
|
||||||
|
<h1>网站组件</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
生成安全的 API 密钥,用于验证你的聊天组件。
|
||||||
|
<span v-if="hasAnyApps" class="subtitle-count">{{ apps.length }} 个组件</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="hasAnyApps" class="btn btn-primary" @click="showCreateModal = true">
|
||||||
|
<PlusIcon class="icon" />
|
||||||
|
创建组件
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Include inactive toggle (only show when there are inactive apps) -->
|
||||||
|
<div v-if="hasInactiveApps" class="filters">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="showInactive"
|
||||||
|
/>
|
||||||
|
显示已停用组件
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在加载网站组件…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button class="btn btn-secondary" @click="fetchApps">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (only when no apps at all) -->
|
||||||
|
<div v-else-if="!hasAnyApps" class="empty-state">
|
||||||
|
<div class="empty-content">
|
||||||
|
<div class="empty-icon-bg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
|
||||||
|
<rect x="4" y="4" width="7" height="7" rx="2"/>
|
||||||
|
<rect x="13" y="4" width="7" height="7" rx="2"/>
|
||||||
|
<rect x="4" y="13" width="7" height="7" rx="2"/>
|
||||||
|
<rect x="13" y="13" width="7" height="7" rx="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>暂无网站组件</h2>
|
||||||
|
<p class="empty-description">
|
||||||
|
网站组件可用于生成安全的 API 密钥,以验证你的聊天组件。
|
||||||
|
创建第一个组件即可开始。
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary create-btn" @click="showCreateModal = true">
|
||||||
|
<PlusIcon class="icon" />
|
||||||
|
创建第一个组件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div v-else-if="apps.length > 0" class="table-container">
|
||||||
|
<div class="table-head">
|
||||||
|
<span class="col-name">名称</span>
|
||||||
|
<span class="col-desc">描述</span>
|
||||||
|
<span class="col-created">创建时间</span>
|
||||||
|
<span class="col-updated">更新时间</span>
|
||||||
|
<span class="col-actions">操作</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="app in apps"
|
||||||
|
:key="app.id"
|
||||||
|
:class="['table-row', { inactive: !app.is_active }]"
|
||||||
|
>
|
||||||
|
<div class="cell-name">
|
||||||
|
<div class="app-name">{{ app.name }}</div>
|
||||||
|
<span :class="['status-badge', app.is_active ? 'active' : 'inactive']">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
{{ app.is_active ? '启用中' : '已停用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cell-desc">{{ app.description || '—' }}</div>
|
||||||
|
<div class="cell-date">{{ formatDate(app.created_at) }}</div>
|
||||||
|
<div class="cell-date">{{ formatDate(app.updated_at) }}</div>
|
||||||
|
<div class="cell-actions">
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title="编辑"
|
||||||
|
@click="handleEditApp(app)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title="重新生成密钥"
|
||||||
|
@click="handleRegenerateKey(app)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn-danger"
|
||||||
|
title="吊销"
|
||||||
|
@click="handleDeleteApp(app)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M6 6l1 14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-14"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All apps filtered state (all inactive and showInactive is false) -->
|
||||||
|
<div v-else-if="hasAnyApps && apps.length === 0" class="filtered-state">
|
||||||
|
<p>所有网站组件当前均已停用。</p>
|
||||||
|
<button class="btn btn-secondary" @click="showInactive = true">
|
||||||
|
显示已停用组件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<Modal v-if="showCreateModal" @close="showCreateModal = false">
|
||||||
|
<template #title>创建网站组件</template>
|
||||||
|
<template #content>
|
||||||
|
<WidgetAppForm
|
||||||
|
@submit="handleCreateApp"
|
||||||
|
@cancel="showCreateModal = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<Modal v-if="showEditModal && selectedApp" @close="showEditModal = false">
|
||||||
|
<template #title>编辑网站组件</template>
|
||||||
|
<template #content>
|
||||||
|
<WidgetAppForm
|
||||||
|
:app="selectedApp"
|
||||||
|
@submit="handleUpdateApp"
|
||||||
|
@cancel="showEditModal = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<Modal v-if="showDeleteModal && selectedApp" @close="showDeleteModal = false">
|
||||||
|
<template #title>删除网站组件</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="confirm-delete">
|
||||||
|
<p>确定要删除 <strong>{{ selectedApp.name }}</strong> 吗?</p>
|
||||||
|
<p class="warning">此操作将立即使该 API 密钥失效。</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-danger" @click="confirmDeleteApp">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="showDeleteModal = false">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- API Key Display Modal -->
|
||||||
|
<ApiKeyModal
|
||||||
|
v-if="showApiKeyModal && newApiKey"
|
||||||
|
:apiKey="newApiKey"
|
||||||
|
@close="closeApiKeyModal"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.widget-app-list {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-count {
|
||||||
|
color: var(--muted2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .btn-primary {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent-solid);
|
||||||
|
color: var(--on-accent-solid);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: filter var(--transition-fast, 0.15s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .btn-primary:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .btn-primary .icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--o08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head,
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) 130px 130px 130px;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
padding: 0 22px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head .col-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
padding: 15px 22px;
|
||||||
|
border-top: 1px solid var(--o06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row.inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-date {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active {
|
||||||
|
background: var(--teal-bg);
|
||||||
|
border: 1px solid var(--teal-border);
|
||||||
|
color: var(--c-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active .status-dot {
|
||||||
|
background: var(--c-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.inactive {
|
||||||
|
background: var(--o06);
|
||||||
|
border: 1px solid var(--o10);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.inactive .status-dot {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--o05);
|
||||||
|
border: 1px solid var(--o12);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background var(--transition-fast, 0.15s), color var(--transition-fast, 0.15s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--o10);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--coral-border);
|
||||||
|
color: var(--c-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: var(--coral-bg);
|
||||||
|
color: var(--c-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-delete {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-delete .warning {
|
||||||
|
color: var(--error-color);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state,
|
||||||
|
.no-permission,
|
||||||
|
.filtered-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtered-state {
|
||||||
|
background: var(--background-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtered-state p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State Styles */
|
||||||
|
.empty-state {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--o08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-content {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-bg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--accent-bg-12);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-content h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 auto 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
padding: 13px 24px;
|
||||||
|
background: var(--accent-solid);
|
||||||
|
color: var(--on-accent-solid);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter var(--transition-fast, 0.15s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn .icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widget Apps Locked Overlay Styles */
|
||||||
|
.widget-apps-locked-overlay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
background: var(--background-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-header {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-icon-wrapper {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-icon-bg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-solid), var(--primary-dark));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--on-accent-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-header h2 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .badge-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-description {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .locked-features {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--info-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .feature-desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .upgrade-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-xl);
|
||||||
|
background: var(--accent-solid);
|
||||||
|
color: var(--on-accent-solid);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .upgrade-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-apps-locked-overlay .upgrade-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { QueueItem } from '@/types/knowledge'
|
||||||
|
|
||||||
|
const normalizeSource = (source: string) => source.trim().toLowerCase().replace(/\/+$/, '')
|
||||||
|
const sameSource = (a: string, b: string) => normalizeSource(a) === normalizeSource(b)
|
||||||
|
|
||||||
|
/** The newest row for each source: a retry must not be judged by the old failure.
|
||||||
|
* The queue API lists newest first. */
|
||||||
|
export const newestPerSource = (items: QueueItem[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter((item) => {
|
||||||
|
const key = normalizeSource(item.source)
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any just-sent source has already failed - a domain that doesn't
|
||||||
|
* exist is refused the moment it is added. One look, no waiting: every other
|
||||||
|
* failure only shows once the crawl has run (often minutes), so holding the
|
||||||
|
* wizard for it would delay every site that reads fine. Later failures are
|
||||||
|
* reported on the Test step.
|
||||||
|
*/
|
||||||
|
export function useCrawlCheck(fetchQueueItems: () => Promise<unknown>, queueItems: Ref<QueueItem[]>) {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
/** The first of `sources` that has already failed, or null. */
|
||||||
|
const firstFailure = async (sources: string[]): Promise<QueueItem | null> => {
|
||||||
|
cancelled = false
|
||||||
|
try {
|
||||||
|
await fetchQueueItems()
|
||||||
|
} catch (err) {
|
||||||
|
// Not knowing is not a failure: move on, the Test step will tell.
|
||||||
|
console.error('Failed to check the knowledge queue:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (cancelled) return null
|
||||||
|
const mine = newestPerSource(queueItems.value).filter((item) => sources.some((s) => sameSource(s, item.source)))
|
||||||
|
return mine.find((item) => item.status === 'failed') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ignore the answer (the user went Back, or the step unmounted). */
|
||||||
|
const cancel = () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { firstFailure, cancel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { widgetAppService } from '@/services/widget-app'
|
||||||
|
import type { WidgetApp, WidgetAppCreate, WidgetAppUpdate } from '@/types/widget-app'
|
||||||
|
|
||||||
|
export function useWidgetApps() {
|
||||||
|
const allApps = ref<WidgetApp[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const showApiKeyModal = ref(false)
|
||||||
|
|
||||||
|
// Selected app state
|
||||||
|
const selectedApp = ref<WidgetApp | null>(null)
|
||||||
|
const newApiKey = ref<string | null>(null) // For showing key after create/regenerate
|
||||||
|
|
||||||
|
// Include inactive toggle
|
||||||
|
const showInactive = ref(false)
|
||||||
|
|
||||||
|
// Computed: Check if there are any apps at all
|
||||||
|
const hasAnyApps = computed(() => allApps.value.length > 0)
|
||||||
|
|
||||||
|
// Computed: Check if there are any inactive apps
|
||||||
|
const hasInactiveApps = computed(() => allApps.value.some(app => !app.is_active))
|
||||||
|
|
||||||
|
// Computed: Filter apps based on showInactive toggle
|
||||||
|
const apps = computed(() => {
|
||||||
|
if (showInactive.value) {
|
||||||
|
return allApps.value
|
||||||
|
}
|
||||||
|
return allApps.value.filter(app => app.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch all apps (always include inactive to know if filter should show)
|
||||||
|
const fetchApps = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const response = await widgetAppService.listApps(true) // Always fetch all
|
||||||
|
allApps.value = response.apps
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '加载组件应用失败'
|
||||||
|
console.error('Error loading widget apps:', err)
|
||||||
|
toast.error('出错了', {
|
||||||
|
description: '加载组件应用失败',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create app
|
||||||
|
const handleCreateApp = async (appData: WidgetAppCreate) => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const result = await widgetAppService.createApp(appData)
|
||||||
|
|
||||||
|
// Add new app to list
|
||||||
|
allApps.value.unshift({
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
description: result.description,
|
||||||
|
organization_id: result.organization_id,
|
||||||
|
created_by: result.created_by,
|
||||||
|
is_active: result.is_active,
|
||||||
|
created_at: result.created_at,
|
||||||
|
updated_at: result.updated_at
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store API key for display
|
||||||
|
newApiKey.value = result.api_key
|
||||||
|
|
||||||
|
// Close create modal, open API key modal
|
||||||
|
showCreateModal.value = false
|
||||||
|
showApiKeyModal.value = true
|
||||||
|
|
||||||
|
toast.success('成功', {
|
||||||
|
description: '组件应用已创建。请立即保存 API Key!',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '创建组件应用失败'
|
||||||
|
toast.error('出错了', {
|
||||||
|
description: err.response?.data?.detail || '创建组件应用失败',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit app
|
||||||
|
const handleEditApp = (app: WidgetApp) => {
|
||||||
|
selectedApp.value = app
|
||||||
|
showEditModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update app (accepts WidgetAppCreate since form always provides required fields)
|
||||||
|
const handleUpdateApp = async (appData: WidgetAppCreate) => {
|
||||||
|
if (!selectedApp.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const updatedApp = await widgetAppService.updateApp(selectedApp.value.id, appData)
|
||||||
|
|
||||||
|
// Update in list
|
||||||
|
const index = allApps.value.findIndex(a => a.id === updatedApp.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
allApps.value[index] = updatedApp
|
||||||
|
}
|
||||||
|
|
||||||
|
showEditModal.value = false
|
||||||
|
selectedApp.value = null
|
||||||
|
|
||||||
|
toast.success('成功', {
|
||||||
|
description: '组件应用已更新',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '更新组件应用失败'
|
||||||
|
toast.error('出错了', {
|
||||||
|
description: err.response?.data?.detail || '更新组件应用失败',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete app
|
||||||
|
const handleDeleteApp = (app: WidgetApp) => {
|
||||||
|
selectedApp.value = app
|
||||||
|
showDeleteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteApp = async () => {
|
||||||
|
if (!selectedApp.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
await widgetAppService.deleteApp(selectedApp.value.id)
|
||||||
|
|
||||||
|
// Remove from list
|
||||||
|
allApps.value = allApps.value.filter(a => a.id !== selectedApp.value?.id)
|
||||||
|
|
||||||
|
showDeleteModal.value = false
|
||||||
|
selectedApp.value = null
|
||||||
|
|
||||||
|
toast.success('成功', {
|
||||||
|
description: '组件应用已删除',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '删除组件应用失败'
|
||||||
|
toast.error('出错了', {
|
||||||
|
description: err.response?.data?.detail || '删除组件应用失败',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate API key
|
||||||
|
const handleRegenerateKey = async (app: WidgetApp) => {
|
||||||
|
if (!confirm('确定吗?这会立即使当前 API Key 失效。')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const result = await widgetAppService.regenerateApiKey(app.id)
|
||||||
|
|
||||||
|
// Update app in list
|
||||||
|
const index = allApps.value.findIndex(a => a.id === result.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
allApps.value[index] = {
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
description: result.description,
|
||||||
|
organization_id: result.organization_id,
|
||||||
|
created_by: result.created_by,
|
||||||
|
is_active: result.is_active,
|
||||||
|
created_at: result.created_at,
|
||||||
|
updated_at: result.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store new API key for display
|
||||||
|
newApiKey.value = result.api_key
|
||||||
|
showApiKeyModal.value = true
|
||||||
|
|
||||||
|
toast.success('成功', {
|
||||||
|
description: 'API Key 已重新生成。请立即保存新的 Key!',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = '重新生成 API Key 失败'
|
||||||
|
toast.error('出错了', {
|
||||||
|
description: err.response?.data?.detail || '重新生成 API Key 失败',
|
||||||
|
duration: 4000,
|
||||||
|
closeButton: true
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close API key modal
|
||||||
|
const closeApiKeyModal = () => {
|
||||||
|
showApiKeyModal.value = false
|
||||||
|
newApiKey.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apps,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
showCreateModal,
|
||||||
|
showEditModal,
|
||||||
|
showDeleteModal,
|
||||||
|
showApiKeyModal,
|
||||||
|
selectedApp,
|
||||||
|
newApiKey,
|
||||||
|
showInactive,
|
||||||
|
hasAnyApps,
|
||||||
|
hasInactiveApps,
|
||||||
|
fetchApps,
|
||||||
|
handleCreateApp,
|
||||||
|
handleEditApp,
|
||||||
|
handleUpdateApp,
|
||||||
|
handleDeleteApp,
|
||||||
|
confirmDeleteApp,
|
||||||
|
handleRegenerateKey,
|
||||||
|
closeApiKeyModal
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { AgentCustomization } from '../types/agent'
|
||||||
|
import WebFont from 'webfontloader'
|
||||||
|
import { postToHost } from '../webclient/host-bridge'
|
||||||
|
|
||||||
|
// Design fonts the redesigned widget always needs (display / body / mono), with
|
||||||
|
// the weights it actually uses. The user's custom font_family is layered on top.
|
||||||
|
const DESIGN_FONT_FAMILIES = [
|
||||||
|
'Space Grotesk:400,500,600,700',
|
||||||
|
'Instrument Sans:400,500,600',
|
||||||
|
'JetBrains Mono:400,500,600',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Load the design fonts plus any custom body font, and apply the custom font to
|
||||||
|
// the chat container once available.
|
||||||
|
const loadFonts = (customFontFamily?: string) => {
|
||||||
|
const families = [...DESIGN_FONT_FAMILIES]
|
||||||
|
|
||||||
|
const primary = customFontFamily?.split(',')[0].trim().replace(/['"]/g, '') || ''
|
||||||
|
const alreadyLoaded = DESIGN_FONT_FAMILIES.some(
|
||||||
|
f => f.toLowerCase().startsWith(primary.toLowerCase())
|
||||||
|
)
|
||||||
|
if (primary && !alreadyLoaded) {
|
||||||
|
families.push(primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
WebFont.load({
|
||||||
|
google: { families },
|
||||||
|
active: () => {
|
||||||
|
if (!customFontFamily) return
|
||||||
|
const chatContainer = document.querySelector('.chat-container') as HTMLElement
|
||||||
|
if (chatContainer) {
|
||||||
|
chatContainer.style.fontFamily = customFontFamily.includes(',')
|
||||||
|
? customFontFamily
|
||||||
|
: `"${customFontFamily}", system-ui, sans-serif`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetCustomization() {
|
||||||
|
const customization = ref<Partial<AgentCustomization>>({})
|
||||||
|
const agentName = ref('')
|
||||||
|
|
||||||
|
const applyCustomization = (newCustomization: Partial<AgentCustomization>) => {
|
||||||
|
customization.value = newCustomization
|
||||||
|
|
||||||
|
|
||||||
|
if (newCustomization.photo_url) {
|
||||||
|
customization.value.photo_url = newCustomization.photo_url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always load the design fonts; layer the custom font_family on top.
|
||||||
|
loadFonts(newCustomization.font_family)
|
||||||
|
|
||||||
|
// Send customization update to parent
|
||||||
|
postToHost({
|
||||||
|
type: 'CUSTOMIZATION_UPDATE',
|
||||||
|
data: {
|
||||||
|
chat_bubble_color: newCustomization.chat_bubble_color || '#C9F24E',
|
||||||
|
chat_style: newCustomization.chat_style,
|
||||||
|
chat_initiation_messages: newCustomization.chat_initiation_messages || [],
|
||||||
|
// Dashboard "Widget placement" defaults — the embed loader merges these
|
||||||
|
// under any options the installing developer set.
|
||||||
|
widget_display: newCustomization.customization_metadata?.widget_display
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize from window.__INITIAL_DATA__
|
||||||
|
const initializeFromData = () => {
|
||||||
|
const initialData = window.__INITIAL_DATA__
|
||||||
|
if (initialData) {
|
||||||
|
applyCustomization(initialData.customization || {})
|
||||||
|
agentName.value = initialData.agentName || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customization,
|
||||||
|
agentName,
|
||||||
|
applyCustomization,
|
||||||
|
initializeFromData
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { widgetEnv, resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||||
|
import { isAbsoluteUrl } from '../utils/avatars'
|
||||||
|
|
||||||
|
// Allowed file types configuration (matching backend)
|
||||||
|
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'])
|
||||||
|
const ALLOWED_DOCUMENT_TYPES = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
])
|
||||||
|
const ALLOWED_FILE_TYPES = new Set([...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOCUMENT_TYPES])
|
||||||
|
|
||||||
|
// Extension to MIME type mapping
|
||||||
|
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.xls': 'application/vnd.ms-excel',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magic byte signatures for client-side validation
|
||||||
|
const MAGIC_BYTES: Record<string, { bytes: number[]; offset: number }[]> = {
|
||||||
|
'image/jpeg': [
|
||||||
|
{ bytes: [0xff, 0xd8, 0xff, 0xe0], offset: 0 },
|
||||||
|
{ bytes: [0xff, 0xd8, 0xff, 0xe1], offset: 0 },
|
||||||
|
{ bytes: [0xff, 0xd8, 0xff, 0xe2], offset: 0 },
|
||||||
|
{ bytes: [0xff, 0xd8, 0xff, 0xdb], offset: 0 },
|
||||||
|
{ bytes: [0xff, 0xd8, 0xff, 0xee], offset: 0 }
|
||||||
|
],
|
||||||
|
'image/png': [{ bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], offset: 0 }],
|
||||||
|
'image/gif': [
|
||||||
|
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], offset: 0 }, // GIF87a
|
||||||
|
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], offset: 0 } // GIF89a
|
||||||
|
],
|
||||||
|
'image/webp': [{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }], // RIFF
|
||||||
|
'application/pdf': [{ bytes: [0x25, 0x50, 0x44, 0x46, 0x2d], offset: 0 }] // %PDF-
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file magic bytes
|
||||||
|
async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?: string }> {
|
||||||
|
const mimeType = file.type
|
||||||
|
|
||||||
|
// Text files don't have magic bytes
|
||||||
|
if (mimeType === 'text/plain' || mimeType === 'text/csv') {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Office files (ZIP-based) start with PK
|
||||||
|
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
|
||||||
|
const buffer = await file.slice(0, 4).arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
// PK signature for ZIP-based or OLE for old Office
|
||||||
|
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) ||
|
||||||
|
(bytes[0] === 0xd0 && bytes[1] === 0xcf)) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
return { valid: false, error: 'File content does not match Office document format' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatures = MAGIC_BYTES[mimeType]
|
||||||
|
if (!signatures) {
|
||||||
|
// Unknown type, will be validated server-side
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read first 16 bytes
|
||||||
|
const buffer = await file.slice(0, 16).arrayBuffer()
|
||||||
|
const fileBytes = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
// WebP special check
|
||||||
|
if (mimeType === 'image/webp') {
|
||||||
|
if (fileBytes[0] === 0x52 && fileBytes[1] === 0x49 &&
|
||||||
|
fileBytes[2] === 0x46 && fileBytes[3] === 0x46 &&
|
||||||
|
fileBytes[8] === 0x57 && fileBytes[9] === 0x45 &&
|
||||||
|
fileBytes[10] === 0x42 && fileBytes[11] === 0x50) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
return { valid: false, error: 'File content does not match WebP format' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check signatures
|
||||||
|
for (const sig of signatures) {
|
||||||
|
let match = true
|
||||||
|
for (let i = 0; i < sig.bytes.length; i++) {
|
||||||
|
if (fileBytes[sig.offset + i] !== sig.bytes[i]) {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: `File content does not match ${mimeType} format` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file extension matches MIME type
|
||||||
|
function validateExtension(filename: string, mimeType: string): { valid: boolean; error?: string } {
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
|
||||||
|
|
||||||
|
if (!ext || ext === '.') {
|
||||||
|
return { valid: false, error: 'File must have an extension' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(ext in EXTENSION_TO_MIME)) {
|
||||||
|
const allowed = Object.keys(EXTENSION_TO_MIME).map(e => e.toUpperCase().replace('.', '')).join(', ')
|
||||||
|
return { valid: false, error: `File type not allowed. Allowed: ${allowed}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedMime = EXTENSION_TO_MIME[ext]
|
||||||
|
|
||||||
|
// Handle jpg/jpeg equivalence
|
||||||
|
if ((mimeType === 'image/jpeg' || mimeType === 'image/jpg') &&
|
||||||
|
(expectedMime === 'image/jpeg' || expectedMime === 'image/jpg')) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedMime !== mimeType) {
|
||||||
|
return { valid: false, error: `File extension ${ext} does not match content type` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetFiles(token: Ref<string | null>, fileInputRef: Ref<HTMLInputElement | null>) {
|
||||||
|
// File handling state
|
||||||
|
const uploadedAttachments = ref<Array<{
|
||||||
|
content: string
|
||||||
|
filename: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
url: string
|
||||||
|
file_url: string
|
||||||
|
}>>([])
|
||||||
|
|
||||||
|
const previewModal = ref(false)
|
||||||
|
const previewFile = ref<{
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
type: string
|
||||||
|
file_url?: string
|
||||||
|
size?: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// Format file size for display
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is an image
|
||||||
|
const isImageAttachment = (contentType: string): boolean => {
|
||||||
|
return contentType.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate download URL for attachments
|
||||||
|
const getDownloadUrl = (fileUrl: string | undefined | null): string => {
|
||||||
|
if (!fileUrl) return ''
|
||||||
|
|
||||||
|
// Blob previews and absolute S3 URLs pass through; local paths already
|
||||||
|
// carry the /api/v1 prefix, so they resolve against the API origin.
|
||||||
|
return resolveWidgetUploadUrl(fileUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate preview URL for file display (similar to FileUpload.vue)
|
||||||
|
const getPreviewUrl = (file: {url: string, file_url?: string}): string => {
|
||||||
|
const urlToUse = file.file_url || file.url
|
||||||
|
if (!urlToUse) return ''
|
||||||
|
|
||||||
|
return resolveWidgetUploadUrl(urlToUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file selection from input
|
||||||
|
const handleFileSelect = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
await uploadFiles(Array.from(target.files))
|
||||||
|
// Reset the input value to allow selecting the same file again
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag and drop
|
||||||
|
const handleDrop = async (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const files = event.dataTransfer?.files
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
await uploadFiles(Array.from(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle paste events
|
||||||
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
|
||||||
|
const files: File[] = []
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) {
|
||||||
|
files.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
await uploadFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress image if it's too large
|
||||||
|
const compressImage = async (file: File, maxSizeKB: number = 500): Promise<{blob: Blob, base64: string}> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
let width = img.width
|
||||||
|
let height = img.height
|
||||||
|
|
||||||
|
// Calculate new dimensions (max 1920px width/height while maintaining aspect ratio)
|
||||||
|
const maxDimension = 1920
|
||||||
|
if (width > maxDimension || height > maxDimension) {
|
||||||
|
if (width > height) {
|
||||||
|
height = (height / width) * maxDimension
|
||||||
|
width = maxDimension
|
||||||
|
} else {
|
||||||
|
width = (width / height) * maxDimension
|
||||||
|
height = maxDimension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Failed to get canvas context'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
|
||||||
|
// Start with quality 0.9 and reduce if needed
|
||||||
|
let quality = 0.9
|
||||||
|
const tryCompress = () => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to compress image'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeKB = blob.size / 1024
|
||||||
|
|
||||||
|
// If still too large and quality can be reduced, try again
|
||||||
|
if (sizeKB > maxSizeKB && quality > 0.3) {
|
||||||
|
quality -= 0.1
|
||||||
|
tryCompress()
|
||||||
|
} else {
|
||||||
|
// Convert blob to base64
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]
|
||||||
|
resolve({ blob, base64 })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
}
|
||||||
|
}, file.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryCompress()
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image'))
|
||||||
|
img.src = e.target?.result as string
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files (convert to base64 and store locally)
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
const MAX_FILES = 3 // Maximum 3 files per message
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB for images
|
||||||
|
const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10MB for documents
|
||||||
|
const TARGET_SIZE_KB = 500 // Target 500KB after compression
|
||||||
|
|
||||||
|
// Check if adding these files would exceed the limit
|
||||||
|
if (uploadedAttachments.value.length >= MAX_FILES) {
|
||||||
|
alert(`每条消息最多 ${MAX_FILES} 个文件`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingSlots = MAX_FILES - uploadedAttachments.value.length
|
||||||
|
const filesToUpload = files.slice(0, remainingSlots)
|
||||||
|
|
||||||
|
if (files.length > remainingSlots) {
|
||||||
|
alert(`最多还能上传 ${remainingSlots} 个文件。每条消息最多 ${MAX_FILES} 个文件。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of filesToUpload) {
|
||||||
|
try {
|
||||||
|
// Check if file with same name already exists
|
||||||
|
const isDuplicate = uploadedAttachments.value.some(att => att.filename === file.name)
|
||||||
|
if (isDuplicate) {
|
||||||
|
console.warn(`File ${file.name} is already selected`)
|
||||||
|
alert(`文件“${file.name}”已选择`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
const maxSize = isImage ? MAX_FILE_SIZE : MAX_DOCUMENT_SIZE
|
||||||
|
|
||||||
|
// Validate file size before upload
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeMB = maxSize / (1024 * 1024)
|
||||||
|
console.error(`File ${file.name} is too large. Maximum size is ${maxSizeMB}MB`)
|
||||||
|
alert(`文件“${file.name}”过大,${isImage ? '图片' : '文档'}最大为 ${maxSizeMB}MB`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
// Compress image before upload
|
||||||
|
try {
|
||||||
|
const { blob, base64 } = await compressImage(file, TARGET_SIZE_KB)
|
||||||
|
const compressedSize = blob.size
|
||||||
|
|
||||||
|
console.log(`Compressed ${file.name}: ${(file.size / 1024).toFixed(2)}KB → ${(compressedSize / 1024).toFixed(2)}KB`)
|
||||||
|
|
||||||
|
uploadedAttachments.value.push({
|
||||||
|
content: base64,
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: compressedSize,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
file_url: URL.createObjectURL(blob)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image compression failed, uploading original:', error)
|
||||||
|
// Fallback to original file if compression fails
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64Content = e.target?.result as string
|
||||||
|
const base64Data = base64Content.split(',')[1]
|
||||||
|
|
||||||
|
uploadedAttachments.value.push({
|
||||||
|
content: base64Data,
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
file_url: URL.createObjectURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-images, read as-is
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64Content = e.target?.result as string
|
||||||
|
const base64Data = base64Content.split(',')[1]
|
||||||
|
|
||||||
|
uploadedAttachments.value.push({
|
||||||
|
content: base64Data,
|
||||||
|
filename: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
url: '',
|
||||||
|
file_url: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File upload error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove attachment and call delete API
|
||||||
|
const removeAttachment = async (index: number) => {
|
||||||
|
const file = uploadedAttachments.value[index]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Call delete API to remove file from storage
|
||||||
|
try {
|
||||||
|
// Extract the file path from the URL
|
||||||
|
let filePath = file.url
|
||||||
|
|
||||||
|
// Remove /uploads/ prefix if present
|
||||||
|
if (filePath.startsWith('/uploads/')) {
|
||||||
|
filePath = filePath.substring(9)
|
||||||
|
} else if (filePath.startsWith('/')) {
|
||||||
|
filePath = filePath.substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For absolute S3/CDN URLs, reduce to the object key (the URL path).
|
||||||
|
// Parsing the URL avoids matching a host substring, which is unreliable
|
||||||
|
// and flagged by static analysis (js/incomplete-url-substring-sanitization).
|
||||||
|
if (isAbsoluteUrl(filePath)) {
|
||||||
|
try {
|
||||||
|
filePath = new URL(filePath).pathname.replace(/^\/+/, '')
|
||||||
|
} catch {
|
||||||
|
// Not a parseable URL — leave the path as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token.value) {
|
||||||
|
headers['Authorization'] = `Bearer ${token.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// API_URL already ends in /api/v1 — do not repeat it here.
|
||||||
|
const response = await fetch(`${widgetEnv.API_URL}/files/upload/${filePath}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
console.error('Failed to delete file:', errorData.detail)
|
||||||
|
// Optionally, show an error message to the user
|
||||||
|
} else {
|
||||||
|
console.log('File deleted successfully from backend.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling delete API:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke blob URLs to free memory
|
||||||
|
if (file.url && file.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.url)
|
||||||
|
}
|
||||||
|
if (file.file_url && file.file_url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.file_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedAttachments.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file preview modal
|
||||||
|
const openPreview = (file: {
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
type: string
|
||||||
|
file_url?: string
|
||||||
|
size?: number
|
||||||
|
}) => {
|
||||||
|
previewFile.value = file
|
||||||
|
previewModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close preview modal
|
||||||
|
const closePreview = () => {
|
||||||
|
previewModal.value = false
|
||||||
|
// Don't clear previewFile immediately to allow smooth transition
|
||||||
|
setTimeout(() => {
|
||||||
|
previewFile.value = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file picker
|
||||||
|
const openFilePicker = () => {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file type is image (utility function)
|
||||||
|
const isImage = (type: string): boolean => {
|
||||||
|
return type.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadedAttachments,
|
||||||
|
previewModal,
|
||||||
|
previewFile,
|
||||||
|
formatFileSize,
|
||||||
|
isImageAttachment,
|
||||||
|
getDownloadUrl,
|
||||||
|
getPreviewUrl,
|
||||||
|
handleFileSelect,
|
||||||
|
handleDrop,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handlePaste,
|
||||||
|
uploadFiles,
|
||||||
|
removeAttachment,
|
||||||
|
openPreview,
|
||||||
|
closePreview,
|
||||||
|
openFilePicker,
|
||||||
|
isImage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AgentCustomization } from '../types/widget'
|
||||||
|
import { isColorDark } from '../types/widget'
|
||||||
|
import { resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||||
|
|
||||||
|
// Themed colours now come from the shared design tokens (widget-theme.ts →
|
||||||
|
// `--rp-*` vars set on `.chat-container`). These computeds return `var(--rp-*)`
|
||||||
|
// references so the widget's existing inline styles resolve to the token values,
|
||||||
|
// while user overrides are folded into the vars by `themeCssVars()`.
|
||||||
|
export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||||
|
const chatStyles = computed(() => ({
|
||||||
|
backgroundColor: 'var(--rp-card)',
|
||||||
|
color: 'var(--rp-text)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Launcher icon still follows the dedicated chat_bubble_color.
|
||||||
|
const chatIconStyles = computed(() => ({
|
||||||
|
backgroundColor: customization.value.chat_bubble_color || '#C9F24E',
|
||||||
|
color: isColorDark(customization.value.chat_bubble_color || '#C9F24E') ? '#FFFFFF' : '#000000'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const agentBubbleStyles = computed(() => ({
|
||||||
|
backgroundColor: 'var(--rp-agent-bg)',
|
||||||
|
color: 'var(--rp-text)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const userBubbleStyles = computed(() => ({
|
||||||
|
backgroundColor: 'var(--rp-accent)',
|
||||||
|
color: 'var(--rp-on-accent)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const messageNameStyles = computed(() => ({
|
||||||
|
color: 'var(--rp-text)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const headerBorderStyles = computed(() => ({
|
||||||
|
borderBottom: '1px solid var(--rp-hairline)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Signed S3/CDN URLs pass through; local paths are resolved against the
|
||||||
|
// runtime API origin (photo_url already carries the /api/v1 prefix).
|
||||||
|
const photoUrl = computed(() => resolveWidgetUploadUrl(customization.value.photo_url))
|
||||||
|
|
||||||
|
const shadowStyle = computed(() => {
|
||||||
|
const bgColor = customization.value.chat_background_color || '#ffffff'
|
||||||
|
return {
|
||||||
|
boxShadow: `0 8px 5px ${isColorDark(bgColor) ? 'rgba(0, 0, 0, 0.24)' : 'rgba(0, 0, 0, 0.12)'}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
chatStyles,
|
||||||
|
chatIconStyles,
|
||||||
|
agentBubbleStyles,
|
||||||
|
userBubbleStyles,
|
||||||
|
messageNameStyles,
|
||||||
|
headerBorderStyles,
|
||||||
|
photoUrl,
|
||||||
|
shadowStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getApiUrl } from '@/config/api'
|
||||||
|
|
||||||
|
// The loader (replypier.min.js) is served by THIS frontend, so resolve its
|
||||||
|
// origin at snippet-generation time from where the dashboard is running — no
|
||||||
|
// build-time VITE_WIDGET_URL bake, so a self-hosted install just works.
|
||||||
|
function getLoaderOrigin(): string {
|
||||||
|
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the embeddable <script> snippet for a widget.
|
||||||
|
* - Simple (anonymous) variant: drops `window.replypierId` + the loader script.
|
||||||
|
* - Token-auth variant: fetches a short-lived token from the host's backend
|
||||||
|
* before loading the widget (used when the agent requires token auth).
|
||||||
|
* Shared by the AI Agents list and the onboarding Launch step. Its exact text
|
||||||
|
* is pinned in __tests__/fixtures/widget-snippet.txt, which other code that
|
||||||
|
* builds the snippet tests against too.
|
||||||
|
*
|
||||||
|
* `window.replypierBaseUrl` is baked into the snippet from the runtime API URL
|
||||||
|
* so the embedded widget (running on the customer's own site, where APP_CONFIG is
|
||||||
|
* absent) talks to THIS install's backend instead of the vendor cloud.
|
||||||
|
*/
|
||||||
|
export function buildWidgetEmbed(widgetId: string, requireTokenAuth?: boolean): string {
|
||||||
|
const loaderOrigin = getLoaderOrigin()
|
||||||
|
const apiUrl = getApiUrl()
|
||||||
|
if (requireTokenAuth) {
|
||||||
|
return `<!-- Get token from your backend: POST /api/v1/generate-token with API key -->
|
||||||
|
<!-- Security Note: Widget ID and token are cryptographically bound in the JWT. -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
fetch('/api/replypier')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
let token, widget_id;
|
||||||
|
if (d.data && d.data.token && d.data.widget_id) {
|
||||||
|
token = d.data.token;
|
||||||
|
widget_id = d.data.widget_id;
|
||||||
|
} else if (d.token && d.token.data && d.token.data.data) {
|
||||||
|
token = d.token.data.data.token;
|
||||||
|
widget_id = d.token.data.data.widget_id;
|
||||||
|
} else if (d.token && d.widget_id) {
|
||||||
|
token = d.token;
|
||||||
|
widget_id = d.widget_id;
|
||||||
|
}
|
||||||
|
if (!token || !widget_id) throw new Error('Failed to extract token or widget_id');
|
||||||
|
window.replypierId = widget_id;
|
||||||
|
window.replypierBaseUrl = '${apiUrl}';
|
||||||
|
localStorage.setItem('rpid', token);
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '${loaderOrigin}/webclient/replypier.min.js';
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})
|
||||||
|
.catch(e => console.error('[ReplyPier] Initialization failed:', e));
|
||||||
|
})();
|
||||||
|
<\/script>`
|
||||||
|
}
|
||||||
|
return `<script>
|
||||||
|
window.replypierId = '${widgetId}';
|
||||||
|
window.replypierBaseUrl = '${apiUrl}';
|
||||||
|
<\/script>
|
||||||
|
<script src="${loaderOrigin}/webclient/replypier.min.js"><\/script>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The Ask AI surface used by the ASK_ANYTHING and AURORA chat styles: a command
|
||||||
|
palette, not a chat bubble. One input pinned at the top, suggested questions while
|
||||||
|
it's empty, and answers streaming underneath with their sources — the shape people
|
||||||
|
know from every "Ask AI" search box.
|
||||||
|
|
||||||
|
Presentational only: all state and socket work stays in WidgetBuilder, which passes
|
||||||
|
it in and handles the emitted intents. Colours come from the --rp-* theme tokens
|
||||||
|
(widget-theme.ts), so both styles and any custom accent are handled for free.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { renderMarkdown } from './markdown'
|
||||||
|
import { postToHost } from './host-bridge'
|
||||||
|
import NewChatConfirm from './NewChatConfirm.vue'
|
||||||
|
import { NEW_CHAT_LABEL } from './new-chat'
|
||||||
|
import type { Message } from '../types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
messages: Message[]
|
||||||
|
draft: string
|
||||||
|
agentName: string
|
||||||
|
suggestions: string[]
|
||||||
|
welcomeTitle?: string
|
||||||
|
welcomeSubtitle?: string
|
||||||
|
placeholder: string
|
||||||
|
/** Whether a message may be sent: false while a reply is in flight. */
|
||||||
|
inputEnabled: boolean
|
||||||
|
/** Whether the field accepts typing. Stays true while a reply is in flight —
|
||||||
|
* disabling the input blurs it, which cost the visitor their cursor after
|
||||||
|
* every reply (#316). Defaults to inputEnabled for callers that don't care. */
|
||||||
|
typingEnabled?: boolean
|
||||||
|
loading: boolean
|
||||||
|
showCitations: boolean
|
||||||
|
disclaimer?: string
|
||||||
|
/** Whether the embedder is actually showing the widget — gates autofocus. */
|
||||||
|
active: boolean
|
||||||
|
/** False when the site disabled the ⌘K chord. */
|
||||||
|
hotkey: boolean
|
||||||
|
citationLabel: (source: any) => string
|
||||||
|
citationTooltip: (source: any) => string
|
||||||
|
/** Typewriter reveal, keyed by index into `messages` (see useTypewriter). */
|
||||||
|
displayText: (index: number, fullText: string) => string
|
||||||
|
isStreaming: (index: number) => boolean
|
||||||
|
/** Opt-in "New chat" control (customization.allow_new_chat). */
|
||||||
|
canStartNewChat: boolean
|
||||||
|
startingNewChat: boolean
|
||||||
|
/** Armed = the confirmation is showing (ending a chat can't be undone). */
|
||||||
|
newChatArmed: boolean
|
||||||
|
/** Set when the last attempt to start a new chat failed. */
|
||||||
|
newChatError?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:draft', value: string): void
|
||||||
|
(e: 'send'): void
|
||||||
|
(e: 'ask', question: string): void
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'newChat'): void
|
||||||
|
(e: 'confirmNewChat'): void
|
||||||
|
(e: 'cancelNewChat'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
const bodyEl = ref<HTMLElement | null>(null)
|
||||||
|
const contentEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Questions, answers and system notes. Interactive kinds (forms, ratings, product
|
||||||
|
// cards) never reach here: WidgetBuilder falls the whole conversation back to the
|
||||||
|
// chat panel rather than let this surface drop them.
|
||||||
|
// The original index travels with each turn: useTypewriter keys its reveal state by
|
||||||
|
// position in the unfiltered `messages` array.
|
||||||
|
const RENDERABLE = ['user', 'bot', 'agent', 'system']
|
||||||
|
const turns = computed(() =>
|
||||||
|
props.messages
|
||||||
|
.map((message, index) => ({ message, index }))
|
||||||
|
.filter(({ message }) => RENDERABLE.includes(message.message_type))
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasConversation = computed(() => turns.value.length > 0)
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
emit('update:draft', (event.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
// The typing gate, not the sending one: a message typed during a reply has to
|
||||||
|
// reach the parent, which decides whether to send it now or hold it (#316).
|
||||||
|
if (!(props.typingEnabled ?? props.inputEnabled) || !props.draft.trim()) return
|
||||||
|
emit('send')
|
||||||
|
}
|
||||||
|
|
||||||
|
const askSuggestion = (question: string) => {
|
||||||
|
if (!props.inputEnabled) return
|
||||||
|
emit('ask', question)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esc closes from inside the palette. The loader's own handler lives on the host page
|
||||||
|
// and can't see keys pressed in this iframe, so without this the shortcut would die
|
||||||
|
// the moment the input takes focus — which is immediately.
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform || '')
|
||||||
|
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same chord that opened it closes it. Platform-specific on purpose: Ctrl+K on
|
||||||
|
// macOS is kill-to-end-of-line inside a text field, and this listener sits on a
|
||||||
|
// surface whose input is focused.
|
||||||
|
const chord = isMac ? (event.metaKey && !event.ctrlKey) : (event.ctrlKey && !event.metaKey)
|
||||||
|
if (props.hotkey && chord && !event.altKey && (event.key === 'k' || event.key === 'K')) {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusInput = () => {
|
||||||
|
nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// An iframe cannot size its embedder, so the palette measures its own content and
|
||||||
|
// asks the loader for that height — that's what makes the box hug a short answer
|
||||||
|
// instead of sitting at a fixed height with dead space under it. The loader clamps.
|
||||||
|
let lastReportedHeight = 0
|
||||||
|
const reportHeight = () => {
|
||||||
|
if (!contentEl.value) return
|
||||||
|
const root = contentEl.value.closest('.askai') as HTMLElement | null
|
||||||
|
const body = bodyEl.value
|
||||||
|
if (!root || !body) return
|
||||||
|
// Fixed chrome (ask bar + footer), then the content's natural height plus the
|
||||||
|
// scroll area's own padding — offsetHeight of the body is the CLAMPED height, so
|
||||||
|
// it can't be used for the content itself.
|
||||||
|
const chrome = root.offsetHeight - body.offsetHeight
|
||||||
|
const style = getComputedStyle(body)
|
||||||
|
const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
|
||||||
|
const height = Math.ceil(chrome + padding + contentEl.value.getBoundingClientRect().height)
|
||||||
|
// Ignore sub-pixel churn, which would otherwise ping-pong with the resize.
|
||||||
|
if (Math.abs(height - lastReportedHeight) < 3) return
|
||||||
|
lastReportedHeight = height
|
||||||
|
postToHost({ type: 'WIDGET_RESIZE', height })
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
// Keep the tail in view as an answer types itself out. Measures REVEALED length, so
|
||||||
|
// the scroll follows the typewriter rather than jumping once on arrival.
|
||||||
|
const revealedLength = computed(() =>
|
||||||
|
turns.value.reduce((n, { message, index }) => n + props.displayText(index, message.message || '').length, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [turns.value.length, revealedLength.value, props.loading] as const,
|
||||||
|
() => nextTick(() => {
|
||||||
|
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// The confirmation bar is chrome outside the measured content, so the ResizeObserver
|
||||||
|
// never sees it come or go - the palette would be reported at the wrong height and
|
||||||
|
// clip it.
|
||||||
|
watch(() => props.newChatArmed, () => nextTick(() => reportHeight()))
|
||||||
|
|
||||||
|
// Focus only once the embedder actually shows the widget: the iframe is created
|
||||||
|
// eagerly at page load, so focusing on mount would steal it from the host page.
|
||||||
|
watch(() => props.active, (visible) => {
|
||||||
|
if (visible) focusInput()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.active) focusInput()
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
if (contentEl.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
contentObserver = new ResizeObserver(() => reportHeight())
|
||||||
|
contentObserver.observe(contentEl.value)
|
||||||
|
}
|
||||||
|
reportHeight()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
contentObserver?.disconnect()
|
||||||
|
contentObserver = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="askai">
|
||||||
|
<!-- Ask bar: stays at the top, follow-ups are typed here too. -->
|
||||||
|
<div class="askai__bar">
|
||||||
|
<svg class="askai__bar-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3l1.9 4.9L19 9.8l-4.9 1.9L12 17l-1.9-5.3L5 9.8l5.1-1.9L12 3z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
type="text"
|
||||||
|
class="askai__input"
|
||||||
|
:value="draft"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="!(typingEnabled ?? inputEnabled)"
|
||||||
|
:aria-label="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown.enter.prevent="submit"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="canStartNewChat"
|
||||||
|
type="button"
|
||||||
|
class="askai__new"
|
||||||
|
:class="{ 'askai__new--armed': newChatArmed }"
|
||||||
|
:disabled="startingNewChat"
|
||||||
|
:title="NEW_CHAT_LABEL"
|
||||||
|
:aria-label="NEW_CHAT_LABEL"
|
||||||
|
:aria-expanded="newChatArmed"
|
||||||
|
@click="emit('newChat')"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="askai__close" aria-label="关闭" title="关闭(Esc)" @click="emit('close')">
|
||||||
|
<span class="askai__kbd">Esc</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewChatConfirm
|
||||||
|
v-if="newChatArmed && canStartNewChat"
|
||||||
|
:busy="startingNewChat"
|
||||||
|
:error="newChatError"
|
||||||
|
@confirm="emit('confirmNewChat')"
|
||||||
|
@cancel="emit('cancelNewChat')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div ref="bodyEl" class="askai__body">
|
||||||
|
<!-- flow-root so child margins don't collapse out of this box: its measured
|
||||||
|
height is what the palette asks the loader to size itself to. -->
|
||||||
|
<div ref="contentEl" class="askai__content">
|
||||||
|
<!-- Empty state. A blank panel is the one thing an answer surface must
|
||||||
|
never show: give people something to click. -->
|
||||||
|
<template v-if="!hasConversation">
|
||||||
|
<div class="askai__intro">
|
||||||
|
<h2 class="askai__title">{{ welcomeTitle || `向 ${agentName} 提问` }}</h2>
|
||||||
|
<p v-if="welcomeSubtitle" class="askai__subtitle">{{ welcomeSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Hidden once the visitor starts typing: picking a suggestion
|
||||||
|
replaces the draft, so offering both would discard their text. -->
|
||||||
|
<div v-if="suggestions.length && !draft.trim()" class="askai__suggestions">
|
||||||
|
<p class="askai__label">推荐提问</p>
|
||||||
|
<button
|
||||||
|
v-for="question in suggestions"
|
||||||
|
:key="question"
|
||||||
|
type="button"
|
||||||
|
class="askai__suggestion"
|
||||||
|
:disabled="!inputEnabled"
|
||||||
|
@click="askSuggestion(question)"
|
||||||
|
>
|
||||||
|
<span>{{ question }}</span>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Answers. Questions read as headings, answers as prose — a document,
|
||||||
|
not a back-and-forth of bubbles. -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Announced only once the reveal finishes: a live region on text that
|
||||||
|
mutates per character makes screen readers re-read the whole answer. -->
|
||||||
|
<div
|
||||||
|
v-for="{ message, index } in turns"
|
||||||
|
:key="index"
|
||||||
|
class="askai__turn"
|
||||||
|
:aria-live="isStreaming(index) ? 'off' : 'polite'"
|
||||||
|
>
|
||||||
|
<p v-if="message.message_type === 'user'" class="askai__question">{{ message.message }}</p>
|
||||||
|
<p v-else-if="message.message_type === 'system'" class="askai__system">{{ message.message }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Revealed progressively while streaming, so an answer types
|
||||||
|
itself out here exactly as it does in the chat panel. -->
|
||||||
|
<div
|
||||||
|
class="askai__answer"
|
||||||
|
:class="{ 'askai__answer--streaming': isStreaming(index) }"
|
||||||
|
v-html="renderMarkdown(isStreaming(index) ? displayText(index, message.message || '') : (message.message || ''))"
|
||||||
|
></div>
|
||||||
|
<div v-if="showCitations && !isStreaming(index) && message.sources && message.sources.length" class="askai__sources">
|
||||||
|
<span class="askai__label">参考来源</span>
|
||||||
|
<span
|
||||||
|
v-for="(source, sourceIndex) in message.sources"
|
||||||
|
:key="sourceIndex"
|
||||||
|
class="askai__source"
|
||||||
|
:title="citationTooltip(source)"
|
||||||
|
>{{ citationLabel(source) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="askai__thinking" role="status" aria-live="polite">
|
||||||
|
<span class="askai__dot"></span><span class="askai__dot"></span><span class="askai__dot"></span>
|
||||||
|
<span class="askai__thinking-text">{{ showCitations ? '正在检索知识库' : '思考中' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="askai__foot">
|
||||||
|
<span v-if="disclaimer">{{ disclaimer }}</span>
|
||||||
|
<a class="askai__brand" href="https://replypier.site" target="_blank" rel="noopener noreferrer">由 ReplyPier 提供支持</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Every colour is a --rp-* theme token, so light (Ask Anything) and dark (Aurora)
|
||||||
|
both work, and a custom accent_color flows through untouched. */
|
||||||
|
.askai {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--rp-card);
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-family: var(--rp-body-font, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 14px 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--rp-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__bar-icon {
|
||||||
|
color: var(--rp-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__input::placeholder { color: var(--rp-muted); }
|
||||||
|
.askai__input:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Icon-only, same weight as the Esc chip beside it — the bar is for asking, not a
|
||||||
|
place for two competing labelled buttons. */
|
||||||
|
.askai__new {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
border: 1px solid var(--rp-border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.askai__new:hover:not(:disabled) {
|
||||||
|
color: var(--rp-text);
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__new:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.askai__new--armed {
|
||||||
|
color: var(--rp-text);
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__close {
|
||||||
|
border: 1px solid var(--rp-border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__close:hover {
|
||||||
|
color: var(--rp-text);
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__kbd {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* Reserve the scrollbar gutter: without it, the bar appearing mid-answer narrows
|
||||||
|
the text, re-wraps it taller, and the auto-grow measurement oscillates. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding: 18px 20px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__content { display: flow-root; }
|
||||||
|
|
||||||
|
.askai__intro { margin-bottom: 18px; }
|
||||||
|
|
||||||
|
.askai__title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__label {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__suggestions { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.askai__suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--rp-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--rp-agent-bg);
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__suggestion:hover:not(:disabled) {
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__suggestion:disabled { opacity: 0.55; cursor: default; }
|
||||||
|
.askai__suggestion svg { color: var(--rp-muted); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.askai__turn + .askai__turn { margin-top: 20px; }
|
||||||
|
|
||||||
|
.askai__question {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__answer {
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--rp-text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinking caret rides the end of the last rendered block while text reveals. */
|
||||||
|
.askai__answer--streaming :deep(> :last-child)::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
background: var(--rp-accent, currentColor);
|
||||||
|
animation: askai-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes askai-blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__answer :deep(p) { margin: 0 0 10px; }
|
||||||
|
.askai__answer :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.askai__answer :deep(ul),
|
||||||
|
.askai__answer :deep(ol) { margin: 0 0 10px; padding-left: 20px; }
|
||||||
|
.askai__answer :deep(li) { margin-bottom: 4px; }
|
||||||
|
.askai__answer :deep(a) { color: var(--rp-accent); }
|
||||||
|
.askai__answer :deep(code) {
|
||||||
|
background: var(--rp-agent-bg);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.askai__answer :deep(pre) {
|
||||||
|
background: var(--rp-agent-bg);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.askai__answer :deep(pre code) { background: none; padding: 0; }
|
||||||
|
/* Wide tables scroll inside the answer instead of forcing a horizontal scrollbar on
|
||||||
|
the whole body (which the height measurement doesn't account for). */
|
||||||
|
.askai__answer :deep(table) { display: block; max-width: 100%; overflow-x: auto; }
|
||||||
|
|
||||||
|
.askai__sources {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__sources .askai__label { margin: 0 2px 0 0; }
|
||||||
|
|
||||||
|
.askai__source {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--rp-border);
|
||||||
|
color: var(--rp-muted);
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__thinking {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__thinking-text { margin-left: 4px; }
|
||||||
|
|
||||||
|
.askai__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rp-accent);
|
||||||
|
animation: askai-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__dot:nth-child(2) { animation-delay: 0.18s; }
|
||||||
|
.askai__dot:nth-child(3) { animation-delay: 0.36s; }
|
||||||
|
|
||||||
|
@keyframes askai-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; transform: translateY(0); }
|
||||||
|
50% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.askai__dot { animation: none; }
|
||||||
|
.askai__suggestion:hover:not(:disabled) { transform: none; }
|
||||||
|
.askai__answer--streaming :deep(> :last-child)::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__system {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
border-top: 1px solid var(--rp-border);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__brand {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.askai__brand:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.askai__body { padding: 16px 16px 20px; }
|
||||||
|
/* 16px keeps iOS from zooming the page when the input takes focus. */
|
||||||
|
.askai__input { font-size: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!--
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Asks before a new chat ends the current one.
|
||||||
|
*
|
||||||
|
* Ending a chat cannot be undone, and the control used to say so only in a tooltip
|
||||||
|
* and a hint that lapsed after a few seconds - easy to miss, and easy to trigger by
|
||||||
|
* accident. This states the consequence inside the widget and waits for an answer.
|
||||||
|
* Shared by the chat header and the Ask AI bar so both ask the same question.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
NEW_CHAT_CANCEL_ACTION,
|
||||||
|
NEW_CHAT_CONFIRM_ACTION,
|
||||||
|
NEW_CHAT_CONFIRM_QUESTION,
|
||||||
|
} from './new-chat'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** Shown instead of the question when the last attempt failed. */
|
||||||
|
error?: string
|
||||||
|
/** Disables the actions while the close is in flight. */
|
||||||
|
busy?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'confirm'): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="new-chat-confirm" role="alertdialog" aria-live="polite" :aria-label="NEW_CHAT_CONFIRM_QUESTION">
|
||||||
|
<p class="new-chat-confirm__question">{{ error || NEW_CHAT_CONFIRM_QUESTION }}</p>
|
||||||
|
<div class="new-chat-confirm__actions">
|
||||||
|
<button type="button" class="new-chat-confirm__button" :disabled="busy" @click="emit('cancel')">
|
||||||
|
{{ NEW_CHAT_CANCEL_ACTION }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="new-chat-confirm__button new-chat-confirm__button--primary" :disabled="busy"
|
||||||
|
@click="emit('confirm')">
|
||||||
|
{{ NEW_CHAT_CONFIRM_ACTION }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.new-chat-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--rp-hairline, rgba(0, 0, 0, 0.07));
|
||||||
|
background: var(--rp-agent-bg, rgba(0, 0, 0, 0.03));
|
||||||
|
font-family: var(--rp-body-font, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__question {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--rp-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__button {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: var(--rp-field-radius, 8px);
|
||||||
|
border: 1px solid var(--rp-border, rgba(0, 0, 0, 0.12));
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rp-text, inherit);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__button--primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--rp-accent, #f34611);
|
||||||
|
color: var(--rp-on-accent, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__button:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ending a chat cannot be undone, and on a phone this is a thumb hitting glass:
|
||||||
|
give both answers a real touch target rather than a 27px pill. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.new-chat-confirm {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-confirm__button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.new-chat-confirm__button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024-2026 ReplyPier
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way the widget talks to the page it is embedded in.
|
||||||
|
*
|
||||||
|
* These messages include the visitor's conversation token, so they are addressed to a
|
||||||
|
* specific origin rather than broadcast with "*": if the embedding page is ever
|
||||||
|
* replaced by another site's document, the browser drops the message instead of
|
||||||
|
* handing that site a live token.
|
||||||
|
*
|
||||||
|
* The widget runs in a srcdoc iframe, which inherits the embedder's origin, so the
|
||||||
|
* host origin is normally readable. When it genuinely cannot be determined we fall
|
||||||
|
* back to "*" - a widget that cannot talk to its loader is broken, and that is worse
|
||||||
|
* than the broadcast we have always done.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANY_ORIGIN = '*'
|
||||||
|
|
||||||
|
/** Origins a browser reports for an opaque (sandboxed, data:, srcdoc) document. */
|
||||||
|
const OPAQUE = new Set(['null', 'about:blank', ''])
|
||||||
|
|
||||||
|
let resolved: string | null = null
|
||||||
|
|
||||||
|
const usable = (origin: string | undefined | null): string | null =>
|
||||||
|
origin && !OPAQUE.has(origin) ? origin : null
|
||||||
|
|
||||||
|
const originOf = (url: string | undefined | null): string | null => {
|
||||||
|
if (!url) return null
|
||||||
|
try {
|
||||||
|
return usable(new URL(url).origin)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin of the page hosting the widget, or "*" when it cannot be established.
|
||||||
|
* Resolved once: an embedding page navigating between routes keeps its origin.
|
||||||
|
*/
|
||||||
|
export function hostOrigin(): string {
|
||||||
|
if (resolved) return resolved
|
||||||
|
|
||||||
|
// Not framed at all - the dashboard preview renders the widget inline.
|
||||||
|
if (window.parent === window) {
|
||||||
|
resolved = usable(window.location.origin) || ANY_ORIGIN
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin parent: the srcdoc frame the embed loader creates. Reading its
|
||||||
|
// location throws for a cross-origin embed, which the catch below handles.
|
||||||
|
try {
|
||||||
|
const parentOrigin = usable(window.parent.location.origin)
|
||||||
|
if (parentOrigin) {
|
||||||
|
resolved = parentOrigin
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* cross-origin parent - fall through */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-origin embed: the browser still names our ancestors (not in Firefox), and
|
||||||
|
// the referrer is the embedding document.
|
||||||
|
try {
|
||||||
|
const ancestors = window.location.ancestorOrigins
|
||||||
|
const nearest = ancestors && ancestors.length ? usable(ancestors[0]) : null
|
||||||
|
if (nearest) {
|
||||||
|
resolved = nearest
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not supported - fall through */
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved = originOf(document.referrer) || ANY_ORIGIN
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a message to the embedding page, addressed to its origin. */
|
||||||
|
export function postToHost(message: Record<string, unknown>): void {
|
||||||
|
window.parent.postMessage(message, hostOrigin())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: forget the cached origin. */
|
||||||
|
export function resetHostOrigin(): void {
|
||||||
|
resolved = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-answer markdown → safe HTML, shared by every widget surface that renders a
|
||||||
|
* reply (the chat panel and the Ask AI palette) so they can't drift apart on either
|
||||||
|
* formatting or sanitisation.
|
||||||
|
*
|
||||||
|
* Output always goes through `sanitizeHtml`, which strips iframes/scripts/handlers —
|
||||||
|
* answers contain model output and knowledge-base content, so it is never trusted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { sanitizeHtml } from '../utils/sanitize'
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
renderer: new marked.Renderer(),
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Links open in a new tab (the widget lives in an iframe, so a same-tab navigation
|
||||||
|
// would replace the chat rather than the page). sanitizeHtml already forces
|
||||||
|
// target="_blank" + rel on every surviving anchor, so nothing is needed here.
|
||||||
|
export const renderMarkdown = (text: string): string => sanitizeHtml(marked(text || '') as string)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024-2026 ReplyPier
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy for the "New chat" control. Starting a new chat ends the current one for
|
||||||
|
* good, so both surfaces (chat header and the Ask AI bar) ask the same question in
|
||||||
|
* the same words, from here.
|
||||||
|
*/
|
||||||
|
export const NEW_CHAT_LABEL = '开始新会话'
|
||||||
|
export const NEW_CHAT_CONFIRM_QUESTION = '开始新会话?当前会话将结束。'
|
||||||
|
export const NEW_CHAT_CONFIRM_ACTION = '开始新会话'
|
||||||
|
export const NEW_CHAT_CANCEL_ACTION = '取消'
|
||||||
|
export const NEW_CHAT_FAILED_TEXT = "无法开始新会话,请重试。"
|
||||||
|
|
||||||
|
/** How long the confirmation stays up before it gives up on being answered. */
|
||||||
|
export const NEW_CHAT_CONFIRM_TIMEOUT_MS = 15000
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
import { buildUploadUrl } from '../utils/avatars';
|
||||||
|
|
||||||
|
export interface InitialData {
|
||||||
|
widgetId: string;
|
||||||
|
agentName: string;
|
||||||
|
customization: Record<string, any>;
|
||||||
|
customerId: string;
|
||||||
|
customer: Record<string, any>;
|
||||||
|
initialToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
interface Window {
|
||||||
|
// @ts-ignore
|
||||||
|
__INITIAL_DATA__: InitialData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get runtime configuration
|
||||||
|
function getRuntimeConfig() {
|
||||||
|
// @ts-ignore - APP_CONFIG might not be available at build time
|
||||||
|
return typeof window !== 'undefined' && window.APP_CONFIG ? window.APP_CONFIG : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const widgetEnv = {
|
||||||
|
get API_URL() {
|
||||||
|
const config = getRuntimeConfig();
|
||||||
|
// Default to the production API so a plain `build:widget` (no VITE_API_URL,
|
||||||
|
// no runtime APP_CONFIG) ships a working widget instead of localhost. Local
|
||||||
|
// dev overrides via VITE_API_URL; self-hosters via window.APP_CONFIG.
|
||||||
|
return config.API_URL || import.meta.env.VITE_API_URL || 'https://api.replypier.site/api/v1';
|
||||||
|
},
|
||||||
|
get WS_URL() {
|
||||||
|
const config = getRuntimeConfig();
|
||||||
|
return config.WS_URL || import.meta.env.VITE_WS_URL || 'wss://api.replypier.site';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget-side counterpart of resolveUploadUrl (src/config/api.ts). Same rules,
|
||||||
|
* but resolved against widgetEnv.API_URL so the widget keeps its own fallback.
|
||||||
|
*/
|
||||||
|
export function resolveWidgetUploadUrl(stored?: string | null): string {
|
||||||
|
return buildUploadUrl(stored, widgetEnv.API_URL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* SHARED chat-surface theme — the single place the widget's themed appearance is
|
||||||
|
* defined. Imported by BOTH the real widget (`WidgetBuilder.vue`) and the admin
|
||||||
|
* preview (`AgentChatPreviewPanel.vue`) so they can never drift.
|
||||||
|
*
|
||||||
|
* Every rule is scoped under `.chat-container.rp-surface` (both surfaces add the
|
||||||
|
* `rp-surface` marker class to their panel root). The extra class both contains the
|
||||||
|
* global import to the widget and gives these rules enough specificity to win over
|
||||||
|
* each component's own scoped `[data-v-*]` CSS. Every value comes from the `--rp-*`
|
||||||
|
* custom properties emitted by `themeCssVars()` in `widget-theme.ts` — no hard-coded
|
||||||
|
* per-theme values live here.
|
||||||
|
*
|
||||||
|
* Components keep only their own NON-themed, structural CSS (layout, animations,
|
||||||
|
* socket/streaming bits, admin-mock chrome) in their scoped <style> blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== Panel ===== */
|
||||||
|
.chat-container.rp-surface {
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
color: var(--rp-text);
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .chat-panel {
|
||||||
|
background: var(--rp-card);
|
||||||
|
color: var(--rp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Header ===== */
|
||||||
|
.chat-container.rp-surface .chat-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--rp-hairline);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .header-info h3 {
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
/* --rp-presence is the accent when it is readable on the card, otherwise the
|
||||||
|
contrast-derived muted colour (see themeCssVars). */
|
||||||
|
.chat-container.rp-surface .rp-presence,
|
||||||
|
.chat-container.rp-surface .status-text {
|
||||||
|
color: var(--rp-presence, var(--rp-accent));
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .status-indicator.online {
|
||||||
|
background: var(--rp-presence, var(--rp-accent));
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Avatars — circle by default, rounded-square on mono (Terminal); no ring ===== */
|
||||||
|
.chat-container.rp-surface .header-orb,
|
||||||
|
.chat-container.rp-surface .header-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--rp-avatar-radius);
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .rp-msg-avatar,
|
||||||
|
.chat-container.rp-surface .rp-msg-avatar-orb,
|
||||||
|
.chat-container.rp-surface .rp-msg-avatar-img,
|
||||||
|
.chat-container.rp-surface .rp-welcome-orb,
|
||||||
|
.chat-container.rp-surface .rp-welcome-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--rp-avatar-radius);
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .rp-msg-avatar-img,
|
||||||
|
.chat-container.rp-surface .rp-welcome-avatar { object-fit: cover; }
|
||||||
|
|
||||||
|
/* ===== Messages ===== */
|
||||||
|
.chat-container.rp-surface .message {
|
||||||
|
max-width: 100%;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 84%;
|
||||||
|
}
|
||||||
|
/* Collapse markdown paragraph margins so a one-line reply is a compact bubble. */
|
||||||
|
.chat-container.rp-surface .message-bubble p { margin: 0; }
|
||||||
|
.chat-container.rp-surface .message-bubble p + p { margin-top: 0.5em; }
|
||||||
|
/* Markdown links inside messages: clearly a link and clickable (v-html content, so
|
||||||
|
this must be a global rule — scoped styles don't reach it). Inherits the bubble
|
||||||
|
colour so it's readable on both the agent surface and the accent user bubble. */
|
||||||
|
.chat-container.rp-surface .message-bubble a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble a:hover { opacity: 0.82; }
|
||||||
|
/* Markdown code & wide content (v-html, so global rules): scroll inside the
|
||||||
|
bubble instead of painting past it and forcing the chat to scroll sideways.
|
||||||
|
Backgrounds derive from currentColor so they read on light and dark bubbles. */
|
||||||
|
.chat-container.rp-surface .message-bubble pre {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: var(--space-sm, 12px);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble pre code {
|
||||||
|
/* Code keeps its formatting; the <pre> scrollbar handles long lines. */
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble table {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-bubble th,
|
||||||
|
.chat-container.rp-surface .message-bubble td {
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
/* Agent/bot bubble: agent surface + hairline, tail top-left. */
|
||||||
|
.chat-container.rp-surface .agent-message .message-bubble,
|
||||||
|
.chat-container.rp-surface .assistant-message .message-bubble {
|
||||||
|
background: var(--rp-agent-bg);
|
||||||
|
color: var(--rp-text);
|
||||||
|
border: 1px solid var(--rp-hairline);
|
||||||
|
border-radius: var(--rp-bubble-tail) var(--rp-bubble) var(--rp-bubble) var(--rp-bubble);
|
||||||
|
}
|
||||||
|
/* User bubble: accent fill, contrast text, tail bottom-right. */
|
||||||
|
.chat-container.rp-surface .user-message .message-bubble {
|
||||||
|
background: var(--rp-accent);
|
||||||
|
color: var(--rp-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--rp-bubble) var(--rp-bubble) var(--rp-bubble-tail) var(--rp-bubble);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Quick-action pills ===== */
|
||||||
|
.chat-container.rp-surface .rp-quick-action {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--rp-accent) 66%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--rp-accent) 18%, transparent);
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .rp-quick-action:hover:not(:disabled) {
|
||||||
|
background: var(--rp-accent);
|
||||||
|
color: var(--rp-on-accent);
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface.theme-terminal .rp-quick-action { border-radius: 6px; }
|
||||||
|
|
||||||
|
/* ===== Input bar + send (transparent bar — card shows through, no grey band) ===== */
|
||||||
|
.chat-container.rp-surface .chat-input {
|
||||||
|
background: transparent;
|
||||||
|
border-top: 1px solid var(--rp-hairline);
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-input input,
|
||||||
|
.chat-container.rp-surface .email-input input {
|
||||||
|
border-radius: var(--rp-field-radius);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--rp-accent) 30%, var(--rp-hairline));
|
||||||
|
background: color-mix(in srgb, var(--rp-text) 6%, transparent);
|
||||||
|
color: var(--rp-text);
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-input input::placeholder,
|
||||||
|
.chat-container.rp-surface .email-input input::placeholder {
|
||||||
|
color: var(--rp-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .message-input input:focus,
|
||||||
|
.chat-container.rp-surface .email-input input:focus {
|
||||||
|
border-color: var(--rp-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--rp-accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .send-button {
|
||||||
|
width: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--rp-field-radius);
|
||||||
|
background: var(--rp-accent);
|
||||||
|
color: var(--rp-on-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Powered-by footer (transparent — card shows, no grey band) ===== */
|
||||||
|
.chat-container.rp-surface .powered-by,
|
||||||
|
.chat-container.rp-surface .powered-by-welcome {
|
||||||
|
background: transparent;
|
||||||
|
border-top: none;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
}
|
||||||
|
.chat-container.rp-surface .rp-powered-prefix { color: var(--rp-muted); opacity: 1; }
|
||||||
|
.chat-container.rp-surface .rp-brand { color: var(--rp-text); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Mono (Terminal): monospace across the whole surface. */
|
||||||
|
.chat-container.rp-surface.theme-terminal,
|
||||||
|
.chat-container.rp-surface.theme-terminal .chat-panel,
|
||||||
|
.chat-container.rp-surface.theme-terminal .header-info h3,
|
||||||
|
.chat-container.rp-surface.theme-terminal .message-bubble,
|
||||||
|
.chat-container.rp-surface.theme-terminal .rp-quick-action,
|
||||||
|
.chat-container.rp-surface.theme-terminal .message-input input,
|
||||||
|
.chat-container.rp-surface.theme-terminal .powered-by {
|
||||||
|
font-family: var(--rp-body-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass: frosted panel. */
|
||||||
|
.chat-container.rp-surface.theme-glass .chat-panel {
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the widget's per-`chat_style` design tokens — colours
|
||||||
|
* (panel / text / muted / agent-bubble / accent) AND structure (radius, glow,
|
||||||
|
* border, bubble radius, mono flag). Every visual value the comp defines lives here.
|
||||||
|
*
|
||||||
|
* `themeCssVars()` flattens these into `--rp-*` custom properties that BOTH the real
|
||||||
|
* widget (`WidgetBuilder.vue`) and the admin preview (`AgentChatPreviewPanel.vue`)
|
||||||
|
* apply on `.chat-container`, and the shared `widget-surface.css` consumes. A user's
|
||||||
|
* explicit `accent_color` / `chat_background_color` / `font_family` overrides the
|
||||||
|
* theme default; otherwise the comp values win.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isColorDark, adjustColorBrightness } from '../types/widget'
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
/** Light surface (affects overlay/contrast + hairline choices). */
|
||||||
|
light: boolean
|
||||||
|
/** Monospace-forward theme (Terminal). */
|
||||||
|
mono: boolean
|
||||||
|
/** Panel corner radius, px. */
|
||||||
|
radius: number
|
||||||
|
/** Message-bubble corner radius, px (the speech "tail" corner is derived from this). */
|
||||||
|
bubble: number
|
||||||
|
/** Ambient glow colour used in the panel shadow. */
|
||||||
|
glow: string
|
||||||
|
/** Panel border colour. */
|
||||||
|
border: string
|
||||||
|
/** Panel/card background (solid or gradient). */
|
||||||
|
card: string
|
||||||
|
/** Primary text colour. */
|
||||||
|
text: string
|
||||||
|
/** Muted/secondary text colour. */
|
||||||
|
muted: string
|
||||||
|
/** Agent message-bubble background. */
|
||||||
|
agentBg: string
|
||||||
|
/** Default accent (user `accent_color` overrides). */
|
||||||
|
accent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token sets from the design comp (Glass / Aurora / Terminal / Calm Mint /
|
||||||
|
// Playful / Sunrise). Each chat_style maps 1:1 to a comp theme. The light themes'
|
||||||
|
// muted values are darkened from the comp to reach WCAG AA (4.5:1) on white.
|
||||||
|
const GLASS: ThemeTokens = {
|
||||||
|
light: false, mono: false, radius: 22, bubble: 16,
|
||||||
|
glow: 'rgba(157,140,255,.26)', border: 'rgba(157,140,255,.32)',
|
||||||
|
card: 'linear-gradient(180deg,rgba(28,26,40,.94),rgba(15,14,22,.97))',
|
||||||
|
text: '#ECEAFA', muted: '#9C97BE', agentBg: 'rgba(255,255,255,.06)', accent: '#9D8CFF',
|
||||||
|
}
|
||||||
|
const AURORA: ThemeTokens = {
|
||||||
|
light: false, mono: false, radius: 26, bubble: 18,
|
||||||
|
glow: 'rgba(157,140,255,.32)', border: 'rgba(157,140,255,.40)',
|
||||||
|
card: 'linear-gradient(180deg,#16131F,#0A0910)',
|
||||||
|
text: '#F2F3F8', muted: '#A7A0CC', agentBg: 'rgba(255,255,255,.05)', accent: '#9D8CFF',
|
||||||
|
}
|
||||||
|
const TERMINAL: ThemeTokens = {
|
||||||
|
light: false, mono: true, radius: 8, bubble: 4,
|
||||||
|
glow: 'rgba(201,242,78,.20)', border: 'rgba(201,242,78,.30)',
|
||||||
|
card: '#070907',
|
||||||
|
text: '#D7F7C8', muted: '#7F9B57', agentBg: 'rgba(201,242,78,.045)', accent: '#C9F24E',
|
||||||
|
}
|
||||||
|
const CALM_MINT: ThemeTokens = {
|
||||||
|
light: false, mono: false, radius: 18, bubble: 14,
|
||||||
|
glow: 'rgba(95,227,214,.22)', border: 'rgba(95,227,214,.30)',
|
||||||
|
card: 'linear-gradient(180deg,#0E1A1A,#0A1414)',
|
||||||
|
text: '#DDF7F3', muted: '#6FAFA8', agentBg: 'rgba(255,255,255,.05)', accent: '#5FE3D6',
|
||||||
|
}
|
||||||
|
const PLAYFUL: ThemeTokens = {
|
||||||
|
light: true, mono: false, radius: 28, bubble: 20,
|
||||||
|
glow: 'rgba(255,138,115,.30)', border: 'rgba(0,0,0,.07)',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
text: '#2A2730', muted: '#736C7C', agentBg: '#F4F1F6', accent: '#FF8A73',
|
||||||
|
}
|
||||||
|
const SUNRISE: ThemeTokens = {
|
||||||
|
light: true, mono: false, radius: 24, bubble: 16,
|
||||||
|
glow: 'rgba(255,138,115,.22)', border: 'rgba(0,0,0,.08)',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
text: '#2A2A33', muted: '#6E6E7B', agentBg: '#F3F3F6', accent: '#FF8A73',
|
||||||
|
}
|
||||||
|
|
||||||
|
// chat_style → token-set mapping. CHATBOT (legacy) and ASK_ANYTHING reuse Sunrise.
|
||||||
|
const THEME_TOKENS: Record<string, ThemeTokens> = {
|
||||||
|
GLASS,
|
||||||
|
AURORA,
|
||||||
|
TERMINAL,
|
||||||
|
CALM_MINT,
|
||||||
|
PLAYFUL,
|
||||||
|
SUNRISE,
|
||||||
|
CHATBOT: SUNRISE,
|
||||||
|
ASK_ANYTHING: SUNRISE,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONO_FONT = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
|
||||||
|
const BODY_FONT = "'Instrument Sans', system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
|
||||||
|
/** Speech-bubble "tail" corner radius, derived from the bubble radius (comp formula). */
|
||||||
|
function bubbleTail(bubble: number): number {
|
||||||
|
return Math.max(4, Math.round(bubble * 0.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrast colour for text/icons sitting ON the accent (user bubble, send button).
|
||||||
|
* Matches the comp `on()`: relative luminance > 0.62 → near-black, else white.
|
||||||
|
*/
|
||||||
|
export function onAccent(hex: string): string {
|
||||||
|
const c = (hex || '').replace('#', '')
|
||||||
|
if (c.length < 6) return '#0B0C10'
|
||||||
|
const r = parseInt(c.slice(0, 2), 16)
|
||||||
|
const g = parseInt(c.slice(2, 4), 16)
|
||||||
|
const b = parseInt(c.slice(4, 6), 16)
|
||||||
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
return lum > 0.62 ? '#0B0C10' : '#FFFFFF'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve structural + colour tokens for a chat style (defaults to the light Sunrise set). */
|
||||||
|
export function getThemeTokens(chatStyle?: string | null): ThemeTokens {
|
||||||
|
return THEME_TOKENS[chatStyle || ''] || SUNRISE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal shape of the customization fields that can override theme defaults. */
|
||||||
|
export interface ThemeOverrides {
|
||||||
|
chat_background_color?: string | null
|
||||||
|
chat_text_color?: string | null
|
||||||
|
accent_color?: string | null
|
||||||
|
font_family?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy DB default for chat_text_color (dark). Treated as "unset" so existing
|
||||||
|
// agents fall back to the per-theme text colour instead of near-black on dark themes.
|
||||||
|
const LEGACY_TEXT_DEFAULT = '#212529'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten tokens (merged with any user overrides) into the `--rp-*` custom
|
||||||
|
* properties consumed by `widget-surface.css`. This is the ONLY place per-theme
|
||||||
|
* values are turned into CSS — both widget and preview call it.
|
||||||
|
*/
|
||||||
|
export function themeCssVars(chatStyle?: string | null, overrides?: ThemeOverrides): Record<string, string> {
|
||||||
|
const t = getThemeTokens(chatStyle)
|
||||||
|
|
||||||
|
// A user-set background is a hex colour from the picker; when present we derive
|
||||||
|
// text/muted/agent-bubble from it (so custom colours stay legible), otherwise we
|
||||||
|
// use the comp's exact per-theme colours.
|
||||||
|
const customBg = overrides?.chat_background_color || ''
|
||||||
|
const hasCustomBg = /^#[0-9a-fA-F]{6}$/.test(customBg)
|
||||||
|
const card = customBg || t.card
|
||||||
|
|
||||||
|
// Text colour precedence: explicit user text colour > derived from a custom
|
||||||
|
// background > the theme's design default.
|
||||||
|
const customText = overrides?.chat_text_color || ''
|
||||||
|
const hasCustomText = /^#[0-9a-fA-F]{6}$/.test(customText) && customText.toLowerCase() !== LEGACY_TEXT_DEFAULT
|
||||||
|
const text = hasCustomText
|
||||||
|
? customText
|
||||||
|
: (hasCustomBg ? (isColorDark(customBg) ? '#FFFFFF' : '#111111') : t.text)
|
||||||
|
const muted = hasCustomBg ? (isColorDark(customBg) ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.62)') : t.muted
|
||||||
|
const agentBg = hasCustomBg ? adjustColorBrightness(customBg, 20) : t.agentBg
|
||||||
|
|
||||||
|
const accent = overrides?.accent_color || t.accent
|
||||||
|
|
||||||
|
// Presence line ("Online · replies instantly"): the comp colours it with the
|
||||||
|
// accent, but a light accent on a light card (e.g. lime on the white Sunrise
|
||||||
|
// header) — or a dark accent on a dark card — is unreadable. Fall back to the
|
||||||
|
// contrast-derived muted colour in that case. Same luminance rule as onAccent().
|
||||||
|
const cardIsLight = hasCustomBg ? !isColorDark(customBg) : t.light
|
||||||
|
const accentIsLight = onAccent(accent) === '#0B0C10'
|
||||||
|
const presence = cardIsLight === accentIsLight ? muted : accent
|
||||||
|
// font_family may be a bare name ("Space Grotesk") or a full stack
|
||||||
|
// ("Instrument Sans, sans-serif"); append it raw so the comma-separated stack
|
||||||
|
// stays valid (quoting the whole stack would make the first token invalid).
|
||||||
|
const bodyFont = t.mono
|
||||||
|
? MONO_FONT
|
||||||
|
: (overrides?.font_family ? `${overrides.font_family}, ${BODY_FONT}` : BODY_FONT)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'--rp-card': card,
|
||||||
|
'--rp-text': text,
|
||||||
|
'--rp-muted': muted,
|
||||||
|
'--rp-agent-bg': agentBg,
|
||||||
|
'--rp-accent': accent,
|
||||||
|
'--rp-on-accent': onAccent(accent),
|
||||||
|
'--rp-presence': presence,
|
||||||
|
'--rp-border': t.border,
|
||||||
|
'--rp-glow': t.glow,
|
||||||
|
'--rp-radius': `${t.radius}px`,
|
||||||
|
'--rp-bubble': `${t.bubble}px`,
|
||||||
|
'--rp-bubble-tail': `${bubbleTail(t.bubble)}px`,
|
||||||
|
'--rp-field-radius': t.mono ? '7px' : '12px',
|
||||||
|
'--rp-avatar-radius': t.mono ? '28%' : '50%',
|
||||||
|
'--rp-hairline': t.light ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.08)',
|
||||||
|
'--rp-body-font': bodyFont,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024-2026 ReplyPier
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import WidgetBuilder from './WidgetBuilder.vue'
|
||||||
|
import './widget.css'
|
||||||
|
|
||||||
|
// Ensure the environment is defined
|
||||||
|
// @ts-ignore
|
||||||
|
if (!window.process) {
|
||||||
|
// @ts-ignore
|
||||||
|
window.process = { env: { NODE_ENV: 'production' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract widget ID and token from window or URL
|
||||||
|
// @ts-ignore
|
||||||
|
const initialData = window.__INITIAL_DATA__
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const isPreviewMode = url.searchParams.get('preview') === 'true'
|
||||||
|
|
||||||
|
// Helper to handle URL params that might be the string "undefined"
|
||||||
|
const getURLParam = (name: string): string | undefined => {
|
||||||
|
const value = url.searchParams.get(name)
|
||||||
|
// Return undefined if value is null, empty, or the literal string "undefined"
|
||||||
|
if (!value || value === 'undefined' || value.trim() === '') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get widget ID - prefer URL param in preview mode
|
||||||
|
const widgetId = isPreviewMode
|
||||||
|
? getURLParam('widget_id') || (initialData?.widgetId) || undefined
|
||||||
|
: (initialData?.widgetId) || undefined
|
||||||
|
|
||||||
|
// Get token - prefer initial data, fallback to URL param in preview mode
|
||||||
|
// @ts-ignore
|
||||||
|
const token = isPreviewMode
|
||||||
|
? (initialData?.initialToken) || getURLParam('token') || undefined
|
||||||
|
: (initialData?.initialToken) || undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Token Handling
|
||||||
|
*
|
||||||
|
* The widget supports two authentication modes based on agent's require_token_auth setting:
|
||||||
|
*
|
||||||
|
* 1. require_token_auth=true: Token MUST be provided from portal backend via /api/v1/generate-token
|
||||||
|
* 2. require_token_auth=false: Backend auto-generates tokens for anonymous users
|
||||||
|
*
|
||||||
|
* Token is a JWT that cryptographically binds:
|
||||||
|
* - widget_id (the specific widget instance)
|
||||||
|
* - customer_id (the authenticated user)
|
||||||
|
* - expiration time (TTL)
|
||||||
|
*
|
||||||
|
* Token signature is verified on the backend before any WebSocket connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const app = createApp(WidgetBuilder, {
|
||||||
|
widgetId: widgetId,
|
||||||
|
token: token || undefined,
|
||||||
|
initialAuthError: null // Let backend determine if auth is required
|
||||||
|
})
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ReplyPier Widget Test
|
||||||
|
|
||||||
|
This is a test project for the ReplyPier chat widget integration.
|
||||||
|
|
||||||
|
## UI options gallery
|
||||||
|
|
||||||
|
`ui-options.html` has one card per widget placement/behaviour (floating left/right,
|
||||||
|
custom size and offsets, sidebar drawers, search-bar trigger, hidden launcher + own
|
||||||
|
button, custom z-index, dashboard defaults, page-overrides-beat-dashboard, mobile).
|
||||||
|
Each opens `ui-test.html` with that configuration plus buttons for the whole JS API
|
||||||
|
(`open/close/toggle/isOpen`, `open({message})`, `hide/showLauncher`, `setPosition`,
|
||||||
|
`data-replypier-open`, `trigger` selector) and an event log.
|
||||||
|
|
||||||
|
Use it in **Stub** mode (fake widget, no backend needed) or **Real** mode (set your
|
||||||
|
widget ID + API URL on the gallery page). The Loader URL setting controls where
|
||||||
|
`replypier.min.js` comes from — the frontend dev server by default.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the test server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open http://localhost:3000 in your browser
|
||||||
|
|
||||||
|
## Testing the Widget
|
||||||
|
|
||||||
|
1. Make sure the ReplyPier frontend development server is running (usually at http://localhost:5173)
|
||||||
|
2. Click the "Initialize Widget" button on the test page
|
||||||
|
3. Use the chat widget that appears in the bottom-right corner
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- The widget script is loaded from the local development server
|
||||||
|
- The test page uses a dummy widget ID ('test-widget-123')
|
||||||
|
- CORS is enabled to allow loading the widget script from a different origin
|
||||||
|
|
||||||
|
## Where things are served from
|
||||||
|
|
||||||
|
- **Loader** (`replypier.min.js`) — the **frontend** (`http://localhost:5173/webclient/…`),
|
||||||
|
the same origin the dashboard's embed snippet points at. The backend does **not**
|
||||||
|
serve it, and no copy is kept in this folder: a copy goes stale and you end up
|
||||||
|
testing an old widget while the source looks correct.
|
||||||
|
It is a build artifact — after editing `frontend/src/webclient/replypier.js` run
|
||||||
|
`cd ../frontend && npm run build:webclient:prod`.
|
||||||
|
- **Widget app** (`widget.js`) — the backend, from `backend/assets/`. Rebuild with
|
||||||
|
`cd ../frontend && npm run build:widget:prod` after changing `WidgetBuilder.vue`
|
||||||
|
or the widget composables.
|
||||||
|
- **API** — set `window.replypierBaseUrl`. Without it the loader falls back to the URL
|
||||||
|
baked in at build time, which for a production build is the cloud API, so a "local"
|
||||||
|
test silently talks to api.replypier.site.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"Chat can't load on 'localhost'"** — the origin isn't in your organization's allowed
|
||||||
|
domains (Organization settings). The port matters: `localhost:3000` allowed does not
|
||||||
|
permit `localhost:3010`.
|
||||||
|
- **Dashboard placement settings not applying** — almost always a stale loader. Check the
|
||||||
|
served file has the newer features: `curl -s <loader-url> | grep -c sidebar-left`.
|
||||||
|
- **Buttons do nothing on the gallery pages** — the loader failed to load; the page shows
|
||||||
|
a red banner saying so.
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ReplyPier Widget Test</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #f34611;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #444;
|
||||||
|
font-size: clamp(1.25rem, 3vw, 1.5rem);
|
||||||
|
margin: 20px 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f34611;
|
||||||
|
box-shadow: 0 0 0 2px rgba(243, 70, 17, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button {
|
||||||
|
background: #f34611;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button:hover {
|
||||||
|
background: #d93d0c;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(243, 70, 17, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button.clear-button {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button.clear-button:hover {
|
||||||
|
background: #c82333;
|
||||||
|
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button.auth-button {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button.auth-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #555;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.loading {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
min-width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
padding: 14px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
background: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
background: #333;
|
||||||
|
border-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input:focus {
|
||||||
|
border-color: #f34611;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section {
|
||||||
|
border-top-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section h3 {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section p {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.success {
|
||||||
|
background: #1e4620;
|
||||||
|
color: #8fdf94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.error {
|
||||||
|
background: #4a1c1c;
|
||||||
|
color: #f5a5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.loading {
|
||||||
|
background: #4a3c00;
|
||||||
|
color: #ffd966;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility improvements */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>ReplyPier Widget Test Page</h1>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>About ReplyPier</h2>
|
||||||
|
<p>
|
||||||
|
This is a test page demonstrating the ReplyPier chat widget integration.
|
||||||
|
The widget provides real-time chat functionality with AI-powered responses
|
||||||
|
and seamless human handoff capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2>Widget Controls</h2>
|
||||||
|
<p>Enter your widget ID and click initialize to load the chat widget.</p>
|
||||||
|
<div class="widget-controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="widgetIdInput"
|
||||||
|
placeholder="Enter Widget ID"
|
||||||
|
value=""
|
||||||
|
class="widget-input"
|
||||||
|
>
|
||||||
|
<button class="demo-button" id="widgetButton" onclick="initializeWidget()">
|
||||||
|
Initialize Widget
|
||||||
|
</button>
|
||||||
|
<button class="demo-button clear-button" id="clearButton" onclick="clearWidget()">
|
||||||
|
Clear Widget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Authentication Section -->
|
||||||
|
<div class="auth-section">
|
||||||
|
<h3>Token Authentication (Optional)</h3>
|
||||||
|
<p>For agents with "Require Token Auth" enabled, provide an API key to authenticate.</p>
|
||||||
|
<div class="widget-controls">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="apiKeyInput"
|
||||||
|
placeholder="Enter API Key (from Widget Apps)"
|
||||||
|
class="widget-input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="customerEmailInput"
|
||||||
|
placeholder="Customer Email (optional)"
|
||||||
|
class="widget-input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="widget-controls" style="margin-top: 10px;">
|
||||||
|
<button class="demo-button auth-button" id="authButton" onclick="authenticateAndInitialize()">
|
||||||
|
Authenticate & Initialize
|
||||||
|
</button>
|
||||||
|
<span id="authStatus" class="auth-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>Integration Code</h2>
|
||||||
|
<p>To add the ReplyPier widget to your website, add the following code before the closing
|
||||||
|
<code></body></code> tag:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px; font-size: 1rem;">Basic (No Authentication)</h3>
|
||||||
|
<pre><code>
|
||||||
|
<script>
|
||||||
|
window.replypierId = 'YOUR_WIDGET_ID';
|
||||||
|
// Required for local testing — without it the widget uses the API URL baked
|
||||||
|
// into the build (the cloud API for a production build).
|
||||||
|
window.replypierBaseUrl = 'http://localhost:8000/api/v1';
|
||||||
|
</script>
|
||||||
|
<script src="http://localhost:5173/webclient/replypier.min.js"></script>
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px; font-size: 1rem;">With Token Authentication</h3>
|
||||||
|
<p style="font-size: 0.9em; color: #666;">For agents with "Require Token Auth" enabled, generate a token server-side:</p>
|
||||||
|
<pre><code>
|
||||||
|
// Server-side: Generate token using your API key
|
||||||
|
const response = await fetch('http://localhost:8000/api/v1/generate-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
widget_id: 'YOUR_WIDGET_ID',
|
||||||
|
customer_email: 'user@example.com', // optional
|
||||||
|
ttl_seconds: 3600
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const { data } = await response.json();
|
||||||
|
// Pass data.token to frontend
|
||||||
|
|
||||||
|
// Client-side: Initialize widget with token
|
||||||
|
<script>
|
||||||
|
window.replypierId = 'YOUR_WIDGET_ID';
|
||||||
|
window.replypierToken = 'TOKEN_FROM_SERVER';
|
||||||
|
window.replypierBaseUrl = 'http://localhost:8000/api/v1';
|
||||||
|
</script>
|
||||||
|
<script src="http://localhost:5173/webclient/replypier.min.js"></script>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ReplyPier Widget Integration -->
|
||||||
|
<script>
|
||||||
|
// Constants for localStorage keys
|
||||||
|
const WIDGET_ID_KEY = 'replypier_widget_id';
|
||||||
|
const WIDGET_INITIALIZED_KEY = 'replypier_initialized';
|
||||||
|
|
||||||
|
// Where the widget talks to. WITHOUT this the loader falls back to the URL
|
||||||
|
// baked in at build time — which for a :prod build is the cloud API, so a
|
||||||
|
// local test would silently chat with api.replypier.site.
|
||||||
|
// Override in the console: localStorage.setItem('replypier_api_base', '...')
|
||||||
|
const CM_API_BASE = localStorage.getItem('replypier_api_base') || 'http://localhost:8000/api/v1';
|
||||||
|
// The loader is served by the FRONTEND (vite in dev, the dashboard origin in
|
||||||
|
// production) — never copied into this folder, where it would go stale.
|
||||||
|
// The backend does not serve it. Rebuild after loader changes:
|
||||||
|
// cd ../frontend && npm run build:webclient:prod
|
||||||
|
// Follow the page's own scheme rather than hardcoding http://: served over
|
||||||
|
// https this page would otherwise pull executable code over an unencrypted
|
||||||
|
// connection, which the browser blocks as mixed content anyway.
|
||||||
|
const CM_LOADER_URL = localStorage.getItem('replypier_loader_url')
|
||||||
|
|| `${window.location.protocol}//localhost:5173/webclient/replypier.min.js`;
|
||||||
|
|
||||||
|
// Load saved widget ID and initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadSavedWidgetId();
|
||||||
|
autoInitializeWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadSavedWidgetId() {
|
||||||
|
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
|
||||||
|
const widgetInput = document.getElementById('widgetIdInput');
|
||||||
|
const widgetButton = document.getElementById('widgetButton');
|
||||||
|
|
||||||
|
if (savedWidgetId) {
|
||||||
|
widgetInput.value = savedWidgetId;
|
||||||
|
widgetButton.textContent = 'Re-initialize Widget';
|
||||||
|
updateButtonStates(true);
|
||||||
|
} else {
|
||||||
|
// Set default widget ID if none saved
|
||||||
|
widgetInput.value = 'd63483f1-3954-4a94-a3f7-bb5039dda633';
|
||||||
|
updateButtonStates(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoInitializeWidget() {
|
||||||
|
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
|
||||||
|
const wasInitialized = localStorage.getItem(WIDGET_INITIALIZED_KEY) === 'true';
|
||||||
|
const hasApiKey = localStorage.getItem('replypier_api_key');
|
||||||
|
|
||||||
|
// Don't auto-initialize if we have an API key saved (requires token auth)
|
||||||
|
if (hasApiKey) {
|
||||||
|
console.log('Skipping auto-initialize: API key detected, use Authenticate & Initialize button');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedWidgetId && wasInitialized) {
|
||||||
|
console.log('Auto-initializing widget with saved ID:', savedWidgetId);
|
||||||
|
performWidgetInitialization(savedWidgetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeWidget() {
|
||||||
|
const widgetId = document.getElementById('widgetIdInput').value;
|
||||||
|
if (!widgetId) {
|
||||||
|
alert('Please enter a widget ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save widget ID to localStorage
|
||||||
|
localStorage.setItem(WIDGET_ID_KEY, widgetId);
|
||||||
|
localStorage.setItem(WIDGET_INITIALIZED_KEY, 'true');
|
||||||
|
|
||||||
|
performWidgetInitialization(widgetId);
|
||||||
|
updateButtonStates(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function performWidgetInitialization(widgetId) {
|
||||||
|
// Set the widget ID
|
||||||
|
window.replypierId = widgetId;
|
||||||
|
window.replypierBaseUrl = CM_API_BASE;
|
||||||
|
|
||||||
|
// Remove existing script if any
|
||||||
|
const existingScript = document.getElementById('replypier-script');
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment for cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
// Create and add new script
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = 'replypier-script';
|
||||||
|
script.src = CM_LOADER_URL;
|
||||||
|
script.onload = function() {
|
||||||
|
// Initialize the widget once script loads
|
||||||
|
if (window.ReplyPier) {
|
||||||
|
window.ReplyPier.init({ id: widgetId });
|
||||||
|
console.log('Widget initialized with ID:', widgetId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWidget() {
|
||||||
|
// Remove widget script first to prevent it from recreating storage items
|
||||||
|
const existingScript = document.getElementById('replypier-script');
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset window properties to disable widget functionality
|
||||||
|
if (window.replypierId) {
|
||||||
|
delete window.replypierId;
|
||||||
|
}
|
||||||
|
if (window.ReplyPier) {
|
||||||
|
// Call destroy method if it exists
|
||||||
|
if (typeof window.ReplyPier.destroy === 'function') {
|
||||||
|
window.ReplyPier.destroy();
|
||||||
|
}
|
||||||
|
delete window.ReplyPier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to clear storage items with specific patterns
|
||||||
|
function clearStorageItems(storage, storageType) {
|
||||||
|
const keysToRemove = [];
|
||||||
|
|
||||||
|
// Collect all keys that match our patterns
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key && (
|
||||||
|
key.includes('replypier') ||
|
||||||
|
key.includes('widget') ||
|
||||||
|
key === 'rpid' ||
|
||||||
|
key.includes('chat') ||
|
||||||
|
key.startsWith('cm_') ||
|
||||||
|
key.includes('conversation') ||
|
||||||
|
key.includes('session')
|
||||||
|
)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all collected keys
|
||||||
|
keysToRemove.forEach(key => {
|
||||||
|
storage.removeItem(key);
|
||||||
|
console.log(`Removed ${storageType} key:`, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keysToRemove.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear localStorage
|
||||||
|
const localKeysRemoved = clearStorageItems(localStorage, 'localStorage');
|
||||||
|
|
||||||
|
// Clear sessionStorage
|
||||||
|
const sessionKeysRemoved = clearStorageItems(sessionStorage, 'sessionStorage');
|
||||||
|
|
||||||
|
// Also remove our specific keys (backup)
|
||||||
|
localStorage.removeItem(WIDGET_ID_KEY);
|
||||||
|
localStorage.removeItem(WIDGET_INITIALIZED_KEY);
|
||||||
|
localStorage.removeItem('rpid');
|
||||||
|
sessionStorage.removeItem('rpid');
|
||||||
|
|
||||||
|
// Clear any widget-related cookies
|
||||||
|
let cookiesCleared = 0;
|
||||||
|
document.cookie.split(";").forEach(function(c) {
|
||||||
|
const cookie = c.trim();
|
||||||
|
if (cookie.includes('replypier') ||
|
||||||
|
cookie.includes('widget') ||
|
||||||
|
cookie.includes('rpid') ||
|
||||||
|
cookie.includes('chat')) {
|
||||||
|
const eqPos = cookie.indexOf("=");
|
||||||
|
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
|
||||||
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
|
||||||
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
|
||||||
|
cookiesCleared++;
|
||||||
|
console.log('Removed cookie:', name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input field
|
||||||
|
document.getElementById('widgetIdInput').value = '';
|
||||||
|
|
||||||
|
// Force a small delay and check again for any recreated items
|
||||||
|
setTimeout(() => {
|
||||||
|
localStorage.removeItem('rpid');
|
||||||
|
sessionStorage.removeItem('rpid');
|
||||||
|
console.log('Final cleanup: Removed any remaining rpid keys');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
updateButtonStates(false);
|
||||||
|
console.log(`Widget cleared completely:
|
||||||
|
- LocalStorage keys removed: ${localKeysRemoved}
|
||||||
|
- SessionStorage keys removed: ${sessionKeysRemoved}
|
||||||
|
- Cookies cleared: ${cookiesCleared}
|
||||||
|
- Widget script removed and window properties cleaned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonStates(isInitialized) {
|
||||||
|
const widgetButton = document.getElementById('widgetButton');
|
||||||
|
const clearButton = document.getElementById('clearButton');
|
||||||
|
|
||||||
|
if (isInitialized) {
|
||||||
|
widgetButton.textContent = 'Re-initialize Widget';
|
||||||
|
clearButton.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
widgetButton.textContent = 'Initialize Widget';
|
||||||
|
clearButton.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button state when input changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const widgetInput = document.getElementById('widgetIdInput');
|
||||||
|
widgetInput.addEventListener('input', function() {
|
||||||
|
const savedWidgetId = localStorage.getItem(WIDGET_ID_KEY);
|
||||||
|
const currentValue = this.value;
|
||||||
|
const widgetButton = document.getElementById('widgetButton');
|
||||||
|
|
||||||
|
if (savedWidgetId && currentValue === savedWidgetId) {
|
||||||
|
widgetButton.textContent = 'Re-initialize Widget';
|
||||||
|
} else {
|
||||||
|
widgetButton.textContent = 'Initialize Widget';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved API key
|
||||||
|
const savedApiKey = localStorage.getItem('replypier_api_key');
|
||||||
|
if (savedApiKey) {
|
||||||
|
document.getElementById('apiKeyInput').value = savedApiKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticate with API key and initialize widget
|
||||||
|
async function authenticateAndInitialize() {
|
||||||
|
const widgetId = document.getElementById('widgetIdInput').value;
|
||||||
|
const apiKey = document.getElementById('apiKeyInput').value;
|
||||||
|
const customerEmail = document.getElementById('customerEmailInput').value;
|
||||||
|
const authStatus = document.getElementById('authStatus');
|
||||||
|
|
||||||
|
if (!widgetId) {
|
||||||
|
alert('Please enter a widget ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
alert('Please enter an API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
authStatus.textContent = 'Authenticating...';
|
||||||
|
authStatus.className = 'auth-status loading';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the generate-token endpoint
|
||||||
|
const response = await fetch(`${CM_API_BASE}/generate-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
widget_id: widgetId,
|
||||||
|
customer_email: customerEmail || undefined,
|
||||||
|
ttl_seconds: 3600
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data?.token) {
|
||||||
|
// Save API key for future use
|
||||||
|
localStorage.setItem('replypier_api_key', apiKey);
|
||||||
|
localStorage.setItem(WIDGET_ID_KEY, widgetId);
|
||||||
|
localStorage.setItem(WIDGET_INITIALIZED_KEY, 'true');
|
||||||
|
|
||||||
|
// Set the token for the widget
|
||||||
|
window.replypierToken = data.data.token;
|
||||||
|
window.replypierId = widgetId;
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
authStatus.textContent = `Authenticated! Token expires: ${new Date(data.data.expires_at).toLocaleString()}`;
|
||||||
|
authStatus.className = 'auth-status success';
|
||||||
|
|
||||||
|
console.log('Token generated successfully:', {
|
||||||
|
widget_id: data.data.widget_id,
|
||||||
|
expires_at: data.data.expires_at,
|
||||||
|
expires_in: data.data.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the widget with the token
|
||||||
|
performAuthenticatedWidgetInitialization(widgetId, data.data.token);
|
||||||
|
updateButtonStates(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response from server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication error:', error);
|
||||||
|
authStatus.textContent = `Error: ${error.message}`;
|
||||||
|
authStatus.className = 'auth-status error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performAuthenticatedWidgetInitialization(widgetId, token) {
|
||||||
|
// Set widget configuration - widget loader checks window.replypierToken first
|
||||||
|
window.replypierId = widgetId;
|
||||||
|
window.replypierToken = token;
|
||||||
|
window.replypierBaseUrl = CM_API_BASE;
|
||||||
|
|
||||||
|
// Remove existing script if any
|
||||||
|
const existingScript = document.getElementById('replypier-script');
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove any existing widget elements to ensure fresh initialization
|
||||||
|
const existingContainer = document.getElementById('replypier-container');
|
||||||
|
const existingButton = document.getElementById('replypier-button');
|
||||||
|
const existingClose = document.getElementById('replypier-mobile-close');
|
||||||
|
const existingTopbar = document.getElementById('replypier-mobile-topbar');
|
||||||
|
if (existingContainer) existingContainer.remove();
|
||||||
|
if (existingButton) existingButton.remove();
|
||||||
|
if (existingClose) existingClose.remove();
|
||||||
|
if (existingTopbar) existingTopbar.remove();
|
||||||
|
|
||||||
|
// Clear ReplyPier instance
|
||||||
|
if (window.ReplyPier) {
|
||||||
|
delete window.ReplyPier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment for cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
// Create and add new script
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = 'replypier-script';
|
||||||
|
script.src = CM_LOADER_URL;
|
||||||
|
|
||||||
|
script.onload = function() {
|
||||||
|
if (window.ReplyPier) {
|
||||||
|
window.ReplyPier.init({
|
||||||
|
id: widgetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
{
|
||||||
|
"name": "replypier-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "replypier-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"negotiator": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "^3.1.2",
|
||||||
|
"content-type": "^2.0.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"http-errors": "^2.0.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"qs": "^6.15.2",
|
||||||
|
"raw-body": "^3.0.2",
|
||||||
|
"type-is": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/content-type": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "^2.0.0",
|
||||||
|
"body-parser": "^2.2.1",
|
||||||
|
"content-disposition": "^1.0.0",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cookie": "^0.7.1",
|
||||||
|
"cookie-signature": "^1.2.1",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"finalhandler": "^2.1.0",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"merge-descriptors": "^2.0.0",
|
||||||
|
"mime-types": "^3.0.0",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"once": "^1.4.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"proxy-addr": "^2.0.7",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"router": "^2.2.0",
|
||||||
|
"send": "^1.1.0",
|
||||||
|
"serve-static": "^2.2.0",
|
||||||
|
"statuses": "^2.0.1",
|
||||||
|
"type-is": "^2.0.1",
|
||||||
|
"vary": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"statuses": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/negotiator/node_modules/content-type": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||||
|
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.7.0",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/router": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
|
"is-promise": "^4.0.0",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"path-to-regexp": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.1",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"statuses": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"send": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^2.0.0",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is/node_modules/content-type": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "replypier-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// This server only hosts the test pages. The widget loader is NOT kept here — the
|
||||||
|
// pages load it from the frontend origin (vite dev server locally, the dashboard
|
||||||
|
// origin in production), exactly like the embed snippet the dashboard generates.
|
||||||
|
// A copy in this folder would go stale and silently test an old widget.
|
||||||
|
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || "http://localhost:5173";
|
||||||
|
|
||||||
|
// Enable CORS for the widget script
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
app.use(express.static(__dirname));
|
||||||
|
|
||||||
|
// Serve index.html
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.sendFile(join(__dirname, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Test server running at http://localhost:${port}`);
|
||||||
|
console.log(`UI options gallery: http://localhost:${port}/ui-options.html`);
|
||||||
|
console.log(`Widget loader from: ${FRONTEND_ORIGIN}/webclient/replypier.min.js`);
|
||||||
|
console.log(`(rebuild it after loader changes: cd ../frontend && npm run build:webclient:prod)`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ReplyPier UI options gallery</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui; margin: 0; padding: 32px; background: #f7f7f5; color: #1c1c1e; }
|
||||||
|
h1 { margin: 0 0 4px; }
|
||||||
|
.hint { color: #666; margin-bottom: 20px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||||
|
a.card { display: block; background: #fff; border: 1px solid #e2e2df; border-radius: 12px; padding: 16px; text-decoration: none; color: inherit; }
|
||||||
|
a.card:hover { border-color: #f34611; }
|
||||||
|
.card h3 { margin: 0 0 6px; font-size: 15px; }
|
||||||
|
.card p { margin: 0; font-size: 13px; color: #666; line-height: 1.5; }
|
||||||
|
code { background: #eee; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.settings { background: #fff; border: 1px solid #e2e2df; border-radius: 12px; padding: 16px; margin-bottom: 24px; display: flex; gap: 12px; flex-wrap: wrap; align-items: end; }
|
||||||
|
.settings label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #666; }
|
||||||
|
.settings input, .settings select { padding: 6px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 13px; min-width: 230px; }
|
||||||
|
.settings button { padding: 8px 16px; border: none; border-radius: 8px; background: #f34611; color: #fff; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ReplyPier UI options gallery</h1>
|
||||||
|
<p class="hint">
|
||||||
|
One card per placement/behaviour. <strong>Stub</strong> mode fakes the widget (no backend
|
||||||
|
needed — good for checking the loader UI alone). <strong>Real</strong> mode loads your
|
||||||
|
actual widget: fill in the widget ID and API URL below.
|
||||||
|
<br>
|
||||||
|
The loader is loaded from the <strong>frontend</strong> (<code>npm run dev</code> in
|
||||||
|
<code>frontend/</code>), the same origin the dashboard's embed snippet points at — the
|
||||||
|
backend does not serve it, and no copy is kept in this folder. The loader is a build
|
||||||
|
artifact: after changing its source run
|
||||||
|
<code>cd frontend && npm run build:webclient:prod</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings">
|
||||||
|
<label>Mode
|
||||||
|
<select id="mode">
|
||||||
|
<option value="stub">Stub (no backend)</option>
|
||||||
|
<option value="real">Real backend</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Loader URL (served by the frontend)
|
||||||
|
<input id="loaderUrl" placeholder="http://localhost:5173/webclient/replypier.min.js">
|
||||||
|
</label>
|
||||||
|
<label>Widget ID (real mode)
|
||||||
|
<input id="widgetId" placeholder="widget uuid">
|
||||||
|
</label>
|
||||||
|
<label>API base URL (real mode)
|
||||||
|
<input id="baseUrl" placeholder="http://localhost:8000/api/v1">
|
||||||
|
</label>
|
||||||
|
<button onclick="saveSettings()">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" data-config="">
|
||||||
|
<h3>1. Default (zero config)</h3>
|
||||||
|
<p>Legacy behaviour — floating bubble, bottom right. The regression baseline.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"position":{"side":"left"}}'>
|
||||||
|
<h3>2. Floating, left side</h3>
|
||||||
|
<p>Bubble and window hugging the bottom-left corner.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"width":520,"height":700}'>
|
||||||
|
<h3>3. Floating, custom size</h3>
|
||||||
|
<p>520 × 700 window instead of the 400 × 560 default.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"position":{"bottom":90,"offset":24}}'>
|
||||||
|
<h3>4. Custom offsets</h3>
|
||||||
|
<p>Launcher lifted 90px — e.g. to clear a fixed bottom nav bar.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"sidebar-right","sidebarWidth":460}'>
|
||||||
|
<h3>5. Sidebar drawer, right</h3>
|
||||||
|
<p>Full-height panel sliding in from the right edge, 460px wide.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"sidebar-left"}'>
|
||||||
|
<h3>6. Sidebar drawer, left</h3>
|
||||||
|
<p>Full-height panel from the left edge, default width.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"search-bar","searchBar":{"placeholder":"Ask our AI anything...","width":380}}'>
|
||||||
|
<h3>7. Search bar trigger</h3>
|
||||||
|
<p>The launcher becomes a search-style bar (desktop; bubble on mobile).</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"launcher":false,"trigger":"#my-trigger"}'>
|
||||||
|
<h3>8. Hidden launcher + own button</h3>
|
||||||
|
<p>No built-in launcher. Open via the page’s buttons, the <code>trigger</code> selector, <code>data-replypier-open</code>, or the JS API.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"zIndex":50}'>
|
||||||
|
<h3>9. Custom z-index</h3>
|
||||||
|
<p>Widget at z-index 50 — the page’s fixed overlay (z-index 100) must cover it.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-dashboard='{"mode":"sidebar-right","sidebar_width":420,"offset_bottom":30,"offset_side":30}'>
|
||||||
|
<h3>10. Dashboard defaults (stub only)</h3>
|
||||||
|
<p>No page config; the fake widget delivers <code>widget_display</code> as saved in the dashboard’s Widget placement section.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"width":520}' data-dashboard='{"mode":"floating","width":320,"height":800,"side":"left"}'>
|
||||||
|
<h3>11. Page config beats dashboard (stub only)</h3>
|
||||||
|
<p>Dashboard says 320-wide, left; the page sets width 520. Expect 520 wide, left side, 800 tall.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"search-bar"}' data-style="ASK_ANYTHING">
|
||||||
|
<h3>12. Ask AI palette (⌘K)</h3>
|
||||||
|
<p>Ask Anything/Aurora open as a centered command palette over a dimmed page. Try ⌘K, Esc, and clicking the backdrop. Use Real mode to see the redesigned interior.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"sidebar-left"}' data-style="ASK_ANYTHING">
|
||||||
|
<h3>13. Drawer + Ask Anything (stub only)</h3>
|
||||||
|
<p>Ask Anything/Aurora draw no in-panel chevron, so the launcher must stay reachable beside the drawer. Every other style hides it while open.</p>
|
||||||
|
</a>
|
||||||
|
<a class="card" data-config='{"displayMode":"sidebar-right"}'>
|
||||||
|
<h3>14. Mobile check</h3>
|
||||||
|
<p>Open any card with device emulation / a narrow window: fullscreen chat must win in every mode.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const S_KEY = 'rp-test-settings'
|
||||||
|
const saved = JSON.parse(localStorage.getItem(S_KEY) || '{}')
|
||||||
|
document.getElementById('mode').value = saved.mode || 'stub'
|
||||||
|
// The frontend serves the loader (the backend does not). '/replypier.min.js' is a
|
||||||
|
// stale saved value from an earlier version of this page — ignore it.
|
||||||
|
const savedLoader = saved.loaderUrl === '/replypier.min.js' ? '' : saved.loaderUrl
|
||||||
|
document.getElementById('loaderUrl').value = savedLoader
|
||||||
|
|| `${location.protocol}//localhost:5173/webclient/replypier.min.js`
|
||||||
|
document.getElementById('widgetId').value = saved.widgetId || ''
|
||||||
|
document.getElementById('baseUrl').value = saved.baseUrl || 'http://localhost:8000/api/v1'
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
localStorage.setItem(S_KEY, JSON.stringify({
|
||||||
|
mode: document.getElementById('mode').value,
|
||||||
|
loaderUrl: document.getElementById('loaderUrl').value.trim(),
|
||||||
|
widgetId: document.getElementById('widgetId').value.trim(),
|
||||||
|
baseUrl: document.getElementById('baseUrl').value.trim(),
|
||||||
|
}))
|
||||||
|
alert('Saved')
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('a.card').forEach(card => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (card.dataset.config) q.set('config', card.dataset.config)
|
||||||
|
if (card.dataset.dashboard) q.set('dashboard', card.dataset.dashboard)
|
||||||
|
if (card.dataset.style) q.set('style', card.dataset.style)
|
||||||
|
q.set('title', card.querySelector('h3').textContent)
|
||||||
|
card.href = 'ui-test.html?' + q.toString()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ReplyPier UI test</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui; margin: 0; padding: 24px; background: #f7f7f5; color: #1c1c1e; }
|
||||||
|
h1 { margin: 0 0 2px; font-size: 20px; }
|
||||||
|
.hint { color: #666; font-size: 13px; margin-bottom: 16px; }
|
||||||
|
.banner { display: none; background: #fdecea; border: 1px solid #f5c2bd; color: #8b2415; padding: 12px 14px; border-radius: 10px; margin-bottom: 14px; font-size: 13px; line-height: 1.6; }
|
||||||
|
.banner code { background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 4px; }
|
||||||
|
.controls button { margin: 3px; padding: 8px 14px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer; }
|
||||||
|
.controls button:hover { border-color: #f34611; }
|
||||||
|
#log { margin-top: 14px; font: 12px/1.6 monospace; background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 10px; height: 170px; overflow: auto; white-space: pre-wrap; }
|
||||||
|
.overlay { position: fixed; top: 30%; right: 40px; width: 220px; padding: 18px; background: rgba(110, 70, 220, 0.92); color: #fff; border-radius: 12px; z-index: 100; font-size: 13px; }
|
||||||
|
.tall { height: 1400px; background: linear-gradient(#eee, #ddd); margin-top: 24px; border-radius: 12px; padding: 16px; color: #999; }
|
||||||
|
a { color: #f34611; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 id="title">ReplyPier UI test</h1>
|
||||||
|
<p class="hint"><a href="ui-options.html">← all scenarios</a> · <span id="mode-label"></span></p>
|
||||||
|
|
||||||
|
<div class="banner" id="banner"></div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="api('open')">open()</button>
|
||||||
|
<button onclick="api('close')">close()</button>
|
||||||
|
<button onclick="api('toggle')">toggle()</button>
|
||||||
|
<button onclick="api('isOpen')">isOpen()</button>
|
||||||
|
<button onclick="api('open', { message: 'Tell me about pricing' })">open({message})</button>
|
||||||
|
<button onclick="api('hideLauncher')">hideLauncher()</button>
|
||||||
|
<button onclick="api('showLauncher')">showLauncher()</button>
|
||||||
|
<button onclick="api('setPosition', { side: 'left', bottom: 60, offset: 40 })">setPosition(left, 60, 40)</button>
|
||||||
|
<button onclick="api('setPosition', { bottom: 20, right: 20 })">setPosition(legacy {bottom,right})</button>
|
||||||
|
<button data-replypier-open>data-replypier-open</button>
|
||||||
|
<button id="my-trigger">#my-trigger (trigger option)</button>
|
||||||
|
</div>
|
||||||
|
<div id="log"></div>
|
||||||
|
<div class="overlay" id="z-overlay" hidden>Fixed overlay at z-index 100 — with the z-index card, the widget stays UNDER this box.</div>
|
||||||
|
<div class="tall">Scroll filler — check the widget stays fixed while scrolling.</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const settings = JSON.parse(localStorage.getItem('rp-test-settings') || '{}')
|
||||||
|
const mode = settings.mode || 'stub'
|
||||||
|
document.getElementById('title').textContent = params.get('title') || 'ReplyPier UI test'
|
||||||
|
document.getElementById('mode-label').textContent = 'mode: ' + mode
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
const el = document.getElementById('log')
|
||||||
|
el.textContent += msg + '\n'
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
console.log('[harness]', msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBanner(html) {
|
||||||
|
const el = document.getElementById('banner')
|
||||||
|
el.innerHTML = html
|
||||||
|
el.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every control goes through here, so a loader that failed to load reports itself
|
||||||
|
// instead of the buttons silently doing nothing.
|
||||||
|
function api(method, arg) {
|
||||||
|
if (!window.ReplyPier) {
|
||||||
|
log('⚠ ReplyPier is not loaded — see the message above')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = window.ReplyPier[method](arg)
|
||||||
|
if (method === 'isOpen') log('isOpen: ' + result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.get('config') && JSON.parse(params.get('config')).zIndex) {
|
||||||
|
document.getElementById('z-overlay').hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'stub') {
|
||||||
|
// Fake widget served instead of the real /widgets/{id}/data. It runs inside the
|
||||||
|
// loader's srcdoc iframe, so its messages pass the loader's source checks —
|
||||||
|
// including the dashboard widget_display defaults used by scenarios 10 and 11.
|
||||||
|
const dashboardDisplay = params.get('dashboard') || 'undefined'
|
||||||
|
const fakeWidgetHtml = `<!DOCTYPE html><html><head><style>
|
||||||
|
body { margin:0; font-family:system-ui; background:#123; color:#fff; display:flex; flex-direction:column; height:100vh; }
|
||||||
|
#head { padding:10px; background:#0a1522; display:flex; justify-content:space-between; align-items:center; }
|
||||||
|
#head button { border:none; border-radius:6px; padding:4px 10px; cursor:pointer; }
|
||||||
|
#body { flex:1; padding:12px; font-size:13px; } #input { padding:12px; background:#0a1522; font-size:13px; }
|
||||||
|
</style></head><body>
|
||||||
|
<div id="head"><span>FAKE WIDGET</span><button onclick="parent.postMessage({type:'WIDGET_MINIMIZE'},'*')">minimize</button></div>
|
||||||
|
<div id="body">waiting for WIDGET_DISPLAY…</div>
|
||||||
|
<div id="input">input: <span id="prefill">(empty)</span></div>
|
||||||
|
<scr` + `ipt>
|
||||||
|
window.parent.postMessage({ type: 'CUSTOMIZATION_UPDATE', data: {
|
||||||
|
chat_bubble_color: '#3366FF',
|
||||||
|
chat_style: '${params.get('style') || 'CHATBOT'}',
|
||||||
|
chat_initiation_messages: ['Hi from the fake widget!'],
|
||||||
|
widget_display: ${dashboardDisplay}
|
||||||
|
}}, '*')
|
||||||
|
window.parent.postMessage({ type: 'UNREAD_COUNT', count: 3 }, '*')
|
||||||
|
window.addEventListener('message', function (e) {
|
||||||
|
if (!e.data) return
|
||||||
|
if (e.data.type === 'PREFILL_MESSAGE') document.getElementById('prefill').textContent = e.data.text
|
||||||
|
if (e.data.type === 'WIDGET_DISPLAY') document.getElementById('body').textContent = 'WIDGET_DISPLAY: ' + JSON.stringify(e.data)
|
||||||
|
})
|
||||||
|
</scr` + `ipt></body></html>`
|
||||||
|
|
||||||
|
const realFetch = window.fetch
|
||||||
|
window.fetch = function (url) {
|
||||||
|
if (String(url).includes('/widgets/')) {
|
||||||
|
log('fetch stubbed: ' + url)
|
||||||
|
return Promise.resolve(new Response(fakeWidgetHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }))
|
||||||
|
}
|
||||||
|
return realFetch.apply(this, arguments)
|
||||||
|
}
|
||||||
|
window.replypierId = 'stub-widget-id'
|
||||||
|
window.replypierBaseUrl = 'http://localhost:9/api/v1' // never hit — fetch is stubbed
|
||||||
|
} else {
|
||||||
|
window.replypierId = settings.widgetId || ''
|
||||||
|
// Without this the loader falls back to the URL baked in at build time, which for
|
||||||
|
// a production build is the cloud API — not your local backend.
|
||||||
|
window.replypierBaseUrl = settings.baseUrl || 'http://localhost:8000/api/v1'
|
||||||
|
log('real mode → ' + window.replypierBaseUrl)
|
||||||
|
if (!window.replypierId) {
|
||||||
|
showBanner('No <strong>Widget ID</strong> set for real mode. Add one on the <a href="ui-options.html">gallery page</a>, or switch back to Stub mode.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario config. The harness always wires #my-trigger (unless the scenario sets
|
||||||
|
// its own selector) so that button works on every card, not only the trigger one.
|
||||||
|
const scenarioConfig = params.get('config') ? JSON.parse(params.get('config')) : {}
|
||||||
|
if (!scenarioConfig.trigger) scenarioConfig.trigger = '#my-trigger'
|
||||||
|
window.replypierConfig = scenarioConfig
|
||||||
|
if (params.get('config')) log('replypierConfig: ' + params.get('config'))
|
||||||
|
|
||||||
|
// The loader comes from the FRONTEND origin (vite in dev, the dashboard origin in
|
||||||
|
// production) — the same place the generated embed snippet points at. It is never
|
||||||
|
// copied into this folder, where it would go stale and test an old widget.
|
||||||
|
// ('/replypier.min.js' is a stale saved setting from an earlier version.)
|
||||||
|
const savedLoader = settings.loaderUrl === '/replypier.min.js' ? '' : settings.loaderUrl
|
||||||
|
// Scheme follows the page — a hardcoded http:// would be blocked as mixed content
|
||||||
|
// if this harness is ever served over https.
|
||||||
|
const loaderUrl = savedLoader || `${location.protocol}//localhost:5173/webclient/replypier.min.js`
|
||||||
|
const s = document.createElement('script')
|
||||||
|
s.src = loaderUrl
|
||||||
|
s.onload = function () {
|
||||||
|
if (!window.ReplyPier) {
|
||||||
|
showBanner('Loaded <code>' + loaderUrl + '</code> but it never defined <code>window.ReplyPier</code> — is that file really the widget loader?')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log('loader ready: ' + loaderUrl)
|
||||||
|
;['ready', 'open', 'close'].forEach(function (ev) {
|
||||||
|
ReplyPier.on(ev, function () { log('event: ' + ev) })
|
||||||
|
})
|
||||||
|
ReplyPier.on('unread', function (count) { log('event: unread ' + count) })
|
||||||
|
}
|
||||||
|
s.onerror = function () {
|
||||||
|
showBanner(
|
||||||
|
'Could not load the widget loader from <code>' + loaderUrl + '</code>, so every button on this page does nothing.<br>' +
|
||||||
|
'Start the frontend with <code>cd ../frontend && npm run dev</code> (it serves the loader), ' +
|
||||||
|
'and build the loader once with <code>npm run build:webclient:prod</code>. ' +
|
||||||
|
'Or set a different Loader URL on the <a href="ui-options.html">gallery page</a>.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
document.head.appendChild(s)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||