anc
Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13)
- Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает
панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском,
Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки
«Skillok Meet / запись». Список записей с поиском — на главной.
- Во время записи один общий уровень вместо «Вы» и «Собеседники».
- Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит,
когда готовы все куски раньше), текст растёт на экране.
- Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос
пишутся на глазах.
- Длинные записи: дорожка не читается в память целиком — нарезка блоками
по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один
раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой
распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ.
- Говорящий в файле без дорожек — «Спикер 1».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3938872
30 сентября 2026 г.
meeting.ts
// Экран записи — рабочее место вокруг записи, как у MacWhisper: сверху
// название и «Копировать», в центре расшифровка по репликам, под ней следующий
// шаг. Пока расшифровки нет — одна карточка с главной кнопкой. Результаты — вкладками:
// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
// Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
// сделать заново или удалить.
import {
api, errorText, events, formatDate, formatDuration, meetingDuration,
type Command, type MeetingDetail,
} from "../api";
import { $, esc } from "../dom";
import { plain, render, sectionBody, sections, 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";
}
function progressNote(done: number, total: number): string {
return `${total ? `Готово ${done} из ${total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.`;
}
/** Реплика расшифровки, как её пишет ядро: «**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);
return rows === null ? `${render(body)}` : `${rows}`;
}
/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
function transcriptRows(paras: string[]): 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 `
${esc(time)}
${who ? `${esc(who)}` : ""}
${esc(text)}
`;
})
.join("");
}
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);
// Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
let job: { kind: "transcribe"; 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 `${recordingPanel(view.settings.warning_text)}`;
}
return `
В этой записи нет звука
Она осталась от прежней версии программы. Её можно удалить.
`;
};
// ── Шаг 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 `
Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}
${pct}%
Отменить
${progressNote(job.done, job.total)}
${transcriptRows(liveLines) ?? ""}
Текст появится здесь, как только распознается первый кусок.
`;
}
if (!hasKey) {
return `
Подключите ИИ, чтобы расшифровать
Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.
Подключить
`;
}
return `
Расшифруйте запись
${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
}.
${jobError ? `${esc(jobError)}` : ""}
Расшифровать
`;
};
// ── Шаг 3: результат ──
const nextStep = () => {
const todo = PRESETS.filter((p) => !section(p.heading));
if (!hasKey) return "";
return `
Дальше
${todo.map((p) => `${esc(p.heading)}`).join("")}
Свой запрос
`;
};
const tabContent = () => {
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 ?? "ИИ отвечает…")}
Отменить
${liveAnswer ? render(liveAnswer) : `Читаю расшифровку…`}`;
}
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
? `Расшифровать заново`
: remakeable
? `Сделать заново
Удалить`
: ""
}
${errorLine}
${isTranscript ? transcriptView(body) : `${render(body)}`}
${isTranscript ? nextStep() : ""}`;
};
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();
};
// ── Действия ──────────────────────────────────────────────────────────────
const transcribe = async () => {
if (job) return;
job = { kind: "transcribe", done: 0, total: 0 };
jobError = null;
liveLines = [];
draw();
try {
const r = await api.transcribe(id);
d.note = r.note;
d.meeting = (await api.getMeeting(id)).meeting;
tab = TRANSCRIPT;
void refreshNav();
if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
} 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("[data-tab]").forEach((b) =>
b.addEventListener("click", () => {
tab = b.dataset.tab!;
editing = false;
jobError = null;
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}`));
});
});
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, done, total, lines }) => {
if (meeting_id !== id || job?.kind !== "transcribe") return;
job = { kind: "transcribe", done, total };
if (lines.length) {
liveLines.push(...lines);
root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? "");
root.querySelector("[data-live-wait]")?.setAttribute("hidden", "");
}
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 ? progressNote(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));
}