Простой путь: записать → расшифровать → получить результат
- Главная: «Записать» (или аудиофайл перетаскиванием) и записи со статусом. Боковая панель и «Новая заметка» убраны. - Экран записи: шаги «Аудио → Расшифровка → Результат», на экране только текущий шаг и одна главная кнопка. - Результаты вкладками: «Расшифровка», «Конспект», «Задачи», «Ревью», «Свой запрос»; копировать, как текст, править, сделать заново, удалить. Повтор команды заменяет раздел, а не дописывает второй. - Задача #9: без ключа кнопки ведут к подключению ИИ; у расшифровки и ответа ИИ — прогресс и «Отменить» (CancellationToken в ядре); идущая запись видна меткой в шапке на любом экране. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
996ce52
· сравнить с родителем
-
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, "Конспект"); -
src-tauri/src/commands.rs +87 −41
Показать разницуСкрыть разницу
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 строк. -
src-tauri/src/lib.rs +5 −3
Показать разницуСкрыть разницу
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, -
src-tauri/src/meetings.rs +28 −24
Показать разницуСкрыть разницу
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"), ¬e)?; 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 +} -
src-tauri/src/transcribe.rs +64 −9
Показать разницуСкрыть разницу
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(); -
src/markdown.ts +16
Показать разницуСкрыть разницу
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 +} -
src/recording.ts +96
Показать разницуСкрыть разницу
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; -
src/views/home.ts +103
Показать разницуСкрыть разницу
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 +} -
src/views/meeting.ts +454
Показать разницуСкрыть разницу
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 -
src/views/sidebar.ts −141
Показать разницуСкрыть разницу
1 −// Боковая панель: три действия и список встреч. 2 −// 3 −// Записать — запись Zoom и микрофона, пока не нажмёте «Стоп» 4 −// Добавить аудио — готовый файл с диктофона или из облака Zoom 5 −// Новая заметка — пустая заметка, текст можно вставить руками 6 − 7 −import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "../api"; 8 −import { esc } from "../dom"; 9 −import { changed, onChange, refreshRecording, state, toast } from "../state"; 10 − 11 −let root: HTMLElement; 12 −let meetings: Meeting[] = []; 13 −let captureSupported = true; 14 − 15 −function currentId(): string | null { 16 − const m = /^#\/m\/([^/]+)/.exec(location.hash); 17 − return m ? decodeURIComponent(m[1]) : null; 18 −} 19 − 20 −function badges(m: Meeting): string { 21 − if (m.recording) return `<span class="badge badge-live">запись</span>`; 22 − if (m.transcribed_at) return ""; 23 − if (m.audio.length) return `<span class="badge">не расшифровано</span>`; 24 − return ""; 25 −} 26 − 27 −function recordBlock(): string { 28 − const r = state.recording; 29 − if (r) { 30 − return ` 31 − <a class="rec-now" href="#/m/${encodeURIComponent(r.meeting_id)}"> 32 − <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span> 33 − <span>${r.paused ? "Пауза" : "Идёт запись"}</span> 34 − <span class="mono-num" data-rec-timer>${elapsed(r.started_at)}</span> 35 − </a> 36 − <button class="side-link side-stop" data-stop>Остановить запись</button>`; 37 − } 38 − return captureSupported 39 − ? `<button class="ui-btn side-main" data-record>Записать</button>` 40 − : `<p class="side-note xs muted">Запись работает в Windows. Здесь — готовые аудиофайлы.</p>`; 41 −} 42 − 43 −function draw(): void { 44 − const active = currentId(); 45 − root.innerHTML = ` 46 − <div class="side-actions"> 47 − ${recordBlock()} 48 − <button class="side-link" data-add-audio>Добавить аудио</button> 49 − <button class="side-link" data-new-note>Новая заметка</button> 50 − </div> 51 − <nav class="side-list" aria-label="Встречи"> 52 − ${ 53 − meetings.length 54 − ? meetings 55 − .map((m) => { 56 − const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · "); 57 − return ` 58 − <a class="side-item${m.id === active ? " is-active" : ""}" href="#/m/${encodeURIComponent(m.id)}"${ 59 − m.id === active ? ` aria-current="page"` : "" 60 − }> 61 − <span class="side-title">${esc(m.title)}</span> 62 − <span class="side-meta xs muted">${esc(meta)} ${badges(m)}</span> 63 − </a>`; 64 − }) 65 − .join("") 66 − : `<p class="side-empty small muted">Встреч пока нет.</p>` 67 − } 68 − </nav>`; 69 −} 70 − 71 −export async function refreshSidebar(): Promise<void> { 72 − meetings = await api.listMeetings(); 73 − draw(); 74 −} 75 − 76 −export async function addAudioFrom(path: string | null): Promise<void> { 77 − if (!path) return; 78 − if (!files.isAudio(path)) { 79 − toast("Это не аудиофайл — подойдут mp3, m4a, wav, aiff, flac, ogg"); 80 − return; 81 − } 82 − toast("Читаю файл…", "ok"); 83 − try { 84 − const m = await api.addAudio(path); 85 − await refreshSidebar(); 86 − location.hash = `#/m/${encodeURIComponent(m.id)}`; 87 − } catch (e) { 88 − toast(e); 89 − } 90 −} 91 − 92 −export async function startRecording(): Promise<void> { 93 − try { 94 − const m = await api.recorderStart(); 95 − await refreshRecording(); 96 − await refreshSidebar(); 97 − location.hash = `#/m/${encodeURIComponent(m.id)}`; 98 − } catch (e) { 99 − toast(e); 100 − } 101 −} 102 − 103 −export async function stopRecording(): Promise<void> { 104 − try { 105 − await api.recorderStop(); 106 − await refreshRecording(); 107 − await refreshSidebar(); 108 − changed(); 109 − } catch (e) { 110 − toast(e); 111 − } 112 −} 113 − 114 −export async function mountSidebar(el: HTMLElement): Promise<void> { 115 − root = el; 116 − captureSupported = (await api.getSettings()).capture_supported; 117 − await refreshRecording(); 118 − await refreshSidebar(); 119 − root.addEventListener("click", async (e) => { 120 − const t = (e.target as HTMLElement).closest<HTMLElement>("button"); 121 − if (!t) return; 122 − if (t.matches("[data-record]")) await startRecording(); 123 − else if (t.matches("[data-stop]")) await stopRecording(); 124 − else if (t.matches("[data-add-audio]")) await addAudioFrom(await files.pickAudio()); 125 − else if (t.matches("[data-new-note]")) { 126 − const m = await api.createMeeting(); 127 − await refreshSidebar(); 128 − location.hash = `#/m/${encodeURIComponent(m.id)}`; 129 − } 130 − }); 131 − window.addEventListener("hashchange", draw); 132 − onChange(draw); 133 − window.setInterval(() => { 134 − const t = root.querySelector("[data-rec-timer]"); 135 − if (t && state.recording) t.textContent = elapsed(state.recording.started_at); 136 − }, 1000); 137 − // Файл, брошенный в окно, — новая встреча. 138 − await files.onDrop(async (paths) => { 139 − for (const p of paths) await addAudioFrom(p); 140 − }); 141 −} -
Показать разницуСкрыть разницу
1 −// Экран без выбранной встречи: что можно сделать. 2 − 3 −import { api, files } from "../api"; 4 −import { addAudioFrom } from "./sidebar"; 5 − 6 −export async function renderStart(root: HTMLElement): Promise<void> { 7 − const view = await api.getSettings(); 8 − const noKey = !view.openrouter_key; 9 − root.innerHTML = ` 10 − <section class="start"> 11 − <h1>Встреча → заметка</h1> 12 − <ol class="start-steps"> 13 − <li><strong>Звук.</strong> ${ 14 − view.capture_supported 15 − ? "«Записать» слева — перед встречей в Zoom: запишутся Zoom и ваш микрофон. Или добавьте" 16 − : "Запись работает в Windows; здесь добавьте" 17 − } готовый аудиофайл — с диктофона или из облака Zoom.</li> 18 − <li><strong>Расшифровка.</strong> Когда нужно — «Расшифровать». Текст ляжет в заметку.</li> 19 − <li><strong>ИИ.</strong> «Конспект», «Задачи», «Ревью» или свой запрос. Всё в заметке можно править и копировать.</li> 20 − </ol> 21 − <button class="drop-zone" data-add-audio> 22 − <strong>Перетащите аудиофайл в окно</strong> 23 − <span class="small muted">или нажмите, чтобы выбрать: mp3, m4a, wav, aiff, flac, ogg</span> 24 − </button> 25 − ${ 26 − noKey 27 − ? `<p class="start-key small">Для расшифровки и ИИ нужен ключ OpenRouter — <a href="#/settings">подключить</a>.</p>` 28 − : "" 29 − } 30 − </section>`; 31 − root.querySelector("[data-add-audio]")?.addEventListener("click", async () => addAudioFrom(await files.pickAudio())); 32 −}