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 г.
home.ts
// Главная: записать встречу или добавить файл, ниже — все записи с поиском,
// как список репозиториев на главной skillok.org.
import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api";
import { esc } from "../dom";
import { bindRecording, recordingPanel } from "../recording";
import type { Scope } from "../scope";
import { addAudio, onChange, startRecording, state } from "../state";
function hero(view: SettingsView): string {
if (state.recording) {
return `${recordingPanel(view.settings.warning_text)}`;
}
const pick = `выберите файл`;
if (!view.capture_supported) {
return `
Добавьте запись встречи
На этой системе запись не поддерживается. Перетащите сюда аудиофайл — mp3, m4a, wav и другие.
Выбрать файл
`;
}
return `
Запишите встречу
Zoom, Google Meet, Телемост — что угодно. Запишутся собеседники и вы, потом — расшифровка, конспект и задачи.
Записать
Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.
Уже есть запись? Перетащите аудиофайл сюда или ${pick}.
mp3, m4a, wav, aiff, flac, ogg
`;
}
function status(m: Meeting): string {
if (m.recording) return `Идёт запись`;
if (!m.audio.length) return `Нет звука`;
if (!m.transcribed_at) return `Не расшифровано`;
return `Расшифровано`;
}
function list(meetings: Meeting[]): string {
if (!meetings.length) return "";
return `
Записи
${meetings
.map((m) => {
const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
return `
${esc(m.title)}
${esc(meta)}
${status(m)}
`;
})
.join("")}
Ничего не нашлось.
`;
}
export async function renderHome(root: HTMLElement, scope: Scope): Promise {
const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]);
if (!scope.alive) return;
const draw = () => {
root.innerHTML = `
${hero(view)}
${
view.openrouter_key
? ""
: `Чтобы расшифровывать записи, подключите ИИ — это минута.`
}
${list(meetings)}
`;
root.querySelector("[data-filter]")?.addEventListener("input", (e) => {
const q = (e.target as HTMLInputElement).value.trim().toLowerCase();
let shown = 0;
root.querySelectorAll("[data-row]").forEach((row) => {
row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q);
if (!row.hidden) shown++;
});
root.querySelector("[data-nothing]")!.hidden = shown > 0;
});
root.querySelector("[data-record]")?.addEventListener("click", startRecording);
root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
// После «Остановить» переход к записи делает подписка ниже.
bindRecording(root, scope, view.settings.warning_text, () => undefined);
};
draw();
// Запись остановили или поставили на паузу — карточка меняется.
let last = JSON.stringify(state.recording);
const off = onChange(async () => {
const now = JSON.stringify(state.recording);
if (now === last || !scope.alive) return;
const stoppedId = last && !state.recording ? (JSON.parse(last) as { meeting_id: string }).meeting_id : null;
last = now;
if (stoppedId) {
location.hash = `#/m/${encodeURIComponent(stoppedId)}`;
return;
}
draw();
});
scope.on(Promise.resolve(off));
}