skillok meeting
996ce529a8d705e470aea24a7b7fb9a19578f1e4
Перейти к файлу
Код
meeting srcviewsnote.ts
anc Интерфейс: список встреч слева, заметка справа - Боковая панель: «Записать», «Добавить аудио» (и перетаскивание файла), «Новая заметка», список встреч. - Экран встречи: название правится на месте, шаги «Расшифровать» и команды ИИ с ходом работы, панель записи с уровнями и текстом предупреждения для чата Zoom. - Заметка: «Текст» / «Правка», автосохранение, «Копировать всё», «Как текст», копирование раздела, живые галочки задач. - Сообщения внизу экрана вместо ошибок в тексте страницы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 996ce52 30 сентября 2026 г.
note.ts
// Экран встречи: заметка в Markdown и шаги над ней.
//
//   1. Звук — записан или добавлен файлом.
//   2. «Расшифровать» — расшифровка ложится в заметку.
//   3. Команды ИИ — «Конспект», «Задачи», «Ревью» или свой запрос; ответ
//      дописывается в конец заметки.
//
// Всё, что в заметке, можно править и копировать: целиком или по разделам.

import {
  api, elapsed, events, formatDate, formatDuration, meetingDuration,
  type Command, type MeetingDetail, type Speaker,
} from "../api";
import { $, esc } from "../dom";
import { plain, render, sections, toggleTask } from "../markdown";
import type { Scope } from "../scope";
import { changed, copy, onChange, state, toast } from "../state";
import { refreshSidebar, stopRecording } from "./sidebar";

type Mode = "view" | "edit";
let mode: Mode = "view";

const SAVE_DELAY_MS = 600;

function audioLine(d: MeetingDetail): string {
  const m = d.meeting;
  if (m.recording) return "идёт запись";
  if (!m.audio.length) return "без звука";
  const who = (s: Speaker) => (s.kind === "me" ? "я" : s.kind === "others" ? "собеседники" : "");
  const names = m.audio.map((a) => (a.speaker.kind === "unknown" ? a.file : who(a.speaker))).join(", ");
  return `звук: ${names}`;
}

function recordingPanel(d: MeetingDetail, warning: string): string {
  const r = state.recording;
  if (!r || r.meeting_id !== d.meeting.id) return "";
  const meter = (label: string, key: string) => `
    <div class="meter"><span class="meter-label small">${label}</span>
    <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span></div>`;
  return `
    <section class="panel panel-live">
      <p class="panel-kicker"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
        r.paused ? "Запись на паузе" : r.zoom ? "Записываю Zoom и микрофон" : "Записываю микрофон — Zoom не найден"
      }</p>
      <p class="panel-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
      <div class="meters">${meter("Я", "me")}${r.zoom ? meter("Собеседники", "others") : ""}</div>
      <p class="small muted">Предупредите участников о записи:
        <button class="link-btn" data-copy-warning>скопировать текст для чата Zoom</button></p>
      <div class="actions">
        <button class="ui-btn" data-stop>Остановить</button>
        <button class="link-btn" data-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
      </div>
      <template data-warning>${esc(warning)}</template>
    </section>`;
}

function steps(d: MeetingDetail): string {
  const m = d.meeting;
  if (m.recording) return "";
  const hasAudio = m.audio.length > 0;
  const transcribe = hasAudio
    ? m.transcribed_at
      ? `<button class="link-btn" data-transcribe>Расшифровать заново</button>`
      : `<button class="ui-btn" data-transcribe>Расшифровать</button>`
    : "";
  return `
    <section class="steps">
      ${
        hasAudio && !m.transcribed_at
          ? `<div class="step-row">${transcribe}<span class="small muted" data-progress>${
              meetingDuration(m) >= 10 * 60_000 ? `Звук ${formatDuration(meetingDuration(m))} — расшифровка займёт несколько минут.` : ""
            }</span></div>`
          : ""
      }
      <div class="step-row ai-row">
        <span class="small muted">ИИ:</span>
        <button class="chip" data-cmd="summary">Конспект</button>
        <button class="chip" data-cmd="tasks">Задачи</button>
        <button class="chip" data-cmd="review">Ревью</button>
        ${hasAudio && m.transcribed_at ? `<span class="spacer"></span>${transcribe}` : ""}
      </div>
      <form class="ask" data-ask>
        <input class="input" name="text" autocomplete="off" placeholder="Или своё: «напиши письмо клиенту по итогам», «выпиши все цифры»" aria-label="Что сделать с заметкой">
        <button class="chip" type="submit">Сделать</button>
      </form>
      <p class="small muted" data-busy hidden></p>
    </section>`;
}

