skillok meeting
7158f1fff92dd10a4e1afa8e1ae52046ac326227
Перейти к файлу
Код
meeting srcviewsmeeting.ts
anc Запись всего звука, папка хранения, точнее распознавание - Звук собеседников — весь звук компьютера, кроме самого Skillok Meet: Zoom, Google Meet, Телемост, что угодно. Windows — process loopback в режиме исключения своего процесса, macOS — ScreenCaptureKit без фильтра по приложению. Поиск Zoom и зависимость windows убраны. - Если звук компьютера недоступен (на Mac — нет разрешения), запись идёт с микрофона, а интерфейс говорит почему. - Настройки: папка хранения записей (выбрать, открыть, вернуть по умолчанию; записи переносятся целиком), точность распознавания — по умолчанию полная whisper-large-v3. - «Открыть папку с файлами» у записи; микрофон на Mac просит у CoreAudio сразу 16 кГц. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 96a28a9 30 сентября 2026 г.
meeting.ts
// Экран записи: ① Аудио → ② Расшифровка → ③ Результат.
//
// На экране только текущий шаг и одна главная кнопка. Результаты — вкладками:
// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
// Каждая вкладка — раздел заметки 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 { 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 stepper(step: Step): string {
  const order: Step[] = ["audio", "transcribe", "result"];
  const names = { audio: "Аудио", transcribe: "Расшифровка", result: "Результат" };
  const at = order.indexOf(step);
  return `
    <ol class="stepper">
      ${order
        .map((s, i) => {
          const cls = i < at ? "is-done" : i === at ? "is-current" : "";
          return `<li class="${cls}"><span class="step-num">${i < at ? "✓" : i + 1}</span>${names[s]}</li>`;
        })
        .join("")}
    </ol>`;
}

