skillok meeting

Ход работы не теряется при уходе с экрана; без «Записать» в шапке и предупреждения

- jobs.ts: расшифровка и ответ ИИ живут, пока открыта программа, а не экран. Вернулся — видны процент, пришедшие реплики, текст ответа и его вкладка; кончилось — экран перечитывает запись сам. Раньше экран показывал «Расшифровать», хотя ядро ещё работало. - В меню у записи — метка «расшифровка» / «ИИ», пока идёт работа. - Кнопка «Записать» из шапки убрана (метка идущей записи осталась). - Блок «Текст предупреждения участникам» убран из настроек, экрана «Новая запись» и панели записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 1 октября 2026 г. a730a5c5ec9fe89299948ec3d7d5d1b31cd2600a
8 файлов
  • src/jobs.ts +110
    Показать разницуСкрыть разницу
    1 +// Работа, которая сейчас идёт над записями: расшифровка или ответ ИИ.
    2 +//
    3 +// Живёт, пока открыта программа, а не пока открыт экран записи. Раньше ход
    4 +// работы хранил сам экран: ушёл с него — и, вернувшись, человек видел кнопку
    5 +// «Расшифровать», хотя ядро ещё расшифровывало. Теперь экран при открытии
    6 +// берёт отсюда процент, пришедшие реплики и текст ответа, а когда работа
    7 +// кончается, обновляется сам — где бы человек ни был в это время.
    8 +
    9 +import { api, errorText, events, type Command, type TranscribeResult } from "./api";
    10 +import { toast } from "./state";
    11 +
    12 +export type Stage = "speech" | "voices";
    13 +
    14 +export type Job =
    15 + | { kind: "transcribe"; stage: Stage; done: number; total: number; lines: string[] }
    16 + | { kind: "command"; tab: string; answer: string };
    17 +
    18 +/** Что изменилось: ход работы, новые реплики, кусок ответа или конец. */
    19 +export type JobEvent =
    20 + | { type: "progress" | "stage" }
    21 + | { type: "lines"; lines: string[] }
    22 + | { type: "answer" }
    23 + | { type: "done"; kind: Job["kind"]; heading?: string };
    24 +
    25 +const running = new Map<string, Job>();
    26 +const errors = new Map<string, string>();
    27 +const listeners = new Set<(id: string, e: JobEvent) => void>();
    28 +
    29 +function emit(id: string, e: JobEvent): void {
    30 + listeners.forEach((cb) => cb(id, e));
    31 +}
    32 +
    33 +export function jobOf(id: string): Job | null {
    34 + return running.get(id) ?? null;
    35 +}
    36 +
    37 +/** Чем кончилась последняя работа над записью, если ошибкой. */
    38 +export function errorOf(id: string): string | null {
    39 + return errors.get(id) ?? null;
    40 +}
    41 +
    42 +export function clearError(id: string): void {
    43 + errors.delete(id);
    44 +}
    45 +
    46 +export function isBusy(id: string): boolean {
    47 + return running.has(id);
    48 +}
    49 +
    50 +/** Подписка на работу над всеми записями; возвращает отписку. */
    51 +export function onJobs(cb: (id: string, e: JobEvent) => void): () => void {
    52 + listeners.add(cb);
    53 + return () => listeners.delete(cb);
    54 +}
    55 +
    56 +export async function transcribe(id: string): Promise<void> {
    57 + if (running.has(id)) return;
    58 + running.set(id, { kind: "transcribe", stage: "speech", done: 0, total: 0, lines: [] });
    59 + errors.delete(id);
    60 + emit(id, { type: "progress" });
    61 + let r: TranscribeResult | null = null;
    62 + try {
    63 + r = await api.transcribe(id);
    64 + } catch (e) {
    65 + if (errorText(e) !== "Расшифровка отменена") errors.set(id, errorText(e));
    66 + }
    67 + running.delete(id);
    68 + emit(id, { type: "done", kind: "transcribe" });
    69 + if (!r) return;
    70 + if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    71 + else if (r.voices_error) toast(`Расшифровка готова, но по голосам не разделилась: ${r.voices_error}`);
    72 + else if (r.voices > 1) toast(`Расшифровка готова. Говорящих по голосам: ${r.voices}`, "ok");
    73 + else toast("Расшифровка готова", "ok");
    74 +}
    75 +
    76 +export async function command(id: string, cmd: Command, tab: string): Promise<void> {
    77 + if (running.has(id)) return;
    78 + running.set(id, { kind: "command", tab, answer: "" });
    79 + errors.delete(id);
    80 + emit(id, { type: "progress" });
    81 + let heading: string | undefined;
    82 + try {
    83 + heading = (await api.runCommand(id, cmd)).heading;
    84 + } catch (e) {
    85 + if (errorText(e) !== "Отменено") errors.set(id, errorText(e));
    86 + }
    87 + running.delete(id);
    88 + emit(id, { type: "done", kind: "command", heading });
    89 +}
    90 +
    91 +/** Слушать ядро один раз на всю программу. */
    92 +export function startJobs(): void {
    93 + void events.progress(({ meeting_id, stage, done, total, lines }) => {
    94 + const job = running.get(meeting_id);
    95 + if (job?.kind !== "transcribe") return;
    96 + const stageChanged = job.stage !== stage;
    97 + Object.assign(job, { stage, done, total });
    98 + if (lines.length) {
    99 + job.lines.push(...lines);
    100 + emit(meeting_id, { type: "lines", lines });
    101 + }
    102 + emit(meeting_id, { type: stageChanged ? "stage" : "progress" });
    103 + });
    104 + void events.answer(({ meeting_id, text }) => {
    105 + const job = running.get(meeting_id);
    106 + if (job?.kind !== "command") return;
    107 + job.answer += text;
    108 + emit(meeting_id, { type: "answer" });
    109 + });
    110 +}
  • src/main.ts +2
    Показать разницуСкрыть разницу
    4 4 import { renderMeeting } from "./views/meeting";
    5 5 import { renderNew } from "./views/new";
    6 6 import { renderSettings } from "./views/settings";
    7 +import { startJobs } from "./jobs";
    7 8 import { refreshNav, setCrumb, startNav } from "./nav";
    8 9 import { errorText, files } from "./api";
    9 10 import { esc } from "./dom";
    38 39 async function start(): Promise<void> {
    39 40 await refreshRecording();
    40 41 startNav();
    42 + startJobs();
    41 43 // Запись началась, встала на паузу или закончилась — шапка и меню меняются.
    42 44 onChange(() => void refreshNav());
    43 45 // Аудиофайл, брошенный в окно с любого экрана, — новая запись.
  • src/nav.ts +12 −2
    Показать разницуСкрыть разницу
    6 6
    7 7 import { api, elapsed, files, type Meeting } from "./api";
    8 8 import { esc } from "./dom";
    9 +import * as jobs from "./jobs";
    9 10 import { addAudio, state } from "./state";
    10 11
    11 12 let meetings: Meeting[] = [];
    44 45 .map((m) => {
    45 46 const mark = m.recording
    46 47 ? `<span class="mn-count" data-strong>запись</span>`
    47 − : m.audio.length && !m.transcribed_at
    48 + : jobs.isBusy(m.id)
    49 + ? `<span class="mn-count" data-strong>${jobs.jobOf(m.id)?.kind === "transcribe" ? "расшифровка" : "ИИ"}</span>`
    50 + : m.audio.length && !m.transcribed_at
    48 51 ? `<span class="mn-dot" title="Не расшифровано"></span>`
    49 52 : "";
    50 53 return `<a class="mn-link" data-rec-row href="#/m/${encodeURIComponent(m.id)}" title="${esc(m.title)}"${
    95 98 <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"}
    96 99 <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`;
    97 100 }
    98 − return captureSupported ? `<a class="ui-btn ui-btn-sm" href="#/new">Записать</a>` : "";
    101 + // Кнопки «Записать» в шапке нет (решение владельца 01.10.2026): запись
    102 + // начинается с главной или из меню.
    103 + return "";
    99 104 }
    100 105
    101 106 function crumbs(): string {
    184 189 }
    185 190
    186 191 export function startNav(): void {
    192 + // Расшифровка началась или кончилась — метка у записи в меню меняется.
    193 + jobs.onJobs((_, e) => {
    194 + if (e.type === "done") void refreshNav();
    195 + else if (e.type === "progress" && !document.querySelector(".drawer[open]")) draw();
    196 + });
    187 197 document.addEventListener("click", (event) => {
    188 198 const target = event.target as HTMLElement;
    189 199 const closer = target.closest("[data-drawer-close]");
  • src/recording.ts +3 −6
    Показать разницуСкрыть разницу
    4 4 import { api, elapsed, events } from "./api";
    5 5 import { esc } from "./dom";
    6 6 import type { Scope } from "./scope";
    7 −import { changed, copy, refreshRecording, state, toast } from "./state";
    7 +import { changed, refreshRecording, state, toast } from "./state";
    8 8
    9 −export function recordingPanel(warning: string): string {
    9 +export function recordingPanel(): string {
    10 10 const r = state.recording;
    11 11 if (!r) return "";
    12 12 return `
    22 22 <div class="actions">
    23 23 <button class="ui-btn" data-rec-stop>Остановить</button>
    24 24 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
    25 − <button class="link-btn" data-rec-warning>Скопировать предупреждение для чата</button>
    26 25 </div>
    27 − <template data-warning>${esc(warning)}</template>
    28 26 </div>`;
    29 27 }
    30 28
    31 29 /** Кнопки панели и живые уровни звука. `onStop` — после остановки. */
    32 −export function bindRecording(root: HTMLElement, scope: Scope, warning: string, onStop: () => void): void {
    30 +export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void {
    33 31 root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", async (e) => {
    34 32 const b = e.currentTarget as HTMLButtonElement;
    35 33 b.disabled = true;
    53 51 toast(err);
    54 52 }
    55 53 });
    56 − root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат встречи"));
    57 54 // Один общий уровень: громче из дорожек за последние полсекунды. Человеку
    58 55 // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники»
    59 56 // незачем (решение владельца 01.10.2026).
  • src/views/home.ts +2 −2
    Показать разницуСкрыть разницу
    9 9
    10 10 function hero(view: SettingsView): string {
    11 11 if (state.recording) {
    12 − return `<section class="hero is-live">${recordingPanel(view.settings.warning_text)}</section>`;
    12 + return `<section class="hero is-live">${recordingPanel()}</section>`;
    13 13 }
    14 14 const pick = `<button class="link-btn" data-pick>выберите файл</button>`;
    15 15 if (!view.capture_supported) {
    89 89 });
    90 90 root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
    91 91 // После «Остановить» переход к записи делает подписка ниже.
    92 − bindRecording(root, scope, view.settings.warning_text, () => undefined);
    92 + bindRecording(root, scope, () => undefined);
    93 93 };
    94 94 draw();
    95 95
  • src/views/meeting.ts +72 −83
    Показать разницуСкрыть разницу
    6 6 // сделать заново или удалить.
    7 7
    8 8 import {
    9 − api, errorText, events, formatDate, formatDuration, meetingDuration,
    9 + api, formatDate, formatDuration, meetingDuration,
    10 10 type Command, type MeetingDetail,
    11 11 } from "../api";
    12 12 import { $, esc } from "../dom";
    13 13 import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown";
    14 14 import { bindRecording, recordingPanel } from "../recording";
    15 15 import type { Scope } from "../scope";
    16 +import * as jobs from "../jobs";
    16 17 import { refreshNav, setCrumb } from "../nav";
    17 18 import { copy, onChange, state, toast } from "../state";
    18 19
    120 121 const d: MeetingDetail = initial;
    121 122 const hasKey = Boolean(view.openrouter_key);
    122 123
    123 − // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
    124 − let job: { kind: "transcribe"; stage: Stage; done: number; total: number } | { kind: "command"; tab: string } | null =
    125 − null;
    126 − let jobError: string | null = null;
    127 − // Что уже пришло по ходу работы: реплики расшифровки и текст ответа ИИ.
    128 − let liveLines: string[] = [];
    129 − let liveAnswer = "";
    130 − let tab = wantTab ?? TRANSCRIPT;
    124 + // Что сейчас идёт над записью (расшифровка или ответ ИИ) — в jobs.ts: оно
    125 + // живёт дольше экрана и видно, даже если уйти и вернуться.
    126 + // Вернулись, пока ИИ пишет ответ, — сразу на его вкладку.
    127 + const running = jobs.jobOf(id);
    128 + let tab = wantTab ?? (running?.kind === "command" ? running.tab : TRANSCRIPT);
    131 129 let editing = false;
    132 130
    133 131 const tabs = (): string[] => {
    142 140 // ── Шаг 1: аудио ──────────────────────────────────────────────────────────
    143 141 const audioStep = () => {
    144 142 if (d.meeting.recording) {
    145 − return `<section class="card">${recordingPanel(view.settings.warning_text)}</section>`;
    143 + return `<section class="card">${recordingPanel()}</section>`;
    146 144 }
    147 145 return `
    148 146 <section class="card">
    153 151
    154 152 // ── Шаг 2: расшифровка ────────────────────────────────────────────────────
    155 153 const transcribeStep = () => {
    154 + const job = jobs.jobOf(id);
    155 + const jobError = jobs.errorOf(id);
    156 156 const dur = formatDuration(meetingDuration(d.meeting));
    157 157 if (job?.kind === "transcribe") {
    158 158 const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
    167 167 </div>
    168 168 <div class="progress"><span style="width:${pct}%"></span></div>
    169 169 <p class="xs muted" data-progress-note>${progressNote(job.stage, job.done, job.total)}</p>
    170 − <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div>
    171 − <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
    170 + <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(job.lines) ?? ""}</div>
    171 + <p class="live-wait small muted" data-live-wait ${job.lines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
    172 172 </section>`;
    173 173 }
    174 174 if (!hasKey) {
    192 192
    193 193 // ── Шаг 3: результат ──
    194 194 const nextStep = () => {
    195 + const job = jobs.jobOf(id);
    195 196 const todo = PRESETS.filter((p) => !section(p.heading));
    196 197 if (!hasKey) return "";
    197 198 return `
    202 203 </div>`;
    203 204 };
    204 205 const tabContent = () => {
    206 + const job = jobs.jobOf(id);
    207 + const jobError = jobs.errorOf(id);
    205 208 const busyHere = job?.kind === "command" && job.tab === tab;
    206 209 const busyElsewhere = job !== null && !busyHere;
    207 210 const errorLine = jobError && !job ? `<p class="error">${esc(jobError)}</p>` : "";
    214 217 <span class="spacer"></span>
    215 218 <button class="link-btn" data-cancel>Отменить</button>
    216 219 </div>
    217 − <div class="pane-body"><div class="md" data-answer>${liveAnswer ? render(liveAnswer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`;
    220 + <div class="pane-body"><div class="md" data-answer>${job?.kind === "command" && job.answer ? render(job.answer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`;
    218 221 }
    219 222
    220 223 if (tab === CUSTOM_TAB) {
    343 346
    344 347 // ── Действия ──────────────────────────────────────────────────────────────
    345 348
    346 − const transcribe = async () => {
    347 − if (job) return;
    348 − job = { kind: "transcribe", stage: "speech", done: 0, total: 0 };
    349 − jobError = null;
    350 − liveLines = [];
    351 − draw();
    352 − try {
    353 − const r = await api.transcribe(id);
    354 − d.note = r.note;
    355 − const fresh = await api.getMeeting(id);
    356 − d.meeting = fresh.meeting;
    357 − d.voices = fresh.voices;
    358 − tab = TRANSCRIPT;
    359 − void refreshNav();
    360 − if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    361 − else if (r.voices_error) toast(`Расшифровка готова, но по голосам не разделилась: ${r.voices_error}`);
    362 − else if (r.voices > 1) toast(`Готово. Говорящих по голосам: ${r.voices}`, "ok");
    363 − } catch (e) {
    364 − jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    365 − }
    366 − job = null;
    367 − if (scope.alive) draw();
    349 + const transcribe = () => {
    350 + tab = TRANSCRIPT;
    351 + void jobs.transcribe(id);
    368 352 };
    369 353
    370 − const runCommand = async (command: Command, forTab: string) => {
    371 − if (job) return;
    372 − job = { kind: "command", tab: forTab };
    373 − jobError = null;
    374 − liveAnswer = "";
    354 + const runCommand = (command: Command, forTab: string) => {
    375 355 tab = forTab;
    376 − draw();
    377 − try {
    378 − const r = await api.runCommand(id, command);
    379 − d.note = r.note;
    380 − tab = r.heading;
    381 − } catch (e) {
    382 − jobError = errorText(e) === "Отменено" ? null : errorText(e);
    383 − }
    384 − job = null;
    385 − if (scope.alive) draw();
    356 + void jobs.command(id, command, forTab);
    386 357 };
    387 358
    388 359 const saveNote = async (note: string) => {
    416 387 }
    417 388 });
    418 389
    419 − bindRecording(root, scope, view.settings.warning_text, async () => {
    390 + bindRecording(root, scope, async () => {
    420 391 d.meeting = (await api.getMeeting(id)).meeting;
    421 392 draw();
    422 393 });
    432 403 b.addEventListener("click", () => {
    433 404 tab = b.dataset.tab!;
    434 405 editing = false;
    435 − jobError = null;
    406 + jobs.clearError(id);
    436 407 draw();
    437 408 }),
    438 409 );
    584 555
    585 556 draw();
    586 557
    587 − scope.on(
    588 − events.progress(({ meeting_id, stage, done, total, lines }) => {
    589 − if (meeting_id !== id || job?.kind !== "transcribe") return;
    590 − if (stage !== job.stage) {
    591 − const title = root.querySelector("[data-stage-title]");
    592 − if (title) title.textContent = STAGE_TITLE[stage];
    593 − }
    594 − job = { kind: "transcribe", stage, done, total };
    595 − if (lines.length) {
    596 − liveLines.push(...lines);
    597 − root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? "");
    598 − root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
    599 − }
    600 − const pct = total ? Math.round((done / total) * 100) : 0;
    601 − const bar = root.querySelector<HTMLElement>(".progress > span");
    602 − const big = root.querySelector("[data-pct]");
    603 − const note = root.querySelector("[data-progress-note]");
    604 − if (bar) bar.style.width = `${pct}%`;
    605 − if (big) big.textContent = `${pct}%`;
    606 − if (note) note.textContent = total || stage === "voices" ? progressNote(stage, done, total) : "В записи не нашлось речи.";
    607 − }),
    608 − );
    609 − // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра.
    558 + // Ход работы над этой записью: реплики и проценты — точечно, без
    559 + // перерисовки (иначе сбивается прокрутка), конец — перечитать запись.
    610 560 let frame = 0;
    611 − scope.on(
    612 − events.answer(({ meeting_id, text }) => {
    613 − if (meeting_id !== id || job?.kind !== "command") return;
    614 − liveAnswer += text;
    561 + const offJobs = jobs.onJobs(async (jobId, e) => {
    562 + if (jobId !== id || !scope.alive) return;
    563 + const job = jobs.jobOf(id);
    564 + if (e.type === "done") {
    565 + const fresh = await api.getMeeting(id);
    566 + if (!scope.alive) return;
    567 + Object.assign(d, fresh);
    568 + if (e.kind === "command" && e.heading) tab = e.heading;
    569 + void refreshNav();
    570 + draw();
    571 + return;
    572 + }
    573 + if (job?.kind === "command") {
    574 + // Новая работа с другой вкладки или первый кусок ответа — перерисовка;
    575 + // дальше ответ дописывается не чаще кадра.
    576 + const el = root.querySelector("[data-answer]");
    577 + if (!el || e.type === "progress") {
    578 + draw();
    579 + return;
    580 + }
    615 581 if (frame) return;
    616 582 frame = requestAnimationFrame(() => {
    617 583 frame = 0;
    618 − const el = root.querySelector("[data-answer]");
    619 − if (el) el.innerHTML = render(liveAnswer);
    584 + const now = jobs.jobOf(id);
    585 + if (now?.kind === "command") el.innerHTML = render(now.answer);
    620 586 });
    621 − }),
    622 − );
    587 + return;
    588 + }
    589 + if (job?.kind !== "transcribe") return;
    590 + if (!root.querySelector("[data-live]")) {
    591 + draw();
    592 + return;
    593 + }
    594 + if (e.type === "stage") {
    595 + const title = root.querySelector("[data-stage-title]");
    596 + if (title) title.textContent = STAGE_TITLE[job.stage];
    597 + }
    598 + if (e.type === "lines") {
    599 + root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(e.lines) ?? "");
    600 + root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
    601 + }
    602 + const { stage, done, total } = job;
    603 + const pct = total ? Math.round((done / total) * 100) : 0;
    604 + const bar = root.querySelector<HTMLElement>(".progress > span");
    605 + const big = root.querySelector("[data-pct]");
    606 + const note = root.querySelector("[data-progress-note]");
    607 + if (bar) bar.style.width = `${pct}%`;
    608 + if (big) big.textContent = `${pct}%`;
    609 + if (note) note.textContent = total || stage === "voices" ? progressNote(stage, done, total) : "В записи не нашлось речи.";
    610 + });
    611 + scope.on(Promise.resolve(offJobs));
    623 612 // Запись остановили из шапки или с главной — шаг меняется.
    624 613 const off = onChange(async () => {
    625 614 if (!scope.alive) return;
  • src/views/new.ts +3 −16
    Показать разницуСкрыть разницу
    1 1 // Экран «Новая запись»: «Записать встречу» ведёт сюда, а не запускает запись
    2 −// сразу (решение владельца 01.10.2026). Здесь видно, что запишется, совет про
    3 −// наушники и предупреждение участникам; запись начинается только кнопкой
    4 −// «Начать запись».
    2 +// сразу (решение владельца 01.10.2026). Здесь видно, что запишется, и совет
    3 +// про наушники; запись начинается только кнопкой «Начать запись».
    5 4
    6 5 import { api } from "../api";
    7 −import { esc } from "../dom";
    8 6 import type { Scope } from "../scope";
    9 −import { copy, startRecording, state } from "../state";
    7 +import { startRecording, state } from "../state";
    10 8
    11 9 export async function renderNew(root: HTMLElement, scope: Scope): Promise<void> {
    12 10 // Запись уже идёт — вторую не начать, показываем идущую.
    40 38 </ul>
    41 39 </div>
    42 40 <p class="muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p>
    43 − <div class="prep-block">
    44 − <p class="field-label">Предупредите участников</p>
    45 − <blockquote class="prep-warning">${esc(view.settings.warning_text)}</blockquote>
    46 − <div class="actions">
    47 − <button class="link-btn" data-copy-warning>Скопировать для чата встречи</button>
    48 − <a class="link-btn" href="#/settings">Изменить текст</a>
    49 − </div>
    50 − </div>
    51 41 ${
    52 42 view.openrouter_key
    53 43 ? ""
    56 46 <button class="ui-btn hero-btn" data-start>Начать запись</button>
    57 47 </section>
    58 48 </div>`;
    59 − root.querySelector("[data-copy-warning]")!.addEventListener("click", () =>
    60 − copy(view.settings.warning_text, "Предупреждение скопировано — вставьте его в чат встречи"),
    61 − );
    62 49 root.querySelector<HTMLButtonElement>("[data-start]")!.addEventListener("click", async (e) => {
    63 50 const b = e.currentTarget as HTMLButtonElement;
    64 51 b.disabled = true;
  • Показать разницуСкрыть разницу
    66 66 <p class="field-hint">Когда язык задан, речь распознаётся точнее.</p>
    67 67 </div>
    68 68 <div class="field">
    69 − <label for="warn">Текст предупреждения участникам</label>
    70 − <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea>
    71 − <p class="field-hint">Во время записи его можно скопировать одной кнопкой и отправить в чат встречи. Записывать разговор без ведома участников нельзя.</p>
    72 − </div>
    73 − <div class="field">
    74 69 <label for="m-stt">Точность распознавания</label>
    75 70 <select id="m-stt" class="input" name="stt_model">
    76 71 ${sttOptions(s.stt_model)}
    177 172 ai_model: String(data.get("ai_model")).trim(),
    178 173 stt_model: String(data.get("stt_model")).trim(),
    179 174 language: String(data.get("language") ?? ""),
    180 − warning_text: String(data.get("warning_text")).trim(),
    175 + // Блок предупреждения снят с экранов (решение владельца 01.10.2026);
    176 + // поле в настройках остаётся как было.
    177 + warning_text: s.warning_text,
    181 178 storage_dir: s.storage_dir,
    182 179 }),
    183 180 );