Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(""); }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
57
frontend/src/features/sip/SipPhone.tsx
Normal file
57
frontend/src/features/sip/SipPhone.tsx
Normal 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>;
|
||||
}
|
||||
171
frontend/src/features/sip/useSipPhone.ts
Normal file
171
frontend/src/features/sip/useSipPhone.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue