skillok meeting
393887285dfc8accae94edba2c7c575b098e705e
Перейти к файлу
Код
meeting srcviewsmeeting.ts
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 ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`;
}

/** Реплики строками таблицы; `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 `
        <div class="tr-row">
          <span class="tr-time mono-num">${esc(time)}</span>
          <div class="tr-text">
            ${who ? `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>` : ""}
            <p>${esc(text)}</p>
          </div>
        </div>`;
    })
    .join("");
}

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 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 `<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="live">
          <div class="live-head">
            <p class="live-title"><span class="spinner"></span>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}
              <span class="mono-num live-pct" data-pct>${pct}%</span></p>
            <button class="link-btn" data-cancel>Отменить</button>
          </div>
          <div class="progress"><span style="width:${pct}%"></span></div>
          <p class="xs muted" data-progress-note>${progressNote(job.done, job.total)}</p>
          <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div>
            <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></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 nextStep = () => {
    const todo = PRESETS.filter((p) => !section(p.heading));
    if (!hasKey) return "";
    return `
      <div class="next">
        <span class="next-label">Дальше</span>
        ${todo.map((p) => `<button class="ui-btn ui-btn-sm" data-make="${esc(p.heading)}" ${job ? "disabled" : ""}>${esc(p.heading)}</button>`).join("")}
        <button class="ui-btn ui-btn-sm" data-tab="${esc(CUSTOM_TAB)}">Свой запрос</button>
      </div>`;
  };
  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-tools">
          <p class="working small"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
          <span class="spacer"></span>
          <button class="link-btn" data-cancel>Отменить</button>
        </div>
        <div class="pane-body"><div class="md" data-answer>${liveAnswer ? render(liveAnswer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></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 !== "";
    const body = sectionBody(s.source);
    return `
      <div class="pane-tools">
        <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="pane-body" data-view>${isTranscript ? transcriptView(body) : `<div class="md">${render(body)}</div>`}</div>
      ${isTranscript ? nextStep() : ""}`;
  };

  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 = () => {
    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 = `
      <article class="meeting">
        <header class="bar">
          <div class="bar-title">
            <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
            <p class="xs muted">${esc(meta)}</p>
          </div>
          ${
            copyable
              ? `<div class="bar-tools">
                   <button class="ui-btn ui-btn-sm" data-copy>Копировать</button>
                   <button class="link-btn" data-copy-plain>как текст</button>
                 </div>`
              : ""
          }
        </header>
        ${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;
    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<HTMLButtonElement>("[data-tab]").forEach((b) =>
      b.addEventListener("click", () => {
        tab = b.dataset.tab!;
        editing = false;
        jobError = null;
        draw();
      }),
    );
    root.querySelectorAll<HTMLButtonElement>("[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<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, 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<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
      }
      const pct = total ? Math.round((done / total) * 100) : 0;
      const bar = root.querySelector<HTMLElement>(".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));
}