skillok meeting

Интерфейс: список встреч слева, заметка справа

- Боковая панель: «Записать», «Добавить аудио» (и перетаскивание файла), «Новая заметка», список встреч. - Экран встречи: название правится на месте, шаги «Расшифровать» и команды ИИ с ходом работы, панель записи с уровнями и текстом предупреждения для чата Zoom. - Заметка: «Текст» / «Правка», автосохранение, «Копировать всё», «Как текст», копирование раздела, живые галочки задач. - Сообщения внизу экрана вместо ошибок в тексте страницы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 30 сентября 2026 г. 996ce529a8d705e470aea24a7b7fb9a19578f1e4
16 файлов
  • README.md +103 −179
    Показать разницуСкрыть разницу
    2 2
    3 3 Рабочее название. Продукт Skillok.
    4 4
    5 −Программа для Windows, которая работает на компьютере пользователя. Сама включает запись,
    6 −когда начинается встреча в Zoom, по ходу ведёт расшифровку, а после встречи делает
    7 −конспект, ревью, решения и задачи. Итог можно сохранить в свой репозиторий на skillok.org.
    5 +Программа для Windows: встреча → заметка. Звук встречи записывается по кнопке или
    6 +добавляется готовым файлом, по кнопке расшифровывается, а дальше ИИ делает с заметкой
    7 +то, что попросите: конспект, задачи, ревью или что угодно своё. Заметка — обычный
    8 +Markdown: её можно править и копировать.
    9 +
    10 +**Программа ничего не делает сама.** Не следит за Zoom, не висит в трее, не запускается
    11 +вместе с Windows. Открыли — сделали — закрыли.
    8 12
    9 13 **Нашего сервера нет.** Пользователь вводит свой ключ OpenRouter — и всё. Звук и
    10 −расшифровки лежат у него на диске. Наружу уходят только запросы к моделям по его
    11 −собственному ключу.
    14 +заметки лежат у него на диске; наружу уходят только запросы к моделям по его ключу.
    12 15
    13 16 ---
    14 17
    15 −## Что делает
    16 −
    17 −1. **Замечает, что началась встреча в Zoom, и включает запись.** Кнопку жать не нужно.
    18 −2. **Ведёт расшифровку по ходу встречи.** Реплики разделены на «я» (микрофон) и
    19 − «собеседники» (звук Zoom).
    20 −3. **Каждые несколько минут обновляет живой конспект.**
    21 −4. **После встречи делает разбор:**
    22 − - краткое содержание;
    23 − - ревью встречи: чего достигли, что осталось открытым, где ушли в сторону;
    24 − - решения;
    25 − - задачи с исполнителем и сроком, если они прозвучали;
    26 − - вопросы на следующую встречу.
    27 −5. **Сохраняет итог в репозиторий на skillok.org** — одним нажатием или автоматически.
    18 +## Как пользоваться
    19 +
    20 +1. **Звук.**
    21 + - «Записать» — пишется звук Zoom и микрофон, пока не нажмёте «Остановить».
    22 + Zoom нужно запустить до нажатия, иначе запишется только микрофон.
    23 + - «Добавить аудио» или просто перетащить файл в окно: mp3, m4a, wav, aiff, flac, ogg —
    24 + с диктофона, из облачной записи Zoom, откуда угодно.
    25 +2. **«Расшифровать».** Расшифровка ложится в заметку разделом «Расшифровка»: время,
    26 + говорящий («Я», «Собеседники» — у записи; у файла говорящих нет), текст.
    27 +3. **ИИ.** Кнопки «Конспект», «Задачи», «Ревью» или своя просьба в поле рядом
    28 + («напиши письмо клиенту по итогам», «выпиши все цифры»). Ответ дописывается в конец
    29 + заметки своим разделом. ИИ читает заметку целиком — с вашими правками.
    30 +4. **Заметка.**
    31 + - «Текст» — просмотр, «Правка» — Markdown; изменения сохраняются сами.
    32 + - «Копировать всё» — в Markdown, «Как текст» — без разметки, для мессенджера.
    33 + - У каждого раздела своя «Копировать».
    34 + - Галочки задач отмечаются прямо в просмотре.
    28 35
    29 36 ---
    30 37
    31 −## Как это работает
    38 +## Как это устроено
    32 39
    33 40 ```
    34 − Zoom.exe ──звук──┐
    35 − ├─► захват (WASAPI) ─► куски 6–28 с ──► OpenRouter: Whisper ─► расшифровка
    36 − микрофон ────────┘ │
    37 − ▼
    38 − OpenRouter: Claude ◄── конспект / разбор
    39 − │
    40 − диск пользователя ◄─────────────────────────┤
    41 − skillok.org (репозиторий) ◄── по желанию ───┘
    41 + «Записать»: Zoom.exe ──┐
    42 + микрофон ──┴─► audio/others.wav, audio/me.wav
    43 + «Добавить аудио»: файл ──────────► audio/<файл>
    44 +
    45 + «Расшифровать»: дорожки ─► 16 кГц моно ─► куски 6–28 с по паузам ─► OpenRouter: Whisper
    46 + (до 4 кусков сразу) │
    47 + ▼
    48 + note.md: ## Расшифровка
    49 +
    50 + Команда ИИ: note.md ─► OpenRouter: Claude ─► note.md: ## Задачи / ## Конспект / …
    42 51 ```
    43 52
    44 −### Захват звука Zoom
    53 +### Запись звука Zoom (Windows)
    45 54
    46 55 - Звук берём **только от процесса Zoom** (process loopback) и отдельно с микрофона.
    47 56 Системные звуки и музыка в запись не попадают.
    48 57 См. [Application loopback sample](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/)
    49 58 и [ActivateAudioInterfaceAsync](https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-activateaudiointerfaceasync).
    50 59 Нужна Windows 10 сборки 20438 или новее.
    51 −- Встреча идёт, пока у `Zoom.exe` есть активная звуковая сессия вывода: во время встречи Zoom
    52 − держит поток открытым, даже когда все молчат. Началом считаем 4 секунды такой сессии
    53 − (звук уведомления — не встреча), концом — 20 секунд без неё (переподключение не рвёт запись).
    54 −- API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на любой
    55 − встрече и на любом плане Zoom, в том числе бесплатном и на чужой встрече.
    60 +- Какой процесс писать, определяется в момент нажатия «Записать»: `Zoom.exe` с открытым
    61 + потоком звука, то есть идущей встречей.
    62 +- API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на
    63 + любой встрече и на любом плане Zoom.
    56 64
    57 65 ### Расшифровка — Whisper через OpenRouter
    58 66
    59 −- Эндпоинт `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для
    60 − текстовых моделей. Модели: `openai/whisper-large-v3-turbo` (по умолчанию),
    61 − `openai/whisper-large-v3`, `openai/whisper-1`.
    67 +- `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для текстовых
    68 + моделей. По умолчанию `openai/whisper-large-v3-turbo`.
    62 69 См. [документацию OpenRouter STT](https://openrouter.ai/docs/guides/overview/multimodal/stt).
    63 −- Эндпоинт принимает файл целиком, потокового режима нет. Поэтому звук режем на куски
    64 − от 6 до 28 секунд по паузам в речи, куски без речи не отправляем. Расшифровка отстаёт от живой речи на длину куска
    65 − плюс время ответа.
    66 −- Дорожки «я» и «собеседники» распознаём раздельно. Так реплики подписаны без узнавания
    67 − людей по голосу.
    68 −
    69 −### Разбор — Claude через OpenRouter
    70 −
    71 −| Задача | Модель по умолчанию | Когда |
    72 −|---|---|---|
    73 −| Живой конспект | Claude Haiku 4.5 | каждые несколько минут |
    74 −| Итоговый разбор: ревью, решения, задачи | Claude Opus 5.5 | после встречи |
    75 −
    76 −- Модели можно поменять в настройках на любую из доступных в OpenRouter.
    77 −- Разбор приходит JSON строго по схеме: `summary`, `review`, `decisions[]`,
    78 − `tasks[]` (текст, исполнитель, срок, цитата-основание), `open_questions[]`.
    79 − Из JSON программа собирает Markdown.
    80 −- У каждой задачи есть ссылка на момент расшифровки, где она прозвучала.
    81 −
    82 −### Сохранение в репозиторий Skillok
    83 −
    84 −- Пользователь создаёт токен доступа на skillok.org (Settings → Access tokens)
    85 − и выбирает репозиторий.
    86 −- Программа кладёт файлы через API Skillok:
    87 − - `POST /api/v1/repositories/{id}/files` — создать файл;
    88 − - `PUT /api/v1/repositories/{id}/files/{path}` — обновить файл;
    89 − - заголовок `Authorization: Bearer <токен>`.
    90 −- Раскладка в репозитории:
    91 −
    92 − ```
    93 − meetings/2026-10-01-планёрка/
    94 − summary.md — конспект, ревью, решения, задачи
    95 − transcript.md — расшифровка с временем
    96 − ```
    97 −
    98 −- Звук в репозиторий не уходит.
    99 −
    100 −---
    70 +- Файл любой длины режется на куски по паузам: у эндпоинта предел 25 МБ и около минуты
    71 + обработки на запрос. Куски без речи не отправляются — это дешевле, и Whisper не
    72 + выдумывает фраз на тишине. Такие фразы («Продолжение следует…») дополнительно отсеиваются.
    73 +- Неудачный кусок повторяется один раз; не распознавшийся остаётся в расшифровке пометкой.
    101 74
    102 −## Интерфейс — как у skillok.org
    103 −
    104 −Та же система, что на skillok.org:
    105 −
    106 −- бумажный фон, чернильный текст, терракотовый акцент;
    107 −- шрифт iA Writer Quattro S, JetBrains Mono только для кода и времени в расшифровке;
    108 −- кегль только из шкалы, не меньше 12px;
    109 −- квадратные углы;
    110 −- кнопки контурные, без заливки.
    75 +### Команды ИИ — Claude через OpenRouter
    111 76
    112 −Токены стиля берём из дизайн-системы Skillok, новых цветов не заводим.
    113 −
    114 −**Экраны:**
    115 −
    116 −- **Встречи** — список: дата, длительность, статус разбора.
    117 −- **Встреча** — вкладки «Расшифровка», «Конспект», «Задачи», кнопка «Сохранить в Skillok».
    118 −- **Идёт запись** — маленькое окно или значок в трее: время, уровень звука, стоп и пауза.
    119 −- **Настройки:**
    120 − - ключ OpenRouter;
    121 − - модели;
    122 − - папка хранения;
    123 − - токен Skillok и репозиторий;
    124 − - автосохранение;
    125 − - текст предупреждения участникам.
    77 +- По умолчанию `anthropic/claude-opus-5.5`; модель меняется в настройках.
    78 +- Промпты — файлами в `prompts/`: общий `base.md` и по одному на команду.
    79 +- Ответ — Markdown без заголовка: заголовок раздела ставит программа.
    126 80
    127 81 ---
    128 82
    129 −## Стек
    130 −
    131 −**Tauri 2**: ядро на Rust, интерфейс на HTML/CSS/TS.
    132 −
    133 −- Интерфейс в HTML/CSS — поэтому стиль skillok.org переносится один в один.
    134 −- Ядро на Rust:
    135 − - захват звука через крейт `windows` (WASAPI, process loopback);
    136 − - разбивка звука на куски по паузам в речи;
    137 − - запросы к OpenRouter и Skillok.
    Разница обрезана: показаны первые 187 строк.
  • index.html +5 −2
    Показать разницуСкрыть разницу
    18 18 Skillok Meet
    19 19 </a>
    20 20 <nav class="header-nav" aria-label="Разделы">
    21 − <a href="#/" data-nav="home">Встречи</a>
    22 21 <a href="#/settings" data-nav="settings">Настройки</a>
    23 22 </nav>
    24 23 </header>
    25 − <main id="app" class="page"></main>
    24 + <div class="layout">
    25 + <aside id="sidebar" class="sidebar"></aside>
    26 + <main id="app" class="main"></main>
    27 + </div>
    28 + <p id="toast" class="toast" role="status" hidden></p>
    26 29 <script type="module" src="/src/main.ts"></script>
    27 30 </body>
    28 31 </html>
  • package-lock.json +41 −1
    Показать разницуСкрыть разницу
    8 8 "name": "skillok-meet",
    9 9 "version": "0.1.0",
    10 10 "dependencies": {
    11 − "@tauri-apps/api": "^2.12.0"
    11 + "@tauri-apps/api": "^2.12.0",
    12 + "@tauri-apps/plugin-dialog": "^2.8.0",
    13 + "dompurify": "^3.4.16",
    14 + "marked": "^18.0.14"
    12 15 },
    13 16 "devDependencies": {
    14 17 "@tauri-apps/cli": "^2.12.0",
    548 551 "node": ">= 10"
    549 552 }
    550 553 },
    554 + "node_modules/@tauri-apps/plugin-dialog": {
    555 + "version": "2.8.0",
    556 + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.0.tgz",
    557 + "integrity": "sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==",
    558 + "license": "MIT OR Apache-2.0",
    559 + "dependencies": {
    560 + "@tauri-apps/api": "^2.12.0"
    561 + }
    562 + },
    563 + "node_modules/@types/trusted-types": {
    564 + "version": "2.0.7",
    565 + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
    566 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
    567 + "license": "MIT",
    568 + "optional": true
    569 + },
    551 570 "node_modules/detect-libc": {
    552 571 "version": "2.1.2",
    553 572 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
    558 577 "node": ">=8"
    559 578 }
    560 579 },
    580 + "node_modules/dompurify": {
    581 + "version": "3.4.16",
    582 + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
    583 + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
    584 + "license": "(MPL-2.0 OR Apache-2.0)",
    585 + "optionalDependencies": {
    586 + "@types/trusted-types": "^2.0.7"
    587 + }
    588 + },
    561 589 "node_modules/fdir": {
    562 590 "version": "6.5.0",
    563 591 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
    864 892 "url": "https://opencollective.com/parcel"
    865 893 }
    866 894 },
    895 + "node_modules/marked": {
    896 + "version": "18.0.14",
    897 + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz",
    898 + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==",
    899 + "license": "MIT",
    900 + "bin": {
    901 + "marked": "bin/marked.js"
    902 + },
    903 + "engines": {
    904 + "node": ">= 20"
    905 + }
    906 + },
    867 907 "node_modules/nanoid": {
    868 908 "version": "3.3.19",
    869 909 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
  • package.json +4 −1
    Показать разницуСкрыть разницу
    11 11 "tauri": "tauri"
    12 12 },
    13 13 "dependencies": {
    14 − "@tauri-apps/api": "^2.12.0"
    14 + "@tauri-apps/api": "^2.12.0",
    15 + "@tauri-apps/plugin-dialog": "^2.8.0",
    16 + "dompurify": "^3.4.16",
    17 + "marked": "^18.0.14"
    15 18 },
    16 19 "devDependencies": {
    17 20 "@tauri-apps/cli": "^2.12.0",
  • src/api.ts +70 −73
    Показать разницуСкрыть разницу
    1 1 // Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
    2 2 // тем, что отдаёт serde на стороне Rust.
    3 3
    4 −export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "named"; name: string };
    4 +export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" };
    5 5
    6 −export interface Segment { start_ms: number; speaker: Speaker; text: string }
    7 −export interface Transcript { segments: Segment[] }
    8 −
    9 −export interface Usage { prompt_tokens: number; completion_tokens: number; cost: number | null }
    10 −
    11 −export interface AnalysisMeta { model: string; at: string; usage: Usage; tasks: number }
    12 −
    13 −export type MeetingState = "recording" | "done" | "interrupted";
    6 +export interface AudioTrack { file: string; speaker: Speaker; duration_ms: number }
    14 7
    15 8 export interface Meeting {
    16 9 id: string;
    17 10 title: string;
    18 11 created_at: string;
    19 − source: "import" | "recording";
    20 − duration_ms: number;
    21 − segments: number;
    22 − analysis: AnalysisMeta | null;
    23 − state: MeetingState;
    24 −}
    25 −
    26 −export interface Analysis {
    27 − title: string;
    28 − summary: string;
    29 − review: { achieved: string[]; open: string[]; off_track: string[]; verdict: string };
    30 − decisions: { text: string; source_segment: number }[];
    31 − tasks: { text: string; owner: string | null; due: string | null; quote: string; source_segment: number }[];
    32 − open_questions: string[];
    12 + updated_at: string;
    13 + audio: AudioTrack[];
    14 + recording: boolean;
    15 + transcribed_at: string | null;
    33 16 }
    34 17
    35 −export interface MeetingDetail {
    36 − meeting: Meeting;
    37 − transcript: Transcript;
    38 − analysis: Analysis | null;
    39 − live_summary: string | null;
    40 −}
    18 +export interface MeetingDetail { meeting: Meeting; note: string }
    41 19
    42 20 export interface Settings {
    43 − live_model: string;
    44 − analysis_model: string;
    21 + ai_model: string;
    45 22 stt_model: string;
    46 − warning_text: string;
    47 − always_warn_myself: boolean;
    48 23 language: string;
    49 − keep_audio: boolean;
    50 − launch_at_login: boolean;
    24 + warning_text: string;
    51 25 }
    52 26
    53 27 export interface SettingsView {
    57 31 capture_supported: boolean;
    58 32 }
    59 33
    60 −/** Состояние записи — `Status` из src-tauri/src/controller.rs. */
    61 −export type RecorderStatus =
    62 − | { kind: "unsupported" }
    63 − | { kind: "watching" }
    64 − | { kind: "awaiting_consent" }
    65 − | { kind: "recording"; meeting_id: string; started_at: string; paused: boolean; zoom: boolean }
    66 − | { kind: "finishing"; meeting_id: string; step: "transcribing" | "analyzing" };
    34 +export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }
    35 +
    36 +export type Command =
    37 + | { kind: "summary" }
    38 + | { kind: "tasks" }
    39 + | { kind: "review" }
    40 + | { kind: "custom"; text: string };
    41 +
    42 +export interface RecordingView { meeting_id: string; started_at: string; paused: boolean; zoom: boolean }
    43 +
    44 +export interface TranscribeResult { note: string; failed: number; last_error: string | null }
    67 45
    68 −/** Событие записи — `Event` из src-tauri/src/recorder.rs. */
    69 46 export type RecorderEvent =
    70 − | { kind: "segments"; data: Segment[] }
    71 47 | { kind: "level"; data: { speaker: Speaker; dbfs: number } }
    72 − | { kind: "live_summary"; data: string }
    73 − | { kind: "track_failed"; data: { speaker: Speaker; error: string } }
    74 − | { kind: "stt_failed"; data: string };
    75 −
    76 −export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }
    48 + | { kind: "track_failed"; data: { speaker: Speaker; error: string } };
    77 49
    78 50 type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
    79 51 type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>;
    80 52
    81 −interface Core { invoke: Invoke; listen: Listen }
    53 +interface Core {
    54 + invoke: Invoke;
    55 + listen: Listen;
    56 + pickAudio: () => Promise<string | null>;
    57 + onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>;
    58 +}
    59 +
    60 +const AUDIO_EXTENSIONS = ["mp3", "m4a", "wav", "aiff", "aif", "flac", "ogg", "aac", "mp4", "mkv", "caf"];
    82 61
    83 62 let core: Promise<Core> | null = null;
    84 63
    87 66 if ("__TAURI_INTERNALS__" in window) {
    88 67 const { invoke } = await import("@tauri-apps/api/core");
    89 68 const { listen } = await import("@tauri-apps/api/event");
    90 − return { invoke, listen: (event, cb) => listen(event, (e) => cb(e.payload as never)) };
    69 + const { getCurrentWebview } = await import("@tauri-apps/api/webview");
    70 + const { open } = await import("@tauri-apps/plugin-dialog");
    71 + return {
    72 + invoke,
    73 + listen: (event, cb) => listen(event, (e) => cb(e.payload as never)),
    74 + pickAudio: async () => {
    75 + const picked = await open({ multiple: false, filters: [{ name: "Аудио", extensions: AUDIO_EXTENSIONS }] });
    76 + return typeof picked === "string" ? picked : null;
    77 + },
    78 + onFileDrop: (cb) =>
    79 + getCurrentWebview().onDragDropEvent((e) => {
    80 + if (e.payload.type === "drop") cb(e.payload.paths);
    81 + }),
    82 + };
    91 83 }
    92 84 if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
    93 85 // Только для просмотра интерфейса в обычном браузере: `npm run dev`
    103 95 return (await getCore()).invoke<T>(cmd, args);
    104 96 }
    105 97
    106 −/** Подписка на событие ядра; возвращает отписку. */
    107 98 export async function on<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
    108 99 return (await getCore()).listen<T>(event, cb);
    109 100 }
    110 101
    111 102 export const events = {
    112 − status: (cb: (s: RecorderStatus) => void) => on<RecorderStatus>("meet://status", cb),
    113 103 recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
    114 − meetingChanged: (cb: (id: string) => void) => on<string>("meet://meeting-changed", cb),
    104 + progress: (cb: (p: { meeting_id: string; done: number; total: number }) => void) => on("meet://progress", cb),
    105 +};
    106 +
    107 +export const files = {
    108 + pickAudio: async () => (await getCore()).pickAudio(),
    109 + onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb),
    110 + isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""),
    115 111 };
    116 112
    117 113 export const api = {
    122 118 deleteOpenrouterKey: () => call<void>("delete_openrouter_key"),
    123 119 listMeetings: () => call<Meeting[]>("list_meetings"),
    124 120 getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
    125 − importMeeting: (title: string, text: string) => call<Meeting>("import_meeting", { title, text }),
    126 − analyzeMeeting: (id: string) => call<Meeting>("analyze_meeting", { id }),
    127 − recorderStatus: () => call<RecorderStatus>("recorder_status"),
    128 − recorderConsent: (record: boolean) => call<void>("recorder_consent", { record }),
    121 + createMeeting: () => call<Meeting>("create_meeting"),
    122 + saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }),
    123 + renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }),
    124 + deleteMeeting: (id: string) => call<void>("delete_meeting", { id }),
    125 + addAudio: (path: string) => call<Meeting>("add_audio", { path }),
    126 + transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }),
    127 + runCommand: (id: string, command: Command) => call<string>("run_command", { id, command }),
    128 + recorderStatus: () => call<RecordingView | null>("recorder_status"),
    129 129 recorderStart: () => call<Meeting>("recorder_start"),
    130 130 recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
    131 − recorderStop: () => call<void>("recorder_stop"),
    131 + recorderStop: () => call<Meeting>("recorder_stop"),
    132 132 };
    133 133
    134 −export function speakerLabel(s: Speaker): string {
    135 − switch (s.kind) {
    136 − case "me": return "Я";
    137 − case "others": return "Собеседники";
    138 − case "named": return s.name;
    139 − }
    140 −}
    141 −
    142 −export function formatTime(ms: number): string {
    143 − const t = Math.floor(ms / 1000);
    144 − const p = (n: number) => String(n).padStart(2, "0");
    145 − return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`;
    146 −}
    147 −
    148 134 export function formatDuration(ms: number): string {
    135 + if (ms <= 0) return "";
    149 136 if (ms < 60000) return "меньше минуты";
    150 137 const min = Math.round(ms / 60000);
    151 138 if (min < 60) return `${min} мин`;
    152 139 return `${Math.floor(min / 60)} ч ${min % 60} мин`;
    153 140 }
    154 141
    142 +export function meetingDuration(m: Meeting): number {
    143 + return Math.max(0, ...m.audio.map((a) => a.duration_ms));
    144 +}
    145 +
    155 146 export function formatDate(iso: string): string {
    156 147 return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
    157 148 }
    158 149
    150 +export function elapsed(since: string): string {
    151 + const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
    152 + const p = (n: number) => String(n).padStart(2, "0");
    153 + return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
    154 +}
    155 +
    159 156 export function errorText(e: unknown): string {
    160 157 return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
    161 158 }
  • src/dev-mock.ts +134 −179
    Показать разницуСкрыть разницу
    1 1 // Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
    2 2 // `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
    3 −//
    4 −// Изображает и запись: «встреча» идёт по репликам планёрки из fixtures, с
    5 −// уровнями звука и живым конспектом. Модель здесь не вызывается — разбор
    6 −// заготовлен руками. Для проверки экранов есть ручки в консоли:
    7 −// __meetMock.meetingStarted() — «началась встреча в Zoom»
    8 −// __meetMock.noKey() — ключ удалён
    3 +// ИИ здесь не вызывается: расшифровка и ответы заготовлены руками.
    9 4
    10 5 import planerka from "../fixtures/transcripts/planerka.txt?raw";
    11 −import klient from "../fixtures/transcripts/klient.txt?raw";
    12 −import type {
    13 − Analysis, KeyInfo, Meeting, MeetingDetail, RecorderStatus, Segment, Settings, SettingsView, Speaker,
    14 −} from "./api";
    15 −
    16 −function parse(text: string): Segment[] {
    17 − const out: Segment[] = [];
    18 − for (const raw of text.split("\n")) {
    19 − const line = raw.trim();
    20 − if (!line) continue;
    21 − const m = /^\[(\d{1,2}):(\d{2}):(\d{2})\]\s*([^:]+):\s*(.*)$/.exec(line);
    22 − if (m) {
    23 − const ms = ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000;
    24 − out.push({ start_ms: ms, speaker: { kind: "named", name: m[4].trim() }, text: m[5] });
    25 − } else if (out.length) {
    26 − out[out.length - 1].text += " " + line;
    27 − } else {
    28 − throw "строка без метки времени перед первой репликой";
    29 − }
    30 − }
    31 − if (!out.length) throw "в тексте нет ни одной реплики";
    32 − return out;
    33 −}
    34 −
    35 −const planerkaAnalysis: Analysis = {
    36 − title: "Планёрка: релиз, отчёт инвестору, отпуск",
    37 − summary:
    38 − "Релиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу. Дежурства на время отпуска Дениса закрыты только на первую неделю.",
    39 − review: {
    40 − achieved: ["Решили судьбу релиза и назначили ответственного за блокер", "Согласовали срок отчёта для инвестора"],
    41 − open: ["Кто дежурит во вторую неделю отпуска Дениса", "Затронет ли переезд на новый биллинг партнёрские выгрузки"],
    42 − off_track: ["Спор о новом биллинге посреди пункта про отчёт"],
    43 − verdict: "Встреча короткая и полезная; спорные темы стоит сразу выносить в отдельный список, не открывая обсуждение.",
    44 − },
    45 − decisions: [
    46 − { text: "Релиз переносится с четверга на следующий вторник", source_segment: 4 },
    47 − { text: "Биллинг обсуждаем на отдельной встрече", source_segment: 17 },
    48 − ],
    49 − tasks: [
    50 − { text: "Починить падение экрана оплаты на Android 12", owner: "Денис", due: "пятница", quote: "Денис, падение на Android 12 — на тебе, срок — пятница.", source_segment: 7 },
    51 − { text: "Выгрузить партнёрские данные для отчёта", owner: "Марат", due: "до среды", quote: "Партнёрские данные у меня, выгружу до среды.", source_segment: 10 },
    52 − { text: "Отправить отчёт инвестору", owner: "Света", due: "в четверг", quote: "тогда отчёт отправлю в четверг.", source_segment: 11 },
    53 − { text: "Собрать встречу по новому биллингу", owner: "Марат", due: "на следующей неделе", quote: "Соберу, на следующей неделе.", source_segment: 18 },
    54 − ],
    55 − open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"],
    56 −};
    6 +import type { Command, KeyInfo, Meeting, MeetingDetail, RecordingView, Settings, SettingsView, Speaker } from "./api";
    57 7
    58 8 // ── Шина событий ────────────────────────────────────────────────────────────
    59 9
    73 23
    74 24 // ── Данные ──────────────────────────────────────────────────────────────────
    75 25
    26 +/** Расшифровка планёрки в том виде, в каком ядро кладёт её в заметку. */
    27 +const TRANSCRIPT =
    28 + "## Расшифровка\n\n" +
    29 + planerka
    30 + .trim()
    31 + .split("\n")
    32 + .map((l) => l.replace(/^\[(\d\d:\d\d:\d\d)\] ([^:]+): /, "**$1 $2:** "))
    33 + .join("\n\n") +
    34 + "\n";
    35 +
    36 +const ANSWERS: Record<string, string> = {
    37 + summary:
    38 + "## Конспект\n\nРелиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу.\n\n### Решения\n\n- Релиз переносится на следующий вторник (00:00:55)\n- Биллинг — отдельной встречей (00:02:40)\n",
    39 + tasks:
    40 + "## Задачи\n\n- [ ] Починить падение оплаты на Android 12 — Денис, пятница (00:01:08)\n- [ ] Выгрузить партнёрские данные — Марат, до среды (00:01:34)\n- [ ] Отправить отчёт инвестору — Света, четверг (00:01:40)\n- [ ] Собрать встречу по биллингу — Марат, на следующей неделе (00:02:45)\n\n### Открытые вопросы\n\n- Кто дежурит во вторую неделю отпуска Дениса?\n",
    41 + review:
    42 + "## Ревью\n\nВстреча короткая и по делу: по релизу и отчёту есть решения и ответственные. В сторону ушли на биллинге — спор начался посреди пункта про отчёт. Открытым остался вопрос дежурств.\n\nСтоила своего времени; спорные темы лучше сразу выносить в список на потом.\n",
    43 +};
    44 +
    76 45 const now = Date.now();
    77 46 const details: MeetingDetail[] = [];
    78 47 const p2 = (n: number) => String(n).padStart(2, "0");
    81 50 return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
    82 51 }
    83 52
    84 −function add(title: string, segments: Segment[], minutesAgo: number, analysis: Analysis | null, state: Meeting["state"] = "done"): MeetingDetail {
    53 +function add(title: string, minutesAgo: number, note: string, audio: Meeting["audio"], transcribed: boolean): MeetingDetail {
    85 54 const created = new Date(now - minutesAgo * 60000);
    86 − const meeting: Meeting = {
    87 − id: makeId(created),
    88 − title,
    89 − created_at: created.toISOString(),
    90 − source: state === "done" && minutesAgo > 60 ? "import" : "recording",
    91 − duration_ms: segments.length ? segments[segments.length - 1].start_ms : 0,
    92 − segments: segments.length,
    93 − analysis: analysis && {
    94 − model: "anthropic/claude-opus-5.5",
    95 − at: created.toISOString(),
    96 − usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 },
    97 − tasks: analysis.tasks.length,
    55 + let id = makeId(created);
    56 + while (details.some((d) => d.meeting.id === id)) id += "-2";
    57 + const d: MeetingDetail = {
    58 + meeting: {
    59 + id, title, created_at: created.toISOString(), updated_at: created.toISOString(), audio,
    60 + recording: false, transcribed_at: transcribed ? created.toISOString() : null,
    98 61 },
    99 − state,
    62 + note,
    100 63 };
    101 − const d: MeetingDetail = { meeting, transcript: { segments }, analysis, live_summary: null };
    102 64 details.unshift(d);
    103 65 return d;
    104 66 }
    105 67
    106 −add("Планёрка команды", parse(planerka), 60 * 26, planerkaAnalysis);
    107 −add("Встреча с клиентом: сайт", parse(klient), 90, null);
    68 +const other: Speaker = { kind: "unknown" };
    69 +add("Планёрка команды", 60 * 26, `${TRANSCRIPT}\n${ANSWERS.tasks}`, [{ file: "Планёрка.m4a", speaker: other, duration_ms: 195_000 }], true);
    70 +add("Созвон с клиентом", 90, "", [{ file: "Созвон с клиентом.m4a", speaker: other, duration_ms: 2_760_000 }], false);
    108 71
    109 72 let settings: Settings = {
    110 − live_model: "anthropic/claude-haiku-4.5",
    111 − analysis_model: "anthropic/claude-opus-5.5",
    73 + ai_model: "anthropic/claude-opus-5.5",
    112 74 stt_model: "openai/whisper-large-v3-turbo",
    75 + language: "ru",
    113 76 warning_text:
    114 77 "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
    115 − always_warn_myself: false,
    116 − language: "ru",
    117 − keep_audio: false,
    118 − launch_at_login: true,
    119 78 };
    120 79 let key: string | null = "sk-or-v1…demo";
    80 +let captureSupported = true;
    121 81 const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false };
    122 82
    123 −// ── Имитация записи ─────────────────────────────────────────────────────────
    124 −
    125 −let status: RecorderStatus = { kind: "watching" };
    126 −let timers: number[] = [];
    83 +let recording: (RecordingView & { timer: number }) | null = null;
    127 84
    128 −function setStatus(s: RecorderStatus): void {
    129 − status = s;
    130 − emit("meet://status", s);
    131 −}
    85 +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
    132 86
    133 −function startRecording(zoom: boolean): Meeting {
    134 − const d = new Date();
    135 − const d0 = add(`${zoom ? "Встреча в Zoom," : "Запись,"} ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, [], 0, null, "recording");
    136 − const id = d0.meeting.id;
    137 − setStatus({ kind: "recording", meeting_id: id, started_at: d.toISOString(), paused: false, zoom });
    138 − const script = parse(planerka);
    139 − let i = 0;
    140 − timers.push(window.setInterval(() => {
    141 − if (status.kind !== "recording" || status.paused || i >= script.length) return;
    142 − const src = script[i++];
    143 − const speaker: Speaker = i % 3 === 1 ? { kind: "me" } : { kind: "others" };
    144 − const seg: Segment = { start_ms: Date.now() - Date.parse(status.started_at), speaker, text: src.text };
    145 − d0.transcript.segments.push(seg);
    146 − d0.meeting.segments++;
    147 − d0.meeting.duration_ms = seg.start_ms;
    148 − emit("meet://recorder", { meeting_id: id, event: { kind: "segments", data: [seg] } });
    149 − if (i === 6) {
    150 − d0.live_summary = "- Обсуждают релиз мобильной версии\n- Договорились: релиз переносится на вторник\n- Задача: Денис чинит оплату на Android 12 до пятницы";
    151 − emit("meet://recorder", { meeting_id: id, event: { kind: "live_summary", data: d0.live_summary } });
    152 − }
    153 − }, 1800));
    154 − timers.push(window.setInterval(() => {
    155 − if (status.kind !== "recording") return;
    156 − const quiet = status.paused;
    157 − for (const speaker of [{ kind: "me" }, ...(zoom ? [{ kind: "others" }] : [])] as Speaker[]) {
    158 − const dbfs = quiet ? -90 : -60 + Math.random() * 45;
    159 − emit("meet://recorder", { meeting_id: id, event: { kind: "level", data: { speaker, dbfs } } });
    160 − }
    161 − }, 200));
    162 − return d0.meeting;
    87 +function find(id: unknown): MeetingDetail {
    88 + const d = details.find((x) => x.meeting.id === id);
    89 + if (!d) throw `встреча «${id}» не найдена`;
    90 + return d;
    163 91 }
    164 92
    165 −function stopRecording(): void {
    166 − if (status.kind !== "recording") throw "Запись не идёт";
    167 − const id = status.meeting_id;
    168 − timers.forEach(clearInterval);
    169 − timers = [];
    170 − const d = details.find((x) => x.meeting.id === id)!;
    171 − setStatus({ kind: "finishing", meeting_id: id, step: "transcribing" });
    172 − setTimeout(() => {
    173 − d.meeting.state = "done";
    174 − emit("meet://meeting-changed", id);
    175 − if (!d.transcript.segments.length) {
    176 − setStatus({ kind: "watching" });
    177 − return;
    178 − }
    179 − setStatus({ kind: "finishing", meeting_id: id, step: "analyzing" });
    180 − setTimeout(() => {
    181 − d.analysis = { ...planerkaAnalysis, decisions: [], tasks: planerkaAnalysis.tasks.map((t) => ({ ...t, source_segment: 0 })) };
    182 − d.meeting.analysis = { model: settings.analysis_model, at: new Date().toISOString(), usage: { prompt_tokens: 900, completion_tokens: 600, cost: 0.0156 }, tasks: 4 };
    183 − emit("meet://meeting-changed", id);
    184 − setStatus({ kind: "watching" });
    185 − }, 2500);
    186 − }, 1200);
    93 +function append(d: MeetingDetail, block: string): string {
    94 + d.note = (d.note.trimEnd() ? d.note.trimEnd() + "\n\n" : "") + block.trim() + "\n";
    95 + return d.note;
    187 96 }
    188 97
    189 98 (window as unknown as { __meetMock: object }).__meetMock = {
    190 − meetingStarted: () => status.kind === "watching" && setStatus({ kind: "awaiting_consent" }),
    191 − meetingEnded: () => (status.kind === "awaiting_consent" ? setStatus({ kind: "watching" }) : status.kind === "recording" && stopRecording()),
    192 − noKey: () => { key = null; setStatus(status); },
    193 − unsupported: () => setStatus({ kind: "unsupported" }),
    99 + noKey: () => (key = null),
    100 + unsupported: () => (captureSupported = false),
    194 101 };
    195 102
    196 −const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
    Разница обрезана: показаны первые 227 строк.
  • src/main.ts +17 −18
    Показать разницуСкрыть разницу
    1 −// Маршрутизация по хешу: #/ (главная), #/meeting/<id>/<вкладка>[/<реплика>], #/settings.
    1 +// Оболочка: боковая панель со встречами слева, экран справа.
    2 +// Маршруты: #/ — начало, #/m/<id> — встреча, #/settings — настройки.
    2 3
    3 −import { renderHome } from "./views/home";
    4 −import { renderMeeting, type Tab } from "./views/meeting";
    4 +import { renderNote } from "./views/note";
    5 5 import { renderSettings } from "./views/settings";
    6 +import { renderStart } from "./views/start";
    7 +import { mountSidebar } from "./views/sidebar";
    6 8 import { errorText } from "./api";
    7 9 import { esc } from "./dom";
    8 10 import { Scope } from "./scope";
    9 11
    10 −const app = document.getElementById("app")!;
    12 +const main = document.getElementById("app")!;
    11 13 let scope = new Scope();
    12 14
    13 15 async function route(): Promise<void> {
    14 16 scope.dispose();
    15 17 scope = new Scope();
    16 − const [section, id, tab, target] = location.hash.replace(/^#\/?/, "").split("/");
    17 − for (const a of document.querySelectorAll<HTMLAnchorElement>("[data-nav]")) {
    18 − const current = a.dataset.nav === (section === "settings" ? "settings" : "home");
    19 − if (current) a.setAttribute("aria-current", "page");
    20 − else a.removeAttribute("aria-current");
    21 − }
    18 + const [section, id] = location.hash.replace(/^#\/?/, "").split("/");
    19 + const settingsLink = document.querySelector("[data-nav=settings]");
    20 + if (section === "settings") settingsLink?.setAttribute("aria-current", "page");
    21 + else settingsLink?.removeAttribute("aria-current");
    22 22 try {
    23 − if (section === "meeting" && id) {
    24 − await renderMeeting(app, scope, decodeURIComponent(id), (tab as Tab) || "transcript", target ? Number(target) : null);
    25 − } else if (section === "settings") {
    26 − await renderSettings(app);
    27 − } else {
    28 − await renderHome(app, scope);
    29 − }
    23 + if (section === "m" && id) await renderNote(main, scope, decodeURIComponent(id));
    24 + else if (section === "settings") await renderSettings(main);
    25 + else await renderStart(main);
    30 26 } catch (e) {
    31 − app.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    27 + main.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    32 28 }
    33 29 }
    34 30
    31 +mountSidebar(document.getElementById("sidebar")!).catch((e) => {
    32 + document.getElementById("sidebar")!.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    33 +});
    35 34 window.addEventListener("hashchange", route);
    36 35 route();
  • Показать разницуСкрыть разницу
    1 +// Markdown заметки: отображение и деление на разделы.
    2 +//
    3 +// Заметка — единственный источник текста. В режиме просмотра она делится на
    4 +// разделы по заголовкам второго уровня (`## Расшифровка`, `## Задачи`), и у
    5 +// каждого раздела своя кнопка «Копировать» — копируется исходный Markdown
    6 +// раздела, а не HTML.
    7 +
    8 +import { marked } from "marked";
    9 +import DOMPurify from "dompurify";
    10 +
    11 +marked.setOptions({ gfm: true, breaks: false });
    12 +
    13 +export function render(md: string): string {
    14 + return DOMPurify.sanitize(marked.parse(md, { async: false }));
    15 +}
    16 +
    17 +export interface Section {
    18 + /** Заголовок раздела без `## `; у текста до первого заголовка — пусто. */
    19 + heading: string;
    20 + /** Исходный Markdown раздела вместе с заголовком. */
    21 + source: string;
    22 +}
    23 +
    24 +export function sections(md: string): Section[] {
    25 + const out: Section[] = [];
    26 + let current: Section = { heading: "", source: "" };
    27 + let fence = false;
    28 + for (const line of md.split("\n")) {
    29 + if (/^(```|~~~)/.test(line)) fence = !fence;
    30 + const h = !fence && /^## (.+)$/.exec(line);
    31 + if (h) {
    32 + if (current.source.trim()) out.push(current);
    33 + current = { heading: h[1].trim(), source: "" };
    34 + }
    35 + current.source += line + "\n";
    36 + }
    37 + if (current.source.trim()) out.push(current);
    38 + for (const s of out) s.source = s.source.trim() + "\n";
    39 + return out;
    40 +}
    41 +
    42 +/** Текст без разметки — для вставки туда, где Markdown не понимают. */
    43 +export function plain(md: string): string {
    44 + const div = document.createElement("div");
    45 + div.innerHTML = render(md);
    46 + return (div.innerText || div.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
    47 +}
    48 +
    49 +/**
    50 + * Переключает n-ю по счёту галочку списка задач (`- [ ]` ↔ `- [x]`) в
    51 + * исходнике. Порядок тот же, что у галочек в отображении: код в ``` не в счёт.
    52 + */
    53 +export function toggleTask(md: string, n: number): string {
    54 + let fence = false;
    55 + let seen = 0;
    56 + return md
    57 + .split("\n")
    58 + .map((line) => {
    59 + if (/^(```|~~~)/.test(line)) fence = !fence;
    60 + if (fence) return line;
    61 + const m = /^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]/.exec(line);
    62 + if (!m) return line;
    63 + if (seen++ !== n) return line;
    64 + const mark = m[2] === " " ? "x" : " ";
    65 + return m[1] + `[${mark}]` + line.slice(m[0].length);
    66 + })
    67 + .join("\n");
    68 +}
  • src/state.ts +45
    Показать разницуСкрыть разницу
    1 +// Общее состояние интерфейса: идёт ли запись и сообщения об ошибках.
    2 +// Боковая панель и экран заметки подписываются на изменения.
    3 +
    4 +import { api, errorText, type RecordingView } from "./api";
    5 +
    6 +type Listener = () => void;
    7 +const listeners = new Set<Listener>();
    8 +
    9 +export const state = {
    10 + recording: null as RecordingView | null,
    11 +};
    12 +
    13 +export function onChange(cb: Listener): () => void {
    14 + listeners.add(cb);
    15 + return () => listeners.delete(cb);
    16 +}
    17 +
    18 +export function changed(): void {
    19 + listeners.forEach((cb) => cb());
    20 +}
    21 +
    22 +export async function refreshRecording(): Promise<void> {
    23 + state.recording = await api.recorderStatus();
    24 + changed();
    25 +}
    26 +
    27 +/** Сообщение внизу экрана: ошибка или «Скопировано». Исчезает само. */
    28 +export function toast(message: unknown, kind: "error" | "ok" = "error"): void {
    29 + const el = document.getElementById("toast");
    30 + if (!el) return;
    31 + el.textContent = kind === "error" ? errorText(message) : String(message);
    32 + el.className = `toast is-${kind}`;
    33 + el.hidden = false;
    34 + window.clearTimeout(Number(el.dataset.timer));
    35 + el.dataset.timer = String(window.setTimeout(() => (el.hidden = true), kind === "error" ? 8000 : 2000));
    36 +}
    37 +
    38 +export async function copy(text: string, what = "Скопировано"): Promise<void> {
    39 + try {
    40 + await navigator.clipboard.writeText(text);
    41 + toast(what, "ok");
    42 + } catch (e) {
    43 + toast(e);
    44 + }
    45 +}
  • src/styles/app.css +163 −75
    Показать разницуСкрыть разницу
    1 −/* Раскладка приложения: шапка как у skillok.org и одна колонка контента. */
    1 +/* Раскладка: шапка как у skillok.org, слева встречи, справа заметка. */
    2 +
    3 +html, body { height: 100%; }
    4 +body { display: flex; flex-direction: column; }
    2 5
    3 6 .header {
    4 − position: sticky; top: 0; z-index: 10;
    7 + flex: none;
    5 8 display: flex; align-items: center; gap: 24px;
    6 − height: 56px; padding: 0 24px;
    9 + height: 52px; padding: 0 20px;
    7 10 background: var(--background);
    8 11 border-bottom: 1px solid var(--color-border);
    9 12 }
    10 13 .brand { display: flex; align-items: center; gap: 10px; color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg); }
    11 14 .brand:hover { text-decoration: none; }
    12 −.brand-mark { width: 24px; height: 24px; }
    15 +.brand-mark { width: 22px; height: 22px; }
    13 16 .header-nav { display: flex; gap: 4px; margin-left: auto; }
    14 17 .header-nav a {
    15 18 display: inline-flex; align-items: center; height: 32px; padding: 0 10px;
    18 21 .header-nav a:hover { background: var(--color-bg-hover); text-decoration: none; }
    19 22 .header-nav a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    20 23
    21 −.page { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; display: grid; gap: 24px; }
    22 −.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
    23 −.page-head .meta { margin-top: 6px; }
    24 −.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
    25 −
    26 −/* Список встреч */
    27 −.meeting-row { cursor: pointer; color: var(--color-text-primary); }
    28 −.meeting-row:hover { text-decoration: none; }
    29 −.meeting-row:hover .title { color: var(--color-accent-text); }
    30 −.meeting-row .title { font-weight: 700; flex: 1; min-width: 0; }
    31 −.meeting-row .when { width: 200px; white-space: nowrap; }
    32 −.meeting-row .status { width: 170px; text-align: right; }
    33 −
    34 −/* Расшифровка */
    35 −.segment { display: grid; grid-template-columns: 72px 1fr; gap: 12px; padding: 8px 16px; border-top: 1px solid var(--kv-line-2); }
    36 −.segment:first-child { border-top: 0; }
    37 −.segment .at { color: var(--color-text-secondary); padding-top: 2px; }
    38 −.segment .who { font-weight: 700; }
    39 −.segment.is-target { background: var(--color-bg-secondary); box-shadow: inset 2px 0 0 var(--color-accent); }
    40 −
    41 −/* Конспект и задачи */
    42 −.prose { display: grid; gap: 12px; max-width: 72ch; }
    43 −.prose ul { margin: 0; padding-left: 20px; display: grid; gap: 4px; }
    44 −.section { display: grid; gap: 8px; }
    45 −.task { display: grid; gap: 4px; padding: 12px 16px; border-top: 1px solid var(--color-border); }
    46 −.task:first-child { border-top: 0; }
    47 −.task .text { font-weight: 700; }
    48 −.task .quote { border-left: 2px solid var(--color-border-light); padding-left: 10px; color: var(--color-text-secondary); font-size: var(--text-sm); }
    49 −.jump { font-family: var(--font-mono); font-size: var(--text-xs); }
    50 −
    51 −.empty { padding: 40px 16px; text-align: center; color: var(--color-text-secondary); }
    52 −.busy { color: var(--color-text-secondary); font-size: var(--text-sm); }
    53 −
    54 −@media (max-width: 640px) {
    55 − .meeting-row { flex-wrap: wrap; }
    56 − .meeting-row .when, .meeting-row .status { width: auto; text-align: left; }
    57 −}
    24 +.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 280px 1fr; }
    25 +.main { min-width: 0; overflow-y: auto; padding: 28px 32px 64px; }
    58 26
    59 −/* ── Карточка состояния (главный экран, задача #1) ── */
    60 −.status-card {
    61 − display: grid; gap: 12px;
    62 − padding: 24px;
    63 − border: 1px solid var(--color-border);
    27 +/* ── Боковая панель ── */
    28 +.sidebar {
    29 + display: flex; flex-direction: column; min-height: 0;
    30 + border-right: 1px solid var(--color-border);
    64 31 background: var(--color-bg-elevated);
    65 32 }
    66 −.status-card.is-attention, .status-card.is-recording { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    67 −.status-kicker { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    68 −.status-title { font-size: var(--text-3xl); }
    69 −.status-text { max-width: 60ch; color: var(--kv-body); }
    33 +.side-actions { display: grid; gap: 4px; padding: 16px; border-bottom: 1px solid var(--color-border); }
    34 +.side-main { width: 100%; margin-bottom: 6px; }
    35 +.side-link {
    36 + justify-self: start; padding: 4px 0;
    37 + font: inherit; font-size: var(--text-md); color: var(--color-text-primary);
    38 + background: none; border: 0; cursor: pointer; text-align: left;
    39 +}
    40 +.side-link::before { content: "+ "; color: var(--color-accent-text); }
    41 +.side-link:hover { color: var(--color-accent-text); }
    42 +.side-stop::before { content: "■ "; color: var(--color-accent); }
    43 +.side-note { margin: 0 0 6px; }
    44 +.rec-now {
    45 + display: flex; align-items: center; gap: 8px; padding: 6px 0;
    46 + color: var(--color-text-primary); font-size: var(--text-sm);
    47 +}
    48 +.rec-now .mono-num { margin-left: auto; }
    49 +.rec-now:hover { text-decoration: none; color: var(--color-accent-text); }
    50 +
    51 +.side-list { flex: 1; overflow-y: auto; padding: 6px 0; }
    52 +.side-item {
    53 + display: grid; gap: 2px; padding: 10px 16px;
    54 + color: var(--color-text-primary); border-left: 2px solid transparent;
    55 +}
    56 +.side-item:hover { background: var(--color-bg-hover); text-decoration: none; }
    57 +.side-item.is-active { background: var(--color-bg-secondary); border-left-color: var(--color-accent); }
    58 +.side-title { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    59 +.side-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
    60 +.side-empty { padding: 12px 16px; }
    61 +.badge {
    62 + display: inline-block; padding: 0 5px; line-height: 18px;
    63 + font-size: var(--text-xs); border: 1px solid var(--color-border-light); color: var(--color-text-secondary);
    64 +}
    65 +.badge-live { border-color: var(--color-accent); color: var(--color-accent-text); }
    66 +
    67 +/* ── Общие мелочи ── */
    68 +.actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
    69 +.spacer { flex: 1; }
    70 70 .mono-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
    71 +.page { max-width: 760px; display: grid; gap: 24px; }
    72 +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
    73 +
    74 +.link-btn {
    75 + padding: 0 2px; min-height: 28px;
    76 + font: inherit; font-size: var(--text-sm);
    77 + color: var(--color-accent-text); background: none; border: 0; cursor: pointer;
    78 + text-decoration: underline; text-underline-offset: 3px;
    79 +}
    80 +.link-btn:hover { color: var(--color-text-primary); }
    81 +.link-btn[disabled] { opacity: 0.5; cursor: default; }
    82 +.link-danger { color: var(--color-error); }
    83 +a.ui-btn, a.ui-btn:hover { text-decoration: none; }
    84 +
    85 +/* Быстрая команда ИИ — лёгкая рамка, не кнопка первого ряда. */
    86 +.chip {
    87 + min-height: 28px; padding: 0 10px;
    88 + font: inherit; font-size: var(--text-sm); color: var(--color-text-primary);
    89 + background: var(--color-bg-elevated); border: 1px solid var(--color-border-light); cursor: pointer;
    90 +}
    91 +.chip:hover { border-color: var(--kv-ink); }
    92 +.chip:active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-accent); }
    93 +.chip[disabled] { opacity: 0.45; cursor: not-allowed; }
    71 94
    72 95 /* Индикатор — квадрат: круг в продукте только у аватаров. */
    73 −.dot { display: inline-block; width: 8px; height: 8px; background: var(--color-text-secondary); }
    74 −.dot-accent { background: var(--color-accent); }
    96 +.dot { display: inline-block; flex: none; width: 8px; height: 8px; background: var(--color-text-secondary); }
    75 97 .dot-live { background: var(--color-accent); animation: blink 1.4s steps(2, jump-none) infinite; }
    76 98 .dot-live.is-paused { animation: none; background: var(--color-text-secondary); }
    77 99 @keyframes blink { 50% { opacity: 0.25; } }
    78 100 @media (prefers-reduced-motion: reduce) { .dot-live { animation: none; } }
    79 101
    80 −.warning-text {
    81 − margin: 0; padding: 12px 16px; max-width: 72ch;
    82 − border-left: 2px solid var(--color-accent);
    83 − background: var(--color-bg-secondary);
    102 +/* ── Заметка ── */
    103 +.note { max-width: 820px; display: grid; gap: 20px; }
    104 +.note-head { display: grid; gap: 4px; }
    105 +.title-input {
    106 + width: 100%; padding: 2px 0; margin: 0;
    107 + font: inherit; font-size: var(--text-3xl); font-weight: 700; line-height: 1.25;
    108 + color: var(--color-text-primary); background: transparent;
    109 + border: 0; border-bottom: 1px solid transparent;
    84 110 }
    111 +.title-input:hover { border-bottom-color: var(--color-border); }
    112 +.title-input:focus { outline: none; border-bottom-color: var(--color-accent); }
    85 113
    114 +.panel { display: grid; gap: 10px; padding: 16px 20px; border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    115 +.panel-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    116 +.panel-kicker { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    117 +.panel-timer { font-size: var(--text-3xl); font-weight: 700; }
    86 118 .meters { display: grid; gap: 6px; max-width: 420px; }
    87 119 .meter { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 12px; }
    88 120 .meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; }
    89 121 .meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    90 122
    91 −.live-lines { display: grid; gap: 4px; max-width: 72ch; font-size: var(--text-sm); min-height: 60px; }
    92 −.live-lines .who { font-weight: 700; }
    123 +.steps { display: grid; gap: 10px; }
    124 +.step-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
    125 +.ai-row { gap: 8px; }
    126 +.ask { display: flex; gap: 8px; }
    127 +.ask .input { flex: 1; min-height: 28px; padding: 3px 10px; font-size: var(--text-sm); }
    93 128
    94 −/* Второстепенное действие — ссылкой, а не второй кнопкой (одна контурная кнопка на экран). */
    95 −.link-btn {
    96 − padding: 0 4px; min-height: 32px;
    97 − font: inherit; font-size: var(--text-sm);
    98 − color: var(--color-accent-text); background: none; border: 0; cursor: pointer;
    99 − text-decoration: underline; text-underline-offset: 3px;
    129 +.editor { border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    130 +.editor-bar {
    131 + display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
    132 + padding: 0 12px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary);
    100 133 }
    101 −.link-btn:hover { color: var(--color-text-primary); }
    102 −.link-btn[disabled] { opacity: 0.5; cursor: default; }
    103 −a.ui-btn { text-decoration: none; }
    104 −a.ui-btn:hover { text-decoration: none; }
    134 +.editor-bar .ui-underline-nav { box-shadow: none; }
    135 +.note-editor {
    136 + display: block; width: 100%; min-height: 60vh; padding: 16px 20px;
    137 + font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.65;
    138 + color: var(--color-text-primary); background: transparent;
    139 + border: 0; resize: vertical;
    140 +}
    141 +.note-editor:focus { outline: none; }
    142 +.note-editor[readonly] { opacity: 0.6; }
    143 +.note-view { padding: 4px 0; }
    144 +.note-empty { padding: 32px 20px; text-align: center; display: grid; gap: 6px; }
    145 +
    146 +.note-section { position: relative; padding: 12px 20px; border-top: 1px solid var(--kv-line-2); }
    147 +.note-section:first-child { border-top: 0; }
    148 +.section-copy { position: absolute; top: 12px; right: 16px; opacity: 0; transition: opacity 0.12s; }
    149 +.note-section:hover .section-copy, .section-copy:focus-visible { opacity: 1; }
    150 +
    151 +.note-foot { padding-top: 8px; }
    152 +
    153 +/* Markdown в заметке */
    154 +.md { max-width: 72ch; display: grid; gap: 10px; }
    155 +.md > * { margin: 0; }
    156 +.md h1 { font-size: var(--text-2xl); }
    157 +.md h2 { font-size: var(--text-xl); padding-right: 96px; }
    158 +.md h3 { font-size: var(--text-lg); margin-top: 6px; }
    159 +.md ul, .md ol { padding-left: 22px; display: grid; gap: 4px; }
    160 +.md li > p { margin: 0; }
    161 +.md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -22px; }
    162 +.md input[type="checkbox"] { margin: 0 8px 0 0; accent-color: var(--color-accent); vertical-align: -1px; }
    163 +.md code { padding: 0 4px; background: var(--color-bg-secondary); }
    164 +.md pre { padding: 12px; background: var(--color-bg-secondary); overflow-x: auto; }
    165 +.md pre code { padding: 0; background: none; }
    166 +.md blockquote { padding: 4px 14px; border-left: 2px solid var(--color-border-light); color: var(--color-text-secondary); }
    167 +.md strong { font-weight: 700; }
    168 +.md a { word-break: break-word; }
    169 +.md table { border-collapse: collapse; }
    170 +.md th, .md td { padding: 4px 10px; border: 1px solid var(--color-border); text-align: left; }
    171 +
    172 +/* ── Начальный экран ── */
    173 +.start { max-width: 640px; display: grid; gap: 20px; padding-top: 24px; }
    174 +.start h1 { font-size: var(--text-3xl); }
    175 +.start-steps { margin: 0; padding-left: 22px; display: grid; gap: 10px; color: var(--kv-body); }
    176 +.drop-zone {
    177 + display: grid; gap: 4px; justify-items: center; padding: 28px 16px;
    178 + font: inherit; color: var(--color-text-primary); cursor: pointer;
    179 + background: var(--color-bg-elevated); border: 1px dashed var(--color-border-light);
    180 +}
    181 +.drop-zone:hover { border-color: var(--color-accent); }
    182 +.start-key { padding: 10px 14px; border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); }
    105 183
    106 −.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
    184 +.advanced { display: grid; gap: 12px; }
    185 +.advanced > summary { cursor: pointer; color: var(--color-accent-text); }
    186 +.advanced[open] > summary { margin-bottom: 12px; }
    187 +select.input { appearance: auto; }
    107 188
    108 −/* Расшифровка: свои реплики отмечены полосой слева. */
    109 −.segment.is-me { box-shadow: inset 2px 0 0 var(--color-border-light); }
    110 −.segment.is-me.is-target { box-shadow: inset 2px 0 0 var(--color-accent); }
    189 +/* ── Сообщение внизу ── */
    190 +.toast {
    191 + position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20;
    192 + max-width: min(640px, calc(100% - 32px)); margin: 0; padding: 10px 16px;
    193 + font-size: var(--text-sm); color: var(--background); background: var(--kv-ink);
    194 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
    195 +}
    196 +.toast.is-error { background: var(--color-error); color: #fff; }
    111 197
    112 −.live-summary .box-head { align-items: baseline; }
    113 −.pre { white-space: pre-wrap; }
    114 −select.input { appearance: auto; }
    198 +@media (max-width: 760px) {
    199 + .layout { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
    200 + .sidebar { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--color-border); }
    201 + .main { padding: 20px 16px 48px; }
    202 +}
  • src/views/home.ts −277
    Показать разницуСкрыть разницу
    1 −// Главный экран: что программа делает прямо сейчас (задача #1) и список встреч.
    2 −//
    3 −// Карточка состояния отвечает на один вопрос — «что происходит и что будет
    4 −// дальше». В каждом состоянии одно главное действие.
    5 −
    6 −import {
    7 − api, errorText, events, formatDate, formatDuration, speakerLabel,
    8 − type Meeting, type RecorderStatus, type Segment, type SettingsView, type Speaker,
    9 −} from "../api";
    10 −import { $, busy, esc, plural } from "../dom";
    11 −import type { Scope } from "../scope";
    12 −
    13 −function elapsed(since: string): string {
    14 − const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
    15 − const p = (n: number) => String(n).padStart(2, "0");
    16 − return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
    17 −}
    18 −
    19 −function meter(label: string, key: string): string {
    20 − return `
    21 − <div class="meter">
    22 − <span class="meter-label small">${esc(label)}</span>
    23 − <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span>
    24 − </div>`;
    25 −}
    26 −
    27 −function card(status: RecorderStatus, view: SettingsView): string {
    28 − const hasKey = Boolean(view.openrouter_key);
    29 − if (status.kind === "unsupported") {
    30 − return `
    31 − <section class="status-card">
    32 − <h2 class="status-title">Запись встреч работает в Windows</h2>
    33 − <p class="status-text">На этом компьютере можно разобрать готовую расшифровку: вставьте её, и ИИ сделает конспект и задачи.</p>
    34 − <div class="actions"><button class="ui-btn" data-open-import>Импортировать расшифровку</button></div>
    35 − </section>`;
    36 − }
    37 − if (!hasKey && (status.kind === "watching" || status.kind === "awaiting_consent")) {
    38 − return `
    39 − <section class="status-card">
    40 − <h2 class="status-title">Подключите ИИ, чтобы записывать встречи</h2>
    41 − <p class="status-text">Расшифровка и разбор идут через ваш ключ OpenRouter. Без него встречу не записать.</p>
    42 − <div class="actions"><a class="ui-btn" href="#/settings">Подключить</a></div>
    43 − </section>`;
    44 − }
    45 − switch (status.kind) {
    46 − case "watching":
    47 − return `
    48 − <section class="status-card">
    49 − <p class="status-kicker"><span class="dot"></span>Слежу за Zoom</p>
    50 − <h2 class="status-title">Жду встречу</h2>
    51 − <p class="status-text">${
    52 − view.settings.always_warn_myself
    53 − ? "Как только начнётся встреча в Zoom, запись включится сама."
    54 − : "Как только начнётся встреча в Zoom, я попрошу предупредить участников и начну запись."
    55 − }</p>
    56 − <div class="actions"><button class="link-btn" data-start>Записать вручную</button></div>
    57 − </section>`;
    58 − case "awaiting_consent":
    59 − return `
    60 − <section class="status-card is-attention">
    61 − <p class="status-kicker"><span class="dot dot-accent"></span>Началась встреча в Zoom</p>
    62 − <h2 class="status-title">Предупредите участников о записи</h2>
    63 − <blockquote class="warning-text" data-warning>${esc(view.settings.warning_text)}</blockquote>
    64 − <div class="actions">
    65 − <button class="ui-btn" data-consent="yes">Предупредил, записывать</button>
    66 − <button class="link-btn" data-copy>Скопировать текст</button>
    67 − <button class="link-btn" data-consent="no">Не записывать</button>
    68 − </div>
    69 − </section>`;
    70 − case "recording":
    71 − return `
    72 − <section class="status-card is-recording">
    73 − <p class="status-kicker"><span class="dot dot-live${status.paused ? " is-paused" : ""}"></span>${
    74 − status.paused ? "Запись на паузе" : status.zoom ? "Записываю встречу в Zoom" : "Записываю с микрофона"
    75 − }</p>
    76 − <h2 class="status-title mono-num" data-timer>${elapsed(status.started_at)}</h2>
    77 − <div class="meters">
    78 − ${meter("Я", "me")}
    79 − ${status.zoom ? meter("Собеседники", "others") : ""}
    80 − </div>
    81 − <div class="live-lines" data-live><p class="muted small">Реплики появятся здесь через несколько секунд после начала разговора.</p></div>
    82 − <div class="actions">
    83 − <button class="ui-btn" data-stop>Остановить</button>
    84 − <button class="link-btn" data-pause>${status.paused ? "Продолжить" : "Пауза"}</button>
    85 − <a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть расшифровку</a>
    86 − </div>
    87 − </section>`;
    88 − case "finishing":
    89 − return `
    90 − <section class="status-card">
    91 − <p class="status-kicker"><span class="dot dot-accent"></span>Запись остановлена</p>
    92 − <h2 class="status-title">${status.step === "transcribing" ? "Дописываю расшифровку…" : "Разбираю встречу…"}</h2>
    93 − <p class="status-text">${
    94 − status.step === "transcribing"
    95 − ? "Распознаю последние реплики. Потом ИИ сделает конспект и задачи."
    96 − : "ИИ пишет конспект, решения и задачи. Обычно это меньше минуты — пришлю уведомление."
    97 − }</p>
    98 − <div class="actions"><a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть встречу</a></div>
    99 − </section>`;
    100 − }
    101 −}
    102 −
    103 −function status(m: Meeting): string {
    104 − if (m.state === "recording") return `<span class="label label-accent">идёт запись</span>`;
    105 − if (m.state === "interrupted" && !m.analysis) return `<span class="label">прервана</span>`;
    106 − if (!m.analysis) return `<span class="label">не разобрана</span>`;
    107 − const n = m.analysis.tasks;
    108 − return `<span class="label label-accent">${n} ${plural(n, "задача", "задачи", "задач")}</span>`;
    109 −}
    110 −
    111 −function list(meetings: Meeting[]): string {
    112 − if (!meetings.length) {
    113 − return `<p class="empty">Здесь появятся записанные встречи.</p>`;
    114 − }
    115 − return meetings
    116 − .map(
    117 − (m) => `
    118 − <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}">
    119 − <span class="title">${esc(m.title)}</span>
    120 − <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span>
    121 − <span class="status">${status(m)}</span>
    122 − </a>`,
    123 − )
    124 − .join("");
    125 −}
    126 −
    127 −function importForm(): string {
    128 − return `
    129 − <form class="box" data-import hidden>
    130 − <div class="box-head"><h2>Импорт расшифровки</h2></div>
    131 − <div class="box-body">
    132 − <div class="field">
    133 − <label for="imp-title">Название</label>
    134 − <input id="imp-title" class="input" name="title" autocomplete="off" required>
    135 − </div>
    136 − <div class="field">
    137 − <label for="imp-text">Расшифровка</label>
    138 − <textarea id="imp-text" class="textarea mono" name="text" rows="10" required
    139 − placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea>
    140 − <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p>
    141 − </div>
    142 − <p class="error" data-import-error hidden></p>
    143 − <div class="actions">
    144 − <button class="ui-btn" type="submit">Создать встречу</button>
    145 − <button class="link-btn" type="button" data-close-import>Отмена</button>
    146 − </div>
    147 − </div>
    148 − </form>`;
    149 −}
    150 −
    151 −function speakerKey(s: Speaker): string {
    152 − return s.kind === "me" ? "me" : "others";
    153 −}
    154 −
    155 −export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
    156 − const [recorder, initialView, meetings] = await Promise.all([api.recorderStatus(), api.getSettings(), api.listMeetings()]);
    157 − if (!scope.alive) return;
    158 − let view = initialView;
    159 −
    160 − root.innerHTML = `
    161 − <div data-card>${card(recorder, view)}</div>
    162 − <p class="error" data-error hidden></p>
    163 − ${importForm()}
    164 − <section class="section">
    165 − <div class="section-head">
    166 − <h2>Встречи</h2>
    167 − <button class="link-btn" data-open-import>Импортировать расшифровку</button>
    168 − </div>
    169 − <div class="box" data-list>${list(meetings)}</div>
    170 − </section>`;
    171 −
    172 − const error = $(root, "[data-error]");
    173 − const fail = (e: unknown) => {
    174 − error.textContent = errorText(e);
    175 − error.hidden = false;
    176 − };
    177 − let current = recorder;
    178 − const live: Segment[] = [];
    179 −
    180 − const bindCard = () => {
    181 − const box = $(root, "[data-card]");
    182 − box.querySelector<HTMLButtonElement>("[data-start]")?.addEventListener("click", (e) =>
    183 − busy(e.currentTarget as HTMLButtonElement, "Запускаю…", () => api.recorderStart()).catch(fail),
    184 − );
    185 − box.querySelectorAll<HTMLButtonElement>("[data-consent]").forEach((b) =>
    186 − b.addEventListener("click", () => api.recorderConsent(b.dataset.consent === "yes").catch(fail)),
    187 − );
    188 − box.querySelector<HTMLButtonElement>("[data-copy]")?.addEventListener("click", async (e) => {
    189 − const b = e.currentTarget as HTMLButtonElement;
    190 − await navigator.clipboard.writeText(view.settings.warning_text).catch(() => undefined);
    191 − b.textContent = "Скопировано";
    192 − });
    193 − box.querySelector<HTMLButtonElement>("[data-stop]")?.addEventListener("click", (e) =>
    194 − busy(e.currentTarget as HTMLButtonElement, "Останавливаю…", () => api.recorderStop()).catch(fail),
    195 − );
    196 − box.querySelector<HTMLButtonElement>("[data-pause]")?.addEventListener("click", () =>
    197 − api.recorderPause(current.kind === "recording" && !current.paused).catch(fail),
    198 − );
    199 − box.querySelector("[data-open-import]")?.addEventListener("click", openImport);
    200 − renderLive();
    201 − };
    202 −
    203 − const renderLive = () => {
    204 − const el = root.querySelector("[data-live]");
    205 − if (!el || !live.length) return;
    206 − el.innerHTML = live
    207 − .slice(-3)
    208 − .map((s) => `<p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>`)
    209 − .join("");
    210 − };
    211 −
    212 − const refreshList = async () => {
    213 − const fresh = await api.listMeetings();
    214 − if (scope.alive) $(root, "[data-list]").innerHTML = list(fresh);
    215 − };
    216 −
    217 − // Импорт
    218 − const form = $(root, "[data-import]") as HTMLFormElement;
    219 − const importError = $(root, "[data-import-error]");
    220 − function openImport() {
    221 − form.hidden = false;
    222 − $(form, "#imp-title").focus();
    223 − }
    224 − root.querySelectorAll("[data-open-import]").forEach((b) => b.addEventListener("click", openImport));
    225 − $(root, "[data-close-import]").addEventListener("click", () => {
    226 − form.hidden = true;
    227 − importError.hidden = true;
    228 − });
    229 − form.addEventListener("submit", async (event) => {
    230 − event.preventDefault();
    231 − const data = new FormData(form);
    232 − try {
    233 − const m = await busy($(form, "[type=submit]") as HTMLButtonElement, "Создаю…", () =>
    234 − api.importMeeting(String(data.get("title")), String(data.get("text"))),
    235 − );
    236 − location.hash = `#/meeting/${encodeURIComponent(m.id)}`;
    237 − } catch (e) {
    238 − importError.textContent = errorText(e);
    239 − importError.hidden = false;
    240 − }
    241 − });
    242 −
    243 − bindCard();
    244 −
    245 − scope.on(
    246 − events.status(async (s) => {
    247 − const changedMeeting = s.kind !== current.kind;
    248 − current = s;
    249 − if (s.kind !== "recording") live.length = 0;
    250 − // Ключ и «предупреждаю сам» могли поменяться, пока экран открыт.
    251 − view = await api.getSettings();
    252 − if (!scope.alive) return;
    253 − error.hidden = true;
    254 − $(root, "[data-card]").innerHTML = card(s, view);
    255 − bindCard();
    256 − if (changedMeeting) await refreshList();
    257 − }),
    258 − );
    259 − scope.on(
    260 − events.recorder(({ event }) => {
    261 − if (event.kind === "segments") {
    262 − live.push(...event.data);
    263 − renderLive();
    264 − } else if (event.kind === "level") {
    265 − const fill = root.querySelector<HTMLElement>(`[data-level="${speakerKey(event.data.speaker)}"]`);
    266 − if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
    267 − } else if (event.kind === "track_failed") {
    268 − fail(event.data.error);
    269 − }
    270 − }),
    271 − );
    272 − scope.on(events.meetingChanged(() => refreshList()));
    273 − scope.every(1000, () => {
    274 − const t = root.querySelector("[data-timer]");
    275 − if (t && current.kind === "recording") t.textContent = elapsed(current.started_at);
    276 − });
    277 −}
  • Показать разницуСкрыть разницу
    1 −// Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора.
    2 −// Пока идёт запись, расшифровка и живой конспект обновляются по событиям ядра.
    3 −
    4 −import {
    5 − api, errorText, events, formatDate, formatDuration, formatTime, speakerLabel,
    6 − type Analysis, type MeetingDetail, type Segment,
    7 −} from "../api";
    8 −import { $, busy, esc, list } from "../dom";
    9 −import type { Scope } from "../scope";
    10 −
    11 −export type Tab = "transcript" | "summary" | "tasks";
    12 −
    13 −function jump(d: MeetingDetail, index: number): string {
    14 − const seg = d.transcript.segments[index];
    15 − if (!seg) return "";
    16 − return `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`;
    17 −}
    18 −
    19 −function segmentRow(s: Segment, i: number, target: number | null): string {
    20 − return `
    21 − <div class="segment${i === target ? " is-target" : ""}${s.speaker.kind === "me" ? " is-me" : ""}" id="seg-${i}">
    22 − <span class="at mono">${formatTime(s.start_ms)}</span>
    23 − <p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>
    24 − </div>`;
    25 −}
    26 −
    27 −function transcriptTab(d: MeetingDetail, target: number | null): string {
    28 − if (!d.transcript.segments.length) {
    29 − return `<section class="box" data-segments><p class="empty">${
    30 − d.meeting.state === "recording"
    31 − ? "Реплики появятся здесь через несколько секунд после начала разговора."
    32 − : "В записи не прозвучало ни одной реплики."
    33 − }</p></section>`;
    34 − }
    35 − return `<section class="box" data-segments>${d.transcript.segments.map((s, i) => segmentRow(s, i, target)).join("")}</section>`;
    36 −}
    37 −
    38 −function liveSummary(d: MeetingDetail): string {
    39 − if (!d.live_summary) return "";
    40 − return `
    41 − <section class="box live-summary">
    42 − <div class="box-head"><h2>Живой конспект</h2><span class="xs muted">обновляется каждые несколько минут</span></div>
    43 − <div class="box-body"><p class="pre">${esc(d.live_summary)}</p></div>
    44 − </section>`;
    45 −}
    46 −
    47 −function notAnalyzed(d: MeetingDetail): string {
    48 − const why =
    49 − d.meeting.state === "recording"
    50 − ? "Разбор появится сам, когда встреча закончится."
    51 − : "Разбора ещё нет. Нажмите «Разобрать» — ИИ сделает конспект, решения и задачи.";
    52 − return `${liveSummary(d)}<section class="box"><p class="empty">${why}</p></section>`;
    53 −}
    54 −
    55 −function summaryTab(d: MeetingDetail, a: Analysis): string {
    56 − const r = a.review;
    57 − const review = [
    58 − r.achieved.length ? `<h3>Чего добились</h3>${list(r.achieved)}` : "",
    59 − r.open.length ? `<h3>Осталось открытым</h3>${list(r.open)}` : "",
    60 − r.off_track.length ? `<h3>Где ушли в сторону</h3>${list(r.off_track)}` : "",
    61 − `<p><strong>Итог:</strong> ${esc(r.verdict)}</p>`,
    62 − ].join("");
    63 − return `
    64 − <section class="prose">
    65 − <p>${esc(a.summary)}</p>
    66 − <div class="section"><h2>Ревью</h2>${review}</div>
    67 − <div class="section"><h2>Решения</h2>${
    68 − a.decisions.length
    69 − ? `<ul>${a.decisions.map((x) => `<li>${esc(x.text)} ${jump(d, x.source_segment)}</li>`).join("")}</ul>`
    70 − : `<p class="muted">Явных решений не было.</p>`
    71 − }</div>
    72 − ${a.open_questions.length ? `<div class="section"><h2>Открытые вопросы</h2>${list(a.open_questions)}</div>` : ""}
    73 − </section>`;
    74 −}
    75 −
    76 −function tasksTab(d: MeetingDetail, a: Analysis): string {
    77 − if (!a.tasks.length) return `<section class="box"><p class="empty">Задач не прозвучало.</p></section>`;
    78 − return `<section class="box">${a.tasks
    79 − .map(
    80 − (t) => `
    81 − <div class="task">
    82 − <p class="text">${esc(t.text)}</p>
    83 − <p class="small muted">${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}</p>
    84 − <p class="quote">${esc(t.quote)}</p>
    85 − </div>`,
    86 − )
    87 − .join("")}</section>`;
    88 −}
    89 −
    90 −function cost(d: MeetingDetail): string {
    91 − const a = d.meeting.analysis;
    92 − if (!a) return "";
    93 − const price = a.usage.cost != null ? ` · $${a.usage.cost.toFixed(4)}` : "";
    94 − return `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`;
    95 −}
    96 −
    97 −export async function renderMeeting(
    98 − root: HTMLElement,
    99 − parent: Scope,
    100 − id: string,
    101 − tab: Tab,
    102 − target: number | null = null,
    103 −): Promise<void> {
    104 − const scope = parent.child();
    105 − const d = await api.getMeeting(id);
    106 − if (!scope.alive) return;
    107 − const rerender = (t: Tab = tab, at: number | null = null) => {
    108 − scope.dispose();
    109 − return renderMeeting(root, parent, id, t, at);
    110 − };
    111 − const a = d.analysis;
    112 − const recording = d.meeting.state === "recording";
    113 − const base = `#/meeting/${encodeURIComponent(id)}`;
    114 − const tabLink = (key: Tab, label: string, count?: number) =>
    115 − `<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${
    116 − count !== undefined ? ` <span class="ui-nav-count">${count}</span>` : ""
    117 − }</a>`;
    118 −
    119 − const body =
    120 − tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed(d) : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
    121 − const stateLabel = recording
    122 − ? ` <span class="label label-accent">идёт запись</span>`
    123 − : d.meeting.state === "interrupted"
    124 − ? ` <span class="label" title="Программа закрылась посреди записи; всё, что успело распознаться, сохранено">прервана</span>`
    125 − : "";
    126 −
    127 − root.innerHTML = `
    128 − <div class="page-head">
    129 − <div>
    130 − <h1>${esc(d.meeting.title)}</h1>
    131 − <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · <span data-count>${d.meeting.segments}</span> реплик${stateLabel}</p>
    132 − </div>
    133 − <div class="actions">
    134 − ${recording ? "" : `<button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>`}
    135 − </div>
    136 − </div>
    137 − <p class="error" data-error hidden></p>
    138 − <nav class="ui-underline-nav" aria-label="Встреча">
    139 − ${tabLink("transcript", "Расшифровка", d.meeting.segments)}
    140 − ${tabLink("summary", "Конспект")}
    141 − ${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)}
    142 − </nav>
    143 − ${body}
    144 − ${cost(d)}`;
    145 −
    146 − if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });
    147 −
    148 − const error = $(root, "[data-error]");
    149 − root.querySelector<HTMLButtonElement>("[data-analyze]")?.addEventListener("click", async (event) => {
    150 − error.hidden = true;
    151 − try {
    152 − await busy(event.currentTarget as HTMLButtonElement, "Разбираю…", () => api.analyzeMeeting(id));
    153 − if (tab === "summary") await rerender("summary");
    154 − else location.hash = `${base}/summary`;
    155 − } catch (e) {
    156 − error.textContent = errorText(e);
    157 − error.hidden = false;
    158 − }
    159 − });
    160 −
    161 − // Живая расшифровка: новые реплики дописываются на место по времени, лента
    162 − // едет вниз сама, пока человек не прокрутил вверх.
    163 − const segments = d.transcript.segments;
    164 − scope.on(
    165 − events.recorder(({ meeting_id, event }) => {
    166 − if (meeting_id !== id) return;
    167 − if (event.kind === "segments") {
    168 − segments.push(...event.data);
    169 − segments.sort((x, y) => x.start_ms - y.start_ms);
    170 − const count = root.querySelector("[data-count]");
    171 − if (count) count.textContent = String(segments.length);
    172 − const box = root.querySelector<HTMLElement>("[data-segments]");
    173 − if (tab !== "transcript" || !box) return;
    174 − const atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
    175 − box.innerHTML = segments.map((s, i) => segmentRow(s, i, null)).join("");
    176 − if (atBottom) window.scrollTo({ top: document.body.scrollHeight });
    177 − } else if (event.kind === "live_summary" && tab !== "transcript" && !a) {
    178 − rerender();
    179 − } else if (event.kind === "stt_failed") {
    180 − error.textContent = `Кусок записи не распознался: ${event.data}. Звук сохранён.`;
    181 − error.hidden = false;
    182 − }
    183 − }),
    184 − );
    185 − scope.on(
    186 − events.meetingChanged((changed) => {
    187 − if (changed === id) rerender(tab, target);
    188 − }),
    189 − );
    190 −}
  • Показать разницуСкрыть разницу
    1 +// Экран встречи: заметка в Markdown и шаги над ней.
    2 +//
    3 +// 1. Звук — записан или добавлен файлом.
    4 +// 2. «Расшифровать» — расшифровка ложится в заметку.
    5 +// 3. Команды ИИ — «Конспект», «Задачи», «Ревью» или свой запрос; ответ
    6 +// дописывается в конец заметки.
    7 +//
    8 +// Всё, что в заметке, можно править и копировать: целиком или по разделам.
    9 +
    10 +import {
    11 + api, elapsed, events, formatDate, formatDuration, meetingDuration,
    12 + type Command, type MeetingDetail, type Speaker,
    13 +} from "../api";
    14 +import { $, esc } from "../dom";
    15 +import { plain, render, sections, toggleTask } from "../markdown";
    16 +import type { Scope } from "../scope";
    17 +import { changed, copy, onChange, state, toast } from "../state";
    18 +import { refreshSidebar, stopRecording } from "./sidebar";
    19 +
    20 +type Mode = "view" | "edit";
    21 +let mode: Mode = "view";
    22 +
    23 +const SAVE_DELAY_MS = 600;
    24 +
    25 +function audioLine(d: MeetingDetail): string {
    26 + const m = d.meeting;
    27 + if (m.recording) return "идёт запись";
    28 + if (!m.audio.length) return "без звука";
    29 + const who = (s: Speaker) => (s.kind === "me" ? "я" : s.kind === "others" ? "собеседники" : "");
    30 + const names = m.audio.map((a) => (a.speaker.kind === "unknown" ? a.file : who(a.speaker))).join(", ");
    31 + return `звук: ${names}`;
    32 +}
    33 +
    34 +function recordingPanel(d: MeetingDetail, warning: string): string {
    35 + const r = state.recording;
    36 + if (!r || r.meeting_id !== d.meeting.id) return "";
    37 + const meter = (label: string, key: string) => `
    38 + <div class="meter"><span class="meter-label small">${label}</span>
    39 + <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span></div>`;
    40 + return `
    41 + <section class="panel panel-live">
    42 + <p class="panel-kicker"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
    43 + r.paused ? "Запись на паузе" : r.zoom ? "Записываю Zoom и микрофон" : "Записываю микрофон — Zoom не найден"
    44 + }</p>
    45 + <p class="panel-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
    46 + <div class="meters">${meter("Я", "me")}${r.zoom ? meter("Собеседники", "others") : ""}</div>
    47 + <p class="small muted">Предупредите участников о записи:
    48 + <button class="link-btn" data-copy-warning>скопировать текст для чата Zoom</button></p>
    49 + <div class="actions">
    50 + <button class="ui-btn" data-stop>Остановить</button>
    51 + <button class="link-btn" data-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
    52 + </div>
    53 + <template data-warning>${esc(warning)}</template>
    54 + </section>`;
    55 +}
    56 +
    57 +function steps(d: MeetingDetail): string {
    58 + const m = d.meeting;
    59 + if (m.recording) return "";
    60 + const hasAudio = m.audio.length > 0;
    61 + const transcribe = hasAudio
    62 + ? m.transcribed_at
    63 + ? `<button class="link-btn" data-transcribe>Расшифровать заново</button>`
    64 + : `<button class="ui-btn" data-transcribe>Расшифровать</button>`
    65 + : "";
    66 + return `
    67 + <section class="steps">
    68 + ${
    69 + hasAudio && !m.transcribed_at
    70 + ? `<div class="step-row">${transcribe}<span class="small muted" data-progress>${
    71 + meetingDuration(m) >= 10 * 60_000 ? `Звук ${formatDuration(meetingDuration(m))} — расшифровка займёт несколько минут.` : ""
    72 + }</span></div>`
    73 + : ""
    74 + }
    75 + <div class="step-row ai-row">
    76 + <span class="small muted">ИИ:</span>
    77 + <button class="chip" data-cmd="summary">Конспект</button>
    78 + <button class="chip" data-cmd="tasks">Задачи</button>
    79 + <button class="chip" data-cmd="review">Ревью</button>
    80 + ${hasAudio && m.transcribed_at ? `<span class="spacer"></span>${transcribe}` : ""}
    81 + </div>
    82 + <form class="ask" data-ask>
    83 + <input class="input" name="text" autocomplete="off" placeholder="Или своё: «напиши письмо клиенту по итогам», «выпиши все цифры»" aria-label="Что сделать с заметкой">
    84 + <button class="chip" type="submit">Сделать</button>
    85 + </form>
    86 + <p class="small muted" data-busy hidden></p>
    87 + </section>`;
    88 +}
    89 +
    90 +function viewHtml(note: string): string {
    91 + const parts = sections(note);
    92 + if (!parts.length) {
    93 + return `<div class="note-empty">
    94 + <p>Заметка пустая.</p>
    95 + <p class="small muted">Расшифруйте звук, попросите ИИ или <button class="link-btn" data-to-edit>напишите сами</button>.</p>
    96 + </div>`;
    97 + }
    98 + return parts
    99 + .map(
    100 + (s, i) => `
    101 + <section class="note-section" data-section="${i}">
    102 + <button class="section-copy link-btn" data-copy-section="${i}" title="Копировать раздел в Markdown">Копировать</button>
    103 + <div class="md">${render(s.source)}</div>
    104 + </section>`,
    105 + )
    106 + .join("");
    107 +}
    108 +
    109 +export async function renderNote(root: HTMLElement, scope: Scope, id: string): Promise<void> {
    110 + const [d, view] = await Promise.all([api.getMeeting(id), api.getSettings()]);
    111 + if (!scope.alive) return;
    112 + let note = d.note;
    113 + let dirty = false;
    114 + let saveTimer = 0;
    115 + let working = false;
    116 +
    117 + const draw = () => {
    118 + // Перерисовка — только когда работа закончилась; флаг снимается здесь,
    119 + // иначе после удачной расшифровки следующая команда молча не запускалась бы.
    120 + working = false;
    121 + root.innerHTML = `
    122 + <article class="note">
    123 + <header class="note-head">
    124 + <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название встречи" spellcheck="false">
    125 + <p class="small muted">${esc(formatDate(d.meeting.created_at))}${
    126 + formatDuration(meetingDuration(d.meeting)) ? ` · ${esc(formatDuration(meetingDuration(d.meeting)))}` : ""
    127 + } · ${esc(audioLine(d))}</p>
    128 + </header>
    129 + ${recordingPanel(d, view.settings.warning_text)}
    130 + ${steps(d)}
    131 + <section class="editor">
    132 + <div class="editor-bar">
    133 + <nav class="ui-underline-nav" aria-label="Вид заметки">
    134 + <a href="#" data-mode="view"${mode === "view" ? ` aria-current="page"` : ""}>Текст</a>
    135 + <a href="#" data-mode="edit"${mode === "edit" ? ` aria-current="page"` : ""}>Правка</a>
    136 + </nav>
    137 + <span class="xs muted" data-save-state></span>
    138 + <span class="spacer"></span>
    139 + <button class="link-btn" data-copy-all>Копировать всё</button>
    140 + <button class="link-btn" data-copy-plain title="Без разметки — для мессенджера или письма">Как текст</button>
    141 + </div>
    142 + ${
    143 + mode === "edit"
    144 + ? `<textarea class="note-editor" data-editor spellcheck="true" placeholder="Пишите в Markdown: # заголовок, - список, **жирный**">${esc(note)}</textarea>`
    145 + : `<div class="note-view" data-view>${viewHtml(note)}</div>`
    146 + }
    147 + </section>
    148 + <footer class="note-foot"><button class="link-btn link-danger" data-delete>Удалить встречу</button></footer>
    149 + </article>`;
    150 + bind();
    151 + };
    152 +
    153 + const saveState = (text: string) => {
    154 + const el = root.querySelector("[data-save-state]");
    155 + if (el) el.textContent = text;
    156 + };
    157 +
    158 + const flush = async () => {
    159 + window.clearTimeout(saveTimer);
    160 + if (!dirty) return;
    161 + dirty = false;
    162 + try {
    163 + await api.saveNote(id, note);
    164 + saveState("Сохранено");
    165 + } catch (e) {
    166 + dirty = true;
    167 + saveState("Не сохранено");
    168 + toast(e);
    169 + }
    170 + };
    171 +
    172 + const setBusy = (text: string | null) => {
    173 + working = text !== null;
    174 + const el = root.querySelector<HTMLElement>("[data-busy]");
    175 + if (el) {
    176 + el.hidden = text === null;
    177 + el.textContent = text ?? "";
    178 + }
    179 + root.querySelectorAll<HTMLButtonElement>("[data-transcribe], [data-cmd], [data-ask] button").forEach((b) => (b.disabled = working));
    180 + const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]");
    181 + if (editor) editor.readOnly = working;
    182 + };
    183 +
    184 + const runCommand = async (command: Command, label: string) => {
    185 + if (working) return;
    186 + await flush();
    187 + setBusy(`${label}: ИИ читает заметку и пишет ответ…`);
    188 + try {
    189 + note = await api.runCommand(id, command);
    190 + mode = "view";
    191 + draw();
    192 + root.querySelector(".note-section:last-child")?.scrollIntoView({ behavior: "smooth", block: "start" });
    193 + } catch (e) {
    194 + setBusy(null);
    195 + toast(e);
    196 + }
    197 + };
    198 +
    199 + const bind = () => {
    200 + const title = $(root, "[data-title]") as HTMLInputElement;
    201 + title.addEventListener("keydown", (e) => {
    202 + if (e.key === "Enter") title.blur();
    203 + if (e.key === "Escape") {
    204 + title.value = d.meeting.title;
    205 + title.blur();
    206 + }
    207 + });
    208 + title.addEventListener("change", async () => {
    209 + try {
    210 + d.meeting = await api.renameMeeting(id, title.value);
    211 + await refreshSidebar();
    212 + } catch (e) {
    213 + title.value = d.meeting.title;
    214 + toast(e);
    215 + }
    216 + });
    217 +
    218 + root.querySelectorAll<HTMLAnchorElement>("[data-mode]").forEach((a) =>
    219 + a.addEventListener("click", async (e) => {
    220 + e.preventDefault();
    221 + await flush();
    222 + mode = a.dataset.mode as Mode;
    223 + draw();
    224 + if (mode === "edit") root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
    225 + }),
    226 + );
    227 + root.querySelector("[data-to-edit]")?.addEventListener("click", () => {
    228 + mode = "edit";
    229 + draw();
    230 + root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
    231 + });
    232 +
    233 + const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]");
    234 + editor?.addEventListener("input", () => {
    235 + note = editor.value;
    236 + dirty = true;
    237 + saveState("Изменено…");
    238 + window.clearTimeout(saveTimer);
    239 + saveTimer = window.setTimeout(flush, SAVE_DELAY_MS);
    240 + });
    241 +
    242 + root.querySelector("[data-copy-all]")?.addEventListener("click", () => copy(note, "Заметка скопирована в Markdown"));
    243 + root.querySelector("[data-copy-plain]")?.addEventListener("click", () => copy(plain(note), "Заметка скопирована текстом"));
    244 + root.querySelectorAll<HTMLButtonElement>("[data-copy-section]").forEach((b) =>
    245 + b.addEventListener("click", () => {
    246 + const s = sections(note)[Number(b.dataset.copySection)];
    247 + if (s) copy(s.source, s.heading ? `Раздел «${s.heading}» скопирован` : "Скопировано");
    248 + }),
    249 + );
    250 +
    251 + // Галочки задач в режиме просмотра — живые: отметка пишется в Markdown.
    252 + root.querySelectorAll<HTMLInputElement>("[data-view] input[type=checkbox]").forEach((box, n) => {
    253 + box.disabled = working;
    254 + box.addEventListener("change", async () => {
    255 + note = toggleTask(note, n);
    256 + dirty = true;
    257 + await flush();
    258 + });
    259 + });
    260 +
    261 + root.querySelector("[data-transcribe]")?.addEventListener("click", async () => {
    262 + if (working) return;
    263 + if (d.meeting.transcribed_at && !confirm("Расшифровать заново? Новая расшифровка добавится в конец заметки, старая останется.")) return;
    264 + await flush();
    265 + setBusy("Готовлю звук…");
    266 + try {
    267 + const result = await api.transcribe(id);
    268 + note = result.note;
    269 + d.meeting = (await api.getMeeting(id)).meeting;
    270 + mode = "view";
    271 + draw();
    272 + if (result.failed) toast(`Не распознано кусков: ${result.failed}. ${result.last_error ?? ""}`);
    273 + await refreshSidebar();
    274 + } catch (e) {
    275 + setBusy(null);
    276 + toast(e);
    277 + }
    278 + });
    279 +
    280 + root.querySelectorAll<HTMLButtonElement>("[data-cmd]").forEach((b) =>
    281 + b.addEventListener("click", () => runCommand({ kind: b.dataset.cmd as "summary" | "tasks" | "review" }, b.textContent ?? "")),
    282 + );
    283 + root.querySelector<HTMLFormElement>("[data-ask]")?.addEventListener("submit", (e) => {
    284 + e.preventDefault();
    285 + const input = (e.currentTarget as HTMLFormElement).elements.namedItem("text") as HTMLInputElement;
    286 + const text = input.value.trim();
    287 + if (!text) {
    288 + input.focus();
    289 + return;
    290 + }
    291 + runCommand({ kind: "custom", text }, "Свой запрос");
    292 + });
    293 +
    294 + root.querySelector("[data-stop]")?.addEventListener("click", async () => {
    295 + await stopRecording();
    296 + d.meeting = (await api.getMeeting(id)).meeting;
    297 + draw();
    298 + });
    299 + root.querySelector("[data-pause]")?.addEventListener("click", async () => {
    300 + if (!state.recording) return;
    301 + try {
    302 + await api.recorderPause(!state.recording.paused);
    303 + state.recording.paused = !state.recording.paused;
    304 + changed();
    305 + } catch (e) {
    306 + toast(e);
    307 + }
    308 + });
    309 + root.querySelector("[data-copy-warning]")?.addEventListener("click", () =>
    310 + copy(view.settings.warning_text, "Текст предупреждения скопирован"),
    311 + );
    312 +
    313 + root.querySelector("[data-delete]")?.addEventListener("click", async () => {
    314 + if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком? Это не отменить.`)) return;
    315 + try {
    316 + await api.deleteMeeting(id);
    317 + await refreshSidebar();
    318 + location.hash = "#/";
    319 + } catch (e) {
    320 + toast(e);
    321 + }
    322 + });
    323 + };
    324 +
    325 + draw();
    326 +
    327 + scope.on(
    328 + events.progress(({ meeting_id, done, total }) => {
    329 + if (meeting_id !== id) return;
    330 + setBusy(total ? `Расшифровываю: ${done} из ${total} кусков` : "В записи не нашлось речи");
    331 + }),
    332 + );
    333 + scope.on(
    334 + events.recorder(({ meeting_id, event }) => {
    335 + if (meeting_id !== id) return;
    336 + if (event.kind === "level") {
    337 + const key = event.data.speaker.kind === "me" ? "me" : "others";
    338 + const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`);
    339 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
    340 + } else {
    341 + toast(`Дорожка «${event.data.speaker.kind === "me" ? "я" : "собеседники"}» перестала писаться: ${event.data.error}`);
    342 + }
    343 + }),
    Разница обрезана: показаны первые 343 строк.
  • src/views/settings.ts +27 −46
    Показать разницуСкрыть разницу
    1 −// Экран «Настройки»: ключ OpenRouter, модели, предупреждение участникам.
    1 +// Экран «Настройки»: ключ OpenRouter, модели, язык, предупреждение участникам.
    2 2
    3 3 import { api, errorText, type KeyInfo, type SettingsView } from "../api";
    4 4 import { $, busy, esc } from "../dom";
    21 21 ? `<p>Сохранён: <code>${esc(view.openrouter_key)}</code></p>
    22 22 <div class="actions">
    23 23 <button class="ui-btn" data-check-key>Проверить</button>
    24 − <button class="ui-btn" data-delete-key>Удалить ключ</button>
    24 + <button class="link-btn link-danger" data-delete-key>Удалить ключ</button>
    25 25 </div>`
    26 26 : `<form class="field" data-key-form>
    27 27 <label for="or-key">Ключ</label>
    28 28 <input id="or-key" class="input" name="key" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…" required>
    29 − <p class="field-hint">Ключ проверяется в OpenRouter и хранится в хранилище паролей Windows, не в файлах. Через него идут расшифровка и разбор.</p>
    29 + <p class="field-hint">Через ключ идут расшифровка и команды ИИ, платите вы OpenRouter напрямую. Ключ проверяется при сохранении и хранится в хранилище паролей системы, не в файлах. <a href="https://openrouter.ai/keys" target="_blank" rel="noreferrer">Где взять ключ</a></p>
    30 30 <div class="actions"><button class="ui-btn" type="submit">Сохранить ключ</button></div>
    31 31 </form>`
    32 32 }
    39 39 const view = await api.getSettings();
    40 40 const s = view.settings;
    41 41 root.innerHTML = `
    42 + <div class="page">
    42 43 <div class="page-head"><h1>Настройки</h1></div>
    43 44 ${keyBox(view)}
    44 45
    45 46 <form class="box" data-settings>
    46 − <div class="box-head"><h2>Запись и ИИ</h2></div>
    47 + <div class="box-head"><h2>Расшифровка и ИИ</h2></div>
    47 48 <div class="box-body">
    48 49 <div class="field">
    49 − <label for="m-analysis">Модель итогового разбора</label>
    50 − <input id="m-analysis" class="input" name="analysis_model" value="${esc(s.analysis_model)}" required spellcheck="false">
    51 − </div>
    52 − <div class="field">
    53 − <label for="m-live">Модель живого конспекта</label>
    54 − <input id="m-live" class="input" name="live_model" value="${esc(s.live_model)}" required spellcheck="false">
    55 − </div>
    56 − <div class="field">
    57 − <label for="m-stt">Модель распознавания речи</label>
    58 − <input id="m-stt" class="input" name="stt_model" value="${esc(s.stt_model)}" required spellcheck="false">
    59 − <p class="field-hint">Имена моделей — как в каталоге OpenRouter.</p>
    60 − </div>
    61 − <div class="field">
    62 − <label for="warn">Предупреждение участникам</label>
    63 − <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea>
    64 − <p class="field-hint">Перед записью этот текст можно скопировать в чат Zoom. Записывать разговор без ведома участников нельзя.</p>
    65 − </div>
    66 − <label class="check">
    67 − <input type="checkbox" name="always_warn_myself"${s.always_warn_myself ? " checked" : ""}>
    68 − <span>Я всегда предупреждаю участников сам — не спрашивать перед каждой записью</span>
    69 − </label>
    70 − <div class="field">
    71 50 <label for="lang">Язык встреч</label>
    72 51 <select id="lang" class="input" name="language">
    73 52 ${[["ru", "Русский"], ["en", "Английский"], ["", "Определять самому"]]
    74 53 .map(([v, t]) => `<option value="${v}"${s.language === v ? " selected" : ""}>${t}</option>`)
    75 54 .join("")}
    76 55 </select>
    77 − <p class="field-hint">Короткие куски речи распознаются точнее, когда язык задан заранее.</p>
    56 + <p class="field-hint">Когда язык задан, речь распознаётся точнее.</p>
    78 57 </div>
    79 − <label class="check">
    80 − <input type="checkbox" name="keep_audio"${s.keep_audio ? " checked" : ""}>
    81 − <span>Хранить звук встреч после разбора. Без этой отметки звук удаляется, остаются текст и разбор.</span>
    82 − </label>
    83 − ${
    84 − view.capture_supported
    85 − ? `<label class="check">
    86 − <input type="checkbox" name="launch_at_login"${s.launch_at_login ? " checked" : ""}>
    87 − <span>Запускать вместе с Windows — иначе встречу, начатую до запуска программы, некому записать</span>
    88 − </label>`
    89 − : ""
    90 − }
    58 + <div class="field">
    59 + <label for="warn">Текст предупреждения участникам</label>
    60 + <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea>
    61 + <p class="field-hint">Во время записи его можно скопировать одной кнопкой и отправить в чат Zoom. Записывать разговор без ведома участников нельзя.</p>
    62 + </div>
    63 + <details class="advanced">
    64 + <summary class="small">Модели</summary>
    65 + <div class="field">
    66 + <label for="m-ai">Модель для команд ИИ</label>
    67 + <input id="m-ai" class="input" name="ai_model" value="${esc(s.ai_model)}" required spellcheck="false">
    68 + </div>
    69 + <div class="field">
    70 + <label for="m-stt">Модель распознавания речи</label>
    71 + <input id="m-stt" class="input" name="stt_model" value="${esc(s.stt_model)}" required spellcheck="false">
    72 + <p class="field-hint">Имена — как в каталоге OpenRouter.</p>
    73 + </div>
    74 + </details>
    91 75 <div class="field">
    92 76 <span class="field-label">Папка встреч</span>
    93 77 <code>${esc(view.meetings_dir)}</code>
    95 79 <p data-settings-status hidden></p>
    96 80 <div class="actions"><button class="ui-btn" type="submit">Сохранить</button></div>
    97 81 </div>
    98 − </form>`;
    82 + </form>
    83 + </div>`;
    99 84
    100 85 const keyStatus = $(root, "[data-key-status]");
    101 86 const show = (el: HTMLElement, text: string, ok: boolean) => {
    142 127 try {
    143 128 await busy($(form, "[type=submit]") as HTMLButtonElement, "Сохраняю…", () =>
    144 129 api.saveSettings({
    145 − analysis_model: String(data.get("analysis_model")).trim(),
    146 − live_model: String(data.get("live_model")).trim(),
    130 + ai_model: String(data.get("ai_model")).trim(),
    147 131 stt_model: String(data.get("stt_model")).trim(),
    148 − warning_text: String(data.get("warning_text")).trim(),
    149 − always_warn_myself: data.get("always_warn_myself") === "on",
    150 132 language: String(data.get("language") ?? ""),
    151 − keep_audio: data.get("keep_audio") === "on",
    152 − launch_at_login: view.capture_supported ? data.get("launch_at_login") === "on" : s.launch_at_login,
    133 + warning_text: String(data.get("warning_text")).trim(),
    153 134 }),
    154 135 );
    155 136 show(status, "Сохранено", true);
  • Показать разницуСкрыть разницу
    1 +// Боковая панель: три действия и список встреч.
    2 +//
    3 +// Записать — запись Zoom и микрофона, пока не нажмёте «Стоп»
    4 +// Добавить аудио — готовый файл с диктофона или из облака Zoom
    5 +// Новая заметка — пустая заметка, текст можно вставить руками
    6 +
    7 +import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "../api";
    8 +import { esc } from "../dom";
    9 +import { changed, onChange, refreshRecording, state, toast } from "../state";
    10 +
    11 +let root: HTMLElement;
    12 +let meetings: Meeting[] = [];
    13 +let captureSupported = true;
    14 +
    15 +function currentId(): string | null {
    16 + const m = /^#\/m\/([^/]+)/.exec(location.hash);
    17 + return m ? decodeURIComponent(m[1]) : null;
    18 +}
    19 +
    20 +function badges(m: Meeting): string {
    21 + if (m.recording) return `<span class="badge badge-live">запись</span>`;
    22 + if (m.transcribed_at) return "";
    23 + if (m.audio.length) return `<span class="badge">не расшифровано</span>`;
    24 + return "";
    25 +}
    26 +
    27 +function recordBlock(): string {
    28 + const r = state.recording;
    29 + if (r) {
    30 + return `
    31 + <a class="rec-now" href="#/m/${encodeURIComponent(r.meeting_id)}">
    32 + <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>
    33 + <span>${r.paused ? "Пауза" : "Идёт запись"}</span>
    34 + <span class="mono-num" data-rec-timer>${elapsed(r.started_at)}</span>
    35 + </a>
    36 + <button class="side-link side-stop" data-stop>Остановить запись</button>`;
    37 + }
    38 + return captureSupported
    39 + ? `<button class="ui-btn side-main" data-record>Записать</button>`
    40 + : `<p class="side-note xs muted">Запись работает в Windows. Здесь — готовые аудиофайлы.</p>`;
    41 +}
    42 +
    43 +function draw(): void {
    44 + const active = currentId();
    45 + root.innerHTML = `
    46 + <div class="side-actions">
    47 + ${recordBlock()}
    48 + <button class="side-link" data-add-audio>Добавить аудио</button>
    49 + <button class="side-link" data-new-note>Новая заметка</button>
    50 + </div>
    51 + <nav class="side-list" aria-label="Встречи">
    52 + ${
    53 + meetings.length
    54 + ? meetings
    55 + .map((m) => {
    56 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    57 + return `
    58 + <a class="side-item${m.id === active ? " is-active" : ""}" href="#/m/${encodeURIComponent(m.id)}"${
    59 + m.id === active ? ` aria-current="page"` : ""
    60 + }>
    61 + <span class="side-title">${esc(m.title)}</span>
    62 + <span class="side-meta xs muted">${esc(meta)} ${badges(m)}</span>
    63 + </a>`;
    64 + })
    65 + .join("")
    66 + : `<p class="side-empty small muted">Встреч пока нет.</p>`
    67 + }
    68 + </nav>`;
    69 +}
    70 +
    71 +export async function refreshSidebar(): Promise<void> {
    72 + meetings = await api.listMeetings();
    73 + draw();
    74 +}
    75 +
    76 +export async function addAudioFrom(path: string | null): Promise<void> {
    77 + if (!path) return;
    78 + if (!files.isAudio(path)) {
    79 + toast("Это не аудиофайл — подойдут mp3, m4a, wav, aiff, flac, ogg");
    80 + return;
    81 + }
    82 + toast("Читаю файл…", "ok");
    83 + try {
    84 + const m = await api.addAudio(path);
    85 + await refreshSidebar();
    86 + location.hash = `#/m/${encodeURIComponent(m.id)}`;
    87 + } catch (e) {
    88 + toast(e);
    89 + }
    90 +}
    91 +
    92 +export async function startRecording(): Promise<void> {
    93 + try {
    94 + const m = await api.recorderStart();
    95 + await refreshRecording();
    96 + await refreshSidebar();
    97 + location.hash = `#/m/${encodeURIComponent(m.id)}`;
    98 + } catch (e) {
    99 + toast(e);
    100 + }
    101 +}
    102 +
    103 +export async function stopRecording(): Promise<void> {
    104 + try {
    105 + await api.recorderStop();
    106 + await refreshRecording();
    107 + await refreshSidebar();
    108 + changed();
    109 + } catch (e) {
    110 + toast(e);
    111 + }
    112 +}
    113 +
    114 +export async function mountSidebar(el: HTMLElement): Promise<void> {
    115 + root = el;
    116 + captureSupported = (await api.getSettings()).capture_supported;
    117 + await refreshRecording();
    118 + await refreshSidebar();
    119 + root.addEventListener("click", async (e) => {
    120 + const t = (e.target as HTMLElement).closest<HTMLElement>("button");
    121 + if (!t) return;
    122 + if (t.matches("[data-record]")) await startRecording();
    123 + else if (t.matches("[data-stop]")) await stopRecording();
    124 + else if (t.matches("[data-add-audio]")) await addAudioFrom(await files.pickAudio());
    125 + else if (t.matches("[data-new-note]")) {
    126 + const m = await api.createMeeting();
    127 + await refreshSidebar();
    128 + location.hash = `#/m/${encodeURIComponent(m.id)}`;
    129 + }
    130 + });
    131 + window.addEventListener("hashchange", draw);
    132 + onChange(draw);
    133 + window.setInterval(() => {
    134 + const t = root.querySelector("[data-rec-timer]");
    135 + if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    136 + }, 1000);
    137 + // Файл, брошенный в окно, — новая встреча.
    138 + await files.onDrop(async (paths) => {
    139 + for (const p of paths) await addAudioFrom(p);
    140 + });
    141 +}
  • Показать разницуСкрыть разницу
    1 +// Экран без выбранной встречи: что можно сделать.
    2 +
    3 +import { api, files } from "../api";
    4 +import { addAudioFrom } from "./sidebar";
    5 +
    6 +export async function renderStart(root: HTMLElement): Promise<void> {
    7 + const view = await api.getSettings();
    8 + const noKey = !view.openrouter_key;
    9 + root.innerHTML = `
    10 + <section class="start">
    11 + <h1>Встреча → заметка</h1>
    12 + <ol class="start-steps">
    13 + <li><strong>Звук.</strong> ${
    14 + view.capture_supported
    15 + ? "«Записать» слева — перед встречей в Zoom: запишутся Zoom и ваш микрофон. Или добавьте"
    16 + : "Запись работает в Windows; здесь добавьте"
    17 + } готовый аудиофайл — с диктофона или из облака Zoom.</li>
    18 + <li><strong>Расшифровка.</strong> Когда нужно — «Расшифровать». Текст ляжет в заметку.</li>
    19 + <li><strong>ИИ.</strong> «Конспект», «Задачи», «Ревью» или свой запрос. Всё в заметке можно править и копировать.</li>
    20 + </ol>
    21 + <button class="drop-zone" data-add-audio>
    22 + <strong>Перетащите аудиофайл в окно</strong>
    23 + <span class="small muted">или нажмите, чтобы выбрать: mp3, m4a, wav, aiff, flac, ogg</span>
    24 + </button>
    25 + ${
    26 + noKey
    27 + ? `<p class="start-key small">Для расшифровки и ИИ нужен ключ OpenRouter — <a href="#/settings">подключить</a>.</p>`
    28 + : ""
    29 + }
    30 + </section>`;
    31 + root.querySelector("[data-add-audio]")?.addEventListener("click", async () => addAudioFrom(await files.pickAudio()));
    32 +}