anc
Убрана полоса «Дальше» под расшифровкой
Конспект, задачи, ревью и свой запрос — на своих вкладках (у пустой
вкладки кнопка «Сделать»). README и CHANGELOG 1.4 — под это.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0fd9dec
2 октября 2026 г.
meeting.ts
// Экран записи — рабочее место вокруг записи, как у MacWhisper: сверху
// название и «Копировать», в центре расшифровка по репликам, под ней следующий
// шаг. Пока расшифровки нет — одна карточка с главной кнопкой. Результаты — вкладками:
// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
// Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
// сделать заново или удалить.
import {
api, formatDate, formatDuration, meetingDuration,
type Command, type Marker, type MeetingDetail,
} from "../api";
import { $, esc } from "../dom";
import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown";
import { bindRecording, clock, recordingPanel } from "../recording";
import type { Scope } from "../scope";
import * as jobs from "../jobs";
import { createPlayer, type Player } from "../player";
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 = { 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, markers: Marker[] = []): string {
const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
const rows = transcriptRows(paras, true, markers);
return rows === null ? `${render(body)}` : `${rows}`;
}
/** «00:03:21» → мс. */
function msOf(time: string): number {
const [h, m, s] = time.split(":").map(Number);
return ((h * 60 + m) * 60 + s) * 1000;
}
function markerRow(m: Marker, index: number): string {
return `
${clock(m.ms)}
★ Отметка
${m.text ? esc(m.text) : `без подписи`}
убрать
`;
}
/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
function transcriptRows(paras: string[], renamable = false, markers: Marker[] = []): string | null {
const rows = paras.map((p) => LINE.exec(p));
if (!rows.length || rows.some((r) => !r)) return paras.length ? null : "";
// Отметки встают между репликами по времени.
const marks = markers.map((m, i) => ({ m, i })).sort((a, b) => a.m.ms - b.m.ms);
let next = 0;
const before = (ms: number) => {
let out = "";
while (next < marks.length && marks[next].m.ms < ms) {
out += markerRow(marks[next].m, marks[next].i);
next++;
}
return out;
};
const body = rows
.map((r) => {
const [, time, who, text] = r!;
const ms = msOf(time);
return `${before(ms)}
${esc(time)}
${
!who
? ""
: renamable
? `${esc(who)}`
: `${esc(who)}`
}
${esc(text)}
`;
})
.join("");
return body + before(Number.POSITIVE_INFINITY);
}
export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise {
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);
// Что сейчас идёт над записью (расшифровка или ответ ИИ) — в jobs.ts: оно
// живёт дольше экрана и видно, даже если уйти и вернуться.
// Вернулись, пока ИИ пишет ответ, — сразу на его вкладку.
const running = jobs.jobOf(id);
let tab = wantTab ?? (running?.kind === "command" ? running.tab : 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 `${recordingPanel()}`;
}
return `
В этой записи нет звука
Она осталась от прежней версии программы. Её можно удалить.
`;
};
// ── Шаг 2: расшифровка ────────────────────────────────────────────────────
const transcribeStep = () => {
const job = jobs.jobOf(id);
const jobError = jobs.errorOf(id);
const dur = formatDuration(meetingDuration(d.meeting));
if (job?.kind === "transcribe") {
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
return `
${STAGE_TITLE[job.stage]}${
job.stage === "speech" && dur ? ` ${esc(dur)} звука` : ""
}
${pct}%
Отменить
${progressNote(job.stage, job.done, job.total)}
${transcriptRows(job.lines) ?? ""}
Текст появится здесь, как только распознается первый кусок.
`;
}
if (!hasKey) {
return `
Подключите ИИ, чтобы расшифровать
Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.
Подключить
`;
}
return `
Расшифруйте запись
${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
}.
${jobError ? `${esc(jobError)}` : ""}
Расшифровать
`;
};
// ── Шаг 3: результат ──
const tabContent = () => {
const job = jobs.jobOf(id);
const jobError = jobs.errorOf(id);
const busyHere = job?.kind === "command" && job.tab === tab;
const busyElsewhere = job !== null && !busyHere;
const errorLine = jobError && !job ? `${esc(jobError)}` : "";
if (busyHere) {
const preset = PRESETS.find((p) => p.heading === tab);
return `
${esc(preset?.working ?? "ИИ отвечает…")}
Отменить
${job?.kind === "command" && job.answer ? render(job.answer) : `Читаю расшифровку…`}`;
}
if (tab === CUSTOM_TAB) {
return `
Что сделать по этой встрече?
Ещё примеры:
выпиши все цифры и даты,
какие риски прозвучали,
составь повестку следующей встречи.
${errorLine}
${
hasKey
? `Сделать`
: `Подключить ИИ`
}
`;
}
const s = section(tab);
const preset = PRESETS.find((p) => p.heading === tab);
if (!s) {
return `
${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}
${esc(preset?.about ?? "")}
${errorLine}
${
!hasKey
? `Подключить ИИ`
: `${esc(preset?.action ?? "Сделать")}`
}
${busyElsewhere ? `Сначала дождитесь, пока закончится текущая работа.` : ""}
`;
}
if (editing) {
return `
${esc(sectionBody(s.source))}
Сохранить
Отмена
Markdown: **жирный**, - список, - [ ] задача
`;
}
const isTranscript = tab === TRANSCRIPT;
const remakeable = !isTranscript && s.heading !== "";
const body = sectionBody(s.source);
return `
Править
${isTranscript ? `Имя говорящего — нажмите, чтобы переименовать` : ""}
${
isTranscript && d.voices
? `Говорящих
как нашлось
${[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `${n}`).join("")}
`
: ""
}
${
isTranscript
? `Расшифровать заново`
: remakeable
? `Сделать заново
Удалить`
: ""
}
${errorLine}
${isTranscript ? transcriptView(body, d.meeting.markers ?? []) : `${render(body)}`}
`;
};
const resultStep = () => `
${tabs()
.map((t) => {
const ready = t === CUSTOM_TAB || Boolean(section(t));
return `${esc(t)}`;
})
.join("")}
${tabContent()}
`;
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 = `
${esc(meta)}
${
copyable
? `
Копировать
как текст
`
: ""
}
${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
Открыть папку с файлами
Удалить запись
`;
bind();
// Проигрыватель живёт дольше перерисовок: его полоса возвращается на место,
// звук не прерывается.
ensurePlayer();
if (player) root.appendChild(player.el);
highlight(player?.current() ?? -1, true);
};
// ── Проигрыватель ─────────────────────────────────────────────────────────
let player: Player | null = null;
let playerLoading = false;
let playing = -1;
const ensurePlayer = () => {
if (player || playerLoading || !d.meeting.audio.length || d.meeting.recording) return;
playerLoading = true;
createPlayer(id, d.meeting.markers ?? [], (m) => {
d.meeting = m;
draw();
})
.then((p) => {
if (!scope.alive) {
p.destroy();
return;
}
player = p;
root.appendChild(p.el);
p.onTime((ms) => highlight(ms));
})
.catch(() => {
// Нет файлов звука (запись старой версии, папку чистили) — без проигрывателя.
})
.finally(() => (playerLoading = false));
};
scope.on(Promise.resolve(() => player?.destroy()));
/** Подсветить реплику, которая сейчас звучит. */
const highlight = (ms: number, force = false) => {
const rows = [...root.querySelectorAll("[data-view] [data-row-ms]")];
let at = -1;
for (let i = 0; i < rows.length && Number(rows[i].dataset.rowMs) <= ms; i++) at = i;
if (at === playing && !force) return;
playing = at;
const started = (player?.current() ?? 0) > 0;
rows.forEach((r, i) => r.classList.toggle("is-playing", started && i === at));
};
// ── Действия ──────────────────────────────────────────────────────────────
const transcribe = () => {
tab = TRANSCRIPT;
void jobs.transcribe(id);
};
const runCommand = (command: Command, forTab: string) => {
tab = forTab;
void jobs.command(id, command, forTab);
};
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, 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("[data-tab]").forEach((b) =>
b.addEventListener("click", () => {
tab = b.dataset.tab!;
editing = false;
jobs.clearError(id);
draw();
}),
);
root.querySelectorAll("[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("[data-ask]");
ask?.querySelectorAll("[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("[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("[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("[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("[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.querySelectorAll("[data-seek-ms]").forEach((b) =>
b.addEventListener("click", () => {
if (!player) {
toast("Проигрыватель ещё загружается");
return;
}
player.seek(Number(b.dataset.seekMs), true);
}),
);
root.querySelectorAll("[data-mark-del]").forEach((b) =>
b.addEventListener("click", async () => {
const list = (d.meeting.markers ?? []).filter((_, i) => i !== Number(b.dataset.markDel));
try {
d.meeting = await api.setMarkers(id, list);
player?.setMarkers(d.meeting.markers);
draw();
} catch (e) {
toast(e);
}
}),
);
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();
// Ход работы над этой записью: реплики и проценты — точечно, без
// перерисовки (иначе сбивается прокрутка), конец — перечитать запись.
let frame = 0;
const offJobs = jobs.onJobs(async (jobId, e) => {
if (jobId !== id || !scope.alive) return;
const job = jobs.jobOf(id);
if (e.type === "done") {
const fresh = await api.getMeeting(id);
if (!scope.alive) return;
Object.assign(d, fresh);
if (e.kind === "command" && e.heading) tab = e.heading;
void refreshNav();
draw();
return;
}
if (job?.kind === "command") {
// Новая работа с другой вкладки или первый кусок ответа — перерисовка;
// дальше ответ дописывается не чаще кадра.
const el = root.querySelector("[data-answer]");
if (!el || e.type === "progress") {
draw();
return;
}
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const now = jobs.jobOf(id);
if (now?.kind === "command") el.innerHTML = render(now.answer);
});
return;
}
if (job?.kind !== "transcribe") return;
if (!root.querySelector("[data-live]")) {
draw();
return;
}
if (e.type === "stage") {
const title = root.querySelector("[data-stage-title]");
if (title) title.textContent = STAGE_TITLE[job.stage];
}
if (e.type === "lines") {
root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(e.lines) ?? "");
root.querySelector("[data-live-wait]")?.setAttribute("hidden", "");
}
const { stage, done, total } = job;
const pct = total ? Math.round((done / total) * 100) : 0;
const bar = root.querySelector(".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) : "В записи не нашлось речи.";
});
scope.on(Promise.resolve(offJobs));
// Запись остановили из шапки или с главной — шаг меняется.
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));
}