anc
Простой путь: записать → расшифровать → получить результат
- Главная: «Записать» (или аудиофайл перетаскиванием) и записи со статусом.
Боковая панель и «Новая заметка» убраны.
- Экран записи: шаги «Аудио → Расшифровка → Результат», на экране только
текущий шаг и одна главная кнопка.
- Результаты вкладками: «Расшифровка», «Конспект», «Задачи», «Ревью»,
«Свой запрос»; копировать, как текст, править, сделать заново, удалить.
Повтор команды заменяет раздел, а не дописывает второй.
- Задача #9: без ключа кнопки ведут к подключению ИИ; у расшифровки и
ответа ИИ — прогресс и «Отменить» (CancellationToken в ядре); идущая
запись видна меткой в шапке на любом экране.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e75bd24
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 `
${order
.map((s, i) => {
const cls = i < at ? "is-done" : i === at ? "is-current" : "";
return `${i < at ? "✓" : i + 1}${names[s]}`;
})
.join("")}
`;
}
export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise {
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 `${recordingPanel(view.settings.warning_text)}`;
}
return `
В этой записи нет звука
Она осталась от прежней версии программы. Её можно удалить.
`;
};
// ── Шаг 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 `
Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}
${job.total ? `Готово ${job.done} из ${job.total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.
Отменить
`;
}
if (!hasKey) {
return `
Подключите ИИ, чтобы расшифровать
Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.
Подключить
`;
}
return `
Расшифруйте запись
${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
}.
${jobError ? `${esc(jobError)}` : ""}
Расшифровать
`;
};
// ── Шаг 3: результат ──────────────────────────────────────────────────────
const tabContent = () => {
const busyHere = job?.kind === "command" && job.tab === tab;
const busyElsewhere = job !== null && !busyHere;
const errorLine = jobError && !job ? `${esc(jobError)}` : "";
if (busyHere) {
const preset = PRESETS.find((p) => p.heading === tab);
return `
${esc(preset?.working ?? "ИИ отвечает…")}
Отменить
`;
}
if (tab === CUSTOM_TAB) {
return `
Что сделать по этой встрече?
Ещё примеры:
выпиши все цифры и даты,
какие риски прозвучали,
составь повестку следующей встречи.
${errorLine}
${
hasKey
? `Сделать`
: `Подключить ИИ`
}
`;
}
const s = section(tab);
const preset = PRESETS.find((p) => p.heading === tab);
if (!s) {
return `
${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}
${esc(preset?.about ?? "")}
${errorLine}
${
!hasKey
? `Подключить ИИ`
: `${esc(preset?.action ?? "Сделать")}`
}
${busyElsewhere ? `Сначала дождитесь, пока закончится текущая работа.` : ""}
`;
}
if (editing) {
return `
${esc(sectionBody(s.source))}
Сохранить
Отмена
Markdown: **жирный**, - список, - [ ] задача
`;
}
const isTranscript = tab === TRANSCRIPT;
const remakeable = !isTranscript && s.heading !== "";
return `
Копировать
Как текст
Править
${
isTranscript
? `Расшифровать заново`
: remakeable
? `Сделать заново
Удалить`
: ""
}
${errorLine}
${render(sectionBody(s.source))}`;
};
const resultStep = () => `
${tabs()
.map((t) => {
const ready = t === CUSTOM_TAB || Boolean(section(t));
return `${esc(t)}`;
})
.join("")}
${tabContent()}
`;
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 = `
← Все записи
${esc(meta)}
${stepper(step)}
${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
Удалить запись
`;
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("[data-tab]").forEach((b) =>
b.addEventListener("click", () => {
tab = b.dataset.tab!;
editing = false;
jobError = null;
draw();
}),
);
root.querySelector("[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("[data-ask]");
ask?.querySelectorAll("[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("[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("[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-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(".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));
}