Files
replypier/backend/app/models/schemas/agent_customization.py
WangShen007 9eb8613c05 feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
2026-09-23 22:05:00 +08:00

173 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