mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(chat): голосовые в web-виджете (дизайн-базлайн v2, кадр H)
Виджет записывает голосовое (MediaRecorder, зеркало хука internal-ui) и шлёт multipart в /webchat/messages/; входящее байтами — voice_content в InboundMessage, скачивание не нужно. Аудио отдаётся по токену сессии (/webchat/messages/<id>/audio/), в пузыре — нативный плеер. Оператор может отправлять голосовые и в WEB-канал (доставка поллингом виджета). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba405bb32b
commit
ce8c753357
12 files changed
+314
-15
No files matched your search
@@ -80,7 +80,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
|
||||
# Явный шаринг контакта: сообщение без текста, но с телефоном.
|
||||
is_contact_share = bool(inbound.phone)
|
||||
is_voice = bool(inbound.voice_file_id or inbound.voice_url)
|
||||
is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content)
|
||||
message_text = inbound.text or (
|
||||
f"Поделился контактом: {inbound.phone}" if is_contact_share else ""
|
||||
) or ("Голосовое сообщение" if is_voice else "")
|
||||
|
||||
@@ -73,15 +73,24 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url:
|
||||
|
||||
|
||||
# Голосовые (дизайн-базлайн v2, кадр H): скачивание входящих — TG (file_id) и
|
||||
# MAX (прямой url); отправка операторских голосовых — Telegram и MAX.
|
||||
# MAX (прямой url) — web-виджет шлёт байты сразу; отправка операторских
|
||||
# голосовых — Telegram, MAX и Web (доставка поллингом виджета).
|
||||
|
||||
def _web_voice_noop(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool:
|
||||
# Web Chat: голосовое уже сохранено в БД, браузер заберёт его поллингом.
|
||||
return True
|
||||
|
||||
|
||||
_VOICE_SEND = {
|
||||
IntegrationProvider.TELEGRAM: _telegram.send_voice,
|
||||
IntegrationProvider.MAX: _max.send_voice,
|
||||
IntegrationProvider.WEB: _web_voice_noop,
|
||||
}
|
||||
|
||||
|
||||
def download_voice(integration, inbound) -> tuple[bytes, str]:
|
||||
if inbound.voice_content:
|
||||
return inbound.voice_content, inbound.voice_mime or "audio/webm"
|
||||
if integration.provider == IntegrationProvider.TELEGRAM and inbound.voice_file_id:
|
||||
return _telegram.download_voice(integration, inbound.voice_file_id)
|
||||
if integration.provider == IntegrationProvider.MAX and inbound.voice_url:
|
||||
|
||||
@@ -37,6 +37,8 @@ class InboundMessage:
|
||||
# провайдера (TG file_id) ИЛИ прямой URL (MAX), длительность и mime.
|
||||
voice_file_id: str = ""
|
||||
voice_url: str = ""
|
||||
# Голосовое, пришедшее телом запроса (web-виджет): скачивать нечего.
|
||||
voice_content: bytes = b""
|
||||
voice_duration: int = 0
|
||||
voice_mime: str = ""
|
||||
|
||||
|
||||
@@ -172,6 +172,26 @@ def post_message(session: WebSession, text: str) -> None:
|
||||
display_name=session.identity.display_name,
|
||||
)
|
||||
ingest_inbound(session.connection, inbound)
|
||||
_remember_widget(session)
|
||||
|
||||
|
||||
def post_voice(session: WebSession, *, content: bytes, content_type: str, duration: int) -> None:
|
||||
"""Голосовое из виджета: байты приходят телом запроса, скачивать нечего."""
|
||||
inbound = InboundMessage(
|
||||
external_id=uuid.uuid4().hex,
|
||||
user_id=session.identity.external_user_id,
|
||||
chat_id="",
|
||||
text="",
|
||||
display_name=session.identity.display_name,
|
||||
voice_content=content,
|
||||
voice_mime=content_type,
|
||||
voice_duration=duration,
|
||||
)
|
||||
ingest_inbound(session.connection, inbound)
|
||||
_remember_widget(session)
|
||||
|
||||
|
||||
def _remember_widget(session: WebSession) -> None:
|
||||
conversation = (
|
||||
Conversation.objects.filter(
|
||||
channel=session.connection.channel,
|
||||
@@ -243,7 +263,15 @@ def messages_payload(session: WebSession, since: int) -> dict:
|
||||
"state": _STATE.get(conversation.control_mode, "ai"),
|
||||
"lifecycle": conversation.lifecycle,
|
||||
"messages": [
|
||||
{"id": m.id, "author": _ROLE.get(m.author_type, "ai"), "kind": m.kind, "text": m.text, "createdAt": m.created_at.isoformat()}
|
||||
{
|
||||
"id": m.id,
|
||||
"author": _ROLE.get(m.author_type, "ai"),
|
||||
"kind": m.kind,
|
||||
"text": m.text,
|
||||
"createdAt": m.created_at.isoformat(),
|
||||
"durationSeconds": m.duration_seconds,
|
||||
"hasAudio": bool(m.audio),
|
||||
}
|
||||
for m in items
|
||||
],
|
||||
"call": _call_payload(session),
|
||||
|
||||
@@ -74,6 +74,54 @@ class PublicWebChatWidgetTests(TestCase):
|
||||
|
||||
self.assertEqual(response.json(), {"available": False})
|
||||
|
||||
def test_voice_message_round_trip(self) -> None:
|
||||
# Голосовое из виджета: multipart → VOICE-сообщение, диалог уходит
|
||||
# оператору; аудио отдаётся только владельцу токена сессии.
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from hub_platform.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
MessageKind,
|
||||
)
|
||||
|
||||
widget = create_web_widget(self.channel, name="Виджет")
|
||||
token = self._session(widget.public_key).json()["token"]
|
||||
|
||||
posted = self.client.post(
|
||||
"/api/v1/webchat/messages/",
|
||||
data={
|
||||
"audio": SimpleUploadedFile("voice.webm", b"WEBMDATA", content_type="audio/webm"),
|
||||
"duration": "4",
|
||||
},
|
||||
format="multipart",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
self.assertEqual(posted.status_code, 201)
|
||||
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
message = conversation.messages.get(kind=MessageKind.VOICE)
|
||||
self.assertEqual(message.duration_seconds, 4)
|
||||
self.assertTrue(message.audio)
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
|
||||
payload = self.client.get(
|
||||
"/api/v1/webchat/messages/?since=0",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
).json()
|
||||
voice = next(m for m in payload["messages"] if m["kind"] == MessageKind.VOICE)
|
||||
self.assertTrue(voice["hasAudio"])
|
||||
self.assertEqual(voice["durationSeconds"], 4)
|
||||
|
||||
audio = self.client.get(f"/api/v1/webchat/messages/{message.id}/audio/?token={token}")
|
||||
self.assertEqual(audio.status_code, 200)
|
||||
self.assertEqual(audio.headers["Content-Type"], "audio/webm")
|
||||
|
||||
# Чужая сессия не видит аудио этого диалога.
|
||||
foreign_token = self._session(widget.public_key).json()["token"]
|
||||
denied = self.client.get(f"/api/v1/webchat/messages/{message.id}/audio/?token={foreign_token}")
|
||||
self.assertEqual(denied.status_code, 404)
|
||||
|
||||
def test_widget_origin_policy_is_scoped_per_widget(self) -> None:
|
||||
allowed = create_web_widget(
|
||||
self.channel,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"),
|
||||
path("messages/<int:message_id>/audio/", views.WebchatMessageAudioView.as_view(), name="webchat-message-audio"),
|
||||
path("contact/", views.WebchatContactView.as_view(), name="webchat-contact"),
|
||||
path("call/open/", views.WebchatCallOpenView.as_view(), name="webchat-call-open"),
|
||||
path("call/decline/", views.WebchatCallDeclineView.as_view(), name="webchat-call-decline"),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.views import View
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
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.conversations.models import Message, MessageKind
|
||||
from hub_platform.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
|
||||
from hub_platform.identity.models import Organization
|
||||
from hub_platform.integrations.models import IntegrationStatus
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
@@ -144,10 +147,32 @@ class WebchatSessionView(_Public):
|
||||
|
||||
|
||||
class WebchatMessagesView(_Public):
|
||||
# JSON — текст, multipart — голосовое из записи в виджете.
|
||||
parser_classes = [JSONParser, MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
upload = request.FILES.get("audio")
|
||||
if upload is not None:
|
||||
# Голосовое из виджета (дизайн-базлайн v2, кадр H).
|
||||
if upload.size > MAX_VOICE_BYTES:
|
||||
return Response({"detail": "Аудио больше 10 МБ"}, status=400)
|
||||
content_type = (upload.content_type or "audio/webm").split(";")[0]
|
||||
if content_type not in ALLOWED_AUDIO_TYPES:
|
||||
return Response({"detail": "Неподдерживаемый формат аудио"}, status=400)
|
||||
try:
|
||||
duration = max(0, int(request.data.get("duration", 0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0
|
||||
services.post_voice(
|
||||
session,
|
||||
content=upload.read(),
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
)
|
||||
return Response({"ok": True}, status=201)
|
||||
text = str(request.data.get("text", "")).strip()
|
||||
if not text:
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
@@ -165,6 +190,31 @@ class WebchatMessagesView(_Public):
|
||||
return Response(services.messages_payload(session, since))
|
||||
|
||||
|
||||
class WebchatMessageAudioView(_Public):
|
||||
def get(self, request: Request, message_id: int) -> Response | FileResponse:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": "Сессия не найдена"}, status=401)
|
||||
message = (
|
||||
Message.objects.filter(
|
||||
id=message_id,
|
||||
kind=MessageKind.VOICE,
|
||||
conversation__channel=session.connection.channel,
|
||||
conversation__contact=session.identity.contact,
|
||||
)
|
||||
.exclude(audio="")
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
return Response({"detail": "Сообщение не найдено"}, status=404)
|
||||
response = FileResponse(
|
||||
message.audio.open("rb"),
|
||||
content_type=message.audio_content_type or "audio/ogg",
|
||||
)
|
||||
response["Cache-Control"] = "private, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
class WebchatContactView(_Public):
|
||||
def post(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
|
||||
},
|
||||
});
|
||||
// Каналы с транспортом отправки голосовых (transports.supports_voice_send).
|
||||
const voiceAvailable = recorder.supported && (channel === "TG" || channel === "MAX");
|
||||
const voiceAvailable = recorder.supported && (channel === "TG" || channel === "MAX" || channel === "WEB");
|
||||
|
||||
if (conversationId == null) {
|
||||
return <div className="sales-composer"><div className="sales-waiting-composer"><div><strong>Выберите диалог</strong></div></div></div>;
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
poll,
|
||||
sendContact,
|
||||
sendMessage,
|
||||
sendVoice,
|
||||
startSession,
|
||||
voiceAudioUrl,
|
||||
type CallInfo,
|
||||
type Poll,
|
||||
type WebConfig,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from "./api";
|
||||
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
|
||||
import { useScrollToLatest } from "./useScrollToLatest";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import { useWidgetActivity } from "./widgetActivity";
|
||||
|
||||
const PARAMS = new URLSearchParams(location.search);
|
||||
@@ -47,6 +50,14 @@ export function App() {
|
||||
const scrollToLatest = useScrollToLatest(bodyRef);
|
||||
const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING"));
|
||||
const notifyNewMessage = useWidgetActivity(incomingCall);
|
||||
const recorder = useVoiceRecorder({
|
||||
onSend: async (audio, durationSeconds) => {
|
||||
if (!token) return;
|
||||
const ok = await sendVoice(token, audio, durationSeconds);
|
||||
if (!ok) throw new Error("Не удалось отправить голосовое");
|
||||
try { ingestPoll(await poll(token, lastId.current)); } catch { /* polling loop will retry */ }
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getConfig(ENTRY, HOST_ORIGIN).then(setConfig).catch(() => setConfig({ available: false }));
|
||||
@@ -149,10 +160,10 @@ export function App() {
|
||||
return (
|
||||
<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" }}>
|
||||
<ChatHeader accent={accent} letter={letter} title={title} statusLabel={status.label} statusDot={status.dot} unavailable={unavailable} onClose={closePanel} />
|
||||
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} />
|
||||
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} letter={letter} title={title} messages={messages} pending={pending} awaiting={awaiting} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} />
|
||||
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
|
||||
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
|
||||
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} />}
|
||||
{config?.available && accepted && <ChatComposer accent={accent} state={state} quickReplies={config.quickReplies ?? []} pendingCount={pending.length} messageCount={messages.length} input={input} onInput={setInput} onSend={() => void send()} voice={recorder} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
|
||||
import type { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
|
||||
export type ComposerVoice = ReturnType<typeof useVoiceRecorder>;
|
||||
|
||||
function formatSeconds(total: number): string {
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unavailable, onClose }: { accent: string; letter: string; title: string; statusLabel: string; statusDot: string; unavailable: boolean; onClose: () => void }) {
|
||||
return (
|
||||
@@ -20,7 +27,7 @@ export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unav
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact }: {
|
||||
export function ChatBody({ bodyRef, config, unavailable, accepted, accent, letter, title, messages, pending, awaiting, lastContactRequestId, showPhoneForm, onSubmitContact, audioUrlFor }: {
|
||||
bodyRef: RefObject<HTMLDivElement | null>;
|
||||
config: WebConfig | null;
|
||||
unavailable: boolean;
|
||||
@@ -34,6 +41,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
|
||||
lastContactRequestId: number;
|
||||
showPhoneForm: boolean;
|
||||
onSubmitContact: (phone: string) => Promise<boolean>;
|
||||
audioUrlFor?: (messageId: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<div ref={bodyRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", background: "#f7f8fa", padding: "18px 16px" }}>
|
||||
@@ -46,7 +54,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
|
||||
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
|
||||
{messages.map((message) => message.author === "system"
|
||||
? <SystemMessage key={message.id} text={message.text} />
|
||||
: <div key={message.id}><Bubble author={message.author} text={message.text} accent={accent} time={message.createdAt} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
|
||||
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || "Голосовое сообщение"} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
|
||||
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" text={text} accent={accent} pendingState />)}
|
||||
{awaiting && <Typing />}
|
||||
</>
|
||||
@@ -73,7 +81,7 @@ export function StartChatFooter({ accent, starting, onAccept }: { accent: string
|
||||
|
||||
const COMPOSER_MAX_HEIGHT = 132;
|
||||
|
||||
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void }) {
|
||||
export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend, voice }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void; voice?: ComposerVoice }) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Поле растёт под текст, как в мессенджере (до COMPOSER_MAX_HEIGHT, дальше скролл).
|
||||
@@ -86,11 +94,31 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag
|
||||
node.style.height = node.scrollHeight > 0 ? `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px` : "";
|
||||
}, [input]);
|
||||
|
||||
if (voice && voice.state !== "idle") {
|
||||
// Режим записи (кадр H, упрощённый для виджета): корзина · таймер · отправить.
|
||||
const sending = voice.state === "sending";
|
||||
return (
|
||||
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 10px", minHeight: 48, boxSizing: "border-box" }}>
|
||||
<button onClick={voice.cancel} disabled={sending} aria-label="Отменить запись" style={{ width: 34, height: 34, borderRadius: 9, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#f5222d", flex: "none", animation: "wcTyping 1.2s infinite ease-in-out" }} />
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: "#f5222d", fontVariantNumeric: "tabular-nums" }}>{formatSeconds(voice.seconds)}</span>
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: "#8c8c8c" }}>{sending ? "Отправка…" : "Идёт запись"}</span>
|
||||
<button onClick={voice.stopAndSend} disabled={sending} aria-label="Отправить голосовое" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none", opacity: sending ? 0.6 : 1 }}><svg viewBox="0 0 24 24" width="17" height="17" 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: "none", background: "#fff", borderTop: "1px solid #f0f0f0", padding: "12px 14px 14px" }}>
|
||||
{voice?.errorText && <div style={{ marginBottom: 8, fontSize: 12, color: "#cf1322" }}>{voice.errorText}</div>}
|
||||
{state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 10 }}>{quickReplies.map((reply) => <button key={reply} onClick={() => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply}</button>)}</div>}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, border: "1px solid #e8e8e8", borderRadius: 14, padding: "6px 6px 6px 14px", background: "#fff" }}>
|
||||
<textarea ref={textareaRef} rows={1} value={input} onChange={(event) => onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? "Напишите сообщение…"} style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 14, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "7px 0", maxHeight: COMPOSER_MAX_HEIGHT }} />
|
||||
{voice?.supported && !input.trim() && (
|
||||
<button onClick={() => void voice.start()} aria-label="Записать голосовое" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: "transparent", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#8c8c8c" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="22" /></svg></button>
|
||||
)}
|
||||
<button onClick={onSend} aria-label="Отправить" style={{ width: 36, height: 36, borderRadius: 10, border: "none", background: accent, 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" 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>
|
||||
@@ -115,11 +143,14 @@ function formatTime(iso: string | undefined): string {
|
||||
|
||||
const META: React.CSSProperties = { fontSize: 11, color: "#bfbfbf", marginTop: 4 };
|
||||
|
||||
export function Bubble({ author, text, accent, time, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean }) {
|
||||
export function Bubble({ author, text, accent, time, pendingState, audioUrl }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string }) {
|
||||
const at = formatTime(time);
|
||||
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}</div></div></div>;
|
||||
const content = audioUrl
|
||||
? <audio controls preload="none" src={audioUrl} style={{ width: 216, height: 36, display: "block" }} />
|
||||
: text;
|
||||
if (author === "client") return <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 12 }}><div style={{ maxWidth: "82%" }}><div style={{ background: accent, color: "#fff", borderRadius: "16px 16px 4px 16px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginRight: 4, textAlign: "right" }}>{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}</div></div></div>;
|
||||
const isOperator = author === "operator";
|
||||
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, 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.5, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width="16" height="16" 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: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}</div></div></div>;
|
||||
return <div style={{ display: "flex", gap: 9, marginBottom: 12 }}><div style={{ width: 30, height: 30, 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.5, fontWeight: 600 }}>О</span> : <svg viewBox="0 0 24 24" width="16" height="16" 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: "82%" }}><div style={{ background: "#fff", border: "1px solid #eee", borderRadius: "16px 16px 16px 4px", padding: audioUrl ? "8px" : "10px 14px", fontSize: 14.5, lineHeight: 1.5, color: "#262626", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{content}</div><div style={{ ...META, marginLeft: 4 }}>{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}</div></div></div>;
|
||||
}
|
||||
|
||||
function formatPhone(raw: string): string {
|
||||
|
||||
@@ -13,8 +13,9 @@ export type WebConfig = {
|
||||
fallback?: { label: string; url: string }[];
|
||||
};
|
||||
|
||||
// kind: "" — текст, "contact_request" — виджет рисует форму телефона, "contact" — клиент поделился номером.
|
||||
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string };
|
||||
// kind: "" — текст, "contact_request" — виджет рисует форму телефона,
|
||||
// "contact" — клиент поделился номером, "VOICE" — голосовое (см. hasAudio).
|
||||
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string; durationSeconds?: number; hasAudio?: boolean };
|
||||
|
||||
// Приглашение/состояние онлайн-звонка (SPEC-HUB-0013).
|
||||
export type CallKind = "AUDIO" | "VIDEO";
|
||||
@@ -70,6 +71,20 @@ export async function sendMessage(token: string, text: string): Promise<boolean>
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function sendVoice(token: string, audio: Blob, durationSeconds: number): Promise<boolean> {
|
||||
const body = new FormData();
|
||||
const type = (audio.type || "audio/webm").split(";")[0];
|
||||
body.append("audio", audio, `voice.${type.split("/")[1] || "webm"}`);
|
||||
body.append("duration", String(Math.round(durationSeconds)));
|
||||
const r = await fetch(`${API}/messages/`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body });
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
// URL аудио голосового: <audio src> не умеет заголовки — токен идёт параметром.
|
||||
export function voiceAudioUrl(token: string, messageId: number): string {
|
||||
return `${API}/messages/${messageId}/audio/?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export async function sendContact(token: string, phone: string): Promise<boolean> {
|
||||
const r = await fetch(`${API}/contact/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Запись голосового в композере виджета (дизайн-базлайн v2, кадр H):
|
||||
// MediaRecorder, таймер, отмена/отправка. Формат — opus (ogg в Firefox,
|
||||
// webm в Chromium). Зеркало internal-ui/features/conversations/useVoiceRecorder.
|
||||
|
||||
export type VoiceRecorderState = "idle" | "recording" | "sending";
|
||||
|
||||
function pickMimeType(): string {
|
||||
if (typeof MediaRecorder === "undefined") return "";
|
||||
for (const candidate of ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"]) {
|
||||
if (MediaRecorder.isTypeSupported(candidate)) return candidate;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useVoiceRecorder({ onSend }: { onSend: (audio: Blob, durationSeconds: number) => Promise<void> }) {
|
||||
const [state, setState] = useState<VoiceRecorderState>("idle");
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef(0);
|
||||
const discardRef = useRef(false);
|
||||
|
||||
const supported = typeof MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
|
||||
const cleanupStream = useCallback(() => {
|
||||
recorderRef.current?.stream.getTracks().forEach((track) => track.stop());
|
||||
recorderRef.current = null;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
discardRef.current = true;
|
||||
recorderRef.current?.state !== "inactive" && recorderRef.current?.stop();
|
||||
cleanupStream();
|
||||
}, [cleanupStream]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!supported || state !== "idle") return;
|
||||
setErrorText("");
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream, pickMimeType() ? { mimeType: pickMimeType() } : undefined);
|
||||
chunksRef.current = [];
|
||||
discardRef.current = false;
|
||||
recorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
});
|
||||
recorder.addEventListener("stop", () => {
|
||||
const duration = (Date.now() - startedAtRef.current) / 1000;
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
||||
cleanupStream();
|
||||
if (discardRef.current || blob.size === 0 || duration < 0.5) {
|
||||
setState("idle");
|
||||
setSeconds(0);
|
||||
return;
|
||||
}
|
||||
setState("sending");
|
||||
onSend(blob, duration)
|
||||
.then(() => {
|
||||
setState("idle");
|
||||
setSeconds(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorText(error instanceof Error ? error.message : "Не удалось отправить голосовое");
|
||||
setState("idle");
|
||||
setSeconds(0);
|
||||
});
|
||||
});
|
||||
recorderRef.current = recorder;
|
||||
startedAtRef.current = Date.now();
|
||||
recorder.start();
|
||||
setState("recording");
|
||||
setSeconds(0);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSeconds(Math.floor((Date.now() - startedAtRef.current) / 1000));
|
||||
}, 250);
|
||||
} catch {
|
||||
setErrorText("Нет доступа к микрофону");
|
||||
}
|
||||
}, [cleanupStream, onSend, state, supported]);
|
||||
|
||||
const stopAndSend = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
discardRef.current = false;
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
discardRef.current = true;
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { supported, state, seconds, errorText, start, stopAndSend, cancel };
|
||||
}
|
||||
Reference in new issue
Block a user