skillok meeting
ad47f897f99f8450c300100150f8c07c6061d85a
Перейти к файлу
Код
meeting srcviewsmeeting.ts
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 `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`;
}

function segmentRow(s: Segment, i: number, target: number | null): string {
  return `
      <div class="segment${i === target ? " is-target" : ""}${s.speaker.kind === "me" ? " is-me" : ""}" id="seg-${i}">
        <span class="at mono">${formatTime(s.start_ms)}</span>
        <p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>
      </div>`;
}

function transcriptTab(d: MeetingDetail, target: number | null): string {
  if (!d.transcript.segments.length) {
    return `<section class="box" data-segments><p class="empty">${
      d.meeting.state === "recording"
        ? "Реплики появятся здесь через несколько секунд после начала разговора."
        : "В записи не прозвучало ни одной реплики."
    }</p></section>`;
  }
  return `<section class="box" data-segments>${d.transcript.segments.map((s, i) => segmentRow(s, i, target)).join("")}</section>`;
}

function liveSummary(d: MeetingDetail): string {
  if (!d.live_summary) return "";
  return `
    <section class="box live-summary">
      <div class="box-head"><h2>Живой конспект</h2><span class="xs muted">обновляется каждые несколько минут</span></div>
      <div class="box-body"><p class="pre">${esc(d.live_summary)}</p></div>
    </section>`;
}

function notAnalyzed(d: MeetingDetail): string {
  const why =
    d.meeting.state === "recording"
      ? "Разбор появится сам, когда встреча закончится."
      : "Разбора ещё нет. Нажмите «Разобрать» — ИИ сделает конспект, решения и задачи.";
  return `${liveSummary(d)}<section class="box"><p class="empty">${why}</p></section>`;
}

function summaryTab(d: MeetingDetail, a: Analysis): string {
  const r = a.review;
  const review = [
    r.achieved.length ? `<h3>Чего добились</h3>${list(r.achieved)}` : "",
    r.open.length ? `<h3>Осталось открытым</h3>${list(r.open)}` : "",
    r.off_track.length ? `<h3>Где ушли в сторону</h3>${list(r.off_track)}` : "",
    `<p><strong>Итог:</strong> ${esc(r.verdict)}</p>`,
  ].join("");
  return `
    <section class="prose">
      <p>${esc(a.summary)}</p>
      <div class="section"><h2>Ревью</h2>${review}</div>
      <div class="section"><h2>Решения</h2>${
        a.decisions.length
          ? `<ul>${a.decisions.map((x) => `<li>${esc(x.text)} ${jump(d, x.source_segment)}</li>`).join("")}</ul>`
          : `<p class="muted">Явных решений не было.</p>`
      }</div>
      ${a.open_questions.length ? `<div class="section"><h2>Открытые вопросы</h2>${list(a.open_questions)}</div>` : ""}
    </section>`;
}

function tasksTab(d: MeetingDetail, a: Analysis): string {
  if (!a.tasks.length) return `<section class="box"><p class="empty">Задач не прозвучало.</p></section>`;
  return `<section class="box">${a.tasks
    .map(
      (t) => `
      <div class="task">
        <p class="text">${esc(t.text)}</p>
        <p class="small muted">${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}</p>
        <p class="quote">${esc(t.quote)}</p>
      </div>`,
    )
    .join("")}</section>`;
}

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 `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`;
}

export async function renderMeeting(
  root: HTMLElement,
  parent: Scope,
  id: string,
  tab: Tab,
  target: number | null = null,
): Promise<void> {
  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) =>
    `<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${
      count !== undefined ? ` <span class="ui-nav-count">${count}</span>` : ""
    }</a>`;

  const body =
    tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed(d) : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
  const stateLabel = recording
    ? ` <span class="label label-accent">идёт запись</span>`
    : d.meeting.state === "interrupted"
      ? ` <span class="label" title="Программа закрылась посреди записи; всё, что успело распознаться, сохранено">прервана</span>`
      : "";

  root.innerHTML = `
    <div class="page-head">
      <div>
        <h1>${esc(d.meeting.title)}</h1>
        <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · <span data-count>${d.meeting.segments}</span> реплик${stateLabel}</p>
      </div>
      <div class="actions">
        ${recording ? "" : `<button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>`}
      </div>
    </div>
    <p class="error" data-error hidden></p>
    <nav class="ui-underline-nav" aria-label="Встреча">
      ${tabLink("transcript", "Расшифровка", d.meeting.segments)}
      ${tabLink("summary", "Конспект")}
      ${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)}
    </nav>
    ${body}
    ${cost(d)}`;

  if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });

  const error = $(root, "[data-error]");
  root.querySelector<HTMLButtonElement>("[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<HTMLElement>("[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);
    }),
  );
}