skillok meeting
d0d9d7d1b42763f9297927180d851d27dda50dcd
Перейти к файлу
Код
meeting srcviewsmeeting.ts
anc Разбор по голосам: «Спикер 1», «Спикер 2»… на компьютере, без сети - crates/voices: отпечаток голоса реплики (WeSpeaker ResNet34 через sherpa-onnx, статические сборки под Windows и macOS) и разбиение снизу вверх со средней связью. Из отпечатков вычитается средний по записи: без этого у подкаста любые две реплики были похожи на 0,5–0,8. - После распознавания реплики «Собеседников» и файла раскладываются по голосам; микрофон — «Я». Отпечатки и реплики — в voices.json рядом с записью: «Говорящих: N» пересобирает разбиение без новой расшифровки. - Ход работы: этап «Разделяю по голосам» с процентом; по ходу распознавания у реплик разбираемых дорожек ещё нет имени. - Исправлено: файл без смены говорящего ложился одним абзацем 00:00:00 (подкаст 1 ч 52 мин — 170 КБ одной строкой). Абзац теперь — реплики одного говорящего без пауз больше 1,5 с и не длиннее ~600 знаков. У реплики появился конец (из ответа Whisper или до следующей). - Модель (26 МБ) не в git: scripts/fetch-models.sh с проверкой SHA-256, в сборку — ресурсом. Windows — со статической средой C (+crt-static, явная libucrt): установщику не нужен VC++ Redistributable; crate-type ядра — только rlib (cdylib нужен лишь мобильным платформам). - Проверено на подкасте Kuji 230 (1 ч 52 мин, трое в студии): трое, 48 % / 36 % / 15 % речи, отпечатки 1184 реплик за ~3 мин. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> d0d9d7d 30 сентября 2026 г.
634 строки 27.8 КБ
meeting.ts
// Экран записи — рабочее место вокруг записи, как у MacWhisper: сверху
// название и «Копировать», в центре расшифровка по репликам, под ней следующий
// шаг. Пока расшифровки нет — одна карточка с главной кнопкой. Результаты — вкладками:
// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
// Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
// сделать заново или удалить.

import {
  api, errorText, events, formatDate, formatDuration, meetingDuration,
  type Command, type MeetingDetail,
} from "../api";
import { $, esc } from "../dom";
import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown";
import { bindRecording, recordingPanel } from "../recording";
import type { Scope } from "../scope";
import { refreshNav, setCrumb } from "../nav";
import { copy, onChange, state, toast } from "../state";

const TRANSCRIPT = "Расшифровка";

interface Preset {
  heading: string;
  command: Command;
  /** Что это и зачем — на пустой вкладке. */
  about: string;
  action: string;
  working: string;
}

const PRESETS: Preset[] = [
  {
    heading: "Конспект",
    command: { kind: "summary" },
    about: "О чём говорили, чем закончилось и какие решения приняли — на полстраницы.",
    action: "Сделать конспект",
    working: "ИИ пишет конспект…",
  },
  {
    heading: "Задачи",
    command: { kind: "tasks" },
    about: "Кто что взял на себя и к какому сроку — списком с галочками. И вопросы, оставшиеся без ответа.",
    action: "Выписать задачи",
    working: "ИИ выписывает задачи…",
  },
  {
    heading: "Ревью",
    command: { kind: "review" },
    about: "Взгляд со стороны: чего добились, что осталось открытым, где ушли в сторону.",
    action: "Сделать ревью",
    working: "ИИ пишет ревью…",
  },
];

const CUSTOM_TAB = "+ Свой запрос";

type Step = "audio" | "transcribe" | "result";

function stepOf(d: MeetingDetail): Step {
  if (d.meeting.recording || !d.meeting.audio.length) return "audio";
  if (!d.meeting.transcribed_at) return "transcribe";
  return "result";
}

type Stage = "speech" | "voices";

const STAGE_TITLE: Record<Stage, string> = { speech: "Расшифровываю", voices: "Разделяю по голосам" };