export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
  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 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 `<section class="card">${recordingPanel(view.settings.warning_text)}</section>`;
    }
    return `
      <section class="card">
        <h2>В этой записи нет звука</h2>
        <p class="muted">Она осталась от прежней версии программы. Её можно удалить.</p>
      </section>`;
  };

  // ── Шаг 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 `
        <section class="card">
          <h2>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}</h2>
          <div class="progress"><span style="width:${pct}%"></span></div>
          <p class="small muted">${job.total ? `Готово ${job.done} из ${job.total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.</p>
          <div class="actions"><button class="link-btn" data-cancel>Отменить</button></div>
        </section>`;
    }
    if (!hasKey) {
      return `
        <section class="card">
          <h2>Подключите ИИ, чтобы расшифровать</h2>
          <p class="muted">Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.</p>
          <a class="ui-btn" href="#/settings">Подключить</a>
        </section>`;
    }
    return `
      <section class="card">
        <h2>Расшифруйте запись</h2>
        <p class="muted">${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
          meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
        }.</p>
        ${jobError ? `<p class="error">${esc(jobError)}</p>` : ""}
        <button class="ui-btn" data-transcribe>Расшифровать</button>
      </section>`;
  };

  // ── Шаг 3: результат ──────────────────────────────────────────────────────
  const tabContent = () => {
    const busyHere = job?.kind === "command" && job.tab === tab;
    const busyElsewhere = job !== null && !busyHere;
    const errorLine = jobError && !job ? `<p class="error">${esc(jobError)}</p>` : "";

    if (busyHere) {
      const preset = PRESETS.find((p) => p.heading === tab);
      return `
        <div class="pane-empty">
          <p class="working"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
          <button class="link-btn" data-cancel>Отменить</button>
        </div>`;
    }

    if (tab === CUSTOM_TAB) {
      return `
        <form class="pane-empty ask" data-ask>
          <h3>Что сделать по этой встрече?</h3>
          <input class="input" name="text" autocomplete="off" placeholder="Например: напиши письмо клиенту по итогам" aria-label="Свой запрос" ${busyElsewhere || !hasKey ? "disabled" : ""}>
          <p class="small muted">Ещё примеры:
            <button type="button" class="link-btn" data-example>выпиши все цифры и даты</button>,
            <button type="button" class="link-btn" data-example>какие риски прозвучали</button>,
            <button type="button" class="link-btn" data-example>составь повестку следующей встречи</button>.</p>
          ${errorLine}
          ${
            hasKey
              ? `<button class="ui-btn" type="submit" ${busyElsewhere ? "disabled" : ""}>Сделать</button>`
              : `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
          }
        </form>`;
    }

    const s = section(tab);
    const preset = PRESETS.find((p) => p.heading === tab);
    if (!s) {
      return `
        <div class="pane-empty">
          <h3>${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}</h3>
          <p class="muted">${esc(preset?.about ?? "")}</p>
          ${errorLine}
          ${
            !hasKey
              ? `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
              : `<button class="ui-btn" data-make="${esc(tab)}" ${busyElsewhere ? "disabled" : ""}>${esc(preset?.action ?? "Сделать")}</button>`
          }
          ${busyElsewhere ? `<p class="small muted">Сначала дождитесь, пока закончится текущая работа.</p>` : ""}
        </div>`;
    }

    if (editing) {
      return `
        <div class="pane-edit">
          <textarea class="section-editor" data-editor spellcheck="true">${esc(sectionBody(s.source))}</textarea>
          <div class="actions">
            <button class="ui-btn" data-edit-save>Сохранить</button>
            <button class="link-btn" data-edit-cancel>Отмена</button>
            <span class="small muted">Markdown: <code>**жирный**</code>, <code>- список</code>, <code>- [ ] задача</code></span>
          </div>
        </div>`;
    }

    const isTranscript = tab === TRANSCRIPT;
    const remakeable = !isTranscript && s.heading !== "";
    return `
      <div class="pane-tools">
        <button class="link-btn" data-copy>Копировать</button>
        <button class="link-btn" data-copy-plain>Как текст</button>
        <button class="link-btn" data-edit>Править</button>
        <span class="spacer"></span>
        ${
          isTranscript
            ? `<button class="link-btn" data-retranscribe ${job ? "disabled" : ""}>Расшифровать заново</button>`
            : remakeable
              ? `<button class="link-btn" data-remake ${job || !hasKey ? "disabled" : ""}>Сделать заново</button>
                 <button class="link-btn link-danger" data-remove>Удалить</button>`
              : ""
        }
      </div>
      ${errorLine}
      <div class="md pane-body" data-view>${render(sectionBody(s.source))}</div>`;
  };

  const resultStep = () => `
    <section class="result">
      <nav class="tabs" role="tablist" aria-label="Результаты">
        ${tabs()
          .map((t) => {
            const ready = t === CUSTOM_TAB || Boolean(section(t));
            return `<button role="tab" class="tab${t === tab ? " is-active" : ""}${ready ? "" : " is-empty"}${
              t === CUSTOM_TAB ? " tab-custom" : ""
            }" aria-selected="${t === tab}" data-tab="${esc(t)}">${esc(t)}</button>`;
          })
          .join("")}
      </nav>
      <div class="pane">${tabContent()}</div>
    </section>`;

  const draw = () => {
    const step = stepOf(d);
    if (!tabs().includes(tab)) tab = TRANSCRIPT;
    const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    root.innerHTML = `
      <article class="meeting">
        <a class="back small" href="#/">← Все записи</a>
        <header class="meeting-head">
          <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
          <p class="small muted">${esc(meta)}</p>
        </header>
        ${stepper(step)}
        ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
        <footer class="meeting-foot">
          <button class="link-btn" data-open-folder>Открыть папку с файлами</button>
          <button class="link-btn link-danger" data-delete ${d.meeting.recording ? "hidden" : ""}>Удалить запись</button>
        </footer>
      </article>`;
    bind();
  };

  // ── Действия ──────────────────────────────────────────────────────────────

  const transcribe = async () => {
    if (job) return;
    job = { kind: "transcribe", done: 0, total: 0 };
    jobError = null;
    draw();
    try {
      const r = await api.transcribe(id);
      d.note = r.note;
      d.meeting = (await api.getMeeting(id)).meeting;
      tab = TRANSCRIPT;
      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;
    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);
      } 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<HTMLButtonElement>("[data-tab]").forEach((b) =>
      b.addEventListener("click", () => {
        tab = b.dataset.tab!;
        editing = false;
        jobError = null;
        draw();
      }),
    );
    root.querySelector<HTMLButtonElement>("[data-make]")?.addEventListener("click", (e) => {
      const heading = (e.currentTarget as HTMLButtonElement).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<HTMLFormElement>("[data-ask]");
    ask?.querySelectorAll<HTMLButtonElement>("[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<HTMLTextAreaElement>("[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<HTMLInputElement>("[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 }) => {
      if (meeting_id !== id || job?.kind !== "transcribe") return;
      job = { kind: "transcribe", done, total };
      const bar = root.querySelector<HTMLElement>(".progress > span");
      const note = root.querySelector(".card .small.muted");
      if (bar && total) bar.style.width = `${Math.round((done / total) * 100)}%`;
      if (note) note.textContent = `${total ? `Готово ${done} из ${total} кусков` : "В записи не нашлось речи"}. Можно уйти с экрана — расшифровка продолжится.`;
    }),
  );
  // Запись остановили из шапки или с главной — шаг меняется.
  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));
}