skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting srcviewshome.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 г.
home.ts
// Главный экран: что программа делает прямо сейчас (задача #1) и список встреч.
//
// Карточка состояния отвечает на один вопрос — «что происходит и что будет
// дальше». В каждом состоянии одно главное действие.

import {
  api, errorText, events, formatDate, formatDuration, speakerLabel,
  type Meeting, type RecorderStatus, type Segment, type SettingsView, type Speaker,
} from "../api";
import { $, busy, esc, plural } from "../dom";
import type { Scope } from "../scope";

function elapsed(since: string): string {
  const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
  const p = (n: number) => String(n).padStart(2, "0");
  return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
}

function meter(label: string, key: string): string {
  return `
    <div class="meter">
      <span class="meter-label small">${esc(label)}</span>
      <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span>
    </div>`;
}

function card(status: RecorderStatus, view: SettingsView): string {
  const hasKey = Boolean(view.openrouter_key);
  if (status.kind === "unsupported") {
    return `
      <section class="status-card">
        <h2 class="status-title">Запись встреч работает в Windows</h2>
        <p class="status-text">На этом компьютере можно разобрать готовую расшифровку: вставьте её, и ИИ сделает конспект и задачи.</p>
        <div class="actions"><button class="ui-btn" data-open-import>Импортировать расшифровку</button></div>
      </section>`;
  }
  if (!hasKey && (status.kind === "watching" || status.kind === "awaiting_consent")) {
    return `
      <section class="status-card">
        <h2 class="status-title">Подключите ИИ, чтобы записывать встречи</h2>
        <p class="status-text">Расшифровка и разбор идут через ваш ключ OpenRouter. Без него встречу не записать.</p>
        <div class="actions"><a class="ui-btn" href="#/settings">Подключить</a></div>
      </section>`;
  }
  switch (status.kind) {
    case "watching":
      return `
        <section class="status-card">
          <p class="status-kicker"><span class="dot"></span>Слежу за Zoom</p>
          <h2 class="status-title">Жду встречу</h2>
          <p class="status-text">${
            view.settings.always_warn_myself
              ? "Как только начнётся встреча в Zoom, запись включится сама."
              : "Как только начнётся встреча в Zoom, я попрошу предупредить участников и начну запись."
          }</p>
          <div class="actions"><button class="link-btn" data-start>Записать вручную</button></div>
        </section>`;
    case "awaiting_consent":
      return `
        <section class="status-card is-attention">
          <p class="status-kicker"><span class="dot dot-accent"></span>Началась встреча в Zoom</p>
          <h2 class="status-title">Предупредите участников о записи</h2>
          <blockquote class="warning-text" data-warning>${esc(view.settings.warning_text)}</blockquote>
          <div class="actions">
            <button class="ui-btn" data-consent="yes">Предупредил, записывать</button>
            <button class="link-btn" data-copy>Скопировать текст</button>
            <button class="link-btn" data-consent="no">Не записывать</button>
          </div>
        </section>`;
    case "recording":
      return `
        <section class="status-card is-recording">
          <p class="status-kicker"><span class="dot dot-live${status.paused ? " is-paused" : ""}"></span>${
            status.paused ? "Запись на паузе" : status.zoom ? "Записываю встречу в Zoom" : "Записываю с микрофона"
          }</p>
          <h2 class="status-title mono-num" data-timer>${elapsed(status.started_at)}</h2>
          <div class="meters">
            ${meter("Я", "me")}
            ${status.zoom ? meter("Собеседники", "others") : ""}
          </div>
          <div class="live-lines" data-live><p class="muted small">Реплики появятся здесь через несколько секунд после начала разговора.</p></div>
          <div class="actions">
            <button class="ui-btn" data-stop>Остановить</button>
            <button class="link-btn" data-pause>${status.paused ? "Продолжить" : "Пауза"}</button>
            <a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть расшифровку</a>
          </div>
        </section>`;
    case "finishing":
      return `
        <section class="status-card">
          <p class="status-kicker"><span class="dot dot-accent"></span>Запись остановлена</p>
          <h2 class="status-title">${status.step === "transcribing" ? "Дописываю расшифровку…" : "Разбираю встречу…"}</h2>
          <p class="status-text">${
            status.step === "transcribing"
              ? "Распознаю последние реплики. Потом ИИ сделает конспект и задачи."
              : "ИИ пишет конспект, решения и задачи. Обычно это меньше минуты — пришлю уведомление."
          }</p>
          <div class="actions"><a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть встречу</a></div>
        </section>`;
  }
}

function status(m: Meeting): string {
  if (m.state === "recording") return `<span class="label label-accent">идёт запись</span>`;
  if (m.state === "interrupted" && !m.analysis) return `<span class="label">прервана</span>`;
  if (!m.analysis) return `<span class="label">не разобрана</span>`;
  const n = m.analysis.tasks;
  return `<span class="label label-accent">${n} ${plural(n, "задача", "задачи", "задач")}</span>`;
}

function list(meetings: Meeting[]): string {
  if (!meetings.length) {
    return `<p class="empty">Здесь появятся записанные встречи.</p>`;
  }
  return meetings
    .map(
      (m) => `
      <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}">
        <span class="title">${esc(m.title)}</span>
        <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span>
        <span class="status">${status(m)}</span>
      </a>`,
    )
    .join("");
}

