Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -23,6 +23,7 @@ function DemoLinks() {
<a href="/instructor" onClick={(event) => openScreen(event, "/instructor")}>Преподаватель</a>
<a href="/trainee" onClick={(event) => openScreen(event, "/trainee")}>Оператор 112</a>
<a href="/dds" onClick={(event) => openScreen(event, "/dds")}>ДДС</a>
<a href="/materials" onClick={(event) => openScreen(event, "/materials")}>Материалы</a>
</div>
</details>
</nav>;

View file

@ -73,7 +73,7 @@ export function useLogout() {
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
export const SCREENS: Record<Role, string[]> = {
admin: ["/admin", "/profile", "/groups"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
trainee: ["/trainee", "/dds", "/profile"],
admin: ["/admin", "/profile", "/groups", "/materials"],
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
};

View file

@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
const ch = callChannel(sessionId, {
onStatus: setStatus,
onStatus: (next) => {
setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onEvent: (event: ServerToTrainee) => {
if (event.type === "card.briefing") {

View file

@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
</p>
<p className={report.passed ? "state-ok" : "state-violated"}>
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
</p>
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
</p>}
<div className="columns">
<section>
@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<th>{metric.title}</th>
<td className={metric.passed ? "state-ok" : "state-violated"}>
{metric.fact} · норматив {metric.norm}
{` · вес ${metric.weight ?? 1}`}
{metric.ref && <span className="ref"> ({metric.ref})</span>}
</td>
</tr>

View file

@ -12,7 +12,7 @@ export function StandStatus() {
if (!health.data) return null;
if (health.data.demo_no_db) {
return <div className="stand stand-warn">Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.</div>;
return <div className="stand stand-warn">Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.</div>;
}
const troubles: string[] = [];

View file

@ -61,7 +61,7 @@ export function Director({
рвёт звук немедленно.
</p>
<p>
<input type="text" value={free} placeholder="своя реплика — нужна сеть и LLM"
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
onChange={(event) => setFree(event.target.value)} />{" "}
<button type="button" disabled={disabled || !free.trim()}
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>

View file

@ -3,7 +3,12 @@ import { useState } from "react";
import type { ScenarioSummary } from "@/shared/api/http";
type JsonObject = Record<string, unknown>;
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
type Draft = {
id: string;
status: "draft";
generation: "template_copy" | "ai_variant" | "ai_full";
body: JsonObject;
};
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
@ -48,15 +53,31 @@ function pretty(body: JsonObject): string {
return JSON.stringify(body, null, 2);
}
function stringField(body: JsonObject, key: string): string {
return typeof body[key] === "string" ? body[key] : "";
}
function generationLabel(generation: Draft["generation"]): string {
if (generation === "ai_full") return "новый сюжет и эталон локальной модели";
if (generation === "ai_variant") return "вариант локальной модели";
return "копия шаблона";
}
export function ScenarioEditor({
scenarios,
selectedScenarioId,
onApproved,
onLibraryChanged,
}: {
scenarios: ScenarioSummary[];
selectedScenarioId: string;
onApproved: (id: string) => void | Promise<void>;
onLibraryChanged: () => void | Promise<void>;
}) {
const [sourceId, setSourceId] = useState("");
const [title, setTitle] = useState("");
const [instruction, setInstruction] = useState("");
const [revisionComment, setRevisionComment] = useState("");
const [draftId, setDraftId] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [bodyText, setBodyText] = useState("");
@ -64,6 +85,10 @@ export function ScenarioEditor({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [archiveCandidate, setArchiveCandidate] = useState("");
const [lastArchived, setLastArchived] = useState<{ id: string; title: string } | null>(null);
const effectiveSourceId = sourceId || selectedScenarioId || scenarios[0]?.id || "";
const effectiveSource = scenarios.find((scenario) => scenario.id === effectiveSourceId);
function openDraft(value: Draft) {
setDraft(value);
@ -89,7 +114,7 @@ export function ScenarioEditor({
async function create() {
await perform(async () => {
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
source_id: sourceId || scenarios[0]?.id,
source_id: effectiveSourceId,
...(title.trim() ? { title: title.trim() } : {}),
});
openDraft(created);
@ -97,6 +122,30 @@ export function ScenarioEditor({
});
}
async function generate() {
await perform(async () => {
setNotice("Qwen формирует вариацию… Обычно это занимает несколько секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate", "POST", {
source_id: effectiveSourceId,
instruction: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала вариацию. Проверьте сюжет, факты и эталон перед утверждением.");
});
}
async function generateFull() {
await perform(async () => {
setNotice("Qwen формирует новый сюжет по полям… Обычно это занимает 10–30 секунд.");
const created = await api<Draft>("/api/scenarios/drafts/generate-from-description", "POST", {
source_id: effectiveSourceId,
description: instruction.trim(),
});
openDraft(created);
setNotice("Локальная модель создала новый сюжет, все ответы и мягкий эталон. Класс ЕКП и структура опроса взяты из выбранного каркаса; проверьте результат.");
});
}
async function reopen() {
if (!draftId.trim()) return;
await perform(async () => {
@ -132,6 +181,20 @@ export function ScenarioEditor({
});
}
async function revise() {
await perform(async () => {
setNotice("Qwen перерабатывает черновик по комментарию…");
const current = await save();
const updated = await api<Draft>(
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/revise`, "POST",
{ comment: revisionComment.trim() },
);
openDraft(updated);
setRevisionComment("");
setNotice("Локальная модель переработала этот же черновик по комментарию. Проверьте изменения заново.");
});
}
async function approve() {
await perform(async () => {
const current = await save();
@ -144,30 +207,107 @@ export function ScenarioEditor({
});
}
async function archiveSelected() {
await perform(async () => {
const selected = scenarios.find((item) => item.id === effectiveSourceId);
if (!selected) throw new Error("Выберите сценарий для архивирования");
await api(`/api/scenarios/${encodeURIComponent(selected.id)}`, "DELETE");
setLastArchived({ id: selected.id, title: selected.title });
setArchiveCandidate("");
setSourceId("");
setNotice(`Сценарий «${selected.title}» перемещён в архив и больше не назначается.`);
await onLibraryChanged();
});
}
async function restoreLast() {
if (!lastArchived) return;
await perform(async () => {
await api(`/api/scenarios/${encodeURIComponent(lastArchived.id)}/restore`, "POST");
const restored = lastArchived;
setLastArchived(null);
setSourceId(restored.id);
setNotice(`Сценарий «${restored.title}» восстановлен в учебной библиотеке.`);
await onLibraryChanged();
});
}
return (
<section className="scenario-editor">
<h2>Редактор учебных сценариев</h2>
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
<h2>ИИ-формирователь и редактор сценариев</h2>
<p className="ref">Можно сделать безопасную вариацию либо новый сюжет из описания. Выбранный утверждённый сценарий задаёт класс ЕКП и структуру опроса; при новом сюжете Qwen заново создаёт все ответы, адрес и число пострадавших. Публикация — только после вашей проверки.</p>
<div className="scenario-editor-create">
<label>Шаблон
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
<label>Каркас категории и опроса
<select value={effectiveSourceId} onChange={(event) => setSourceId(event.target.value)}>
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
</select>
{effectiveSource && <small className="ref">
Фиксированный путь ЕКП: {effectiveSource.signs?.length
? effectiveSource.signs.join(" → ") : "без размеченных признаков"}
{effectiveSource.incident_code ? ` · код ${effectiveSource.incident_code}` : ""}
</small>}
</label>
<label>Название нового сценария
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
</label>
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
</div>
<div className="scenario-editor-create">
<label>Указание для локальной модели
<input value={instruction} onChange={(event) => setInstruction(event.target.value)}
placeholder="Например: ночное возгорание, плотный дым, заявитель паникует"
maxLength={1000} />
<small className="ref">Описание меняет обстоятельства, но не показанный выше путь ЕКП.</small>
</label>
<button type="button" onClick={generate} disabled={busy || !scenarios.length || instruction.trim().length < 10}>
Создать вариант ИИ
</button>
<button type="button" onClick={generateFull}
disabled={busy || !scenarios.length || instruction.trim().length < 20}>
Создать новый сюжет и эталон
</button>
</div>
<div className="scenario-editor-open">
<label>Номер черновика
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
</label>
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
</div>
<div className="scenario-library-actions">
{!archiveCandidate ? <button type="button" disabled={busy || !effectiveSourceId || !effectiveSource?.can_manage}
onClick={() => setArchiveCandidate(effectiveSourceId)}>Архивировать выбранный сценарий</button> : <>
<span>Сценарий исчезнет из назначения, но история занятий сохранится.</span>
<button type="button" className="danger" disabled={busy}
onClick={archiveSelected}>Подтвердить архивирование</button>
<button type="button" disabled={busy} onClick={() => setArchiveCandidate("")}>Отмена</button>
</>}
{effectiveSource && !effectiveSource.can_manage && <small className="ref">
Базовые сценарии и сценарии других преподавателей доступны только для чтения.
</small>}
{lastArchived && <button type="button" disabled={busy} onClick={restoreLast}>
Вернуть «{lastArchived.title}» из архива
</button>}
</div>
{draft && (
<>
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
<div className="scenario-editor-result">
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
)}</ul>
</div>
<div className="scenario-editor-create">
<label>Комментарий для переработки
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
placeholder="Например: убери упоминание газа и сделай дым плотнее"
maxLength={1000} />
</label>
<button type="button" onClick={revise}
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
</div>
<label className="scenario-editor-json">JSON тела сценария
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
</label>

View file

@ -8,7 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
import type { SessionMode } from "@/shared/types/generated";
import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
export function useControl(sessionId: string | null) {
const [status, setStatus] = useState<ChannelStatus>("closed");
@ -25,7 +25,7 @@ export function useControl(sessionId: string | null) {
}, [sessionId]);
const start = useCallback(
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, scenarioIds?: string[], handoffToDds = false) => {
(scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria) => {
const message = {
type: "scenario.start",
scenario_id: scenarioId,
@ -33,9 +33,13 @@ export function useControl(sessionId: string | null) {
trainee,
group_id: group || null,
trainee_id: traineeId || null,
dds_service: ddsService || null,
exercise,
handoff_to_dds: handoffToDds,
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
dds_max_waiting: ddsMaxWaiting,
criteria,
} as const;
channel.current?.send(message);
},

View file

@ -74,7 +74,8 @@ function Row({
{referenceArm && field.path === "incident_type" ? "Что случилось?"
: referenceArm && field.path === "caller_number" ? "АОН"
: referenceArm && field.path === "caller_contact" ? "Предоставленный"
: referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: referenceArm && field.path === "phone_on_scene" ? "Телефон на место"
: referenceArm && field.path === "caller_name" ? "Фамилия и имя заявителя"
: field.label}
{required && <span title="обязательное поле" className="required"> ·</span>}
</div>

View file

@ -61,6 +61,7 @@ export const GROUPS: FieldGroup[] = [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
{ path: "caller_contact", label: "Контактный телефон", kind: "text" },
{ path: "phone_on_scene", label: "Телефон на место", kind: "text" },
{ path: "language", label: "Язык общения", kind: "text" },
],
},

View file

@ -48,6 +48,12 @@ export function useObserve(sessionId: string | null): ObservedSession {
useEffect(() => {
if (!sessionId) return;
// A report may already be archived when an instructor opens a history link;
// the observe socket is then intentionally unavailable after process restart.
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
.then((response) => (response.ok ? response.json() : null))
.then((saved) => { if (saved) setReport(saved as SessionReport); })
.catch(() => undefined);
const channel = observeChannel(sessionId, {
onStatus: setStatus,
onEvent: (event) => {

View file

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from "react";
import { type SipStatus, useSipPhone } from "./useSipPhone";
const LABEL: Record<SipStatus, string> = {
idle: "не подключён",
connecting: "подключение…",
registered: "зарегистрирован в Asterisk",
incoming: "входящий вызов",
calling: "вызов…",
connected: "разговор идёт",
error: "ошибка",
};
export function SipPhone({
defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
}: {
defaultExtension?: string;
defaultTarget?: string;
title?: string;
}) {
const phone = useSipPhone();
const [extension, setExtension] = useState(defaultExtension);
const [password, setPassword] = useState("");
const [target, setTarget] = useState(defaultTarget);
const audio = useRef<HTMLAudioElement | null>(null);
const online = phone.status !== "idle" && phone.status !== "error";
const inCall = phone.status === "calling" || phone.status === "incoming" || phone.status === "connected";
useEffect(() => {
if (audio.current) audio.current.srcObject = phone.remoteStream;
}, [phone.remoteStream]);
return <section className="sip-phone" aria-label={title}>
<header><strong>{title}</strong><span className={phone.status === "registered" || phone.status === "connected" ? "state-ok" : "state-warn"}>● {LABEL[phone.status]}</span></header>
<p className="sip-phone-note">Звук идёт напрямую через локальный Asterisk. Пароль хранится только в памяти этой вкладки.</p>
{!online && <div className="sip-phone-settings">
<label>Внутренний номер<input aria-label="SIP внутренний номер" inputMode="numeric" value={extension}
onChange={(event) => setExtension(event.target.value)} /></label>
<label>Пароль SIP<input aria-label="Пароль SIP" type="password" autoComplete="off" value={password}
onChange={(event) => setPassword(event.target.value)} /></label>
<button type="button" disabled={!password || phone.status === "connecting"}
onClick={() => phone.connect({ extension, password })}>Подключить телефон</button>
</div>}
{online && <div className="sip-phone-controls">
<label>Номер собеседника<input aria-label="Номер SIP собеседника" inputMode="numeric" value={target}
disabled={inCall} onChange={(event) => setTarget(event.target.value)} /></label>
{phone.status === "registered" && <button type="button" onClick={() => phone.dial(target)}>Позвонить</button>}
{phone.status === "incoming" && <button type="button" className="sip-answer" onClick={phone.answer}>Ответить · {phone.peer}</button>}
{inCall && phone.status !== "incoming" && <span>{phone.peer || target}</span>}
{inCall && <button type="button" className="sip-hangup" onClick={phone.hangup}>Завершить</button>}
{!inCall && <button type="button" onClick={phone.disconnect}>Отключить</button>}
</div>}
{phone.error && <p className="state-violated" role="alert">{phone.error}</p>}
<audio ref={audio} autoPlay playsInline aria-label="Звук SIP-собеседника" />
</section>;
}

View file

@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { UA, WebSocketInterface } from "jssip";
import type { RTCSession } from "jssip/lib/RTCSession";
import type { RTCSessionEvent } from "jssip/lib/UA";
export type SipStatus =
| "idle" | "connecting" | "registered" | "incoming" | "calling" | "connected" | "error";
export interface SipCredentials {
extension: string;
password: string;
domain?: string;
}
const media = { audio: true, video: false } satisfies MediaStreamConstraints;
export function useSipPhone() {
const uaRef = useRef<UA | null>(null);
const sessionRef = useRef<RTCSession | null>(null);
const registeredRef = useRef(false);
const domainRef = useRef(location.hostname);
const [status, setStatus] = useState<SipStatus>("idle");
const [error, setError] = useState("");
const [peer, setPeer] = useState("");
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const clearSession = useCallback(() => {
sessionRef.current = null;
setPeer("");
setRemoteStream(null);
setStatus(registeredRef.current ? "registered" : "idle");
}, []);
const attachSession = useCallback((session: RTCSession, incoming: boolean) => {
if (sessionRef.current === session) return;
if (sessionRef.current && !sessionRef.current.isEnded()) {
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
return;
}
sessionRef.current = session;
setError("");
setPeer(session.remote_identity?.uri?.user || "абонент");
setStatus(incoming ? "incoming" : "calling");
const observedConnections = new WeakSet<RTCPeerConnection>();
const observeConnection = () => {
const connection = session.connection;
if (!connection || observedConnections.has(connection)) return;
observedConnections.add(connection);
connection.addEventListener("track", (event) => {
const stream = event.streams[0] ?? new MediaStream([event.track]);
setRemoteStream(stream);
});
};
observeConnection();
session.on("peerconnection", observeConnection);
session.on("accepted", () => setStatus("connected"));
session.on("confirmed", () => setStatus("connected"));
session.on("ended", clearSession);
session.on("failed", (event) => {
setError(`Вызов завершён: ${event.cause}`);
clearSession();
});
}, [clearSession]);
const disconnect = useCallback(() => {
const session = sessionRef.current;
if (session && !session.isEnded()) session.terminate();
sessionRef.current = null;
registeredRef.current = false;
uaRef.current?.stop();
uaRef.current = null;
setRemoteStream(null);
setPeer("");
setError("");
setStatus("idle");
}, []);
const connect = useCallback((credentials: SipCredentials) => {
const extension = credentials.extension.trim();
const password = credentials.password;
const domain = credentials.domain?.trim() || location.hostname;
if (!/^\d{4}$/.test(extension) || !password) {
setError("Укажите четырёхзначный внутренний номер и пароль SIP.");
return false;
}
disconnect();
domainRef.current = domain;
setStatus("connecting");
try {
const scheme = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocketInterface(`${scheme}://${location.host}/sip-ws`);
const ua = new UA({
sockets: socket,
uri: `sip:${extension}@${domain}`,
authorization_user: extension,
password,
register: true,
register_expires: 300,
connection_recovery_min_interval: 2,
connection_recovery_max_interval: 8,
user_agent: "LCT-112-WebRTC/1.0",
});
ua.on("registered", () => {
registeredRef.current = true;
setError("");
setStatus("registered");
});
ua.on("registrationFailed", (event) => {
registeredRef.current = false;
setError(`Регистрация SIP отклонена: ${event.cause || "проверьте номер и пароль"}`);
setStatus("error");
});
ua.on("disconnected", () => {
registeredRef.current = false;
if (!uaRef.current) return;
setStatus("connecting");
});
ua.on("newRTCSession", (event: RTCSessionEvent) => {
attachSession(event.session, event.originator === "remote");
});
uaRef.current = ua;
ua.start();
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
return false;
}
}, [attachSession, disconnect]);
const dial = useCallback((target: string) => {
const normalized = target.trim();
if (!uaRef.current || !registeredRef.current || !/^\d{4}$/.test(normalized)) {
setError("Сначала зарегистрируйте телефон и укажите номер из четырёх цифр.");
return false;
}
try {
const session = uaRef.current.call(`sip:${normalized}@${domainRef.current}`, {
mediaConstraints: media,
});
attachSession(session, false);
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
return false;
}
}, [attachSession]);
const answer = useCallback(() => {
const session = sessionRef.current;
if (!session || status !== "incoming") return false;
try {
session.answer({ mediaConstraints: media });
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
return false;
}
}, [status]);
const hangup = useCallback(() => {
const session = sessionRef.current;
if (!session || session.isEnded()) return;
session.terminate();
}, []);
useEffect(() => disconnect, [disconnect]);
return { status, error, peer, remoteStream, connect, disconnect, dial, answer, hangup };
}