feat(widget): Widget 配置、loader 与右下角悬浮入口

生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
WangShen007 committed 2026-09-25 20:50:00 +08:00
1 parent e24d3e2a65
commit 5d0a48505a
57 files changed
+32086

No files matched your search

+704
View File
@@ -0,0 +1,704 @@
"""
Copyright 2024-2026 ChatterMate
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.chattermate.chat``, 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"}
+337
View File
@@ -0,0 +1,337 @@
"""
Copyright 2024-2026 ChatterMate
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"
)
+88
View File
@@ -0,0 +1,88 @@
"""
Copyright 2024-2026 ChatterMate
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)
+188
View File
@@ -0,0 +1,188 @@
"""
Copyright 2024-2026 ChatterMate
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
(_chattermate.<domain> = cm-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 = "_chattermate"
# 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"cm-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)
+95
View File
@@ -0,0 +1,95 @@
"""
Copyright 2024-2026 ChatterMate
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)
+1
View File
@@ -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(--cm-hairline, rgba(0, 0, 0, .07));background:var(--cm-agent-bg, rgba(0, 0, 0, .03));font-family:var(--cm-body-font, inherit)}.new-chat-confirm__question[data-v-6c78f353]{margin:0;font-size:12.5px;line-height:1.35;color:var(--cm-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(--cm-field-radius, 8px);border:1px solid var(--cm-border, rgba(0, 0, 0, .12));background:transparent;color:var(--cm-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(--cm-accent, #f34611);color:var(--cm-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(--cm-card);color:var(--cm-text);font-family:var(--cm-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(--cm-border);flex-shrink:0}.askai__bar-icon[data-v-edea9534]{color:var(--cm-accent);flex-shrink:0}.askai__input[data-v-edea9534]{flex:1;min-width:0;border:none;outline:none;background:transparent;color:var(--cm-text);font-size:16px;font-family:inherit;padding:2px 0}.askai__input[data-v-edea9534]::placeholder{color:var(--cm-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(--cm-border);background:transparent;border-radius:6px;cursor:pointer;color:var(--cm-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(--cm-text);border-color:var(--cm-accent)}.askai__new[data-v-edea9534]:disabled{opacity:.5;cursor:default}.askai__new--armed[data-v-edea9534]{color:var(--cm-text);border-color:var(--cm-accent)}.askai__close[data-v-edea9534]{border:1px solid var(--cm-border);background:transparent;border-radius:6px;padding:3px 7px;cursor:pointer;color:var(--cm-muted);flex-shrink:0;transition:color .15s ease,border-color .15s ease}.askai__close[data-v-edea9534]:hover{color:var(--cm-text);border-color:var(--cm-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(--cm-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(--cm-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(--cm-border);border-radius:10px;background:var(--cm-agent-bg);color:var(--cm-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(--cm-accent);transform:translate(2px)}.askai__suggestion[data-v-edea9534]:disabled{opacity:.55;cursor:default}.askai__suggestion svg[data-v-edea9534]{color:var(--cm-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(--cm-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(--cm-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
File diff suppressed because it is too large. Load diff