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:
AndreyandClaude Opus 4.8 committed 2026-06-28 18:55:00 +03:00
1 parent 0881c838d7
commit d79ebda2b7
18 files changed
+649 -26

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
"hub_platform.channels",
"hub_platform.conversations",
"hub_platform.notifications",
"hub_platform.webchat",
"hub_platform.health",
"hub_platform.events",
]
+4
View File
@@ -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
+35
View File
@@ -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>&lt;script src="/chat-widget.js" data-channel="edevs" async&gt;&lt;/script&gt;</code>
</div>
<script src="/chat-widget.js" data-channel="edevs" async></script>
</body>
</html>
+219 -13
View File
@@ -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>
);
}
+45
View File
@@ -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();
}
+6 -13
View File
@@ -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); }
}
+4
View File
@@ -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,
},
});
+14
View File
@@ -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;