function progressNote(stage: Stage, done: number, total: number): string {
  const what =
    stage === "voices"
      ? `Сравниваю голоса: ${done} из ${total} реплик`
      : total
        ? `Готово ${done} из ${total} кусков`
        : "Готовлю звук…";
  return `${what}. Можно уйти с экрана — расшифровка продолжится.`;
}

/** Реплика расшифровки, как её пишет ядро: «**00:03:21 Вы:** текст» или «**00:03:21** текст». */
const LINE = /^\*\*(\d\d:\d\d:\d\d)(?: ([^*]+?):)?\*\*\s*([\s\S]*)$/;

/**
 * Расшифровка — по репликам: время слева, кто говорит и текст справа, как у
 * MacWhisper. Если человек переписал её по-своему — показываем как Markdown.
 */
function transcriptView(body: string): string {
  const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
  const rows = transcriptRows(paras, true);
  return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`;
}

/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
function transcriptRows(paras: string[], renamable = false): string | null {
  const rows = paras.map((p) => LINE.exec(p));
  if (!rows.length || rows.some((r) => !r)) return paras.length ? null : "";
  return rows
    .map((r) => {
      const [, time, who, text] = r!;
      return `
        <div class="tr-row">
          <span class="tr-time mono-num">${esc(time)}</span>
          <div class="tr-text">
            ${
              !who
                ? ""
                : renamable
                  ? `<button type="button" class="tr-who${who === "Я" ? " is-me" : ""}" data-speaker="${esc(who)}" title="Переименовать «${esc(who)}» во всей записи">${esc(who)}</button>`
                  : `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>`
            }
            <p>${esc(text)}</p>
          </div>
        </div>`;
    })
    .join("");
}

export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
  const scope = parent.child();
  const [initial, view] = await Promise.all([api.getMeeting(id), api.getSettings()]);
  if (!scope.alive) return;
  const d: MeetingDetail = initial;
  const hasKey = Boolean(view.openrouter_key);

  // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
  let job: { kind: "transcribe"; stage: Stage; done: number; total: number } | { kind: "command"; tab: string } | null =
    null;
  let jobError: string | null = null;
  // Что уже пришло по ходу работы: реплики расшифровки и текст ответа ИИ.
  let liveLines: string[] = [];
  let liveAnswer = "";
  let tab = wantTab ?? TRANSCRIPT;
  let editing = false;

  const tabs = (): string[] => {
    const have = sections(d.note).map((s) => s.heading || "Заметки");
    const order = [TRANSCRIPT, ...PRESETS.map((p) => p.heading)];
    const extra = have.filter((h) => !order.includes(h));
    return [...order, ...extra, CUSTOM_TAB];
  };

  const section = (heading: string) => sections(d.note).find((s) => (s.heading || "Заметки") === heading);

  // ── Шаг 1: аудио ──────────────────────────────────────────────────────────
  const audioStep = () => {
    if (d.meeting.recording) {
      return `<section class="card">${recordingPanel(view.settings.warning_text)}</section>`;
    }
    return `
      <section class="card">
        <h2>В этой записи нет звука</h2>
        <p class="muted">Она осталась от прежней версии программы. Её можно удалить.</p>
      </section>`;
  };

  // ── Шаг 2: расшифровка ────────────────────────────────────────────────────
  const transcribeStep = () => {
    const dur = formatDuration(meetingDuration(d.meeting));
    if (job?.kind === "transcribe") {
      const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
      return `
        <section class="live">
          <div class="live-head">
            <p class="live-title"><span class="spinner"></span><span data-stage-title>${STAGE_TITLE[job.stage]}${
              job.stage === "speech" && dur ? ` ${esc(dur)} звука` : ""
            }</span>
              <span class="mono-num live-pct" data-pct>${pct}%</span></p>
            <button class="link-btn" data-cancel>Отменить</button>
          </div>
          <div class="progress"><span style="width:${pct}%"></span></div>
          <p class="xs muted" data-progress-note>${progressNote(job.stage, job.done, job.total)}</p>
          <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div>
            <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
        </section>`;
    }
    if (!hasKey) {
      return `
        <section class="card">
          <h2>Подключите ИИ, чтобы расшифровать</h2>
          <p class="muted">Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.</p>
          <a class="ui-btn" href="#/settings">Подключить</a>
        </section>`;
    }
    return `
      <section class="card">
        <h2>Расшифруйте запись</h2>
        <p class="muted">${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
          meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
        }.</p>
        ${jobError ? `<p class="error">${esc(jobError)}</p>` : ""}
        <button class="ui-btn" data-transcribe>Расшифровать</button>
      </section>`;
  };

  // ── Шаг 3: результат ──
  const nextStep = () => {
    const todo = PRESETS.filter((p) => !section(p.heading));
    if (!hasKey) return "";
    return `
      <div class="next">
        <span class="next-label">Дальше</span>
        ${todo.map((p) => `<button class="ui-btn ui-btn-sm" data-make="${esc(p.heading)}" ${job ? "disabled" : ""}>${esc(p.heading)}</button>`).join("")}
        <button class="ui-btn ui-btn-sm" data-tab="${esc(CUSTOM_TAB)}">Свой запрос</button>
      </div>`;
  };
  const tabContent = () => {
    const busyHere = job?.kind === "command" && job.tab === tab;
    const busyElsewhere = job !== null && !busyHere;
    const errorLine = jobError && !job ? `<p class="error">${esc(jobError)}</p>` : "";

    if (busyHere) {
      const preset = PRESETS.find((p) => p.heading === tab);
      return `
        <div class="pane-tools">
          <p class="working small"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
          <span class="spacer"></span>
          <button class="link-btn" data-cancel>Отменить</button>
        </div>
        <div class="pane-body"><div class="md" data-answer>${liveAnswer ? render(liveAnswer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`;
    }

    if (tab === CUSTOM_TAB) {
      return `
        <form class="pane-empty ask" data-ask>
          <h3>Что сделать по этой встрече?</h3>
          <input class="input" name="text" autocomplete="off" placeholder="Например: напиши письмо клиенту по итогам" aria-label="Свой запрос" ${busyElsewhere || !hasKey ? "disabled" : ""}>
          <p class="small muted">Ещё примеры:
            <button type="button" class="link-btn" data-example>выпиши все цифры и даты</button>,
            <button type="button" class="link-btn" data-example>какие риски прозвучали</button>,
            <button type="button" class="link-btn" data-example>составь повестку следующей встречи</button>.</p>
          ${errorLine}
          ${
            hasKey
              ? `<button class="ui-btn" type="submit" ${busyElsewhere ? "disabled" : ""}>Сделать</button>`
              : `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
          }
        </form>`;
    }

    const s = section(tab);
    const preset = PRESETS.find((p) => p.heading === tab);
    if (!s) {
      return `
        <div class="pane-empty">
          <h3>${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}</h3>
          <p class="muted">${esc(preset?.about ?? "")}</p>
          ${errorLine}
          ${
            !hasKey
              ? `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
              : `<button class="ui-btn" data-make="${esc(tab)}" ${busyElsewhere ? "disabled" : ""}>${esc(preset?.action ?? "Сделать")}</button>`
          }
          ${busyElsewhere ? `<p class="small muted">Сначала дождитесь, пока закончится текущая работа.</p>` : ""}
        </div>`;
    }

    if (editing) {
      return `
        <div class="pane-edit">
          <textarea class="section-editor" data-editor spellcheck="true">${esc(sectionBody(s.source))}</textarea>
          <div class="actions">
            <button class="ui-btn" data-edit-save>Сохранить</button>
            <button class="link-btn" data-edit-cancel>Отмена</button>
            <span class="small muted">Markdown: <code>**жирный**</code>, <code>- список</code>, <code>- [ ] задача</code></span>
          </div>
        </div>`;
    }

    const isTranscript = tab === TRANSCRIPT;
    const remakeable = !isTranscript && s.heading !== "";
    const body = sectionBody(s.source);
    return `
      <div class="pane-tools">
        <button class="link-btn" data-edit>Править</button>
        ${isTranscript ? `<span class="xs muted">Имя говорящего — нажмите, чтобы переименовать</span>` : ""}
        ${
          isTranscript && d.voices
            ? `<label class="xs muted speakers-pick">Говорящих
                 <select class="input" data-speakers ${job ? "disabled" : ""}>
                   <option value="">как нашлось</option>
                   ${[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `<option value="${n}">${n}</option>`).join("")}
                 </select></label>`
            : ""
        }
        <span class="spacer"></span>
        ${
          isTranscript
            ? `<button class="link-btn" data-retranscribe ${job ? "disabled" : ""}>Расшифровать заново</button>`
            : remakeable
              ? `<button class="link-btn" data-remake ${job || !hasKey ? "disabled" : ""}>Сделать заново</button>
                 <button class="link-btn link-danger" data-remove>Удалить</button>`
              : ""
        }
      </div>
      ${errorLine}
      <div class="pane-body" data-view>${isTranscript ? transcriptView(body) : `<div class="md">${render(body)}</div>`}</div>
      ${isTranscript ? nextStep() : ""}`;
  };

  const resultStep = () => `
    <section class="result">
      <nav class="tabs" role="tablist" aria-label="Результаты">
        ${tabs()
          .map((t) => {
            const ready = t === CUSTOM_TAB || Boolean(section(t));
            return `<button role="tab" class="tab${t === tab ? " is-active" : ""}${ready ? "" : " is-empty"}${
              t === CUSTOM_TAB ? " tab-custom" : ""
            }" aria-selected="${t === tab}" data-tab="${esc(t)}">${esc(t)}</button>`;
          })
          .join("")}
      </nav>
      <div class="pane">${tabContent()}</div>
    </section>`;

  const draw = () => {
    setCrumb(d.meeting.title);
    const step = stepOf(d);
    if (!tabs().includes(tab)) tab = TRANSCRIPT;
    const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    const copyable = step === "result" && !editing && Boolean(section(tab));
    root.innerHTML = `
      <article class="meeting">
        <header class="bar">
          <div class="bar-title">
            <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
            <p class="xs muted">${esc(meta)}</p>
          </div>
          ${
            copyable
              ? `<div class="bar-tools">
                   <button class="ui-btn ui-btn-sm" data-copy>Копировать</button>
                   <button class="link-btn" data-copy-plain>как текст</button>
                 </div>`
              : ""
          }
        </header>
        ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
        <footer class="meeting-foot">
          <button class="link-btn" data-open-folder>Открыть папку с файлами</button>
          <button class="link-btn link-danger" data-delete ${d.meeting.recording ? "hidden" : ""}>Удалить запись</button>
        </footer>
      </article>`;
    bind();
  };

  // ── Действия ──────────────────────────────────────────────────────────────

  const transcribe = async () => {
    if (job) return;
    job = { kind: "transcribe", stage: "speech", done: 0, total: 0 };
    jobError = null;
    liveLines = [];
    draw();
    try {
      const r = await api.transcribe(id);
      d.note = r.note;
      const fresh = await api.getMeeting(id);
      d.meeting = fresh.meeting;
      d.voices = fresh.voices;
      tab = TRANSCRIPT;
      void refreshNav();
      if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
      else if (r.voices_error) toast(`Расшифровка готова, но по голосам не разделилась: ${r.voices_error}`);
      else if (r.voices > 1) toast(`Готово. Говорящих по голосам: ${r.voices}`, "ok");
    } catch (e) {
      jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    }
    job = null;
    if (scope.alive) draw();
  };

  const runCommand = async (command: Command, forTab: string) => {
    if (job) return;
    job = { kind: "command", tab: forTab };
    jobError = null;
    liveAnswer = "";
    tab = forTab;
    draw();
    try {
      const r = await api.runCommand(id, command);
      d.note = r.note;
      tab = r.heading;
    } catch (e) {
      jobError = errorText(e) === "Отменено" ? null : errorText(e);
    }
    job = null;
    if (scope.alive) draw();
  };

  const saveNote = async (note: string) => {
    const before = d.note;
    d.note = note;
    try {
      await api.saveNote(id, note);
    } catch (e) {
      d.note = before;
      toast(e);
      draw();
    }
  };

  const bind = () => {
    const title = $(root, "[data-title]") as HTMLInputElement;
    title.addEventListener("keydown", (e) => {
      if (e.key === "Enter") title.blur();
      if (e.key === "Escape") {
        title.value = d.meeting.title;
        title.blur();
      }
    });
    title.addEventListener("change", async () => {
      try {
        d.meeting = await api.renameMeeting(id, title.value);
        void refreshNav();
      } catch (e) {
        title.value = d.meeting.title;
        toast(e);
      }
    });

    bindRecording(root, scope, view.settings.warning_text, async () => {
      d.meeting = (await api.getMeeting(id)).meeting;
      draw();
    });

    root.querySelector("[data-transcribe]")?.addEventListener("click", transcribe);
    root.querySelector("[data-retranscribe]")?.addEventListener("click", async () => {
      if (!confirm("Расшифровать заново? Нынешняя расшифровка и ваши правки в ней заменятся новой.")) return;
      await transcribe();
    });
    root.querySelector("[data-cancel]")?.addEventListener("click", () => api.cancelJob(id));

    root.querySelectorAll<HTMLButtonElement>("[data-tab]").forEach((b) =>
      b.addEventListener("click", () => {
        tab = b.dataset.tab!;
        editing = false;
        jobError = null;
        draw();
      }),
    );
    root.querySelectorAll<HTMLButtonElement>("[data-make]").forEach((btn) =>
      btn.addEventListener("click", () => {
        const heading = btn.dataset.make!;
        const preset = PRESETS.find((p) => p.heading === heading);
        if (preset) runCommand(preset.command, heading);
      }),
    );
    root.querySelector("[data-remake]")?.addEventListener("click", () => {
      const preset = PRESETS.find((p) => p.heading === tab);
      // Свой запрос повторяется своим же текстом: он и есть заголовок вкладки.
      runCommand(preset ? preset.command : { kind: "custom", text: tab }, tab);
    });
    root.querySelector("[data-remove]")?.addEventListener("click", async () => {
      if (!confirm(`Удалить «${tab}»?`)) return;
      try {
        d.note = await api.removeSection(id, tab);
        if (!PRESETS.some((p) => p.heading === tab)) tab = TRANSCRIPT;
        draw();
      } catch (e) {
        toast(e);
      }
    });

    const ask = root.querySelector<HTMLFormElement>("[data-ask]");
    ask?.querySelectorAll<HTMLButtonElement>("[data-example]").forEach((b) =>
      b.addEventListener("click", () => {
        const input = ask.elements.namedItem("text") as HTMLInputElement;
        input.value = b.textContent ?? "";
        input.focus();
      }),
    );
    ask?.addEventListener("submit", (e) => {
      e.preventDefault();
      const input = ask.elements.namedItem("text") as HTMLInputElement;
      const text = input.value.trim();
      if (!text) {
        input.focus();
        return;
      }
      const heading = text.charAt(0).toUpperCase() + text.slice(1);
      runCommand({ kind: "custom", text }, heading.length > 60 ? heading.slice(0, 60).trimEnd() + "…" : heading);
    });

    const s = section(tab);
    root.querySelector("[data-copy]")?.addEventListener("click", () => s && copy(sectionBody(s.source), `«${tab}» скопировано`));
    root.querySelector("[data-copy-plain]")?.addEventListener("click", () => s && copy(plain(sectionBody(s.source)), `«${tab}» скопировано текстом`));
    root.querySelector("[data-edit]")?.addEventListener("click", () => {
      editing = true;
      draw();
      root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
    });
    root.querySelector("[data-edit-cancel]")?.addEventListener("click", () => {
      editing = false;
      draw();
    });
    root.querySelector("[data-edit-save]")?.addEventListener("click", async () => {
      const body = (root.querySelector("[data-editor]") as HTMLTextAreaElement).value.trim();
      editing = false;
      await saveNote(upsertSection(d.note, tab, `## ${tab}\n\n${body}`));
      draw();
    });

    // Галочки задач — живые: отметка пишется в заметку.
    root.querySelectorAll<HTMLInputElement>("[data-view] input[type=checkbox]").forEach((box, n) => {
      box.disabled = false;
      box.addEventListener("change", async () => {
        const sec = section(tab);
        if (!sec) return;
        const body = toggleTask(sectionBody(sec.source), n);
        await saveNote(upsertSection(d.note, tab, `## ${tab}\n\n${body}`));
      });
    });

    // Имя говорящего — кнопка: нажал, вписал «Ольга», Enter — имя сменилось
    // во всей записи. Esc — отмена.
    root.querySelectorAll<HTMLButtonElement>("[data-speaker]").forEach((btn) =>
      btn.addEventListener("click", () => {
        const from = btn.dataset.speaker!;
        const input = document.createElement("input");
        input.className = "input tr-who-input";
        input.value = from;
        input.setAttribute("aria-label", `Новое имя для «${from}»`);
        btn.replaceWith(input);
        input.focus();
        input.select();
        let done = false;
        const finish = async (apply: boolean) => {
          if (done) return;
          done = true;
          const to = input.value.trim();
          if (!apply || to === from) {
            draw();
            return;
          }
          const problem = speakerNameProblem(to);
          if (problem) {
            toast(problem);
            draw();
            return;
          }
          const r = renameSpeaker(d.note, TRANSCRIPT, from, to);
          await saveNote(r.note);
          draw();
          toast(`«${from}» теперь «${to}» — реплик: ${r.lines}`, "ok");
        };
        input.addEventListener("keydown", (e) => {
          if (e.key === "Enter") void finish(true);
          if (e.key === "Escape") void finish(false);
        });
        input.addEventListener("blur", () => void finish(true));
      }),
    );

    // Сколько говорящих: разбиение пересобирается из сохранённых отпечатков,
    // без новой расшифровки. Правки и имена в расшифровке заменятся.
    root.querySelector<HTMLSelectElement>("[data-speakers]")?.addEventListener("change", async (e) => {
      const select = e.currentTarget as HTMLSelectElement;
      const count = select.value ? Number(select.value) : null;
      const ask = `Разделить расшифровку на ${count ? `${count} говорящих` : "столько говорящих, сколько найдётся"}? Правки и переименования в расшифровке заменятся.`;
      if (!confirm(ask)) {
        select.value = "";
        return;
      }
      try {
        const r = await api.regroupSpeakers(id, count);
        d.note = r.note;
        draw();
        toast(`Говорящих: ${r.voices}`, "ok");
      } catch (err) {
        toast(err);
      }
    });

    root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast));

    root.querySelector("[data-delete]")?.addEventListener("click", async () => {
      if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком и результатами? Это не отменить.`)) return;
      try {
        await api.deleteMeeting(id);
        location.hash = "#/";
      } catch (e) {
        toast(e);
      }
    });
  };

  draw();

  scope.on(
    events.progress(({ meeting_id, stage, done, total, lines }) => {
      if (meeting_id !== id || job?.kind !== "transcribe") return;
      if (stage !== job.stage) {
        const title = root.querySelector("[data-stage-title]");
        if (title) title.textContent = STAGE_TITLE[stage];
      }
      job = { kind: "transcribe", stage, done, total };
      if (lines.length) {
        liveLines.push(...lines);
        root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? "");
        root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
      }
      const pct = total ? Math.round((done / total) * 100) : 0;
      const bar = root.querySelector<HTMLElement>(".progress > span");
      const big = root.querySelector("[data-pct]");
      const note = root.querySelector("[data-progress-note]");
      if (bar) bar.style.width = `${pct}%`;
      if (big) big.textContent = `${pct}%`;
      if (note) note.textContent = total || stage === "voices" ? progressNote(stage, done, total) : "В записи не нашлось речи.";
    }),
  );
  // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра.
  let frame = 0;
  scope.on(
    events.answer(({ meeting_id, text }) => {
      if (meeting_id !== id || job?.kind !== "command") return;
      liveAnswer += text;
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        const el = root.querySelector("[data-answer]");
        if (el) el.innerHTML = render(liveAnswer);
      });
    }),
  );
  // Запись остановили из шапки или с главной — шаг меняется.
  const off = onChange(async () => {
    if (!scope.alive) return;
    if (d.meeting.recording && state.recording?.meeting_id !== id) {
      d.meeting = (await api.getMeeting(id)).meeting;
      draw();
    } else if (d.meeting.recording) {
      draw();
    }
  });
  scope.on(Promise.resolve(off));
}