anc
Этап 1: запись встречи в Zoom, живая расшифровка и разбор
- crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process
loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом
тишины, распознавание начала и конца встречи по звуковой сессии Zoom.
- Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка,
живой конспект раз в три минуты, по окончании — разбор сам; звук
удаляется после разбора, если не включено хранение.
- Контроллер: слежу за Zoom → прошу предупредить участников → пишу;
запись вручную, пауза, стоп; прерванная запись помечается при запуске.
- Трей, уведомления, крестик прячет окно, запуск вместе с Windows.
- Интерфейс: главный экран с карточкой состояния (задача #1), живая
расшифровка и конспект на экране встречи, новые настройки.
- reqwest на системном TLS: под Windows без C-кода ring.
- 67 тестов; код под Windows проверен компиляцией и clippy с Mac,
на Windows не запускался.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
30 сентября 2026 г.
meeting.ts
// Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора.
// Пока идёт запись, расшифровка и живой конспект обновляются по событиям ядра.
import {
api, errorText, events, formatDate, formatDuration, formatTime, speakerLabel,
type Analysis, type MeetingDetail, type Segment,
} from "../api";
import { $, busy, esc, list } from "../dom";
import type { Scope } from "../scope";
export type Tab = "transcript" | "summary" | "tasks";
function jump(d: MeetingDetail, index: number): string {
const seg = d.transcript.segments[index];
if (!seg) return "";
return `${formatTime(seg.start_ms)}`;
}
function segmentRow(s: Segment, i: number, target: number | null): string {
return `
${formatTime(s.start_ms)}
${esc(speakerLabel(s.speaker))}: ${esc(s.text)}
`;
}
function transcriptTab(d: MeetingDetail, target: number | null): string {
if (!d.transcript.segments.length) {
return `${
d.meeting.state === "recording"
? "Реплики появятся здесь через несколько секунд после начала разговора."
: "В записи не прозвучало ни одной реплики."
}`;
}
return `${d.transcript.segments.map((s, i) => segmentRow(s, i, target)).join("")}`;
}
function liveSummary(d: MeetingDetail): string {
if (!d.live_summary) return "";
return `
Живой конспектобновляется каждые несколько минут
${esc(d.live_summary)}
`;
}
function notAnalyzed(d: MeetingDetail): string {
const why =
d.meeting.state === "recording"
? "Разбор появится сам, когда встреча закончится."
: "Разбора ещё нет. Нажмите «Разобрать» — ИИ сделает конспект, решения и задачи.";
return `${liveSummary(d)}${why}`;
}
function summaryTab(d: MeetingDetail, a: Analysis): string {
const r = a.review;
const review = [
r.achieved.length ? `Чего добились${list(r.achieved)}` : "",
r.open.length ? `Осталось открытым${list(r.open)}` : "",
r.off_track.length ? `Где ушли в сторону${list(r.off_track)}` : "",
`Итог: ${esc(r.verdict)}`,
].join("");
return `
${esc(a.summary)}
Ревью${review}
Решения${
a.decisions.length
? `${a.decisions.map((x) => `${esc(x.text)} ${jump(d, x.source_segment)}`).join("")}`
: `Явных решений не было.`
}
${a.open_questions.length ? `Открытые вопросы${list(a.open_questions)}` : ""}
`;
}
function tasksTab(d: MeetingDetail, a: Analysis): string {
if (!a.tasks.length) return `Задач не прозвучало.`;
return `${a.tasks
.map(
(t) => `
${esc(t.text)}
${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}
${esc(t.quote)}
`,
)
.join("")}`;
}
function cost(d: MeetingDetail): string {
const a = d.meeting.analysis;
if (!a) return "";
const price = a.usage.cost != null ? ` · $${a.usage.cost.toFixed(4)}` : "";
return `Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}`;
}
export async function renderMeeting(
root: HTMLElement,
parent: Scope,
id: string,
tab: Tab,
target: number | null = null,
): Promise {
const scope = parent.child();
const d = await api.getMeeting(id);
if (!scope.alive) return;
const rerender = (t: Tab = tab, at: number | null = null) => {
scope.dispose();
return renderMeeting(root, parent, id, t, at);
};
const a = d.analysis;
const recording = d.meeting.state === "recording";
const base = `#/meeting/${encodeURIComponent(id)}`;
const tabLink = (key: Tab, label: string, count?: number) =>
`${label}${
count !== undefined ? ` ${count}` : ""
}`;
const body =
tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed(d) : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
const stateLabel = recording
? ` идёт запись`
: d.meeting.state === "interrupted"
? ` прервана`
: "";
root.innerHTML = `
${esc(d.meeting.title)}
${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · ${d.meeting.segments} реплик${stateLabel}
${recording ? "" : `${a ? "Разобрать заново" : "Разобрать"}`}
${tabLink("transcript", "Расшифровка", d.meeting.segments)}
${tabLink("summary", "Конспект")}
${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)}
${body}
${cost(d)}`;
if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });
const error = $(root, "[data-error]");
root.querySelector("[data-analyze]")?.addEventListener("click", async (event) => {
error.hidden = true;
try {
await busy(event.currentTarget as HTMLButtonElement, "Разбираю…", () => api.analyzeMeeting(id));
if (tab === "summary") await rerender("summary");
else location.hash = `${base}/summary`;
} catch (e) {
error.textContent = errorText(e);
error.hidden = false;
}
});
// Живая расшифровка: новые реплики дописываются на место по времени, лента
// едет вниз сама, пока человек не прокрутил вверх.
const segments = d.transcript.segments;
scope.on(
events.recorder(({ meeting_id, event }) => {
if (meeting_id !== id) return;
if (event.kind === "segments") {
segments.push(...event.data);
segments.sort((x, y) => x.start_ms - y.start_ms);
const count = root.querySelector("[data-count]");
if (count) count.textContent = String(segments.length);
const box = root.querySelector("[data-segments]");
if (tab !== "transcript" || !box) return;
const atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
box.innerHTML = segments.map((s, i) => segmentRow(s, i, null)).join("");
if (atBottom) window.scrollTo({ top: document.body.scrollHeight });
} else if (event.kind === "live_summary" && tab !== "transcript" && !a) {
rerender();
} else if (event.kind === "stt_failed") {
error.textContent = `Кусок записи не распознался: ${event.data}. Звук сохранён.`;
error.hidden = false;
}
}),
);
scope.on(
events.meetingChanged((changed) => {
if (changed === id) rerender(tab, target);
}),
);
}