mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
feat(webchat): embeddable Web Chat widget as a channel connection
Web Chat is a WEB connection of a processing channel (SPEC-0003/ADR-0012), reusing the whole contour: a public widget loads from the hub domain via one async tag (<script src=hub.edevs.tech/chat-widget.js data-channel=...>), opens an isolated iframe panel. Backend webchat app: public config/session/messages endpoints (anonymous session, hash-at-rest token); inbound goes through the same ingest (AI release + retrieval + handoff + notifications); WEB transport send is a no-op (browser polls). Panel rebuilt 1:1 from the baseline (welcome+consent / ai / operator / unavailable, quick replies, typing). nginx serves /chat-widget.js and /chat/ from the hub domain; demo host page at /chat/demo.html. Verified end-to-end: config→session→message→AI reply→poll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0881c838d7
commit
d79ebda2b7
18 files changed
+649
-26
No files matched your search
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
|
||||
"hub_platform.channels",
|
||||
"hub_platform.conversations",
|
||||
"hub_platform.notifications",
|
||||
"hub_platform.webchat",
|
||||
"hub_platform.health",
|
||||
"hub_platform.events",
|
||||
]
|
||||
|
||||
@@ -2,8 +2,11 @@ from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework.schemas import get_schema_view
|
||||
|
||||
from hub_platform.webchat.views import WidgetLoaderView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
|
||||
path("api/v1/schema/", get_schema_view(title="Edevs Hub API", version="0.1.0"), name="openapi-schema"),
|
||||
path("api/v1/auth/", include("hub_platform.identity.auth_urls")),
|
||||
path("api/v1/company/", include("hub_platform.identity.company_urls")),
|
||||
@@ -14,5 +17,6 @@ urlpatterns = [
|
||||
path("api/v1/channels/", include("hub_platform.channels.urls")),
|
||||
path("api/v1/conversations/", include("hub_platform.conversations.urls")),
|
||||
path("api/v1/notifications/", include("hub_platform.notifications.urls")),
|
||||
path("api/v1/webchat/", include("hub_platform.webchat.urls")),
|
||||
path("api/v1/health/", include("hub_platform.health.urls")),
|
||||
]
|
||||
@@ -6,9 +6,15 @@ _POLL = {
|
||||
IntegrationProvider.MAX: _max.poll_updates,
|
||||
IntegrationProvider.TELEGRAM: _telegram.poll_updates,
|
||||
}
|
||||
def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
# Web Chat: ответ уже сохранён в БД, браузер забирает его поллингом — внешней отправки нет.
|
||||
return True
|
||||
|
||||
|
||||
_SEND = {
|
||||
IntegrationProvider.MAX: _max.send_text,
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_text,
|
||||
IntegrationProvider.WEB: _web_noop,
|
||||
}
|
||||
|
||||
# Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки.
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WebchatConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "webchat"
|
||||
name = "hub_platform.webchat"
|
||||
verbose_name = "Web chat widget"
|
||||
@@ -0,0 +1,54 @@
|
||||
# Публичный JS-лоадер виджета (SPEC-HUB-0003 §3). Подключается одним тегом:
|
||||
# <script src="https://hub.edevs.tech/chat-widget.js" data-channel="edevs" async></script>
|
||||
# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/).
|
||||
|
||||
LOADER_JS = r"""
|
||||
(function () {
|
||||
var script = document.currentScript;
|
||||
if (!script) return;
|
||||
var channel = script.getAttribute("data-channel") || "edevs";
|
||||
var origin = new URL(script.src, location.href).origin;
|
||||
var panelUrl = origin + "/chat/?channel=" + encodeURIComponent(channel);
|
||||
|
||||
var open = false, frame = null;
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.setAttribute("aria-label", "Открыть чат");
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:60px;height:60px;border-radius:50%;border:none;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.4);cursor:pointer;z-index:2147483000;display:flex;align-items:center;justify-content:center;";
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>';
|
||||
|
||||
var dot = document.createElement("span");
|
||||
dot.style.cssText = "position:absolute;top:10px;right:10px;width:12px;height:12px;border-radius:50%;background:#ff4d4f;border:2px solid #1677ff;display:none;";
|
||||
btn.appendChild(dot);
|
||||
|
||||
function ensureFrame() {
|
||||
if (frame) return;
|
||||
frame = document.createElement("iframe");
|
||||
frame.src = panelUrl;
|
||||
frame.title = "Чат";
|
||||
frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(384px,calc(100vw - 32px));height:min(600px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;";
|
||||
document.body.appendChild(frame);
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== origin) return;
|
||||
var d = e.data || {};
|
||||
if (d.type === "edevs-chat-close") setOpen(false);
|
||||
if (d.type === "edevs-chat-unread") dot.style.display = d.unread && !open ? "block" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function setOpen(next) {
|
||||
ensureFrame();
|
||||
open = next;
|
||||
frame.style.display = open ? "block" : "none";
|
||||
btn.style.display = open ? "none" : "flex";
|
||||
if (open) {
|
||||
dot.style.display = "none";
|
||||
try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () { setOpen(true); });
|
||||
if (document.body) document.body.appendChild(btn);
|
||||
else window.addEventListener("DOMContentLoaded", function () { document.body.appendChild(btn); });
|
||||
})();
|
||||
"""
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-28 15:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0001_initial'),
|
||||
('integrations', '0002_integration_channel_integration_poll_marker'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_seen_at', models.DateTimeField(auto_now=True)),
|
||||
('connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_sessions', to='integrations.integration')),
|
||||
('identity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_sessions', to='conversations.connectionidentity')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,15 @@
|
||||
from django.db import models
|
||||
|
||||
# Анонимная браузерная сессия Web Chat (SPEC-HUB-0003 §7). Храним только hash
|
||||
# токена; токен живёт в браузере и идентифицирует ConnectionIdentity канала.
|
||||
|
||||
|
||||
class WebSession(models.Model):
|
||||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
connection = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="web_sessions")
|
||||
identity = models.ForeignKey("conversations.ConnectionIdentity", on_delete=models.CASCADE, related_name="web_sessions")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_seen_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"websession:{self.identity_id}"
|
||||
@@ -0,0 +1,132 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.conversations.ingest import ingest_inbound
|
||||
from hub_platform.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
Conversation,
|
||||
ControlMode,
|
||||
LifecycleState,
|
||||
)
|
||||
from hub_platform.conversations.transports.base import InboundMessage
|
||||
from hub_platform.integrations.models import Integration, IntegrationProvider
|
||||
from hub_platform.webchat.models import WebSession
|
||||
|
||||
DEFAULT_GREETING = "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?"
|
||||
DEFAULT_CONSENT = "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение."
|
||||
DEFAULT_ACCENT = "#1677ff"
|
||||
|
||||
_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"}
|
||||
_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"}
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def web_connection_for_channel(channel_code: str) -> Integration | None:
|
||||
return (
|
||||
Integration.objects.select_related("channel")
|
||||
.filter(provider=IntegrationProvider.WEB, channel__code=channel_code)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _host_allowed(integration: Integration, origin: str) -> bool:
|
||||
allowed = integration.config.get("allowed_domains") or []
|
||||
if not allowed:
|
||||
return True # dev: пустой allowlist = разрешено
|
||||
if not origin:
|
||||
return False
|
||||
host = origin.split("//")[-1].split("/")[0].split(":")[0]
|
||||
return any(host == d or host.endswith("." + d) for d in allowed)
|
||||
|
||||
|
||||
def public_config(channel_code: str, origin: str) -> dict:
|
||||
integration = web_connection_for_channel(channel_code)
|
||||
if integration is None or integration.channel_id is None:
|
||||
return {"available": False}
|
||||
if not _host_allowed(integration, origin):
|
||||
return {"available": False, "reason": "domain"}
|
||||
cfg = integration.config
|
||||
channel = integration.channel
|
||||
fallback = []
|
||||
for sib in Integration.objects.filter(channel=channel).exclude(id=integration.id):
|
||||
username = sib.config.get("bot_username")
|
||||
if sib.provider == IntegrationProvider.TELEGRAM and username:
|
||||
fallback.append({"label": "Написать в Telegram", "url": f"https://t.me/{username}"})
|
||||
elif sib.provider == IntegrationProvider.MAX and username:
|
||||
fallback.append({"label": "Написать в MAX", "url": ""})
|
||||
return {
|
||||
"available": True,
|
||||
"channel": channel.code,
|
||||
"title": cfg.get("title") or channel.name,
|
||||
"accent": cfg.get("accent") or DEFAULT_ACCENT,
|
||||
"greeting": cfg.get("greeting") or DEFAULT_GREETING,
|
||||
"consent": {"text": cfg.get("consent_text") or DEFAULT_CONSENT, "version": cfg.get("consent_version") or "v1"},
|
||||
"quickReplies": cfg.get("quick_replies") or [],
|
||||
"fallback": fallback,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def issue_session(channel_code: str) -> dict | None:
|
||||
integration = web_connection_for_channel(channel_code)
|
||||
if integration is None or integration.channel_id is None:
|
||||
return None
|
||||
session_id = uuid.uuid4().hex
|
||||
contact = Contact.objects.create(organization=integration.channel.organization, name="Веб-гость")
|
||||
identity = ConnectionIdentity.objects.create(
|
||||
contact=contact, connection=integration, external_user_id=session_id, display_name="Веб-гость"
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
WebSession.objects.create(token_hash=_hash(token), connection=integration, identity=identity)
|
||||
return {"token": token, "sessionId": session_id}
|
||||
|
||||
|
||||
def resolve_session(token: str) -> WebSession | None:
|
||||
if not token:
|
||||
return None
|
||||
return (
|
||||
WebSession.objects.select_related(
|
||||
"connection", "connection__channel", "identity", "identity__contact"
|
||||
)
|
||||
.filter(token_hash=_hash(token))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def post_message(session: WebSession, text: str) -> None:
|
||||
inbound = InboundMessage(
|
||||
external_id=uuid.uuid4().hex,
|
||||
user_id=session.identity.external_user_id,
|
||||
chat_id="",
|
||||
text=text,
|
||||
display_name=session.identity.display_name,
|
||||
)
|
||||
ingest_inbound(session.connection, inbound)
|
||||
|
||||
|
||||
def messages_payload(session: WebSession, since: int) -> dict:
|
||||
conversation = (
|
||||
Conversation.objects.filter(
|
||||
channel=session.connection.channel, contact=session.identity.contact, lifecycle=LifecycleState.OPEN
|
||||
)
|
||||
.order_by("-last_activity_at")
|
||||
.first()
|
||||
)
|
||||
if conversation is None:
|
||||
return {"state": "ai", "lifecycle": LifecycleState.OPEN, "messages": []}
|
||||
items = conversation.messages.filter(id__gt=since).order_by("created_at")
|
||||
return {
|
||||
"state": _STATE.get(conversation.control_mode, "ai"),
|
||||
"lifecycle": conversation.lifecycle,
|
||||
"messages": [
|
||||
{"id": m.id, "author": _ROLE.get(m.author_type, "ai"), "text": m.text, "createdAt": m.created_at.isoformat()}
|
||||
for m in items
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from hub_platform.webchat import views
|
||||
|
||||
urlpatterns = [
|
||||
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
|
||||
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
|
||||
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.http import HttpResponse
|
||||
from django.views import View
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from hub_platform.webchat import services
|
||||
from hub_platform.webchat.loader import LOADER_JS
|
||||
|
||||
|
||||
def _origin(request: Request) -> str:
|
||||
return request.headers.get("Origin") or request.headers.get("Referer") or ""
|
||||
|
||||
|
||||
def _token(request: Request) -> str:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:]
|
||||
if request.method == "POST":
|
||||
return str(request.data.get("token", ""))
|
||||
return request.GET.get("token", "")
|
||||
|
||||
|
||||
class _Public(APIView):
|
||||
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
class WebchatConfigView(_Public):
|
||||
def get(self, request: Request) -> Response:
|
||||
return Response(services.public_config(request.GET.get("channel", ""), _origin(request)))
|
||||
|
||||
|
||||
class WebchatSessionView(_Public):
|
||||
def post(self, request: Request) -> Response:
|
||||
result = services.issue_session(str(request.data.get("channel", "")))
|
||||
if result is None:
|
||||
return Response({"detail": "Канал недоступен"}, status=404)
|
||||
return Response(result, status=201)
|
||||
|
||||
|
||||
class WebchatMessagesView(_Public):
|
||||
def post(self, request: Request) -> Response:
|
||||
session = services.resolve_session(_token(request))
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
services.post_message(session, text[:4000])
|
||||
return Response({"ok": True}, status=201)
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
session = services.resolve_session(_token(request))
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
try:
|
||||
since = int(request.GET.get("since", "0") or 0)
|
||||
except ValueError:
|
||||
since = 0
|
||||
return Response(services.messages_payload(session, since))
|
||||
|
||||
|
||||
class WidgetLoaderView(View):
|
||||
def get(self, request) -> HttpResponse:
|
||||
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
|
||||
response["Cache-Control"] = "public, max-age=300"
|
||||
return response
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Demo · Edevs Web Chat</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #1f1f1f; background: #fbfbfc; }
|
||||
nav { height: 58px; display: flex; align-items: center; justify-content: space-between; padding: 0 40px; border-bottom: 1px solid #f0f0f0; background: #fff; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 16px; font-weight: 700; }
|
||||
.brand span { width: 28px; height: 28px; border-radius: 8px; background: #1f1f1f; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; }
|
||||
.hero { padding: 72px 40px; max-width: 680px; }
|
||||
.hero h1 { font-size: 42px; font-weight: 800; letter-spacing: -0.03em; line-height: 1.08; margin: 0; }
|
||||
.hero p { margin: 20px 0 0; font-size: 16px; color: #6b6b6b; line-height: 1.55; max-width: 480px; }
|
||||
.note { margin: 28px 40px; padding: 14px 16px; background: #fff; border: 1px dashed #d9d9d9; border-radius: 10px; font-size: 13px; color: #595959; max-width: 680px; }
|
||||
code { background: #f5f5f5; padding: 1px 6px; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="brand"><span>E</span>Demo site</div>
|
||||
<div style="font-size:13px;color:#595959;">Возможности · Тарифы · Контакты</div>
|
||||
</nav>
|
||||
<div class="hero">
|
||||
<h1>Демо-страница для виджета Edevs</h1>
|
||||
<p>Это фейковый сайт для проверки web-чата. Виджет подключён одним тегом снизу — launcher в правом нижнем углу.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
Встраивание: <code><script src="/chat-widget.js" data-channel="edevs" async></script></code>
|
||||
</div>
|
||||
|
||||
<script src="/chat-widget.js" data-channel="edevs" async></script>
|
||||
</body>
|
||||
</html>
|
||||
+219
-13
@@ -1,18 +1,224 @@
|
||||
import { ConfigProvider, FloatButton, Typography } from "antd";
|
||||
import { edevsHubTheme } from "@edevs/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getConfig, poll, sendMessage, startSession, type WebConfig, type WebMessage } from "./api";
|
||||
|
||||
const CHANNEL = new URLSearchParams(location.search).get("channel") || "edevs";
|
||||
const TOKEN_KEY = `edevs-chat-token:${CHANNEL}`;
|
||||
|
||||
function closePanel() {
|
||||
window.parent.postMessage({ type: "edevs-chat-close" }, "*");
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [config, setConfig] = useState<WebConfig | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [accepted, setAccepted] = useState<boolean>(() => Boolean(localStorage.getItem(TOKEN_KEY)));
|
||||
const [messages, setMessages] = useState<WebMessage[]>([]);
|
||||
const [pending, setPending] = useState<string[]>([]);
|
||||
const [state, setState] = useState<"ai" | "operator" | "waiting">("ai");
|
||||
const [awaiting, setAwaiting] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [starting, setStarting] = useState(false);
|
||||
const lastId = useRef(0);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getConfig(CHANNEL).then(setConfig).catch(() => setConfig({ available: false }));
|
||||
}, []);
|
||||
|
||||
function ingestPoll(data: { state: "ai" | "operator" | "waiting"; messages: WebMessage[] }) {
|
||||
setState(data.state);
|
||||
if (data.messages.length) {
|
||||
lastId.current = Math.max(lastId.current, ...data.messages.map((m) => m.id));
|
||||
setMessages((prev) => [...prev, ...data.messages.filter((m) => !prev.some((p) => p.id === m.id))]);
|
||||
if (data.messages.some((m) => m.author !== "client")) setAwaiting(false);
|
||||
setPending([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Поллинг ответов AI/оператора.
|
||||
useEffect(() => {
|
||||
if (!accepted || !token) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const data = await poll(token, lastId.current);
|
||||
if (alive) ingestPoll(data);
|
||||
} catch {
|
||||
/* keep trying */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const timer = setInterval(tick, 2500);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, [accepted, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = bodyRef.current;
|
||||
if (node) node.scrollTop = node.scrollHeight;
|
||||
}, [messages, pending, awaiting]);
|
||||
|
||||
const accent = config?.accent || "#1677ff";
|
||||
const title = config?.title || "Чат";
|
||||
const letter = title.trim()[0]?.toUpperCase() || "E";
|
||||
|
||||
async function accept() {
|
||||
setStarting(true);
|
||||
const t = await startSession(CHANNEL);
|
||||
setStarting(false);
|
||||
if (!t) return;
|
||||
localStorage.setItem(TOKEN_KEY, t);
|
||||
setToken(t);
|
||||
setAccepted(true);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.trim();
|
||||
if (!text || !token || awaiting) return;
|
||||
setInput("");
|
||||
setPending((p) => [...p, text]);
|
||||
setAwaiting(true);
|
||||
await sendMessage(token, text).catch(() => undefined);
|
||||
try {
|
||||
ingestPoll(await poll(token, lastId.current));
|
||||
} catch {
|
||||
/* polling loop will retry */
|
||||
}
|
||||
setPending([]);
|
||||
setAwaiting(false);
|
||||
}
|
||||
|
||||
const status = state === "operator"
|
||||
? { label: "Отвечает специалист", dot: "#52c41a" }
|
||||
: state === "waiting"
|
||||
? { label: "Передаём оператору", dot: "#faad14" }
|
||||
: { label: "Виртуальный помощник", dot: "#52c41a" };
|
||||
const unavailable = config !== null && !config.available;
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={edevsHubTheme}>
|
||||
<main className="web-chat-demo">
|
||||
<section className="chat-panel">
|
||||
<Typography.Title level={2}>Web Chat</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
Каркас публичного виджета готов. Детальная реализация UI ждёт прохождения DG-04.
|
||||
</Typography.Paragraph>
|
||||
</section>
|
||||
<FloatButton badge={{ dot: true }} tooltip="Открыть чат" />
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
|
||||
<div style={{ flex: "none", background: accent, padding: "14px 16px", display: "flex", alignItems: "center", gap: 11 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: "rgba(255,255,255,0.18)", display: "flex", alignItems: "center", justifyContent: "center", flex: "none", fontSize: 15, fontWeight: 700, color: "#fff" }}>{letter}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 600, color: "#fff", lineHeight: 1.2 }}>{title}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: status.dot, flex: "none" }} />
|
||||
<span style={{ fontSize: 11.5, color: "rgba(255,255,255,0.92)" }}>{unavailable ? "Временно недоступен" : status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={closePanel} aria-label="Свернуть" style={{ width: 30, height: 30, borderRadius: 8, border: "none", background: "rgba(255,255,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}>
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "16px 14px" }}>
|
||||
{config === null && <div style={{ textAlign: "center", color: "#8c8c8c", padding: 24, fontSize: 13 }}>Загрузка…</div>}
|
||||
|
||||
{unavailable && (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "26px 14px" }}>
|
||||
<div style={{ width: 52, height: 52, borderRadius: "50%", background: "#fff7e6", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}>
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#d48806" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700 }}>Чат временно недоступен</h3>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>Напишите нам в другом канале — ответим как можно скорее.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.available && !accepted && (
|
||||
<>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", padding: "20px 12px 8px" }}>
|
||||
<div style={{ width: 56, height: 56, borderRadius: 16, background: "#e6f4ff", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 14 }}><span style={{ fontSize: 24, fontWeight: 700, color: accent }}>{letter}</span></div>
|
||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>{title}</h3>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#595959", lineHeight: 1.5, maxWidth: 280 }}>{config.greeting}</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 18, background: "#fff", border: "1px solid #f0f0f0", borderRadius: 12, padding: 14 }}>
|
||||
<div style={{ fontSize: 12, color: "#595959", lineHeight: 1.5 }}>{config.consent?.text} · ред. {config.consent?.version}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config?.available && accepted && (
|
||||
<>
|
||||
<div style={{ textAlign: "center", marginBottom: 14 }}><span style={{ display: "inline-block", padding: "3px 11px", borderRadius: 20, background: "#eef0f2", fontSize: 11, color: "#8c8c8c" }}>Сегодня</span></div>
|
||||
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
|
||||
{messages.map((m) => (m.author === "system"
|
||||
? <div key={m.id} style={{ textAlign: "center", margin: "12px 0" }}><span style={{ display: "inline-block", padding: "4px 12px", borderRadius: 20, background: "#e6f4ff", border: "1px solid #91caff", fontSize: 11.5, color: "#0958d9" }}>{m.text}</span></div>
|
||||
: <Bubble key={m.id} author={m.author} text={m.text} accent={accent} />))}
|
||||
{pending.map((t, i) => <Bubble key={`p${i}`} author="client" text={t} accent={accent} pendingState />)}
|
||||
{awaiting && <Typing />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config?.available && !accepted && (
|
||||
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
|
||||
<button onClick={accept} disabled={starting} style={{ width: "100%", height: 44, borderRadius: 10, border: "none", background: accent, color: "#fff", fontSize: 14, fontWeight: 600, cursor: "pointer" }}>{starting ? "Запуск…" : "Принять и начать чат"}</button>
|
||||
</div>
|
||||
)}
|
||||
{config?.available && accepted && (
|
||||
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "10px 12px 12px" }}>
|
||||
{state === "ai" && (config.quickReplies?.length ?? 0) > 0 && pending.length === 0 && messages.length === 0 && (
|
||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 9 }}>
|
||||
{config.quickReplies!.map((q) => (
|
||||
<button key={q} onClick={() => setInput(q)} style={{ padding: "6px 12px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>{q}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 12, padding: "6px 6px 6px 12px", background: "#fff" }}>
|
||||
<textarea
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void send(); } }}
|
||||
placeholder="Напишите сообщение…"
|
||||
style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 13.5, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "6px 0", maxHeight: 90 }}
|
||||
/>
|
||||
<button onClick={() => void send()} aria-label="Отправить" style={{ width: 34, height: 34, borderRadius: 8, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) {
|
||||
if (author === "client") {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
|
||||
<div style={{ maxWidth: "78%" }}>
|
||||
<div style={{ background: accent, color: "#fff", borderRadius: "14px 14px 4px 14px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, whiteSpace: "pre-wrap" }}>{text}</div>
|
||||
<div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 4px 0 0", textAlign: "right" }}>{pendingState ? "отправка…" : "доставлено"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isOperator = author === "operator";
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: isOperator ? accent : "#eef0f2", border: isOperator ? "none" : "1px solid #e3e6ea", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
|
||||
{isOperator
|
||||
? <span style={{ fontSize: 11, fontWeight: 600 }}>О</span>
|
||||
: <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>}
|
||||
</div>
|
||||
<div style={{ maxWidth: "78%" }}>
|
||||
<div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "9px 13px", fontSize: 13.5, lineHeight: 1.45, color: "#262626", whiteSpace: "pre-wrap" }}>{text}</div>
|
||||
<div style={{ fontSize: 10.5, color: "#bfbfbf", margin: "3px 0 0 4px" }}>{isOperator ? "Специалист" : "Виртуальный помощник"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Typing() {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#eef0f2", border: "1px solid #e3e6ea", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#8c8c8c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></svg>
|
||||
</div>
|
||||
<div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "14px 14px 14px 4px", padding: "12px 14px", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
{[0, 0.2, 0.4].map((d) => <span key={d} style={{ width: 6, height: 6, borderRadius: "50%", background: "#b37feb", animation: `wcTyping 1.2s infinite ease-in-out ${d}s` }} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const API = "/api/v1/webchat";
|
||||
|
||||
export type WebConfig = {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
channel?: string;
|
||||
title?: string;
|
||||
accent?: string;
|
||||
greeting?: string;
|
||||
consent?: { text: string; version: string };
|
||||
quickReplies?: string[];
|
||||
fallback?: { label: string; url: string }[];
|
||||
};
|
||||
|
||||
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; text: string; createdAt: string };
|
||||
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[] };
|
||||
|
||||
export async function getConfig(channel: string): Promise<WebConfig> {
|
||||
const r = await fetch(`${API}/config/?channel=${encodeURIComponent(channel)}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function startSession(channel: string): Promise<string | null> {
|
||||
const r = await fetch(`${API}/session/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ channel }),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()).token as string;
|
||||
}
|
||||
|
||||
export async function sendMessage(token: string, text: string): Promise<boolean> {
|
||||
const r = await fetch(`${API}/messages/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function poll(token: string, since: number): Promise<Poll> {
|
||||
const r = await fetch(`${API}/messages/?since=${since}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
return r.json();
|
||||
}
|
||||
@@ -1,22 +1,15 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.web-chat-demo {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #f6f8fb;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
margin-left: auto;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgb(15 23 42 / 10%);
|
||||
@keyframes wcTyping {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Панель отдаётся с домена hub под путём /chat/ (nginx).
|
||||
base: "/chat/",
|
||||
server: {
|
||||
port: 5175,
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Web Chat widget loader (served from the hub domain).
|
||||
location = /chat-widget.js {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Web Chat panel (iframe content) — Vite base /chat/.
|
||||
location /chat/ {
|
||||
proxy_pass http://web-chat:5175;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://internal-ui:5173;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
Reference in new issue
Block a user