function importForm(): string {
  return `
    <form class="box" data-import hidden>
      <div class="box-head"><h2>Импорт расшифровки</h2></div>
      <div class="box-body">
        <div class="field">
          <label for="imp-title">Название</label>
          <input id="imp-title" class="input" name="title" autocomplete="off" required>
        </div>
        <div class="field">
          <label for="imp-text">Расшифровка</label>
          <textarea id="imp-text" class="textarea mono" name="text" rows="10" required
            placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea>
          <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p>
        </div>
        <p class="error" data-import-error hidden></p>
        <div class="actions">
          <button class="ui-btn" type="submit">Создать встречу</button>
          <button class="link-btn" type="button" data-close-import>Отмена</button>
        </div>
      </div>
    </form>`;
}

function speakerKey(s: Speaker): string {
  return s.kind === "me" ? "me" : "others";
}

export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
  const [recorder, initialView, meetings] = await Promise.all([api.recorderStatus(), api.getSettings(), api.listMeetings()]);
  if (!scope.alive) return;
  let view = initialView;

  root.innerHTML = `
    <div data-card>${card(recorder, view)}</div>
    <p class="error" data-error hidden></p>
    ${importForm()}
    <section class="section">
      <div class="section-head">
        <h2>Встречи</h2>
        <button class="link-btn" data-open-import>Импортировать расшифровку</button>
      </div>
      <div class="box" data-list>${list(meetings)}</div>
    </section>`;

  const error = $(root, "[data-error]");
  const fail = (e: unknown) => {
    error.textContent = errorText(e);
    error.hidden = false;
  };
  let current = recorder;
  const live: Segment[] = [];

  const bindCard = () => {
    const box = $(root, "[data-card]");
    box.querySelector<HTMLButtonElement>("[data-start]")?.addEventListener("click", (e) =>
      busy(e.currentTarget as HTMLButtonElement, "Запускаю…", () => api.recorderStart()).catch(fail),
    );
    box.querySelectorAll<HTMLButtonElement>("[data-consent]").forEach((b) =>
      b.addEventListener("click", () => api.recorderConsent(b.dataset.consent === "yes").catch(fail)),
    );
    box.querySelector<HTMLButtonElement>("[data-copy]")?.addEventListener("click", async (e) => {
      const b = e.currentTarget as HTMLButtonElement;
      await navigator.clipboard.writeText(view.settings.warning_text).catch(() => undefined);
      b.textContent = "Скопировано";
    });
    box.querySelector<HTMLButtonElement>("[data-stop]")?.addEventListener("click", (e) =>
      busy(e.currentTarget as HTMLButtonElement, "Останавливаю…", () => api.recorderStop()).catch(fail),
    );
    box.querySelector<HTMLButtonElement>("[data-pause]")?.addEventListener("click", () =>
      api.recorderPause(current.kind === "recording" && !current.paused).catch(fail),
    );
    box.querySelector("[data-open-import]")?.addEventListener("click", openImport);
    renderLive();
  };

  const renderLive = () => {
    const el = root.querySelector("[data-live]");
    if (!el || !live.length) return;
    el.innerHTML = live
      .slice(-3)
      .map((s) => `<p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>`)
      .join("");
  };

  const refreshList = async () => {
    const fresh = await api.listMeetings();
    if (scope.alive) $(root, "[data-list]").innerHTML = list(fresh);
  };

  // Импорт
  const form = $(root, "[data-import]") as HTMLFormElement;
  const importError = $(root, "[data-import-error]");
  function openImport() {
    form.hidden = false;
    $(form, "#imp-title").focus();
  }
  root.querySelectorAll("[data-open-import]").forEach((b) => b.addEventListener("click", openImport));
  $(root, "[data-close-import]").addEventListener("click", () => {
    form.hidden = true;
    importError.hidden = true;
  });
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const data = new FormData(form);
    try {
      const m = await busy($(form, "[type=submit]") as HTMLButtonElement, "Создаю…", () =>
        api.importMeeting(String(data.get("title")), String(data.get("text"))),
      );
      location.hash = `#/meeting/${encodeURIComponent(m.id)}`;
    } catch (e) {
      importError.textContent = errorText(e);
      importError.hidden = false;
    }
  });

  bindCard();

  scope.on(
    events.status(async (s) => {
      const changedMeeting = s.kind !== current.kind;
      current = s;
      if (s.kind !== "recording") live.length = 0;
      // Ключ и «предупреждаю сам» могли поменяться, пока экран открыт.
      view = await api.getSettings();
      if (!scope.alive) return;
      error.hidden = true;
      $(root, "[data-card]").innerHTML = card(s, view);
      bindCard();
      if (changedMeeting) await refreshList();
    }),
  );
  scope.on(
    events.recorder(({ event }) => {
      if (event.kind === "segments") {
        live.push(...event.data);
        renderLive();
      } else if (event.kind === "level") {
        const fill = root.querySelector<HTMLElement>(`[data-level="${speakerKey(event.data.speaker)}"]`);
        if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
      } else if (event.kind === "track_failed") {
        fail(event.data.error);
      }
    }),
  );
  scope.on(events.meetingChanged(() => refreshList()));
  scope.every(1000, () => {
    const t = root.querySelector("[data-timer]");
    if (t && current.kind === "recording") t.textContent = elapsed(current.started_at);
  });
}