Ход работы не теряется при уходе с экрана; без «Записать» в шапке и предупреждения
- jobs.ts: расшифровка и ответ ИИ живут, пока открыта программа, а не экран. Вернулся — видны процент, пришедшие реплики, текст ответа и его вкладка; кончилось — экран перечитывает запись сам. Раньше экран показывал «Расшифровать», хотя ядро ещё работало. - В меню у записи — метка «расшифровка» / «ИИ», пока идёт работа. - Кнопка «Записать» из шапки убрана (метка идущей записи осталась). - Блок «Текст предупреждения участникам» убран из настроек, экрана «Новая запись» и панели записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
778ade1
· сравнить с родителем
-
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; -
src/views/settings.ts +3 −6
Показать разницуСкрыть разницу
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 );