function viewHtml(note: string): string {
  const parts = sections(note);
  if (!parts.length) {
    return `<div class="note-empty">
      <p>Заметка пустая.</p>
      <p class="small muted">Расшифруйте звук, попросите ИИ или <button class="link-btn" data-to-edit>напишите сами</button>.</p>
    </div>`;
  }
  return parts
    .map(
      (s, i) => `
      <section class="note-section" data-section="${i}">
        <button class="section-copy link-btn" data-copy-section="${i}" title="Копировать раздел в Markdown">Копировать</button>
        <div class="md">${render(s.source)}</div>
      </section>`,
    )
    .join("");
}

export async function renderNote(root: HTMLElement, scope: Scope, id: string): Promise<void> {
  const [d, view] = await Promise.all([api.getMeeting(id), api.getSettings()]);
  if (!scope.alive) return;
  let note = d.note;
  let dirty = false;
  let saveTimer = 0;
  let working = false;

  const draw = () => {
    // Перерисовка — только когда работа закончилась; флаг снимается здесь,
    // иначе после удачной расшифровки следующая команда молча не запускалась бы.
    working = false;
    root.innerHTML = `
      <article class="note">
        <header class="note-head">
          <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название встречи" spellcheck="false">
          <p class="small muted">${esc(formatDate(d.meeting.created_at))}${
            formatDuration(meetingDuration(d.meeting)) ? ` · ${esc(formatDuration(meetingDuration(d.meeting)))}` : ""
          } · ${esc(audioLine(d))}</p>
        </header>
        ${recordingPanel(d, view.settings.warning_text)}
        ${steps(d)}
        <section class="editor">
          <div class="editor-bar">
            <nav class="ui-underline-nav" aria-label="Вид заметки">
              <a href="#" data-mode="view"${mode === "view" ? ` aria-current="page"` : ""}>Текст</a>
              <a href="#" data-mode="edit"${mode === "edit" ? ` aria-current="page"` : ""}>Правка</a>
            </nav>
            <span class="xs muted" data-save-state></span>
            <span class="spacer"></span>
            <button class="link-btn" data-copy-all>Копировать всё</button>
            <button class="link-btn" data-copy-plain title="Без разметки — для мессенджера или письма">Как текст</button>
          </div>
          ${
            mode === "edit"
              ? `<textarea class="note-editor" data-editor spellcheck="true" placeholder="Пишите в Markdown: # заголовок, - список, **жирный**">${esc(note)}</textarea>`
              : `<div class="note-view" data-view>${viewHtml(note)}</div>`
          }
        </section>
        <footer class="note-foot"><button class="link-btn link-danger" data-delete>Удалить встречу</button></footer>
      </article>`;
    bind();
  };

  const saveState = (text: string) => {
    const el = root.querySelector("[data-save-state]");
    if (el) el.textContent = text;
  };

  const flush = async () => {
    window.clearTimeout(saveTimer);
    if (!dirty) return;
    dirty = false;
    try {
      await api.saveNote(id, note);
      saveState("Сохранено");
    } catch (e) {
      dirty = true;
      saveState("Не сохранено");
      toast(e);
    }
  };

  const setBusy = (text: string | null) => {
    working = text !== null;
    const el = root.querySelector<HTMLElement>("[data-busy]");
    if (el) {
      el.hidden = text === null;
      el.textContent = text ?? "";
    }
    root.querySelectorAll<HTMLButtonElement>("[data-transcribe], [data-cmd], [data-ask] button").forEach((b) => (b.disabled = working));
    const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]");
    if (editor) editor.readOnly = working;
  };

  const runCommand = async (command: Command, label: string) => {
    if (working) return;
    await flush();
    setBusy(`${label}: ИИ читает заметку и пишет ответ…`);
    try {
      note = await api.runCommand(id, command);
      mode = "view";
      draw();
      root.querySelector(".note-section:last-child")?.scrollIntoView({ behavior: "smooth", block: "start" });
    } catch (e) {
      setBusy(null);
      toast(e);
    }
  };

  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);
        await refreshSidebar();
      } catch (e) {
        title.value = d.meeting.title;
        toast(e);
      }
    });

    root.querySelectorAll<HTMLAnchorElement>("[data-mode]").forEach((a) =>
      a.addEventListener("click", async (e) => {
        e.preventDefault();
        await flush();
        mode = a.dataset.mode as Mode;
        draw();
        if (mode === "edit") root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
      }),
    );
    root.querySelector("[data-to-edit]")?.addEventListener("click", () => {
      mode = "edit";
      draw();
      root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
    });

    const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]");
    editor?.addEventListener("input", () => {
      note = editor.value;
      dirty = true;
      saveState("Изменено…");
      window.clearTimeout(saveTimer);
      saveTimer = window.setTimeout(flush, SAVE_DELAY_MS);
    });

    root.querySelector("[data-copy-all]")?.addEventListener("click", () => copy(note, "Заметка скопирована в Markdown"));
    root.querySelector("[data-copy-plain]")?.addEventListener("click", () => copy(plain(note), "Заметка скопирована текстом"));
    root.querySelectorAll<HTMLButtonElement>("[data-copy-section]").forEach((b) =>
      b.addEventListener("click", () => {
        const s = sections(note)[Number(b.dataset.copySection)];
        if (s) copy(s.source, s.heading ? `Раздел «${s.heading}» скопирован` : "Скопировано");
      }),
    );

    // Галочки задач в режиме просмотра — живые: отметка пишется в Markdown.
    root.querySelectorAll<HTMLInputElement>("[data-view] input[type=checkbox]").forEach((box, n) => {
      box.disabled = working;
      box.addEventListener("change", async () => {
        note = toggleTask(note, n);
        dirty = true;
        await flush();
      });
    });

    root.querySelector("[data-transcribe]")?.addEventListener("click", async () => {
      if (working) return;
      if (d.meeting.transcribed_at && !confirm("Расшифровать заново? Новая расшифровка добавится в конец заметки, старая останется.")) return;
      await flush();
      setBusy("Готовлю звук…");
      try {
        const result = await api.transcribe(id);
        note = result.note;
        d.meeting = (await api.getMeeting(id)).meeting;
        mode = "view";
        draw();
        if (result.failed) toast(`Не распознано кусков: ${result.failed}. ${result.last_error ?? ""}`);
        await refreshSidebar();
      } catch (e) {
        setBusy(null);
        toast(e);
      }
    });

    root.querySelectorAll<HTMLButtonElement>("[data-cmd]").forEach((b) =>
      b.addEventListener("click", () => runCommand({ kind: b.dataset.cmd as "summary" | "tasks" | "review" }, b.textContent ?? "")),
    );
    root.querySelector<HTMLFormElement>("[data-ask]")?.addEventListener("submit", (e) => {
      e.preventDefault();
      const input = (e.currentTarget as HTMLFormElement).elements.namedItem("text") as HTMLInputElement;
      const text = input.value.trim();
      if (!text) {
        input.focus();
        return;
      }
      runCommand({ kind: "custom", text }, "Свой запрос");
    });

    root.querySelector("[data-stop]")?.addEventListener("click", async () => {
      await stopRecording();
      d.meeting = (await api.getMeeting(id)).meeting;
      draw();
    });
    root.querySelector("[data-pause]")?.addEventListener("click", async () => {
      if (!state.recording) return;
      try {
        await api.recorderPause(!state.recording.paused);
        state.recording.paused = !state.recording.paused;
        changed();
      } catch (e) {
        toast(e);
      }
    });
    root.querySelector("[data-copy-warning]")?.addEventListener("click", () =>
      copy(view.settings.warning_text, "Текст предупреждения скопирован"),
    );

    root.querySelector("[data-delete]")?.addEventListener("click", async () => {
      if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком? Это не отменить.`)) return;
      try {
        await api.deleteMeeting(id);
        await refreshSidebar();
        location.hash = "#/";
      } catch (e) {
        toast(e);
      }
    });
  };

  draw();

  scope.on(
    events.progress(({ meeting_id, done, total }) => {
      if (meeting_id !== id) return;
      setBusy(total ? `Расшифровываю: ${done} из ${total} кусков` : "В записи не нашлось речи");
    }),
  );
  scope.on(
    events.recorder(({ meeting_id, event }) => {
      if (meeting_id !== id) return;
      if (event.kind === "level") {
        const key = event.data.speaker.kind === "me" ? "me" : "others";
        const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`);
        if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
      } else {
        toast(`Дорожка «${event.data.speaker.kind === "me" ? "я" : "собеседники"}» перестала писаться: ${event.data.error}`);
      }
    }),
  );
  // Запись поставили на паузу или остановили из боковой панели.
  const off = onChange(async () => {
    if (!scope.alive) return;
    const wasRecording = d.meeting.recording;
    if (wasRecording && !state.recording) d.meeting = (await api.getMeeting(id)).meeting;
    if (mode === "view" || wasRecording) draw();
  });
  scope.on(Promise.resolve(off));
  scope.every(1000, () => {
    const t = root.querySelector("[data-timer]");
    if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
  });
  // Уходя с экрана, не терять набранное.
  scope.on(Promise.resolve(() => void flush()));
  window.addEventListener("beforeunload", () => void flush(), { once: true });
}