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) => `
${label}
`;
return `
${
r.paused ? "Запись на паузе" : r.zoom ? "Записываю Zoom и микрофон" : "Записываю микрофон — Zoom не найден"
}
${elapsed(r.started_at)}
${meter("Я", "me")}${r.zoom ? meter("Собеседники", "others") : ""}
Предупредите участников о записи:
скопировать текст для чата Zoom
Остановить
${r.paused ? "Продолжить" : "Пауза"}
${esc(warning)}
`;
}
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
? `Расшифровать заново`
: `Расшифровать`
: "";
return `
${
hasAudio && !m.transcribed_at
? `${transcribe}${
meetingDuration(m) >= 10 * 60_000 ? `Звук ${formatDuration(meetingDuration(m))} — расшифровка займёт несколько минут.` : ""
}`
: ""
}
ИИ:
Конспект
Задачи
Ревью
${hasAudio && m.transcribed_at ? `${transcribe}` : ""}
Сделать
`;
}
function viewHtml(note: string): string {
const parts = sections(note);
if (!parts.length) {
return `
Заметка пустая.
Расшифруйте звук, попросите ИИ или напишите сами.
`;
}
return parts
.map(
(s, i) => `
Копировать
${render(s.source)}
`,
)
.join("");
}
export async function renderNote(root: HTMLElement, scope: Scope, id: string): Promise {
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 = `
${esc(formatDate(d.meeting.created_at))}${
formatDuration(meetingDuration(d.meeting)) ? ` · ${esc(formatDuration(meetingDuration(d.meeting)))}` : ""
} · ${esc(audioLine(d))}
${recordingPanel(d, view.settings.warning_text)}
${steps(d)}
Текст
Правка
Копировать всё
Как текст
${
mode === "edit"
? `${esc(note)}`
: `${viewHtml(note)}`
}
Удалить встречу
`;
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("[data-busy]");
if (el) {
el.hidden = text === null;
el.textContent = text ?? "";
}
root.querySelectorAll("[data-transcribe], [data-cmd], [data-ask] button").forEach((b) => (b.disabled = working));
const editor = root.querySelector("[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("[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("[data-editor]")?.focus();
}),
);
root.querySelector("[data-to-edit]")?.addEventListener("click", () => {
mode = "edit";
draw();
root.querySelector("[data-editor]")?.focus();
});
const editor = root.querySelector("[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("[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("[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("[data-cmd]").forEach((b) =>
b.addEventListener("click", () => runCommand({ kind: b.dataset.cmd as "summary" | "tasks" | "review" }, b.textContent ?? "")),
);
root.querySelector("[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(`[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 });
}