172 lines
6.6 KiB
Python
172 lines
6.6 KiB
Python
"""
|
|||
|
|
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 pydantic import BaseModel, Field, field_serializer, field_validator
|
||
|
|
from typing import Optional, Dict, List
|
||
|
|
from typing_extensions import Annotated
|
||
|
|
from uuid import UUID
|
||
|
|
import enum
|
||
|
|
|
||
|
|
from app.core.s3 import sign_s3_url, strip_s3_signature
|
||
|
|
|
||
|
|
# Cap launcher/welcome copy so it can't balloon the widget (the launcher nudge is
|
||
|
|
# also clamped to 4 lines client-side). Limits match the frontend input maxlengths
|
||
|
|
# so the UI never lets a user type something the API would reject.
|
||
|
|
InitiationMessage = Annotated[str, Field(max_length=100)]
|
||
|
|
|
||
|
|
|
||
|
|
class ChatStyle(str, enum.Enum):
|
||
|
|
CHATBOT = "CHATBOT"
|
||
|
|
ASK_ANYTHING = "ASK_ANYTHING"
|
||
|
|
# Premium design presets
|
||
|
|
GLASS = "GLASS"
|
||
|
|
TERMINAL = "TERMINAL"
|
||
|
|
PLAYFUL = "PLAYFUL"
|
||
|
|
CALM_MINT = "CALM_MINT"
|
||
|
|
AURORA = "AURORA"
|
||
|
|
SUNRISE = "SUNRISE"
|
||
|
|
|
||
|
|
|
||
|
|
class WidgetPosition(str, enum.Enum):
|
||
|
|
FLOATING = "FLOATING"
|
||
|
|
FIXED = "FIXED"
|
||
|
|
|
||
|
|
|
||
|
|
class WidgetDisplayMode(str, enum.Enum):
|
||
|
|
FLOATING = "floating"
|
||
|
|
SIDEBAR_LEFT = "sidebar-left"
|
||
|
|
SIDEBAR_RIGHT = "sidebar-right"
|
||
|
|
SEARCH_BAR = "search-bar"
|
||
|
|
|
||
|
|
|
||
|
|
class WidgetDisplaySide(str, enum.Enum):
|
||
|
|
LEFT = "left"
|
||
|
|
RIGHT = "right"
|
||
|
|
|
||
|
|
|
||
|
|
class WidgetDisplayConfig(BaseModel):
|
||
|
|
"""Dashboard "Widget placement" defaults, stored in customization_metadata.
|
||
|
|
|
||
|
|
The embed loader (replypier.js) merges these under any options the
|
||
|
|
installing developer set on the page. Bounds keep saved values renderable
|
||
|
|
on any reasonable screen; the loader clamps again client-side.
|
||
|
|
"""
|
||
|
|
mode: Optional[WidgetDisplayMode] = None
|
||
|
|
side: Optional[WidgetDisplaySide] = None
|
||
|
|
launcher: Optional[bool] = None
|
||
|
|
width: Optional[int] = Field(default=None, ge=280, le=800)
|
||
|
|
height: Optional[int] = Field(default=None, ge=400, le=900)
|
||
|
|
sidebar_width: Optional[int] = Field(default=None, ge=320, le=640)
|
||
|
|
search_placeholder: Optional[str] = Field(default=None, max_length=80)
|
||
|
|
offset_bottom: Optional[int] = Field(default=None, ge=0, le=200)
|
||
|
|
offset_side: Optional[int] = Field(default=None, ge=0, le=200)
|
||
|
|
z_index: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||
|
|
|
||
|
|
|
||
|
|
# Predefined chat initiation messages
|
||
|
|
DEFAULT_CHAT_INITIATIONS = [
|
||
|
|
"👋 Hi! Need help? Ask me anything!",
|
||
|
|
"💬 Have a question? I'm here to help!",
|
||
|
|
"🤝 Welcome! How can I assist you today?",
|
||
|
|
"✨ Got questions? Let's chat!",
|
||
|
|
"👨💼 Need support? Click to chat with us!"
|
||
|
|
]
|
||
|
|
|
||
|
|
# Predefined quick-action buttons (clicking sends the label as a message)
|
||
|
|
DEFAULT_QUICK_ACTIONS = [
|
||
|
|
"Track my order",
|
||
|
|
"Start a return",
|
||
|
|
"Talk to a human"
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class CustomizationBase(BaseModel):
|
||
|
|
photo_url: Optional[str] = None
|
||
|
|
|
||
|
|
@field_validator('photo_url')
|
||
|
|
@classmethod
|
||
|
|
def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]:
|
||
|
|
"""Keep the bare S3 URL as the stored value.
|
||
|
|
|
||
|
|
CustomizationResponse serializes photo_url signed, and the agent
|
||
|
|
customization form POSTs the value it was given straight back, so
|
||
|
|
without this the expiring signature would be written to the column.
|
||
|
|
"""
|
||
|
|
return strip_s3_signature(v)
|
||
|
|
chat_background_color: Optional[str] = "#F8F9FA"
|
||
|
|
chat_bubble_color: Optional[str] = "#E9ECEF"
|
||
|
|
chat_text_color: Optional[str] = "#212529"
|
||
|
|
icon_url: Optional[str] = None
|
||
|
|
icon_color: Optional[str] = "#6C757D"
|
||
|
|
accent_color: Optional[str] = "#f34611"
|
||
|
|
font_family: Optional[str] = "Inter, system-ui, sans-serif"
|
||
|
|
custom_css: Optional[str] = None
|
||
|
|
customization_metadata: Optional[Dict] = {}
|
||
|
|
chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT
|
||
|
|
widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING
|
||
|
|
# NOTE: length caps live on CustomizationCreate (input), NOT here — the response
|
||
|
|
# model also inherits this base, and existing rows may exceed a newly-lowered cap;
|
||
|
|
# enforcing on the response would 500 the whole agent list.
|
||
|
|
welcome_title: Optional[str] = None
|
||
|
|
welcome_subtitle: Optional[str] = None
|
||
|
|
welcome_message: Optional[str] = None
|
||
|
|
chat_initiation_messages: Optional[List[str]] = None
|
||
|
|
quick_actions: Optional[List[str]] = None
|
||
|
|
show_citations: Optional[bool] = False
|
||
|
|
collect_email: Optional[bool] = False
|
||
|
|
show_ai_disclaimer: Optional[bool] = True
|
||
|
|
allow_new_chat: Optional[bool] = False
|
||
|
|
|
||
|
|
|
||
|
|
class CustomizationCreate(CustomizationBase):
|
||
|
|
# Enforce length caps on write only, so the UI can't submit values the widget
|
||
|
|
# can't display. Reads (CustomizationResponse) intentionally stay unconstrained.
|
||
|
|
welcome_title: Optional[str] = Field(default=None, max_length=100)
|
||
|
|
welcome_subtitle: Optional[str] = Field(default=None, max_length=250)
|
||
|
|
welcome_message: Optional[str] = Field(default=None, max_length=500)
|
||
|
|
chat_initiation_messages: Optional[List[InitiationMessage]] = None
|
||
|
|
|
||
|
|
@field_validator('customization_metadata')
|
||
|
|
@classmethod
|
||
|
|
def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]:
|
||
|
|
"""Validate and normalize the structured widget_display key on write.
|
||
|
|
|
||
|
|
The rest of customization_metadata stays free-form (avatar_style etc.);
|
||
|
|
widget_display is consumed by the embed loader on customer pages, so bad
|
||
|
|
values must be rejected here. Unknown keys inside it are dropped and
|
||
|
|
unset keys aren't stored. An explicit null clears the settings.
|
||
|
|
"""
|
||
|
|
if not v or v.get('widget_display') is None:
|
||
|
|
return v
|
||
|
|
validated = WidgetDisplayConfig.model_validate(v['widget_display'])
|
||
|
|
# mode='json' turns enums into plain strings — the dict goes straight into
|
||
|
|
# a JSON column, so it must hold JSON primitives, not enum members.
|
||
|
|
v['widget_display'] = validated.model_dump(mode='json', exclude_none=True)
|
||
|
|
return v
|
||
|
|
|
||
|
|
|
||
|
|
class CustomizationResponse(CustomizationBase):
|
||
|
|
id: int
|
||
|
|
agent_id: UUID
|
||
|
|
|
||
|
|
@field_serializer('photo_url')
|
||
|
|
def _sign_photo_url(self, v: Optional[str]) -> Optional[str]:
|
||
|
|
"""Sign on the way out, every response. Signing is a local HMAC (~0.05ms),
|
||
|
|
so there is nothing to gain by caching the result in the database."""
|
||
|
|
return sign_s3_url(v) if v else v
|
||
|
|
|
||
|
|
class Config:
|
||
|
|
from_attributes = True
|