skillok meeting

Простой путь: записать → расшифровать → получить результат

- Главная: «Записать» (или аудиофайл перетаскиванием) и записи со статусом. Боковая панель и «Новая заметка» убраны. - Экран записи: шаги «Аудио → Расшифровка → Результат», на экране только текущий шаг и одна главная кнопка. - Результаты вкладками: «Расшифровка», «Конспект», «Задачи», «Ревью», «Свой запрос»; копировать, как текст, править, сделать заново, удалить. Повтор команды заменяет раздел, а не дописывает второй. - Задача #9: без ключа кнопки ведут к подключению ИИ; у расшифровки и ответа ИИ — прогресс и «Отменить» (CancellationToken в ядре); идущая запись видна меткой в шапке на любом экране. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 30 сентября 2026 г. e75bd240fcc7fc458270d9e00688eec8cce33cd9
26 файлов
  • Cargo.lock +1
    Показать разницуСкрыть разницу
    3152 3152 "tempfile",
    3153 3153 "thiserror 2.0.21",
    3154 3154 "tokio",
    3155 + "tokio-util",
    3155 3156 ]
    3156 3157
    3157 3158 [[package]]
  • README.md +37 −28
    Показать разницуСкрыть разницу
    2 2
    3 3 Рабочее название. Продукт Skillok.
    4 4
    5 −Программа для Windows: встреча → заметка. Звук встречи записывается по кнопке или
    6 −добавляется готовым файлом, по кнопке расшифровывается, а дальше ИИ делает с заметкой
    7 −то, что попросите: конспект, задачи, ревью или что угодно своё. Заметка — обычный
    8 −Markdown: её можно править и копировать.
    5 +Программа для Windows. Один путь: **записать → расшифровать → получить результат.**
    6 +Звук встречи записывается по кнопке или добавляется готовым файлом, по кнопке
    7 +расшифровывается, а дальше ИИ делает конспект, задачи, ревью или то, что попросите.
    8 +Всё можно скопировать и поправить.
    9 9
    10 10 **Программа ничего не делает сама.** Не следит за Zoom, не висит в трее, не запускается
    11 11 вместе с Windows. Открыли — сделали — закрыли.
    17 17
    18 18 ## Как пользоваться
    19 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 − - Галочки задач отмечаются прямо в просмотре.
    20 +На главной — «Записать» и список записей. У каждой записи три шага, на экране только
    21 +текущий:
    22 +
    23 +1. **Аудио.**
    24 + - «Записать» — пишется звук Zoom и микрофон, пока не нажмёте «Остановить»; есть пауза
    25 + и кнопка «Скопировать предупреждение для чата». Zoom нужно запустить до нажатия,
    26 + иначе запишется только микрофон.
    27 + - Готовый файл — перетащить в окно или «выберите файл»: mp3, m4a, wav, aiff, flac, ogg.
    28 +2. **Расшифровка** — кнопка «Расшифровать». Виден ход работы, можно «Отменить».
    29 +3. **Результат** — вкладки:
    30 + - «Расшифровка» — текст с временем и говорящими («Вы», «Собеседники» у записи);
    31 + - «Конспект», «Задачи», «Ревью» — у пустой вкладки одна кнопка «Сделать»;
    32 + - «+ Свой запрос» — «напиши письмо клиенту по итогам», «выпиши все цифры»; ответ
    33 + становится своей вкладкой.
    34 +
    35 + У готовой вкладки: «Копировать» (Markdown), «Как текст», «Править», «Сделать заново»,
    36 + «Удалить». Галочки задач отмечаются прямо в тексте.
    37 +
    38 +Пока запись идёт, в шапке на любом экране видна метка «Запись 12:34».
    35 39
    36 40 ---
    37 41
    72 76 выдумывает фраз на тишине. Такие фразы («Продолжение следует…») дополнительно отсеиваются.
    73 77 - Неудачный кусок повторяется один раз; не распознавшийся остаётся в расшифровке пометкой.
    74 78
    75 −### Команды ИИ — Claude через OpenRouter
    79 +### Результаты — Claude через OpenRouter
    76 80
    77 81 - По умолчанию `anthropic/claude-opus-5.5`; модель меняется в настройках.
    78 82 - Промпты — файлами в `prompts/`: общий `base.md` и по одному на команду.
    79 −- Ответ — Markdown без заголовка: заголовок раздела ставит программа.
    83 +- Все результаты хранятся одной заметкой `note.md`, раздел на вкладку. Повтор команды
    84 + заменяет её раздел; прежний ответ модель при этом не видит.
    80 85
    81 86 ---
    82 87
    88 93 - Токены стиля — `src/styles/tokens.css`, копия дизайн-системы Skillok. Новых цветов не
    89 94 заводим.
    90 95
    91 −Слева — «Записать», «Добавить аудио», «Новая заметка» и список встреч. Справа —
    92 −выбранная встреча: название (правится на месте), шаги и заметка.
    96 +Одна колонка. Главная — «Записать» и записи со статусом («Нужно расшифровать»,
    97 +«Конспект · Задачи»). Экран записи — шаги «Аудио → Расшифровка → Результат» и вкладки
    98 +результатов.
    93 99
    94 100 ---
    95 101
    170 176 win.rs Windows: захват процесса Zoom и микрофона, поиск Zoom
    171 177 src-tauri/src/ ядро на Rust
    172 178 meetings.rs встречи на диске: meeting.json, note.md, audio/
    179 + notes.rs разделы заметки: поставить на место, убрать
    173 180 transcript.rs реплики и блок «Расшифровка» для заметки
    174 181 transcribe.rs расшифровка: дорожки → куски → Whisper → реплики
    175 182 ai.rs команды ИИ над заметкой
    178 185 settings.rs настройки; ключ — в хранилище паролей ОС (keyring)
    179 186 commands.rs команды для интерфейса
    180 187 src/ интерфейс: HTML/CSS/TS
    181 − views/ sidebar — список и действия, note — встреча, start, settings
    188 + views/ home — главная, meeting — шаги и вкладки, settings
    189 + recording.ts панель записи и метка «идёт запись» в шапке
    182 190 markdown.ts отображение Markdown, разделы, галочки задач
    183 191 dev-mock.ts заглушка ядра — только для просмотра в браузере
    184 192 prompts/ промпты команд ИИ
    195 203 ### Сделано
    196 204
    197 205 - [x] Каркас Tauri 2, стиль skillok.org, ключ OpenRouter в хранилище паролей ОС.
    198 −- [x] Встреча = заметка Markdown: правка, автосохранение, копирование целиком, текстом и
    199 − по разделам, галочки задач.
    200 −- [x] «Добавить аудио» (кнопкой и перетаскиванием) и «Расшифровать» с ходом работы.
    201 −- [x] Команды ИИ: «Конспект», «Задачи», «Ревью», свой запрос.
    202 206 - [x] «Записать» по кнопке: звук Zoom и микрофон двумя дорожками, пауза, стоп.
    207 +- [x] Аудиофайл кнопкой и перетаскиванием.
    208 +- [x] «Расшифровать» с ходом работы и отменой.
    209 +- [x] Результаты вкладками: «Конспект», «Задачи», «Ревью», свой запрос; копировать,
    210 + править, сделать заново, удалить; живые галочки задач.
    211 +- [x] Состояния без ключа, во время работы и во время записи (задача #9).
    203 212
    204 213 ### Дальше
    205 214
  • index.html +2 −4
    Показать разницуСкрыть разницу
    17 17 </svg>
    18 18 Skillok Meet
    19 19 </a>
    20 + <a id="rec-badge" class="rec-badge" hidden></a>
    20 21 <nav class="header-nav" aria-label="Разделы">
    21 22 <a href="#/settings" data-nav="settings">Настройки</a>
    22 23 </nav>
    23 24 </header>
    24 − <div class="layout">
    25 − <aside id="sidebar" class="sidebar"></aside>
    26 − <main id="app" class="main"></main>
    27 − </div>
    25 + <main id="app" class="main"></main>
    28 26 <p id="toast" class="toast" role="status" hidden></p>
    29 27 <script type="module" src="/src/main.ts"></script>
    30 28 </body>
  • prompts/base.md +1 −1
    Показать разницуСкрыть разницу
    1 −Ты помогаешь человеку работать с заметкой о встрече. Заметка — Markdown: в ней может быть его собственный текст, расшифровка встречи и ответы на прошлые просьбы. Он просит тебя о чём-то одном; твой ответ будет вставлен в конец заметки под заголовком, который программа поставит сама.
    1 +Ты помогаешь человеку получить пользу из записи встречи. У тебя заметка в Markdown: расшифровка встречи (человек мог её поправить) и, возможно, другие результаты — конспект, задачи. Он просит тебя о чём-то одном; твой ответ станет отдельным разделом заметки под заголовком, который программа поставит сама.
    2 2
    3 3 Расшифровка сделана автоматически: в ней бывают ошибки распознавания, оборванные фразы и повторы. Реплики помечены временем `**чч:мм:сс Говорящий:**`; «Я» — владелец заметки, «Собеседники» — остальные участники одной дорожкой, без разделения по людям. Если говорящих нет, запись шла одним файлом.
    4 4
  • Показать разницуСкрыть разницу
    18 18 meet-audio = { path = "../crates/audio" }
    19 19 base64 = "0.22"
    20 20 tokio = { version = "1", features = ["rt", "sync", "time", "macros"] }
    21 +tokio-util = "0.7"
    21 22 serde = { version = "1", features = ["derive"] }
    22 23 serde_json = "1"
    23 24 thiserror = "2"
  • src-tauri/src/ai.rs +28 −15
    Показать разницуСкрыть разницу
    1 −//! Команды ИИ над заметкой: готовые («Конспект», «Задачи», «Ревью») и свой
    2 −//! запрос человека. Модель получает заметку целиком — с правками человека,
    3 −//! расшифровкой и прошлыми ответами — и отвечает Markdown, который программа
    4 −//! дописывает в конец заметки под заголовком команды.
    1 +//! Результаты по расшифровке: готовые («Конспект», «Задачи», «Ревью») и свой
    2 +//! запрос человека. Модель получает заметку — расшифровку с правками человека
    3 +//! и другие результаты — и отвечает Markdown; программа кладёт ответ разделом
    4 +//! с заголовком команды на место прежнего.
    5 5
    6 6 use serde::Deserialize;
    7 7
    24 24 pub enum Error {
    25 25 #[error("Напишите, что сделать")]
    26 26 EmptyRequest,
    27 − #[error("Заметка пустая — сначала расшифруйте аудио или напишите текст")]
    27 + #[error("Сначала расшифруйте запись")]
    28 28 EmptyNote,
    29 29 }
    30 30
    36 36 pub user: String,
    37 37 }
    38 38
    39 −pub fn prepare(command: &Command, note: &str) -> Result<Prepared, Error> {
    40 − if note.trim().is_empty() {
    41 − return Err(Error::EmptyNote);
    42 − }
    43 − let (heading, task) = match command {
    44 − Command::Summary => ("Конспект".to_string(), SUMMARY.trim().to_string()),
    45 − Command::Tasks => ("Задачи".to_string(), TASKS.trim().to_string()),
    46 − Command::Review => ("Ревью".to_string(), REVIEW.trim().to_string()),
    39 +/// Заголовок раздела, в который ляжет ответ команды.
    40 +pub fn heading(command: &Command) -> Result<String, Error> {
    41 + Ok(match command {
    42 + Command::Summary => "Конспект".to_string(),
    43 + Command::Tasks => "Задачи".to_string(),
    44 + Command::Review => "Ревью".to_string(),
    47 45 Command::Custom(request) => {
    48 46 let request = request.trim();
    49 47 if request.is_empty() {
    50 48 return Err(Error::EmptyRequest);
    51 49 }
    52 − (heading_for(request), format!("Просьба человека: {request}"))
    50 + heading_for(request)
    53 51 }
    52 + })
    53 +}
    54 +
    55 +pub fn prepare(command: &Command, note: &str) -> Result<Prepared, Error> {
    56 + let heading = heading(command)?;
    57 + if note.trim().is_empty() {
    58 + return Err(Error::EmptyNote);
    59 + }
    60 + let task = match command {
    61 + Command::Summary => SUMMARY.trim().to_string(),
    62 + Command::Tasks => TASKS.trim().to_string(),
    63 + Command::Review => REVIEW.trim().to_string(),
    64 + Command::Custom(request) => format!("Просьба человека: {}", request.trim()),
    54 65 };
    55 66 Ok(Prepared {
    56 67 heading,
    100 111 fn ready_commands_have_headings_and_task_text() {
    101 112 let p = prepare(&Command::Tasks, "## Расшифровка\n\nтекст").unwrap();
    102 113 assert_eq!(p.heading, "Задачи");
    103 − assert!(p.system.starts_with("Ты помогаешь человеку работать с заметкой"));
    114 + assert!(p
    115 + .system
    116 + .starts_with("Ты помогаешь человеку получить пользу из записи встречи"));
    104 117 assert!(p.system.contains("- [ ] Что сделать"));
    105 118 assert_eq!(p.user, "<заметка>\n## Расшифровка\n\nтекст\n</заметка>");
    106 119 assert_eq!(prepare(&Command::Summary, "x").unwrap().heading, "Конспект");
  • Показать разницуСкрыть разницу
    2 2 //! ничего не запускает. Ошибка уходит строкой и показывается как есть, поэтому
    3 3 //! ошибки модулей написаны по-русски.
    4 4
    5 −use std::collections::HashSet;
    5 +use std::collections::HashMap;
    6 6 use std::path::{Path, PathBuf};
    7 7 use std::sync::{Arc, Mutex};
    8 8
    9 9 use chrono::{DateTime, Local};
    10 10 use serde::Serialize;
    11 11 use tauri::{AppHandle, Emitter, State};
    12 +use tokio_util::sync::CancellationToken;
    12 13
    13 14 use meet_audio::source::{Capture, CaptureError};
    14 15
    32 33 pub store: Arc<Store>,
    33 34 pub secrets: Arc<dyn Secrets>,
    34 35 pub recording: Mutex<Option<Recording>>,
    35 − /// Встречи, которые сейчас расшифровываются или обрабатываются ИИ.
    36 − pub busy: Mutex<HashSet<String>>,
    36 + /// Работа, которая сейчас идёт над встречей (расшифровка или ответ ИИ),
    37 + /// и её отмена — по кнопке «Отменить».
    38 + pub jobs: Mutex<HashMap<String, CancellationToken>>,
    37 39 }
    38 40
    39 41 impl AppState {
    48 50 .ok_or_else(|| "Не подключён ИИ: добавьте ключ OpenRouter в настройках".into())
    49 51 }
    50 52
    51 − /// Отмечает встречу занятой на время работы; вторая такая же работа — отказ.
    52 − fn claim(&self, id: &str) -> Result<BusyGuard<'_>, String> {
    53 − let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
    54 − if !busy.insert(id.to_string()) {
    55 − return Err("Со встречей уже идёт работа — дождитесь её".into());
    53 + /// Начинает работу над встречей; вторая работа над той же встречей — отказ.
    54 + /// Работа снимается, когда `Job` уходит из области видимости.
    55 + fn start_job(&self, id: &str) -> Result<Job<'_>, String> {
    56 + let mut jobs = self.jobs.lock().unwrap_or_else(|p| p.into_inner());
    57 + if jobs.contains_key(id) {
    58 + return Err("Над этой записью уже идёт работа — дождитесь её или отмените".into());
    56 59 }
    57 − Ok(BusyGuard {
    60 + let token = CancellationToken::new();
    61 + jobs.insert(id.to_string(), token.clone());
    62 + Ok(Job {
    58 63 state: self,
    59 64 id: id.to_string(),
    65 + token,
    60 66 })
    61 67 }
    62 68
    69 75 }
    70 76 }
    71 77
    72 −struct BusyGuard<'a> {
    78 +struct Job<'a> {
    73 79 state: &'a AppState,
    74 80 id: String,
    81 + token: CancellationToken,
    75 82 }
    76 83
    77 −impl Drop for BusyGuard<'_> {
    84 +impl Drop for Job<'_> {
    78 85 fn drop(&mut self) {
    79 86 self.state
    80 − .busy
    87 + .jobs
    81 88 .lock()
    82 89 .unwrap_or_else(|p| p.into_inner())
    83 90 .remove(&self.id);
    163 170 }
    164 171
    165 172 #[tauri::command]
    166 −pub fn create_meeting(state: State<'_, AppState>) -> Result<Meeting, String> {
    173 +pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> {
    174 + state.store.save_note(&id, &text).map_err(|e| e.to_string())
    175 +}
    176 +
    177 +/// Убрать результат — раздел заметки с этим заголовком.
    178 +#[tauri::command]
    179 +pub fn remove_section(state: State<'_, AppState>, id: String, heading: String) -> Result<String, String> {
    167 180 state
    168 181 .store
    169 − .create(&title_now("Заметка"), Local::now())
    182 + .remove_section(&id, &heading)
    170 183 .map_err(|e| e.to_string())
    171 184 }
    172 185
    186 +/// «Отменить» — расшифровку или ответ ИИ, что сейчас идёт над встречей.
    173 187 #[tauri::command]
    174 −pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> {
    175 − state.store.save_note(&id, &text).map_err(|e| e.to_string())
    188 +pub fn cancel_job(state: State<'_, AppState>, id: String) {
    189 + if let Some(token) = state.jobs.lock().unwrap_or_else(|p| p.into_inner()).get(&id) {
    190 + token.cancel();
    191 + }
    176 192 }
    177 193
    178 194 #[tauri::command]
    185 201 if state.is_recording(&id) {
    186 202 return Err("Сначала остановите запись".into());
    187 203 }
    188 − let _busy = state.claim(&id)?;
    204 + let _job = state.start_job(&id)?;
    189 205 state.store.delete(&id).map_err(|e| e.to_string())
    190 206 }
    191 207
    269 285 if state.is_recording(&id) {
    270 286 return Err("Сначала остановите запись".into());
    271 287 }
    272 − let _busy = state.claim(&id)?;
    273 288 let key = state.key()?;
    289 + let job = state.start_job(&id)?;
    274 290 let settings = state.settings()?;
    275 291 let meeting = state.store.get(&id).map_err(|e| e.to_string())?.meeting;
    276 292 if meeting.audio.is_empty() {
    297 313 model: settings.stt_model.clone(),
    298 314 language: settings.stt_language().map(str::to_string),
    299 315 });
    300 − let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), |done, total| {
    301 − let _ = app.emit(
    302 − "meet://progress",
    303 − Progress {
    304 − meeting_id: &id,
    305 − done,
    306 − total,
    307 − },
    308 − );
    309 − })
    310 − .await;
    316 + let outcome = transcribe::run(
    317 + tracks,
    318 + stt,
    319 + transcribe::Config::default(),
    320 + &job.token,
    321 + |done, total| {
    322 + let _ = app.emit(
    323 + "meet://progress",
    324 + Progress {
    Разница обрезана: показаны первые 139 строк.
  • Показать разницуСкрыть разницу
    4 4
    5 5 pub mod ai;
    6 6 pub mod meetings;
    7 +pub mod notes;
    7 8 pub mod openrouter;
    8 9 pub mod recorder;
    9 10 pub mod settings;
    12 13
    13 14 mod commands;
    14 15
    15 −use std::collections::HashSet;
    16 +use std::collections::HashMap;
    16 17 use std::sync::{Arc, Mutex};
    17 18
    18 19 use tauri::Manager;
    31 32 store,
    32 33 secrets: Arc::new(settings::Keyring),
    33 34 recording: Mutex::new(None),
    34 − busy: Mutex::new(HashSet::new()),
    35 + jobs: Mutex::new(HashMap::new()),
    35 36 });
    36 37 Ok(())
    37 38 })
    43 44 commands::delete_openrouter_key,
    44 45 commands::list_meetings,
    45 46 commands::get_meeting,
    46 − commands::create_meeting,
    47 47 commands::save_note,
    48 + commands::remove_section,
    49 + commands::cancel_job,
    48 50 commands::rename_meeting,
    49 51 commands::delete_meeting,
    50 52 commands::add_audio,
  • Показать разницуСкрыть разницу
    3 3 //! ```text
    4 4 //! <корень>/20260930-154300/
    5 5 //! meeting.json — название, даты, дорожки звука, когда расшифрована
    6 −//! note.md — заметка: её пишет человек, в неё же ложатся расшифровка
    7 −//! и ответы ИИ; единственный источник текста
    6 +//! note.md — результаты разделами: «Расшифровка», «Конспект», «Задачи»…
    7 +//! Человек может их править; единственный источник текста
    8 8 //! audio/ — дорожки: me.wav и others.wav из записи или исходный файл
    9 9 //! ```
    10 10 //!
    11 11 //! Программа ничего не делает сама: каждое изменение — по действию человека
    12 −//! (записать, добавить аудио, расшифровать, команда ИИ, правка заметки).
    13 −//! Изменения «прочитал — поменял — записал» идут под одним замком: расшифровка
    14 −//! дописывается в заметку, пока человек может её править.
    12 +//! (записать, добавить аудио, расшифровать, получить результат, правка).
    13 +//! Изменения «прочитал — поменял — записал» идут под одним замком.
    15 14
    16 15 use std::path::{Path, PathBuf};
    17 16 use std::sync::Mutex;
    185 184 Ok(m)
    186 185 }
    187 186
    188 − /// Дописывает блок в конец заметки, отделяя пустой строкой. Возвращает заметку целиком.
    189 − pub fn append_to_note(&self, id: &str, block: &str) -> Result<String, Error> {
    187 + /// Ставит раздел на место прежнего с тем же заголовком или в конец.
    188 + /// Возвращает заметку целиком.
    189 + pub fn upsert_section(&self, id: &str, heading: &str, block: &str) -> Result<String, Error> {
    190 + self.change_note(id, |note| crate::notes::upsert(note, heading, block))
    191 + }
    192 +
    193 + pub fn remove_section(&self, id: &str, heading: &str) -> Result<String, Error> {
    194 + self.change_note(id, |note| crate::notes::remove(note, heading))
    195 + }
    196 +
    197 + fn change_note(&self, id: &str, change: impl Fn(&str) -> String) -> Result<String, Error> {
    190 198 let _guard = self.lock();
    191 199 let dir = self.dir(id)?;
    192 − let mut note = std::fs::read_to_string(dir.join("note.md")).unwrap_or_default();
    193 − let trimmed = note.trim_end().len();
    194 − note.truncate(trimmed);
    195 − if !note.is_empty() {
    196 − note.push_str("\n\n");
    197 − }
    198 − note.push_str(block.trim());
    199 − note.push('\n');
    200 + let note = change(&std::fs::read_to_string(dir.join("note.md")).unwrap_or_default());
    200 201 std::fs::write(dir.join("note.md"), &note)?;
    201 202 let mut m: Meeting = read_json(&dir.join("meeting.json"))?;
    202 203 m.updated_at = Local::now();
    333 334 }
    334 335
    335 336 #[test]
    336 − fn note_is_saved_and_blocks_are_appended() {
    337 + fn sections_are_placed_and_removed() {
    337 338 let dir = tempfile::tempdir().unwrap();
    338 339 let store = Store::new(dir.path());
    339 340 let m = store.create("Встреча", at(10, 0)).unwrap();
    340 − assert_eq!(
    341 − store.append_to_note(&m.id, "## Расшифровка\n\nтекст").unwrap(),
    342 − "## Расшифровка\n\nтекст\n"
    343 − );
    344 341 store
    345 − .save_note(&m.id, "# Мои заметки\n\n## Расшифровка\n\nтекст\n\n\n")
    342 + .upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nтекст")
    343 + .unwrap();
    344 + store
    345 + .upsert_section(&m.id, "Задачи", "## Задачи\n\n- [ ] отчёт")
    346 346 .unwrap();
    347 347 let note = store
    348 − .append_to_note(&m.id, "\n## Задачи\n\n- [ ] отчёт\n")
    348 + .upsert_section(&m.id, "Расшифровка", "## Расшифровка\n\nновый текст")
    349 349 .unwrap();
    350 350 assert_eq!(
    351 351 note,
    352 − "# Мои заметки\n\n## Расшифровка\n\nтекст\n\n## Задачи\n\n- [ ] отчёт\n"
    352 + "## Расшифровка\n\nновый текст\n\n## Задачи\n\n- [ ] отчёт\n"
    353 + );
    354 + assert_eq!(
    355 + store.remove_section(&m.id, "Задачи").unwrap(),
    356 + "## Расшифровка\n\nновый текст\n"
    353 357 );
    354 − assert_eq!(store.get(&m.id).unwrap().note, note);
    358 + assert_eq!(store.get(&m.id).unwrap().note, "## Расшифровка\n\nновый текст\n");
    355 359 }
    356 360
    357 361 #[test]
  • Показать разницуСкрыть разницу
    1 +//! Разделы заметки. Заметка — Markdown; раздел — всё от заголовка второго
    2 +//! уровня (`## Задачи`) до следующего такого же. Интерфейс показывает разделы
    3 +//! вкладками, поэтому результат команды не дописывается вторым разделом с тем
    4 +//! же заголовком, а заменяет прежний.
    5 +//!
    6 +//! Правило разбиения то же, что в `src/markdown.ts`: `## ` в начале строки вне
    7 +//! блока кода ``` / ~~~.
    8 +
    9 +/// Заголовки разделов по порядку.
    10 +pub fn headings(note: &str) -> Vec<String> {
    11 + split(note).into_iter().filter_map(|(h, _)| h).collect()
    12 +}
    13 +
    14 +/// Кладёт `block` (он начинается с `## heading`) на место раздела с тем же
    15 +/// заголовком или, если такого нет, в конец. Прочие разделы не трогает.
    16 +pub fn upsert(note: &str, heading: &str, block: &str) -> String {
    17 + let block = block.trim().to_string();
    18 + let mut parts = split(note);
    19 + match parts.iter_mut().find(|(h, _)| h.as_deref() == Some(heading)) {
    20 + Some(part) => part.1 = block,
    21 + None => parts.push((Some(heading.to_string()), block)),
    22 + }
    23 + join(parts)
    24 +}
    25 +
    26 +/// Убирает раздел с таким заголовком, если он есть.
    27 +pub fn remove(note: &str, heading: &str) -> String {
    28 + join(
    29 + split(note)
    30 + .into_iter()
    31 + .filter(|(h, _)| h.as_deref() != Some(heading))
    32 + .collect(),
    33 + )
    34 +}
    35 +
    36 +/// Разделы: заголовок (у текста до первого заголовка — нет) и исходник целиком.
    37 +fn split(note: &str) -> Vec<(Option<String>, String)> {
    38 + let mut parts: Vec<(Option<String>, String)> = Vec::new();
    39 + let mut current: (Option<String>, Vec<&str>) = (None, Vec::new());
    40 + let mut fence = false;
    41 + for line in note.lines() {
    42 + if line.starts_with("```") || line.starts_with("~~~") {
    43 + fence = !fence;
    44 + }
    45 + if !fence {
    46 + if let Some(h) = line.strip_prefix("## ") {
    47 + let done = std::mem::replace(&mut current, (Some(h.trim().to_string()), Vec::new()));
    48 + push(&mut parts, done);
    49 + }
    50 + }
    51 + current.1.push(line);
    52 + }
    53 + push(&mut parts, current);
    54 + parts
    55 +}
    56 +
    57 +fn push(parts: &mut Vec<(Option<String>, String)>, (heading, lines): (Option<String>, Vec<&str>)) {
    58 + let text = lines.join("\n").trim().to_string();
    59 + if !text.is_empty() {
    60 + parts.push((heading, text));
    61 + }
    62 +}
    63 +
    64 +fn join(parts: Vec<(Option<String>, String)>) -> String {
    65 + let body: Vec<String> = parts
    66 + .into_iter()
    67 + .map(|(_, t)| t)
    68 + .filter(|t| !t.is_empty())
    69 + .collect();
    70 + if body.is_empty() {
    71 + String::new()
    72 + } else {
    73 + body.join("\n\n") + "\n"
    74 + }
    75 +}
    76 +
    77 +#[cfg(test)]
    78 +mod tests {
    79 + use super::*;
    80 +
    81 + const NOTE: &str = "## Расшифровка\n\n**00:00:01 Я:** Привет.\n\n## Задачи\n\n- [ ] Отчёт\n";
    82 +
    83 + #[test]
    84 + fn new_section_goes_to_the_end() {
    85 + assert_eq!(
    86 + upsert(NOTE, "Конспект", "## Конспект\n\nКоротко.\n"),
    87 + "## Расшифровка\n\n**00:00:01 Я:** Привет.\n\n## Задачи\n\n- [ ] Отчёт\n\n## Конспект\n\nКоротко.\n"
    88 + );
    89 + assert_eq!(
    90 + upsert("", "Расшифровка", "## Расшифровка\n\nтекст"),
    91 + "## Расшифровка\n\nтекст\n"
    92 + );
    93 + }
    94 +
    95 + #[test]
    96 + fn same_heading_is_replaced_in_place() {
    97 + let out = upsert(
    98 + NOTE,
    99 + "Расшифровка",
    100 + "## Расшифровка\n\n**00:00:01 Я:** Здравствуйте.",
    101 + );
    102 + assert_eq!(
    103 + out,
    104 + "## Расшифровка\n\n**00:00:01 Я:** Здравствуйте.\n\n## Задачи\n\n- [ ] Отчёт\n"
    105 + );
    106 + assert_eq!(headings(&out), vec!["Расшифровка", "Задачи"]);
    107 + }
    108 +
    109 + #[test]
    110 + fn text_before_first_heading_and_code_blocks_survive() {
    111 + let note = "Мои пометки.\n\n## Письмо\n\n```\n## не заголовок\n```\n";
    112 + assert_eq!(headings(note), vec!["Письмо"]);
    113 + let out = upsert(note, "Задачи", "## Задачи\n\n- [ ] Позвонить");
    114 + assert!(
    115 + out.starts_with("Мои пометки.\n\n## Письмо\n\n```\n## не заголовок\n```\n\n## Задачи"),
    116 + "{out}"
    117 + );
    118 + }
    119 +
    120 + #[test]
    121 + fn remove_drops_only_that_section() {
    122 + assert_eq!(
    123 + remove(NOTE, "Задачи"),
    124 + "## Расшифровка\n\n**00:00:01 Я:** Привет.\n"
    125 + );
    126 + assert_eq!(remove(NOTE, "Нет такого"), NOTE);
    127 + assert_eq!(remove("## Задачи\n\n- [ ] x\n", "Задачи"), "");
    128 + }
    129 +}
  • Показать разницуСкрыть разницу
    16 16 use meet_audio::chunker::{self, Chunk, Chunker};
    17 17 use meet_audio::pcm::{self, samples_to_ms};
    18 18 use tokio::task::JoinSet;
    19 +use tokio_util::sync::CancellationToken;
    19 20
    20 21 use crate::openrouter::SttSegment;
    21 22 use crate::transcript::{Segment, Speaker};
    64 65 out
    65 66 }
    66 67
    68 +/// Расшифровку отменил человек.
    69 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
    70 +#[error("Расшифровка отменена")]
    71 +pub struct Cancelled;
    72 +
    67 73 /// Расшифровывает дорожки. `progress(готово, всего)` зовётся после каждого куска.
    74 +/// По `cancel` неотправленные куски не отправляются, идущие обрываются.
    68 75 pub async fn run(
    69 76 tracks: Vec<(Speaker, Vec<i16>)>,
    70 77 stt: Arc<dyn Stt>,
    71 78 cfg: Config,
    79 + cancel: &CancellationToken,
    72 80 progress: impl Fn(usize, usize),
    73 −) -> Outcome {
    81 +) -> Result<Outcome, Cancelled> {
    74 82 let jobs: Vec<(Speaker, Chunk)> = tracks
    75 83 .into_iter()
    76 84 .flat_map(|(speaker, samples)| {
    105 113 (speaker, chunk.start_ms, result)
    106 114 });
    107 115 }
    108 − let Some(joined) = set.join_next().await else {
    116 + let joined = tokio::select! {
    117 + joined = set.join_next() => joined,
    118 + _ = cancel.cancelled() => {
    119 + set.abort_all();
    120 + return Err(Cancelled);
    121 + }
    122 + };
    123 + let Some(joined) = joined else {
    109 124 break;
    110 125 };
    111 126 let (speaker, start_ms, result) = joined.expect("задача распознавания упала");
    129 144 progress(done, total);
    130 145 }
    131 146 segments.sort_by_key(|s| s.start_ms);
    132 − Outcome {
    147 + Ok(Outcome {
    133 148 segments,
    134 149 failed,
    135 150 last_error,
    136 − }
    151 + })
    137 152 }
    138 153
    139 154 #[cfg(test)]
    211 226 vec![(Speaker::Me, me), (Speaker::Others, others)],
    212 227 Fake::new(0),
    213 228 cfg(),
    229 + &CancellationToken::new(),
    214 230 |d, t| log.lock().unwrap().push((d, t)),
    215 231 )
    216 − .await;
    232 + .await
    233 + .unwrap();
    217 234 assert_eq!(out.failed, 0);
    218 235 assert_eq!(out.segments.len(), 3, "{:#?}", out.segments);
    219 236 let starts: Vec<u64> = out.segments.iter().map(|s| s.start_ms).collect();
    234 251 vec![(Speaker::Unknown, long)],
    235 252 fake.clone(),
    236 253 Config { parallel: 3, ..cfg() },
    254 + &CancellationToken::new(),
    237 255 |_, _| {},
    238 256 )
    239 − .await;
    257 + .await
    258 + .unwrap();
    240 259 assert_eq!(out.segments.len(), 10);
    241 260 let max = fake.max_in_flight.load(Ordering::SeqCst);
    242 261 assert!((2..=3).contains(&max), "одновременно было {max}");
    249 268 vec![(Speaker::Unknown, one.clone())],
    250 269 Fake::new(1),
    251 270 cfg(),
    271 + &CancellationToken::new(),
    252 272 |_, _| {},
    253 273 )
    254 − .await;
    274 + .await
    275 + .unwrap();
    255 276 assert_eq!(out.failed, 0, "одна ошибка — повтор прошёл");
    256 − let out = run(vec![(Speaker::Unknown, one)], Fake::new(2), cfg(), |_, _| {}).await;
    277 + let out = run(
    278 + vec![(Speaker::Unknown, one)],
    279 + Fake::new(2),
    280 + cfg(),
    281 + &CancellationToken::new(),
    282 + |_, _| {},
    283 + )
    284 + .await
    285 + .unwrap();
    257 286 assert_eq!(out.failed, 1);
    258 287 assert_eq!(out.segments[0].text, UNRECOGNIZED);
    259 288 assert_eq!(out.last_error.as_deref(), Some("сеть"));
    260 289 }
    261 290
    262 291 #[tokio::test]
    292 + async fn cancel_stops_the_work() {
    293 + let fake = Fake::new(0);
    294 + let cancel = CancellationToken::new();
    295 + let stop = cancel.clone();
    296 + let out = run(
    297 + vec![(Speaker::Unknown, speech(28_000 * 20))],
    298 + fake.clone(),
    299 + Config { parallel: 2, ..cfg() },
    300 + &cancel,
    301 + move |done, _| {
    302 + if done == 2 {
    303 + stop.cancel();
    304 + }
    305 + },
    306 + )
    307 + .await;
    308 + assert_eq!(out, Err(Cancelled));
    309 + assert!(
    310 + fake.calls.load(Ordering::SeqCst) < 20,
    311 + "после отмены куски не отправляются"
    312 + );
    313 + }
    314 +
    315 + #[tokio::test]
    263 316 async fn silence_costs_nothing() {
    264 317 let fake = Fake::new(0);
    265 318 let out = run(
    266 319 vec![(Speaker::Others, silence(60_000))],
    267 320 fake.clone(),
    268 321 cfg(),
    322 + &CancellationToken::new(),
    269 323 |_, _| {},
    270 324 )
    271 − .await;
    325 + .await
    326 + .unwrap();
    272 327 assert!(out.segments.is_empty());
    273 328 assert_eq!(fake.calls.load(Ordering::SeqCst), 0);
    274 329 }
  • Показать разницуСкрыть разницу
    42 42 pub text: String,
    43 43 }
    44 44
    45 +/// Заголовок раздела с расшифровкой в заметке.
    46 +pub const HEADING: &str = "Расшифровка";
    47 +
    45 48 /// Блок «Расшифровка» для заметки: реплика — абзац с временем. Подряд идущие
    46 49 /// реплики одного говорящего склеиваются в абзац — так читается легче.
    47 50 pub fn to_markdown(segments: &[Segment]) -> String {
  • src/api.ts +5 −2
    Показать разницуСкрыть разницу
    43 43
    44 44 export interface TranscribeResult { note: string; failed: number; last_error: string | null }
    45 45
    46 +export interface CommandResult { note: string; heading: string }
    47 +
    46 48 export type RecorderEvent =
    47 49 | { kind: "level"; data: { speaker: Speaker; dbfs: number } }
    48 50 | { kind: "track_failed"; data: { speaker: Speaker; error: string } };
    118 120 deleteOpenrouterKey: () => call<void>("delete_openrouter_key"),
    119 121 listMeetings: () => call<Meeting[]>("list_meetings"),
    120 122 getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
    121 − createMeeting: () => call<Meeting>("create_meeting"),
    122 123 saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }),
    124 + removeSection: (id: string, heading: string) => call<string>("remove_section", { id, heading }),
    125 + cancelJob: (id: string) => call<void>("cancel_job", { id }),
    123 126 renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }),
    124 127 deleteMeeting: (id: string) => call<void>("delete_meeting", { id }),
    125 128 addAudio: (path: string) => call<Meeting>("add_audio", { path }),
    126 129 transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }),
    127 − runCommand: (id: string, command: Command) => call<string>("run_command", { id, command }),
    130 + runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }),
    128 131 recorderStatus: () => call<RecordingView | null>("recorder_status"),
    129 132 recorderStart: () => call<Meeting>("recorder_start"),
    130 133 recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
  • src/dev-mock.ts +26 −11
    Показать разницуСкрыть разницу
    3 3 // ИИ здесь не вызывается: расшифровка и ответы заготовлены руками.
    4 4
    5 5 import planerka from "../fixtures/transcripts/planerka.txt?raw";
    6 +import { sections, upsertSection } from "./markdown";
    6 7 import type { Command, KeyInfo, Meeting, MeetingDetail, RecordingView, Settings, SettingsView, Speaker } from "./api";
    7 8
    8 9 // ── Шина событий ────────────────────────────────────────────────────────────
    90 91 return d;
    91 92 }
    92 93
    93 −function append(d: MeetingDetail, block: string): string {
    94 − d.note = (d.note.trimEnd() ? d.note.trimEnd() + "\n\n" : "") + block.trim() + "\n";
    94 +function upsert(d: MeetingDetail, heading: string, block: string): string {
    95 + d.note = upsertSection(d.note, heading, block);
    95 96 return d.note;
    96 97 }
    97 98
    99 +let cancelled = false;
    100 +
    98 101 (window as unknown as { __meetMock: object }).__meetMock = {
    99 102 noKey: () => (key = null),
    100 103 unsupported: () => (captureSupported = false),
    132 135 return details.map((d) => d.meeting) as T;
    133 136 case "get_meeting":
    134 137 return structuredClone(find(args.id)) as T;
    135 − case "create_meeting": {
    136 − const d = new Date();
    137 − return add(`Заметка ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false).meeting as T;
    138 − }
    139 138 case "save_note":
    140 139 find(args.id).note = String(args.text);
    141 140 return find(args.id).meeting as T;
    145 144 find(args.id).meeting.title = t;
    146 145 return find(args.id).meeting as T;
    147 146 }
    147 + case "remove_section": {
    148 + const d = find(args.id);
    149 + const rest = sections(d.note).filter((x) => x.heading !== args.heading);
    150 + d.note = rest.length ? rest.map((x) => x.source.trim()).join("\n\n") + "\n" : "";
    151 + return d.note as T;
    152 + }
    153 + case "cancel_job":
    154 + cancelled = true;
    155 + return undefined as T;
    148 156 case "delete_meeting":
    149 157 details.splice(details.indexOf(find(args.id)), 1);
    150 158 return undefined as T;
    159 167 if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
    160 168 if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл";
    161 169 const total = 12;
    170 + cancelled = false;
    162 171 for (let i = 0; i <= total; i++) {
    172 + if (cancelled) throw "Расшифровка отменена";
    163 173 emit("meet://progress", { meeting_id: d.meeting.id, done: i, total });
    164 − await wait(180);
    174 + await wait(250);
    165 175 }
    166 176 d.meeting.transcribed_at = new Date().toISOString();
    167 − return { note: append(d, TRANSCRIPT), failed: 0, last_error: null } as T;
    177 + return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } as T;
    168 178 }
    169 179 case "run_command": {
    170 180 const d = find(args.id);
    172 182 if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
    173 183 if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст";
    174 184 if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать";
    175 − await wait(1200);
    185 + cancelled = false;
    186 + for (let i = 0; i < 8; i++) {
    187 + await wait(200);
    188 + if (cancelled) throw "Отменено";
    189 + }
    190 + const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind];
    176 191 const block =
    177 192 c.kind === "custom"
    178 − ? `## ${c.text.trim().replace(/^./, (x) => x.toUpperCase())}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n`
    193 + ? `## ${heading}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n`
    179 194 : ANSWERS[c.kind];
    180 − return append(d, block) as T;
    195 + return { note: upsert(d, heading, block), heading } as T;
    181 196 }
    182 197 case "recorder_status":
    183 198 return (recording && { ...recording, timer: undefined }) as T;
  • src/main.ts +32 −18
    Показать разницуСкрыть разницу
    1 −// Оболочка: боковая панель со встречами слева, экран справа.
    2 −// Маршруты: #/ — начало, #/m/<id> — встреча, #/settings — настройки.
    1 +// Оболочка. Маршруты: #/ — главная, #/m/<id>[/<вкладка>] — запись, #/settings.
    3 2
    4 −import { renderNote } from "./views/note";
    3 +import { renderHome } from "./views/home";
    4 +import { renderMeeting } from "./views/meeting";
    5 5 import { renderSettings } from "./views/settings";
    6 −import { renderStart } from "./views/start";
    7 −import { mountSidebar } from "./views/sidebar";
    8 −import { errorText } from "./api";
    6 +import { drawHeaderBadge, startHeaderBadge } from "./recording";
    7 +import { errorText, files } from "./api";
    9 8 import { esc } from "./dom";
    10 9 import { Scope } from "./scope";
    10 +import { addAudio, onChange, refreshRecording } from "./state";
    11 11
    12 12 const main = document.getElementById("app")!;
    13 13 let scope = new Scope();
    15 15 async function route(): Promise<void> {
    16 16 scope.dispose();
    17 17 scope = new Scope();
    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");
    18 + const [section, id, tab] = location.hash.replace(/^#\/?/, "").split("/");
    19 + document.querySelector("[data-nav=settings]")?.toggleAttribute("aria-current", section === "settings");
    20 + main.scrollTop = 0;
    22 21 try {
    23 − if (section === "m" && id) await renderNote(main, scope, decodeURIComponent(id));
    24 − else if (section === "settings") await renderSettings(main);
    25 − else await renderStart(main);
    22 + if (section === "m" && id) {
    23 + await renderMeeting(main, scope, decodeURIComponent(id), tab ? decodeURIComponent(tab) : null);
    24 + } else if (section === "settings") {
    25 + await renderSettings(main);
    26 + } else {
    27 + await renderHome(main, scope);
    28 + }
    26 29 } catch (e) {
    27 − main.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    30 + main.innerHTML = `<div class="page"><a class="back small" href="#/">← Все записи</a><p class="error">${esc(errorText(e))}</p></div>`;
    28 31 }
    29 32 }
    30 33
    31 −mountSidebar(document.getElementById("sidebar")!).catch((e) => {
    32 − document.getElementById("sidebar")!.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    34 +async function start(): Promise<void> {
    35 + await refreshRecording();
    36 + startHeaderBadge();
    37 + onChange(drawHeaderBadge);
    38 + drawHeaderBadge();
    39 + // Аудиофайл, брошенный в окно с любого экрана, — новая запись.
    40 + await files.onDrop(async (paths) => {
    41 + for (const p of paths) await addAudio(p);
    42 + });
    43 + window.addEventListener("hashchange", route);
    44 + await route();
    45 +}
    46 +
    47 +start().catch((e) => {
    48 + main.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    33 49 });
    34 −window.addEventListener("hashchange", route);
    35 −route();
  • Показать разницуСкрыть разницу
    66 66 })
    67 67 .join("\n");
    68 68 }
    69 +
    70 +/** Кладёт раздел на место прежнего с тем же заголовком или в конец — как
    71 + * `notes::upsert` в ядре. `source` начинается с `## заголовок`. */
    72 +export function upsertSection(md: string, heading: string, source: string): string {
    73 + const parts = sections(md);
    74 + const block = { heading, source: source.trim() + "\n" };
    75 + const i = parts.findIndex((s) => s.heading === heading);
    76 + if (i >= 0) parts[i] = block;
    77 + else parts.push(block);
    78 + return parts.map((s) => s.source.trim()).join("\n\n") + "\n";
    79 +}
    80 +
    81 +/** Тело раздела без строки заголовка — для правки. */
    82 +export function sectionBody(source: string): string {
    83 + return source.replace(/^## .*\n?/, "").trim();
    84 +}
  • Показать разницуСкрыть разницу
    1 +// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
    2 +// экране записи — и метка «идёт запись» в шапке, видная с любого экрана.
    3 +
    4 +import { api, elapsed, events } from "./api";
    5 +import { esc } from "./dom";
    6 +import type { Scope } from "./scope";
    7 +import { changed, copy, refreshRecording, state, toast } from "./state";
    8 +
    9 +export function recordingPanel(warning: string): string {
    10 + const r = state.recording;
    11 + if (!r) return "";
    12 + const meter = (label: string, key: string) => `
    13 + <div class="meter"><span class="meter-label small">${label}</span>
    14 + <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span></div>`;
    15 + return `
    16 + <div class="rec" data-rec>
    17 + <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
    18 + r.paused ? "Запись на паузе" : r.zoom ? "Идёт запись: Zoom и микрофон" : "Идёт запись: микрофон (Zoom не найден)"
    19 + }</p>
    20 + <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
    21 + <div class="meters">${meter("Вы", "me")}${r.zoom ? meter("Собеседники", "others") : ""}</div>
    22 + <div class="actions">
    23 + <button class="ui-btn" data-rec-stop>Остановить</button>
    24 + <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
    25 + <button class="link-btn" data-rec-warning>Скопировать предупреждение для чата</button>
    26 + </div>
    27 + <template data-warning>${esc(warning)}</template>
    28 + </div>`;
    29 +}
    30 +
    31 +/** Кнопки панели и живые уровни звука. `onStop` — после остановки. */
    32 +export function bindRecording(root: HTMLElement, scope: Scope, warning: string, onStop: () => void): void {
    33 + root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", async (e) => {
    34 + const b = e.currentTarget as HTMLButtonElement;
    35 + b.disabled = true;
    36 + b.textContent = "Останавливаю…";
    37 + try {
    38 + await api.recorderStop();
    39 + await refreshRecording();
    40 + onStop();
    41 + } catch (err) {
    42 + b.disabled = false;
    43 + toast(err);
    44 + }
    45 + });
    46 + root.querySelector("[data-rec-pause]")?.addEventListener("click", async () => {
    47 + if (!state.recording) return;
    48 + try {
    49 + await api.recorderPause(!state.recording.paused);
    50 + state.recording.paused = !state.recording.paused;
    51 + changed();
    52 + } catch (err) {
    53 + toast(err);
    54 + }
    55 + });
    56 + root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат Zoom"));
    57 + scope.on(
    58 + events.recorder(({ event }) => {
    59 + if (event.kind === "level") {
    60 + const key = event.data.speaker.kind === "me" ? "me" : "others";
    61 + const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`);
    62 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
    63 + } else {
    64 + toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`);
    65 + }
    66 + }),
    67 + );
    68 + scope.every(1000, () => {
    69 + const t = root.querySelector("[data-timer]");
    70 + if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    71 + });
    72 +}
    73 +
    74 +/** Метка в шапке: запись идёт, а открыт другой экран. */
    75 +export function drawHeaderBadge(): void {
    76 + const el = document.getElementById("rec-badge");
    77 + if (!el) return;
    78 + const r = state.recording;
    79 + const onItsPage = r && location.hash === `#/m/${encodeURIComponent(r.meeting_id)}`;
    80 + const onHome = r && (location.hash === "" || location.hash === "#/");
    81 + if (!r || onItsPage || onHome) {
    82 + el.hidden = true;
    83 + return;
    84 + }
    85 + el.hidden = false;
    86 + el.setAttribute("href", `#/m/${encodeURIComponent(r.meeting_id)}`);
    87 + el.innerHTML = `<span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"} <span class="mono-num" data-badge-timer>${elapsed(r.started_at)}</span>`;
    88 +}
    89 +
    90 +export function startHeaderBadge(): void {
    91 + window.addEventListener("hashchange", drawHeaderBadge);
    92 + window.setInterval(() => {
    93 + const t = document.querySelector("[data-badge-timer]");
    94 + if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    95 + }, 1000);
    96 +}
  • src/state.ts +28 −3
    Показать разницуСкрыть разницу
    1 −// Общее состояние интерфейса: идёт ли запись и сообщения об ошибках.
    2 −// Боковая панель и экран заметки подписываются на изменения.
    1 +// Общее состояние интерфейса: идёт ли запись, есть ли ключ, короткие сообщения.
    3 2
    4 −import { api, errorText, type RecordingView } from "./api";
    3 +import { api, errorText, files, type RecordingView } from "./api";
    5 4
    6 5 type Listener = () => void;
    7 6 const listeners = new Set<Listener>();
    10 9 recording: null as RecordingView | null,
    11 10 };
    12 11
    12 +/** Файл, выбранный или перетащенный в окно, — новая запись. */
    13 +export async function addAudio(path: string | null): Promise<void> {
    14 + if (!path) return;
    15 + if (!files.isAudio(path)) {
    16 + toast("Это не аудиофайл — подойдут mp3, m4a, wav, aiff, flac, ogg");
    17 + return;
    18 + }
    19 + toast("Читаю файл…", "ok");
    20 + try {
    21 + const m = await api.addAudio(path);
    22 + location.hash = `#/m/${encodeURIComponent(m.id)}`;
    23 + } catch (e) {
    24 + toast(e);
    25 + }
    26 +}
    27 +
    28 +export async function startRecording(): Promise<void> {
    29 + try {
    30 + const m = await api.recorderStart();
    31 + await refreshRecording();
    32 + location.hash = `#/m/${encodeURIComponent(m.id)}`;
    33 + } catch (e) {
    34 + toast(e);
    35 + }
    36 +}
    37 +
    13 38 export function onChange(cb: Listener): () => void {
    14 39 listeners.add(cb);
    15 40 return () => listeners.delete(cb);
  • src/styles/app.css +124 −117
    Показать разницуСкрыть разницу
    1 −/* Раскладка: шапка как у skillok.org, слева встречи, справа заметка. */
    1 +/* Раскладка: шапка как у skillok.org и одна колонка по центру.
    2 + Путь один: записать → расшифровать → получить результат. */
    2 3
    3 4 html, body { height: 100%; }
    4 5 body { display: flex; flex-direction: column; }
    5 6
    6 7 .header {
    7 8 flex: none;
    8 − display: flex; align-items: center; gap: 24px;
    9 − height: 52px; padding: 0 20px;
    9 + display: flex; align-items: center; gap: 16px;
    10 + height: 52px; padding: 0 24px;
    10 11 background: var(--background);
    11 12 border-bottom: 1px solid var(--color-border);
    12 13 }
    19 20 color: var(--color-text-primary); font-size: var(--text-md);
    20 21 }
    21 22 .header-nav a:hover { background: var(--color-bg-hover); text-decoration: none; }
    22 −.header-nav a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    23 −
    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; }
    26 −
    27 −/* ── Боковая панель ── */
    28 −.sidebar {
    29 − display: flex; flex-direction: column; min-height: 0;
    30 − border-right: 1px solid var(--color-border);
    31 − background: var(--color-bg-elevated);
    32 −}
    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);
    23 +.header-nav a[aria-current] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    24 +.rec-badge {
    25 + display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px;
    26 + margin-left: auto; font-size: var(--text-sm); color: var(--color-accent-text);
    27 + border: 1px solid var(--color-accent);
    47 28 }
    48 −.rec-now .mono-num { margin-left: auto; }
    49 −.rec-now:hover { text-decoration: none; color: var(--color-accent-text); }
    29 +.rec-badge:hover { text-decoration: none; background: var(--color-bg-hover); }
    30 +.rec-badge[hidden] { display: none; }
    31 +.rec-badge:not([hidden]) + .header-nav { margin-left: 8px; }
    50 32
    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); }
    33 +.main { flex: 1; min-height: 0; overflow-y: auto; padding: 32px 24px 80px; }
    34 +.home, .meeting, .page { max-width: 760px; margin: 0 auto; display: grid; gap: 24px; }
    66 35
    67 −/* ── Общие мелочи ── */
    68 −.actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
    36 +/* ── Общее ── */
    37 +.actions { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
    69 38 .spacer { flex: 1; }
    70 39 .mono-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
    71 −.page { max-width: 760px; display: grid; gap: 24px; }
    40 +.back { color: var(--color-text-secondary); justify-self: start; }
    41 +.back:hover { color: var(--color-text-primary); text-decoration: none; }
    72 42 .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
    73 43
    74 44 .link-btn {
    78 48 text-decoration: underline; text-underline-offset: 3px;
    79 49 }
    80 50 .link-btn:hover { color: var(--color-text-primary); }
    81 −.link-btn[disabled] { opacity: 0.5; cursor: default; }
    51 +.link-btn[disabled] { opacity: 0.45; cursor: default; text-decoration: none; }
    82 52 .link-danger { color: var(--color-error); }
    83 53 a.ui-btn, a.ui-btn:hover { text-decoration: none; }
    54 +.ui-btn { justify-self: start; }
    84 55
    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;
    56 +.card, .hero {
    57 + display: grid; gap: 14px; justify-items: start;
    58 + padding: 28px; border: 1px solid var(--color-border); background: var(--color-bg-elevated);
    90 59 }
    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; }
    60 +.card h2 { font-size: var(--text-2xl); }
    61 +.card > p { max-width: 60ch; }
    62 +.notice { margin: 0; padding: 12px 16px; border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); }
    94 63
    95 64 /* Индикатор — квадрат: круг в продукте только у аватаров. */
    96 65 .dot { display: inline-block; flex: none; width: 8px; height: 8px; background: var(--color-text-secondary); }
    97 66 .dot-live { background: var(--color-accent); animation: blink 1.4s steps(2, jump-none) infinite; }
    98 67 .dot-live.is-paused { animation: none; background: var(--color-text-secondary); }
    99 68 @keyframes blink { 50% { opacity: 0.25; } }
    100 −@media (prefers-reduced-motion: reduce) { .dot-live { animation: none; } }
    69 +.spinner {
    70 + display: inline-block; flex: none; width: 10px; height: 10px; margin-right: 12px;
    71 + background: var(--color-accent); animation: spin 0.9s linear infinite;
    72 +}
    73 +@keyframes spin { to { transform: rotate(360deg); } }
    74 +@media (prefers-reduced-motion: reduce) { .dot-live, .spinner { animation: none; } }
    75 +
    76 +/* ── Главная ── */
    77 +.hero { padding: 40px 32px; gap: 16px; }
    78 +.hero h1 { font-size: var(--text-3xl); }
    79 +.hero-text { max-width: 56ch; color: var(--kv-body); font-size: var(--text-lg); }
    80 +.hero-btn { min-height: 44px; padding: 0 28px; font-size: var(--text-md); }
    81 +.hero.is-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    82 +
    83 +.records { display: grid; gap: 12px; }
    84 +.records h2 { font-size: var(--text-lg); }
    85 +.record-list { border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    86 +.record {
    87 + display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status";
    88 + gap: 2px 16px; align-items: center; padding: 14px 18px;
    89 + color: var(--color-text-primary); border-top: 1px solid var(--color-border);
    90 +}
    91 +.record:first-child { border-top: 0; }
    92 +.record:hover { background: var(--color-bg-hover); text-decoration: none; }
    93 +.record-title { grid-area: title; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    94 +.record-meta { grid-area: meta; }
    95 +.status { grid-area: status; font-size: var(--text-sm); color: var(--color-text-secondary); text-align: right; }
    96 +.status-todo { color: var(--color-accent-text); }
    97 +.status-live { color: var(--color-accent-text); font-weight: 700; }
    98 +.status-done { color: var(--color-text-primary); }
    99 +
    100 +/* ── Запись идёт ── */
    101 +.rec { display: grid; gap: 14px; justify-items: start; width: 100%; }
    102 +.rec-state { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    103 +.rec-timer { font-size: var(--text-6xl); font-weight: 700; line-height: 1; }
    104 +.meters { display: grid; gap: 6px; width: min(420px, 100%); }
    105 +.meter { display: grid; grid-template-columns: 100px 1fr; align-items: center; gap: 12px; }
    106 +.meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; }
    107 +.meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    101 108
    102 −/* ── Заметка ── */
    103 −.note { max-width: 820px; display: grid; gap: 20px; }
    104 −.note-head { display: grid; gap: 4px; }
    109 +/* ── Экран записи ── */
    110 +.meeting-head { display: grid; gap: 4px; }
    105 111 .title-input {
    106 112 width: 100%; padding: 2px 0; margin: 0;
    107 113 font: inherit; font-size: var(--text-3xl); font-weight: 700; line-height: 1.25;
    111 117 .title-input:hover { border-bottom-color: var(--color-border); }
    112 118 .title-input:focus { outline: none; border-bottom-color: var(--color-accent); }
    113 119
    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; }
    118 −.meters { display: grid; gap: 6px; max-width: 420px; }
    119 −.meter { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 12px; }
    120 −.meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; }
    121 −.meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    120 +.stepper { display: flex; margin: 0; padding: 0; list-style: none; }
    121 +.stepper li {
    122 + flex: 1; display: flex; align-items: center; gap: 10px; padding: 10px 0;
    123 + font-size: var(--text-sm); color: var(--color-text-secondary);
    124 + border-bottom: 2px solid var(--color-border);
    125 +}
    126 +.stepper li.is-done { color: var(--color-text-primary); border-bottom-color: var(--kv-ink); }
    127 +.stepper li.is-current { color: var(--color-text-primary); font-weight: 700; border-bottom-color: var(--color-accent); }
    128 +.step-num {
    129 + display: inline-grid; place-items: center; width: 22px; height: 22px;
    130 + font-size: var(--text-xs); border: 1px solid currentColor;
    131 +}
    132 +.stepper li.is-done .step-num { background: var(--kv-ink); color: var(--background); border-color: var(--kv-ink); }
    133 +.stepper li.is-current .step-num { background: var(--color-accent); color: var(--color-on-accent); border-color: var(--color-accent); }
    122 134
    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); }
    135 +.progress { width: 100%; height: 6px; background: var(--color-bg-secondary); overflow: hidden; }
    136 +.progress > span { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.3s; }
    128 137
    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);
    138 +.result { display: grid; }
    139 +.tabs { display: flex; flex-wrap: wrap; gap: 0 4px; border-bottom: 1px solid var(--color-border); }
    140 +.tab {
    141 + height: 38px; padding: 0 12px;
    142 + font: inherit; font-size: var(--text-md); color: var(--color-text-primary);
    143 + background: none; border: 0; cursor: pointer;
    133 144 }
    134 −.editor-bar .ui-underline-nav { box-shadow: none; }
    135 −.note-editor {
    136 − display: block; width: 100%; min-height: 60vh; padding: 16px 20px;
    145 +.tab:hover { background: var(--color-bg-hover); }
    146 +.tab.is-empty { color: var(--color-text-secondary); }
    147 +.tab.is-active { box-shadow: inset 0 -2px 0 var(--color-accent); color: var(--color-text-primary); font-weight: 700; }
    148 +.tab-custom { color: var(--color-accent-text); }
    149 +
    150 +.pane { border: 1px solid var(--color-border); border-top: 0; background: var(--color-bg-elevated); }
    151 +.pane-tools {
    152 + display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px;
    153 + padding: 8px 20px; border-bottom: 1px solid var(--kv-line-2);
    154 +}
    155 +.pane-body { padding: 20px 24px 28px; }
    156 +.pane > .error { padding: 12px 24px 0; }
    157 +.pane-empty { display: grid; gap: 12px; justify-items: start; padding: 36px 28px; }
    158 +.pane-empty h3 { font-size: var(--text-xl); }
    159 +.pane-empty > p { max-width: 60ch; }
    160 +.pane-empty.ask .input { width: 100%; max-width: 560px; }
    161 +.working { display: flex; align-items: center; font-size: var(--text-lg); }
    162 +.pane-edit { display: grid; gap: 12px; padding: 16px 20px; }
    163 +.section-editor {
    164 + width: 100%; min-height: 50vh; padding: 12px 14px;
    137 165 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;
    166 + color: var(--color-text-primary); background: var(--background);
    167 + border: 1px solid var(--color-border-light); resize: vertical;
    140 168 }
    Разница обрезана: показаны первые 239 строк.
  • Показать разницуСкрыть разницу
    50 50 --text-xl: 18px;
    51 51 --text-2xl: 20px;
    52 52 --text-3xl: 24px; /* заголовок экрана */
    53 + --text-4xl: 28px;
    54 + --text-5xl: 32px;
    55 + --text-6xl: 40px; /* таймер записи */
    53 56
    54 57 --background: #F4F4F2;
    55 58 --kv-ink: #0A0A0A;
  • Показать разницуСкрыть разницу
    1 +// Главная: записать встречу (или добавить файл) и список записей.
    2 +
    3 +import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api";
    4 +import { esc } from "../dom";
    5 +import { sections } from "../markdown";
    6 +import { bindRecording, recordingPanel } from "../recording";
    7 +import type { Scope } from "../scope";
    8 +import { addAudio, onChange, startRecording, state } from "../state";
    9 +
    10 +function hero(view: SettingsView): string {
    11 + if (state.recording) {
    12 + return `<section class="hero is-live">${recordingPanel(view.settings.warning_text)}</section>`;
    13 + }
    14 + const pick = `<button class="link-btn" data-pick>выберите файл</button>`;
    15 + if (!view.capture_supported) {
    16 + return `
    17 + <section class="hero">
    18 + <h1>Добавьте запись встречи</h1>
    19 + <p class="hero-text">Запись звука работает в Windows. Здесь — готовые файлы: mp3, m4a, wav и другие.</p>
    20 + <button class="ui-btn" data-pick>Выбрать аудиофайл</button>
    21 + <p class="small muted">Или перетащите файл в окно.</p>
    22 + </section>`;
    23 + }
    24 + return `
    25 + <section class="hero">
    26 + <h1>Запишите встречу</h1>
    27 + <p class="hero-text">Запустите встречу в Zoom и нажмите «Записать» — запишутся собеседники и вы. Потом расшифруете и получите конспект и задачи.</p>
    28 + <button class="ui-btn hero-btn" data-record>Записать</button>
    29 + <p class="small muted">Уже есть запись? Перетащите аудиофайл в окно или ${pick}.</p>
    30 + </section>`;
    31 +}
    32 +
    33 +function status(m: Meeting, note: string | undefined): string {
    34 + if (m.recording) return `<span class="status status-live">Идёт запись</span>`;
    35 + if (!m.audio.length) return `<span class="status">Нет звука</span>`;
    36 + if (!m.transcribed_at) return `<span class="status status-todo">Нужно расшифровать</span>`;
    37 + const got = sections(note ?? "")
    38 + .map((s) => s.heading)
    39 + .filter((h) => h && h !== "Расшифровка");
    40 + return `<span class="status status-done">${got.length ? esc(got.join(" · ")) : "Расшифровано"}</span>`;
    41 +}
    42 +
    43 +function list(meetings: Meeting[], notes: Map<string, string>): string {
    44 + if (!meetings.length) return "";
    45 + return `
    46 + <section class="records">
    47 + <h2>Записи</h2>
    48 + <div class="record-list">
    49 + ${meetings
    50 + .map((m) => {
    51 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    52 + return `
    53 + <a class="record" href="#/m/${encodeURIComponent(m.id)}">
    54 + <span class="record-title">${esc(m.title)}</span>
    55 + <span class="record-meta small muted">${esc(meta)}</span>
    56 + ${status(m, notes.get(m.id))}
    57 + </a>`;
    58 + })
    59 + .join("")}
    60 + </div>
    61 + </section>`;
    62 +}
    63 +
    64 +export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
    65 + const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]);
    66 + // Что уже получено по записи — из её заметки; записей немного, читаем все.
    67 + const details = await Promise.all(meetings.filter((m) => m.transcribed_at).map((m) => api.getMeeting(m.id)));
    68 + if (!scope.alive) return;
    69 + const notes = new Map(details.map((d) => [d.meeting.id, d.note]));
    70 +
    71 + const draw = () => {
    72 + root.innerHTML = `
    73 + <div class="home">
    74 + ${hero(view)}
    75 + ${
    76 + view.openrouter_key
    77 + ? ""
    78 + : `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>`
    79 + }
    80 + ${list(meetings, notes)}
    81 + </div>`;
    82 + root.querySelector("[data-record]")?.addEventListener("click", startRecording);
    83 + root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
    84 + // После «Остановить» переход к записи делает подписка ниже.
    85 + bindRecording(root, scope, view.settings.warning_text, () => undefined);
    86 + };
    87 + draw();
    88 +
    89 + // Запись остановили или поставили на паузу — карточка меняется.
    90 + let last = JSON.stringify(state.recording);
    91 + const off = onChange(async () => {
    92 + const now = JSON.stringify(state.recording);
    93 + if (now === last || !scope.alive) return;
    94 + const stoppedId = last && !state.recording ? (JSON.parse(last) as { meeting_id: string }).meeting_id : null;
    95 + last = now;
    96 + if (stoppedId) {
    97 + location.hash = `#/m/${encodeURIComponent(stoppedId)}`;
    98 + return;
    99 + }
    100 + draw();
    101 + });
    102 + scope.on(Promise.resolve(off));
    103 +}
  • Показать разницуСкрыть разницу
    1 +// Экран записи: ① Аудио → ② Расшифровка → ③ Результат.
    2 +//
    3 +// На экране только текущий шаг и одна главная кнопка. Результаты — вкладками:
    4 +// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
    5 +// Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
    6 +// сделать заново или удалить.
    7 +
    8 +import {
    9 + api, errorText, events, formatDate, formatDuration, meetingDuration,
    10 + type Command, type MeetingDetail,
    11 +} from "../api";
    12 +import { $, esc } from "../dom";
    13 +import { plain, render, sectionBody, sections, toggleTask, upsertSection } from "../markdown";
    14 +import { bindRecording, recordingPanel } from "../recording";
    15 +import type { Scope } from "../scope";
    16 +import { copy, onChange, state, toast } from "../state";
    17 +
    18 +const TRANSCRIPT = "Расшифровка";
    19 +
    20 +interface Preset {
    21 + heading: string;
    22 + command: Command;
    23 + /** Что это и зачем — на пустой вкладке. */
    24 + about: string;
    25 + action: string;
    26 + working: string;
    27 +}
    28 +
    29 +const PRESETS: Preset[] = [
    30 + {
    31 + heading: "Конспект",
    32 + command: { kind: "summary" },
    33 + about: "О чём говорили, чем закончилось и какие решения приняли — на полстраницы.",
    34 + action: "Сделать конспект",
    35 + working: "ИИ пишет конспект…",
    36 + },
    37 + {
    38 + heading: "Задачи",
    39 + command: { kind: "tasks" },
    40 + about: "Кто что взял на себя и к какому сроку — списком с галочками. И вопросы, оставшиеся без ответа.",
    41 + action: "Выписать задачи",
    42 + working: "ИИ выписывает задачи…",
    43 + },
    44 + {
    45 + heading: "Ревью",
    46 + command: { kind: "review" },
    47 + about: "Взгляд со стороны: чего добились, что осталось открытым, где ушли в сторону.",
    48 + action: "Сделать ревью",
    49 + working: "ИИ пишет ревью…",
    50 + },
    51 +];
    52 +
    53 +const CUSTOM_TAB = "+ Свой запрос";
    54 +
    55 +type Step = "audio" | "transcribe" | "result";
    56 +
    57 +function stepOf(d: MeetingDetail): Step {
    58 + if (d.meeting.recording || !d.meeting.audio.length) return "audio";
    59 + if (!d.meeting.transcribed_at) return "transcribe";
    60 + return "result";
    61 +}
    62 +
    63 +function stepper(step: Step): string {
    64 + const order: Step[] = ["audio", "transcribe", "result"];
    65 + const names = { audio: "Аудио", transcribe: "Расшифровка", result: "Результат" };
    66 + const at = order.indexOf(step);
    67 + return `
    68 + <ol class="stepper">
    69 + ${order
    70 + .map((s, i) => {
    71 + const cls = i < at ? "is-done" : i === at ? "is-current" : "";
    72 + return `<li class="${cls}"><span class="step-num">${i < at ? "✓" : i + 1}</span>${names[s]}</li>`;
    73 + })
    74 + .join("")}
    75 + </ol>`;
    76 +}
    77 +
    78 +export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
    79 + const scope = parent.child();
    80 + const [initial, view] = await Promise.all([api.getMeeting(id), api.getSettings()]);
    81 + if (!scope.alive) return;
    82 + const d: MeetingDetail = initial;
    83 + const hasKey = Boolean(view.openrouter_key);
    84 +
    85 + // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
    86 + let job: { kind: "transcribe"; done: number; total: number } | { kind: "command"; tab: string } | null = null;
    87 + let jobError: string | null = null;
    88 + let tab = wantTab ?? TRANSCRIPT;
    89 + let editing = false;
    90 +
    91 + const tabs = (): string[] => {
    92 + const have = sections(d.note).map((s) => s.heading || "Заметки");
    93 + const order = [TRANSCRIPT, ...PRESETS.map((p) => p.heading)];
    94 + const extra = have.filter((h) => !order.includes(h));
    95 + return [...order, ...extra, CUSTOM_TAB];
    96 + };
    97 +
    98 + const section = (heading: string) => sections(d.note).find((s) => (s.heading || "Заметки") === heading);
    99 +
    100 + // ── Шаг 1: аудио ──────────────────────────────────────────────────────────
    101 + const audioStep = () => {
    102 + if (d.meeting.recording) {
    103 + return `<section class="card">${recordingPanel(view.settings.warning_text)}</section>`;
    104 + }
    105 + return `
    106 + <section class="card">
    107 + <h2>В этой записи нет звука</h2>
    108 + <p class="muted">Она осталась от прежней версии программы. Её можно удалить.</p>
    109 + </section>`;
    110 + };
    111 +
    112 + // ── Шаг 2: расшифровка ────────────────────────────────────────────────────
    113 + const transcribeStep = () => {
    114 + const dur = formatDuration(meetingDuration(d.meeting));
    115 + if (job?.kind === "transcribe") {
    116 + const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
    117 + return `
    118 + <section class="card">
    119 + <h2>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}</h2>
    120 + <div class="progress"><span style="width:${pct}%"></span></div>
    121 + <p class="small muted">${job.total ? `Готово ${job.done} из ${job.total} кусков` : "Готовлю звук…"}. Можно уйти с экрана — расшифровка продолжится.</p>
    122 + <div class="actions"><button class="link-btn" data-cancel>Отменить</button></div>
    123 + </section>`;
    124 + }
    125 + if (!hasKey) {
    126 + return `
    127 + <section class="card">
    128 + <h2>Подключите ИИ, чтобы расшифровать</h2>
    129 + <p class="muted">Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.</p>
    130 + <a class="ui-btn" href="#/settings">Подключить</a>
    131 + </section>`;
    132 + }
    133 + return `
    134 + <section class="card">
    135 + <h2>Расшифруйте запись</h2>
    136 + <p class="muted">${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
    137 + meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
    138 + }.</p>
    139 + ${jobError ? `<p class="error">${esc(jobError)}</p>` : ""}
    140 + <button class="ui-btn" data-transcribe>Расшифровать</button>
    141 + </section>`;
    142 + };
    143 +
    144 + // ── Шаг 3: результат ──────────────────────────────────────────────────────
    145 + const tabContent = () => {
    146 + const busyHere = job?.kind === "command" && job.tab === tab;
    147 + const busyElsewhere = job !== null && !busyHere;
    148 + const errorLine = jobError && !job ? `<p class="error">${esc(jobError)}</p>` : "";
    149 +
    150 + if (busyHere) {
    151 + const preset = PRESETS.find((p) => p.heading === tab);
    152 + return `
    153 + <div class="pane-empty">
    154 + <p class="working"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
    155 + <button class="link-btn" data-cancel>Отменить</button>
    156 + </div>`;
    157 + }
    158 +
    159 + if (tab === CUSTOM_TAB) {
    160 + return `
    161 + <form class="pane-empty ask" data-ask>
    162 + <h3>Что сделать по этой встрече?</h3>
    163 + <input class="input" name="text" autocomplete="off" placeholder="Например: напиши письмо клиенту по итогам" aria-label="Свой запрос" ${busyElsewhere || !hasKey ? "disabled" : ""}>
    164 + <p class="small muted">Ещё примеры:
    165 + <button type="button" class="link-btn" data-example>выпиши все цифры и даты</button>,
    166 + <button type="button" class="link-btn" data-example>какие риски прозвучали</button>,
    167 + <button type="button" class="link-btn" data-example>составь повестку следующей встречи</button>.</p>
    168 + ${errorLine}
    169 + ${
    170 + hasKey
    171 + ? `<button class="ui-btn" type="submit" ${busyElsewhere ? "disabled" : ""}>Сделать</button>`
    172 + : `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
    173 + }
    174 + </form>`;
    175 + }
    176 +
    177 + const s = section(tab);
    178 + const preset = PRESETS.find((p) => p.heading === tab);
    179 + if (!s) {
    180 + return `
    181 + <div class="pane-empty">
    182 + <h3>${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}</h3>
    183 + <p class="muted">${esc(preset?.about ?? "")}</p>
    184 + ${errorLine}
    185 + ${
    186 + !hasKey
    187 + ? `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
    188 + : `<button class="ui-btn" data-make="${esc(tab)}" ${busyElsewhere ? "disabled" : ""}>${esc(preset?.action ?? "Сделать")}</button>`
    189 + }
    190 + ${busyElsewhere ? `<p class="small muted">Сначала дождитесь, пока закончится текущая работа.</p>` : ""}
    191 + </div>`;
    192 + }
    193 +
    194 + if (editing) {
    195 + return `
    196 + <div class="pane-edit">
    197 + <textarea class="section-editor" data-editor spellcheck="true">${esc(sectionBody(s.source))}</textarea>
    198 + <div class="actions">
    199 + <button class="ui-btn" data-edit-save>Сохранить</button>
    200 + <button class="link-btn" data-edit-cancel>Отмена</button>
    201 + <span class="small muted">Markdown: <code>**жирный**</code>, <code>- список</code>, <code>- [ ] задача</code></span>
    202 + </div>
    203 + </div>`;
    204 + }
    205 +
    206 + const isTranscript = tab === TRANSCRIPT;
    207 + const remakeable = !isTranscript && s.heading !== "";
    208 + return `
    209 + <div class="pane-tools">
    210 + <button class="link-btn" data-copy>Копировать</button>
    211 + <button class="link-btn" data-copy-plain>Как текст</button>
    212 + <button class="link-btn" data-edit>Править</button>
    213 + <span class="spacer"></span>
    214 + ${
    215 + isTranscript
    216 + ? `<button class="link-btn" data-retranscribe ${job ? "disabled" : ""}>Расшифровать заново</button>`
    217 + : remakeable
    218 + ? `<button class="link-btn" data-remake ${job || !hasKey ? "disabled" : ""}>Сделать заново</button>
    219 + <button class="link-btn link-danger" data-remove>Удалить</button>`
    220 + : ""
    221 + }
    222 + </div>
    223 + ${errorLine}
    224 + <div class="md pane-body" data-view>${render(sectionBody(s.source))}</div>`;
    225 + };
    226 +
    227 + const resultStep = () => `
    228 + <section class="result">
    229 + <nav class="tabs" role="tablist" aria-label="Результаты">
    230 + ${tabs()
    231 + .map((t) => {
    232 + const ready = t === CUSTOM_TAB || Boolean(section(t));
    233 + return `<button role="tab" class="tab${t === tab ? " is-active" : ""}${ready ? "" : " is-empty"}${
    234 + t === CUSTOM_TAB ? " tab-custom" : ""
    235 + }" aria-selected="${t === tab}" data-tab="${esc(t)}">${esc(t)}</button>`;
    236 + })
    237 + .join("")}
    238 + </nav>
    239 + <div class="pane">${tabContent()}</div>
    240 + </section>`;
    241 +
    242 + const draw = () => {
    243 + const step = stepOf(d);
    244 + if (!tabs().includes(tab)) tab = TRANSCRIPT;
    245 + const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    246 + root.innerHTML = `
    247 + <article class="meeting">
    248 + <a class="back small" href="#/">← Все записи</a>
    249 + <header class="meeting-head">
    250 + <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
    251 + <p class="small muted">${esc(meta)}</p>
    252 + </header>
    253 + ${stepper(step)}
    254 + ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
    255 + <footer class="meeting-foot">
    256 + <button class="link-btn link-danger" data-delete ${d.meeting.recording ? "hidden" : ""}>Удалить запись</button>
    257 + </footer>
    258 + </article>`;
    259 + bind();
    260 + };
    261 +
    262 + // ── Действия ──────────────────────────────────────────────────────────────
    263 +
    264 + const transcribe = async () => {
    265 + if (job) return;
    266 + job = { kind: "transcribe", done: 0, total: 0 };
    267 + jobError = null;
    268 + draw();
    269 + try {
    270 + const r = await api.transcribe(id);
    271 + d.note = r.note;
    272 + d.meeting = (await api.getMeeting(id)).meeting;
    273 + tab = TRANSCRIPT;
    274 + if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    275 + } catch (e) {
    276 + jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    277 + }
    278 + job = null;
    279 + if (scope.alive) draw();
    280 + };
    281 +
    282 + const runCommand = async (command: Command, forTab: string) => {
    283 + if (job) return;
    284 + job = { kind: "command", tab: forTab };
    285 + jobError = null;
    286 + tab = forTab;
    287 + draw();
    288 + try {
    289 + const r = await api.runCommand(id, command);
    290 + d.note = r.note;
    291 + tab = r.heading;
    292 + } catch (e) {
    293 + jobError = errorText(e) === "Отменено" ? null : errorText(e);
    294 + }
    295 + job = null;
    296 + if (scope.alive) draw();
    297 + };
    298 +
    299 + const saveNote = async (note: string) => {
    300 + const before = d.note;
    301 + d.note = note;
    302 + try {
    303 + await api.saveNote(id, note);
    304 + } catch (e) {
    305 + d.note = before;
    306 + toast(e);
    307 + draw();
    308 + }
    309 + };
    310 +
    311 + const bind = () => {
    312 + const title = $(root, "[data-title]") as HTMLInputElement;
    Разница обрезана: показаны первые 312 строк.
  • src/views/note.ts −360
    Показать разницуСкрыть разницу
    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 −
    Разница обрезана: показаны первые 312 строк.
  • Показать разницуСкрыть разницу
    40 40 const s = view.settings;
    41 41 root.innerHTML = `
    42 42 <div class="page">
    43 + <a class="back small" href="#/">← Все записи</a>
    43 44 <div class="page-head"><h1>Настройки</h1></div>
    44 45 ${keyBox(view)}
    45 46
  • Показать разницуСкрыть разницу
    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 −}
  • src/views/start.ts −32
    Показать разницуСкрыть разницу
    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 −}