skillok meeting

Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13)

- Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

anc · 30 сентября 2026 г. 393887285dfc8accae94edba2c7c575b098e705e
15 файлов
  • Показать разницуСкрыть разницу
    3 3 //! своего ресемплера нет.
    4 4
    5 5 use std::fs::File;
    6 −use std::io::{self, Seek, SeekFrom, Write};
    7 −use std::path::Path;
    6 +use std::io::{self, Read, Seek, SeekFrom, Write};
    7 +use std::path::{Path, PathBuf};
    8 8
    9 9 pub const SAMPLE_RATE: u32 = 16_000;
    10 10 pub const SAMPLES_PER_MS: u64 = SAMPLE_RATE as u64 / 1000;
    210 210 Err(bad("нет блока data"))
    211 211 }
    212 212
    213 +/// Дорожка в нашем формате на диске, читаемая по кускам. Двухчасовая встреча —
    214 +/// 230 МБ на дорожку: держать её в памяти целиком незачем, расшифровке
    215 +/// одновременно нужны несколько кусков по полминуты.
    216 +#[derive(Debug, Clone)]
    217 +pub struct WavFile {
    218 + path: PathBuf,
    219 + data_start: u64,
    220 + samples: usize,
    221 +}
    222 +
    223 +impl WavFile {
    224 + pub fn open(path: &Path) -> io::Result<Self> {
    225 + let bad = |m: &str| io::Error::new(io::ErrorKind::InvalidData, m.to_string());
    226 + let mut file = File::open(path)?;
    227 + let file_len = file.metadata()?.len();
    228 + let mut riff = [0u8; 12];
    229 + file.read_exact(&mut riff).map_err(|_| bad("не WAV"))?;
    230 + if &riff[0..4] != b"RIFF" || &riff[8..12] != b"WAVE" {
    231 + return Err(bad("не WAV"));
    232 + }
    233 + let mut pos = 12u64;
    234 + let mut format_ok = false;
    235 + while pos + 8 <= file_len {
    236 + let mut head = [0u8; 8];
    237 + file.seek(SeekFrom::Start(pos))?;
    238 + file.read_exact(&mut head)?;
    239 + let size = u32::from_le_bytes(head[4..8].try_into().unwrap()) as u64;
    240 + let body = pos + 8;
    241 + if &head[0..4] == b"fmt " {
    242 + let mut f = [0u8; 16];
    243 + file.read_exact(&mut f).map_err(|_| bad("короткий fmt"))?;
    244 + let fmt = u16::from_le_bytes([f[0], f[1]]);
    245 + let channels = u16::from_le_bytes([f[2], f[3]]);
    246 + let rate = u32::from_le_bytes(f[4..8].try_into().unwrap());
    247 + let bits = u16::from_le_bytes([f[14], f[15]]);
    248 + format_ok = fmt == 1 && channels == 1 && rate == SAMPLE_RATE && bits == 16;
    249 + if !format_ok {
    250 + return Err(bad("нужен WAV 16 кГц, моно, 16 бит"));
    251 + }
    252 + } else if &head[0..4] == b"data" {
    253 + if !format_ok {
    254 + return Err(bad("data раньше fmt"));
    255 + }
    256 + // Как у `decode_wav`: заголовок обгонять файл не может.
    257 + let bytes = size.min(file_len - body);
    258 + return Ok(Self {
    259 + path: path.to_path_buf(),
    260 + data_start: body,
    261 + samples: (bytes / 2) as usize,
    262 + });
    263 + }
    264 + pos = body + size + (size & 1);
    265 + }
    266 + Err(bad("нет блока data"))
    267 + }
    268 +
    269 + /// Сколько сэмплов в дорожке.
    270 + pub fn len(&self) -> usize {
    271 + self.samples
    272 + }
    273 +
    274 + pub fn is_empty(&self) -> bool {
    275 + self.samples == 0
    276 + }
    277 +
    278 + /// `n` сэмплов с сэмпла `start`; у конца дорожки — сколько есть.
    279 + pub fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> {
    280 + let n = n.min(self.samples.saturating_sub(start));
    281 + let mut file = File::open(&self.path)?;
    282 + file.seek(SeekFrom::Start(self.data_start + start as u64 * 2))?;
    283 + let mut bytes = vec![0u8; n * 2];
    284 + file.read_exact(&mut bytes)?;
    285 + Ok(bytes
    286 + .as_chunks::<2>()
    287 + .0
    288 + .iter()
    289 + .map(|c| i16::from_le_bytes(*c))
    290 + .collect())
    291 + }
    292 +}
    293 +
    213 294 /// Дорожка встречи на диске. Заголовок дописывается при каждом `flush`, поэтому
    214 295 /// файл читается, даже если программа упала посреди записи.
    215 296 pub struct WavWriter {
    368 449 vec![0, 32767, -32767, 32767, -32767]
    369 450 );
    370 451 }
    452 +
    453 + #[test]
    454 + fn wav_file_reads_slices_from_disk() {
    455 + let path = std::env::temp_dir().join(format!("meet-wavfile-{}.wav", std::process::id()));
    456 + let samples: Vec<i16> = (0..50_000).map(|i| (i % 3000) as i16 - 1500).collect();
    457 + {
    458 + let mut w = WavWriter::create(&path).unwrap();
    459 + w.write(&samples).unwrap();
    460 + }
    461 + let f = WavFile::open(&path).unwrap();
    462 + assert_eq!(f.len(), samples.len());
    463 + assert_eq!(f.read(0, 10).unwrap(), samples[..10]);
    464 + assert_eq!(f.read(12_345, 4_000).unwrap(), samples[12_345..16_345]);
    465 + assert_eq!(
    466 + f.read(49_990, 100).unwrap(),
    467 + samples[49_990..],
    468 + "у конца — сколько есть"
    469 + );
    470 + assert!(f.read(60_000, 10).unwrap().is_empty());
    471 + std::fs::write(&path, b"RIFF....WAVEjunk").unwrap();
    472 + assert!(WavFile::open(&path).is_err());
    473 + let _ = std::fs::remove_file(&path);
    474 + }
    371 475 }
  • index.html +16 −4
    Показать разницуСкрыть разницу
    9 9 <link rel="stylesheet" href="/src/styles/app.css">
    10 10 </head>
    11 11 <body>
    12 − <div class="shell">
    13 − <aside id="side" class="side" aria-label="Записи"></aside>
    14 − <main id="app" class="main"></main>
    15 − </div>
    12 + <header class="hdr">
    13 + <div class="hdr-row">
    14 + <details class="drawer hdr-nav" name="shell-top">
    15 + <summary class="hdr-burger" title="Меню" aria-label="Меню">
    16 + <span class="hdr-burger-bar"></span>
    17 + <span class="hdr-burger-bar"></span>
    18 + <span class="hdr-burger-bar"></span>
    19 + </summary>
    20 + <div class="drawer-body" id="drawer"></div>
    21 + </details>
    22 + <nav class="hdr-crumbs" id="crumbs" aria-label="Где я"></nav>
    23 + <span class="hdr-spacer"></span>
    24 + <div class="hdr-right" id="hdr-right"></div>
    25 + </div>
    26 + </header>
    27 + <main id="app" class="main"></main>
    16 28 <p id="toast" class="toast" role="status" hidden></p>
    17 29 <script type="module" src="/src/main.ts"></script>
    18 30 </body>
  • Показать разницуСкрыть разницу
    11 11 use tauri::{AppHandle, Emitter, State};
    12 12 use tokio_util::sync::CancellationToken;
    13 13
    14 +use meet_audio::pcm::{WavFile, WavWriter};
    14 15 use meet_audio::source::{Capture, CaptureError};
    15 16
    16 17 use crate::ai::{self, Command};
    318 319 meeting_id: &'a str,
    319 320 done: usize,
    320 321 total: usize,
    322 + /// Новые реплики по порядку времени, абзацами как в заметке.
    323 + lines: Vec<String>,
    321 324 }
    322 325
    323 −fn read_track(path: &Path) -> Result<Vec<i16>, String> {
    324 − let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
    325 − // Свои дорожки записи читаются напрямую, чужие файлы — декодером.
    326 − match meet_audio::pcm::decode_wav(&bytes) {
    327 − Ok(samples) => Ok(samples),
    328 − Err(_) => meet_audio::decode::decode_file(path).map_err(|e| e.to_string()),
    326 +/// Дорожка для расшифровки, читаемая с диска по кускам. Свои дорожки записи —
    327 +/// WAV нашего формата; чужой файл (mp3, m4a…) один раз перегоняется в такой
    328 +/// WAV рядом, чтобы и двухчасовой файл не держать в памяти всю расшифровку.
    329 +/// Второй путь — файл-времянка, его надо удалить после.
    330 +fn open_track(path: &Path) -> Result<(WavFile, Option<PathBuf>), String> {
    331 + if let Ok(f) = WavFile::open(path) {
    332 + return Ok((f, None));
    329 333 }
    334 + let samples = meet_audio::decode::decode_file(path).map_err(|e| e.to_string())?;
    335 + let temp = path.with_extension("transcribe.wav");
    336 + let mut w = WavWriter::create(&temp).map_err(|e| format!("{}: {e}", temp.display()))?;
    337 + w.write(&samples).map_err(|e| e.to_string())?;
    338 + w.flush().map_err(|e| e.to_string())?;
    339 + drop(samples);
    340 + let f = WavFile::open(&temp).map_err(|e| e.to_string())?;
    341 + Ok((f, Some(temp)))
    330 342 }
    331 343
    332 344 #[tauri::command]
    352 364 state.store().audio_path(&id, file).map_err(|e| e.to_string())?,
    353 365 ));
    354 366 }
    355 − let tracks = tokio::task::spawn_blocking(move || {
    367 + let opened = tokio::task::spawn_blocking(move || {
    356 368 tracks
    357 369 .into_iter()
    358 − .map(|(s, p)| read_track(&p).map(|a| (s, a)))
    370 + .map(|(s, p)| open_track(&p).map(|a| (s, a)))
    359 371 .collect::<Result<Vec<_>, _>>()
    360 372 })
    361 373 .await
    362 374 .map_err(|e| e.to_string())??;
    375 + let mut temps = Vec::new();
    376 + let tracks: Vec<(Speaker, Arc<dyn transcribe::Samples>)> = opened
    377 + .into_iter()
    378 + .map(|(speaker, (file, temp))| {
    379 + temps.extend(temp);
    380 + (speaker, Arc::new(file) as Arc<dyn transcribe::Samples>)
    381 + })
    382 + .collect();
    383 + // Времянки удаляются при любом исходе, в том числе при отмене.
    384 + let _cleanup = RemoveOnDrop(temps);
    363 385
    364 386 let stt = Arc::new(OpenRouterStt {
    365 387 client: Client::new(key),
    366 388 model: settings.stt_model.clone(),
    367 389 language: settings.stt_language().map(str::to_string),
    368 390 });
    369 − let outcome = transcribe::run(
    370 − tracks,
    371 − stt,
    372 − transcribe::Config::default(),
    373 − &job.token,
    374 − |done, total| {
    375 − let _ = app.emit(
    376 − "meet://progress",
    377 − Progress {
    378 − meeting_id: &id,
    379 − done,
    380 − total,
    381 − },
    382 − );
    383 − },
    384 − )
    391 + let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), &job.token, |u| {
    392 + let _ = app.emit(
    393 + "meet://progress",
    394 + Progress {
    395 + meeting_id: &id,
    396 + done: u.done,
    397 + total: u.total,
    398 + lines: u.fresh.iter().map(transcript::line).collect(),
    399 + },
    400 + );
    401 + })
    385 402 .await
    386 403 .map_err(|e| e.to_string())?;
    387 404 if outcome.failed > 0 && outcome.failed == outcome.segments.len() {
    409 426 })
    410 427 }
    411 428
    429 +struct RemoveOnDrop(Vec<PathBuf>);
    430 +
    431 +impl Drop for RemoveOnDrop {
    432 + fn drop(&mut self) {
    433 + for p in &self.0 {
    434 + let _ = std::fs::remove_file(p);
    435 + }
    436 + }
    437 +}
    438 +
    412 439 // ── Команды ИИ ──────────────────────────────────────────────────────────────
    413 440
    414 441 #[derive(Serialize)]
    420 447
    421 448 /// Получить результат: ответ ИИ ложится разделом на место прежнего с тем же
    422 449 /// заголовком. Прежний ответ модель не видит — иначе она его пересказывает.
    450 +#[derive(Serialize, Clone)]
    451 +struct AnswerPiece<'a> {
    452 + meeting_id: &'a str,
    453 + heading: &'a str,
    454 + /// Новый кусок ответа; интерфейс дописывает его к уже показанному.
    455 + text: &'a str,
    456 +}
    457 +
    423 458 #[tauri::command]
    424 459 pub async fn run_command(
    460 + app: AppHandle,
    425 461 state: State<'_, AppState>,
    426 462 id: String,
    427 463 command: Command,
    435 471 ai::prepare(&command, &crate::notes::remove(&note, &heading)).map_err(|e| e.to_string())?;
    436 472 let client = Client::new(key);
    437 473 let answer = tokio::select! {
    438 − answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user) => answer.map_err(|e| e.to_string())?.0,
    474 + answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user, |text| {
    475 + let _ = app.emit("meet://answer", AnswerPiece { meeting_id: &id, heading: &prepared.heading, text });
    476 + }) => answer.map_err(|e| e.to_string())?.0,
    439 477 _ = job.token.cancelled() => return Err("Отменено".into()),
    440 478 };
    441 479 let note = state
  • Показать разницуСкрыть разницу
    120 120 }
    121 121
    122 122 /// Ответ модели простым текстом (Markdown): команда ИИ над заметкой.
    123 − pub async fn complete(&self, model: &str, system: &str, user: &str) -> Result<(String, Usage), Error> {
    123 + /// Ответ идёт потоком (`stream: true`), и `on_text` получает каждый новый
    124 + /// кусок — человек видит, как пишется конспект, а не ждёт минуту пустой
    125 + /// экран. Документация: https://openrouter.ai/docs/api/reference/streaming
    126 + pub async fn complete(
    127 + &self,
    128 + model: &str,
    129 + system: &str,
    130 + user: &str,
    131 + mut on_text: impl FnMut(&str),
    132 + ) -> Result<(String, Usage), Error> {
    124 133 let request = json!({
    125 134 "model": model,
    126 135 "max_tokens": 16000,
    136 + "stream": true,
    137 + "usage": { "include": true },
    127 138 "messages": [
    128 139 { "role": "system", "content": system },
    129 140 { "role": "user", "content": user }
    130 141 ]
    131 142 });
    132 − let body = self
    133 − .send(
    134 − self.http
    135 − .post(format!("{}/chat/completions", self.base))
    136 − .json(&request),
    137 − )
    138 − .await?;
    139 − let choice = &body["choices"][0];
    140 − let finish = choice["finish_reason"].as_str().unwrap_or("");
    141 − let text = choice["message"]["content"]
    142 − .as_str()
    143 − .unwrap_or("")
    144 − .trim()
    145 − .to_string();
    146 − if finish != "stop" || text.is_empty() {
    147 − let why = if finish.is_empty() {
    148 − "пустой ответ"
    149 − } else {
    150 − finish
    151 − };
    152 − return Err(Error::Incomplete(why.to_string()));
    143 + let mut response = self
    144 + .http
    145 + .post(format!("{}/chat/completions", self.base))
    146 + .json(&request)
    147 + .bearer_auth(&self.key)
    148 + .header("X-Title", "Skillok Meet")
    149 + .send()
    150 + .await
    151 + .map_err(|e| Error::Network(e.to_string()))?;
    152 + let status = response.status().as_u16();
    153 + let is_stream = response
    154 + .headers()
    155 + .get(reqwest::header::CONTENT_TYPE)
    156 + .and_then(|v| v.to_str().ok())
    157 + .is_some_and(|v| v.contains("text/event-stream"));
    158 + if !(200..=299).contains(&status) || !is_stream {
    159 + // Ошибка или ответ целиком, без потока, — разбирается как обычно.
    160 + let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?;
    161 + let body = parse_reply(status, &text)?;
    162 + let (text, usage) = answer_of(&body)?;
    163 + on_text(&text);
    164 + return Ok((text, usage));
    165 + }
    166 + let mut sse = SseAnswer::default();
    167 + let mut pending = Vec::new();
    168 + while let Some(bytes) = response
    169 + .chunk()
    170 + .await
    171 + .map_err(|e| Error::Network(e.to_string()))?
    172 + {
    173 + pending.extend_from_slice(&bytes);
    174 + // Событие — строка `data: {...}`; строка может прийти по частям.
    175 + while let Some(end) = pending.iter().position(|&b| b == b'\n') {
    176 + let line: Vec<u8> = pending.drain(..=end).collect();
    177 + if let Some(delta) = sse.line(String::from_utf8_lossy(&line).trim())? {
    178 + on_text(&delta);
    179 + }
    180 + }
    153 181 }
    154 − Ok((text, usage_of(&body)))
    182 + sse.finish()
    155 183 }
    156 184
    157 185 async fn send(&self, request: reqwest::RequestBuilder) -> Result<Value, Error> {
    163 191 .map_err(|e| Error::Network(e.to_string()))?;
    164 192 let status = response.status().as_u16();
    165 193 let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?;
    166 − match status {
    167 − 200..=299 => serde_json::from_str(&text).map_err(|e| Error::Http {
    194 + parse_reply(status, &text)
    195 + }
    196 +}
    197 +
    198 +fn parse_reply(status: u16, text: &str) -> Result<Value, Error> {
    199 + match status {
    200 + 200..=299 => serde_json::from_str(text).map_err(|e| Error::Http {
    201 + status,
    202 + message: format!("ответ не JSON: {e}"),
    203 + }),
    204 + 401 | 403 => Err(Error::Unauthorized),
    205 + 402 => Err(Error::PaymentRequired),
    206 + 429 => Err(Error::RateLimited),
    207 + _ => {
    208 + let message = serde_json::from_str::<Value>(text)
    209 + .ok()
    210 + .and_then(|v| v["error"]["message"].as_str().map(str::to_string))
    211 + .unwrap_or_else(|| text.chars().take(300).collect());
    212 + Err(Error::Http { status, message })
    213 + }
    214 + }
    215 +}
    216 +
    217 +/// Ответ целиком: текст, если модель его закончила.
    218 +fn answer_of(body: &Value) -> Result<(String, Usage), Error> {
    219 + let choice = &body["choices"][0];
    220 + let finish = choice["finish_reason"].as_str().unwrap_or("");
    221 + let text = choice["message"]["content"]
    222 + .as_str()
    223 + .unwrap_or("")
    224 + .trim()
    225 + .to_string();
    226 + finished(finish, text, usage_of(body))
    227 +}
    228 +
    229 +fn finished(finish: &str, text: String, usage: Usage) -> Result<(String, Usage), Error> {
    230 + if finish != "stop" || text.is_empty() {
    231 + let why = if finish.is_empty() {
    232 + "пустой ответ"
    233 + } else {
    234 + finish
    235 + };
    236 + return Err(Error::Incomplete(why.to_string()));
    237 + }
    238 + Ok((text, usage))
    239 +}
    240 +
    241 +/// Сборка ответа из потока событий `data: {...}`. Строки `: ...` — сигнал
    242 +/// «ещё работаю» от OpenRouter, их пропускаем.
    243 +#[derive(Default)]
    244 +struct SseAnswer {
    245 + text: String,
    246 + finish: String,
    247 + usage: Usage,
    248 +}
    249 +
    250 +impl SseAnswer {
    251 + /// Разбирает строку потока; возвращает новый кусок текста, если он есть.
    252 + fn line(&mut self, line: &str) -> Result<Option<String>, Error> {
    253 + let Some(data) = line.strip_prefix("data:") else {
    254 + return Ok(None);
    255 + };
    256 + let data = data.trim();
    257 + if data.is_empty() || data == "[DONE]" {
    258 + return Ok(None);
    259 + }
    260 + let Ok(event) = serde_json::from_str::<Value>(data) else {
    261 + return Ok(None);
    262 + };
    263 + if let Some(message) = event["error"]["message"].as_str() {
    264 + // Ошибка посреди потока: статус уже 200, причина — в событии.
    265 + let status = event["error"]["code"].as_u64().unwrap_or(500) as u16;
    266 + return Err(Error::Http {
    168 267 status,
    169 − message: format!("ответ не JSON: {e}"),
    170 − }),
    171 − 401 | 403 => Err(Error::Unauthorized),
    172 − 402 => Err(Error::PaymentRequired),
    173 − 429 => Err(Error::RateLimited),
    174 − _ => {
    175 − let message = serde_json::from_str::<Value>(&text)
    176 − .ok()
    177 − .and_then(|v| v["error"]["message"].as_str().map(str::to_string))
    178 − .unwrap_or_else(|| text.chars().take(300).collect());
    179 − Err(Error::Http { status, message })
    180 − }
    268 + message: message.to_string(),
    269 + });
    270 + }
    271 + if event["usage"].is_object() {
    272 + self.usage = usage_of(&event);
    273 + }
    274 + let choice = &event["choices"][0];
    275 + if let Some(f) = choice["finish_reason"].as_str() {
    276 + self.finish = f.to_string();
    181 277 }
    278 + let delta = choice["delta"]["content"].as_str().unwrap_or("");
    279 + if delta.is_empty() {
    280 + return Ok(None);
    281 + }
    282 + self.text.push_str(delta);
    283 + Ok(Some(delta.to_string()))
    284 + }
    285 +
    286 + fn finish(self) -> Result<(String, Usage), Error> {
    287 + finished(&self.finish, self.text.trim().to_string(), self.usage)
    182 288 }
    183 289 }
    184 290
    261 367 /// Заглушка одного HTTP-ответа. Возвращает адрес и канал, по которому
    262 368 /// придёт полученный запрос целиком.
    263 369 fn serve_once(status: u16, body: &str) -> (String, mpsc::Receiver<String>) {
    370 + serve_once_as(status, "application/json", body)
    371 + }
    372 +
    373 + fn serve_once_as(status: u16, content_type: &str, body: &str) -> (String, mpsc::Receiver<String>) {
    374 + let content_type = content_type.to_string();
    264 375 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    265 376 let addr = listener.local_addr().unwrap();
    266 377 let body = body.to_string();
    292 403 }
    293 404 tx.send(String::from_utf8_lossy(&buf).to_string()).unwrap();
    294 405 let reply = format!(
    295 − "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
    406 + "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
    296 407 body.len()
    297 408 );
    298 409 stream.write_all(reply.as_bytes()).unwrap();
    423 534 let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": " ## Задачи\n\n- [ ] Отчёт\n" } }] });
    424 535 let (base, rx) = serve_once(200, &reply.to_string());
    425 536 let (text, _) = Client::with_base(base, "k")
    Разница обрезана: показаны первые 225 строк.
  • Показать разницуСкрыть разницу
    7 7 //! Куски без речи не отправляются: это экономит деньги, и Whisper не сочиняет
    8 8 //! фраз на тишине. Неудачный кусок повторяется один раз; если и повтор не
    9 9 //! прошёл, на его месте в расшифровке остаётся пометка, а не молчаливая дыра.
    10 +//!
    11 +//! Длинные записи: дорожка не читается в память целиком (два часа — 230 МБ на
    12 +//! дорожку). Сначала она проходит блоками по 10 секунд и оставляет только
    13 +//! границы кусков, потом каждый кусок читается с диска перед отправкой.
    14 +//!
    15 +//! Живой ход: готовые реплики отдаются наружу по порядку времени — кусок
    16 +//! уходит к человеку, когда распознаны все куски раньше него, и текст растёт
    17 +//! сверху вниз, а не вразнобой.
    10 18
    19 +use std::collections::BTreeMap;
    11 20 use std::future::Future;
    21 +use std::io;
    12 22 use std::pin::Pin;
    13 23 use std::sync::Arc;
    14 24 use std::time::Duration;
    15 25
    16 −use meet_audio::chunker::{self, Chunk, Chunker};
    17 −use meet_audio::pcm::{self, samples_to_ms};
    26 +use meet_audio::chunker::{self, Chunker};
    27 +use meet_audio::pcm::{self, ms_to_samples, samples_to_ms, WavFile};
    18 28 use tokio::task::JoinSet;
    19 29 use tokio_util::sync::CancellationToken;
    20 30
    28 38 fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>>;
    29 39 }
    30 40
    41 +/// Звук дорожки: файл на диске в приложении, вектор в тестах.
    42 +pub trait Samples: Send + Sync + 'static {
    43 + fn len(&self) -> usize;
    44 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>>;
    45 + fn is_empty(&self) -> bool {
    46 + self.len() == 0
    47 + }
    48 +}
    49 +
    50 +impl Samples for WavFile {
    51 + fn len(&self) -> usize {
    52 + WavFile::len(self)
    53 + }
    54 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> {
    55 + WavFile::read(self, start, n)
    56 + }
    57 +}
    58 +
    59 +impl Samples for Vec<i16> {
    60 + fn len(&self) -> usize {
    61 + self.as_slice().len()
    62 + }
    63 + fn read(&self, start: usize, n: usize) -> io::Result<Vec<i16>> {
    64 + let start = start.min(self.as_slice().len());
    65 + let end = (start + n).min(self.as_slice().len());
    66 + Ok(self[start..end].to_vec())
    67 + }
    68 +}
    69 +
    31 70 pub const UNRECOGNIZED: &str = "*(этот кусок не распознался — нажмите «Расшифровать» ещё раз)*";
    32 71
    33 72 #[derive(Debug, Clone, Copy)]
    57 96 pub last_error: Option<String>,
    58 97 }
    59 98
    60 −/// Режет дорожку на куски по паузам.
    61 −pub fn chunks_of(samples: &[i16], cfg: chunker::Config) -> Vec<Chunk> {
    99 +/// Ход расшифровки: сколько кусков готово и какие реплики добавились.
    100 +#[derive(Debug)]
    101 +pub struct Update<'a> {
    102 + pub done: usize,
    103 + pub total: usize,
    104 + /// Новые реплики по порядку времени; пусто, пока не готов кусок раньше.
    105 + pub fresh: &'a [Segment],
    106 +}
    107 +
    108 +/// Кусок для распознавания: где он в дорожке. Самого звука здесь нет.
    109 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    110 +pub struct Span {
    111 + pub start_ms: u64,
    112 + pub samples: usize,
    113 +}
    114 +
    115 +/// Блок, которым дорожка проходит через нарезку.
    116 +const BLOCK_MS: u64 = 10_000;
    117 +
    118 +/// Режет дорожку на куски по паузам, читая её блоками.
    119 +pub fn spans_of(source: &dyn Samples, cfg: chunker::Config) -> io::Result<Vec<Span>> {
    62 120 let mut c = Chunker::new(cfg);
    63 − let mut out = c.push(samples_to_ms(samples.len()), samples);
    64 − out.extend(c.flush());
    65 − out
    121 + let block = ms_to_samples(BLOCK_MS);
    122 + let mut spans = Vec::new();
    123 + let mut pos = 0;
    124 + let span = |ch: chunker::Chunk| Span {
    125 + start_ms: ch.start_ms,
    126 + samples: ch.samples.len(),
    127 + };
    128 + while pos < source.len() {
    129 + let samples = source.read(pos, block)?;
    130 + if samples.is_empty() {
    131 + break;
    132 + }
    133 + pos += samples.len();
    134 + spans.extend(c.push(samples_to_ms(pos), &samples).into_iter().map(span));
    135 + }
    136 + spans.extend(c.flush().into_iter().map(span));
    137 + Ok(spans)
    66 138 }
    67 139
    68 140 /// Расшифровку отменил человек.
    70 142 #[error("Расшифровка отменена")]
    71 143 pub struct Cancelled;
    72 144
    73 −/// Расшифровывает дорожки. `progress(готово, всего)` зовётся после каждого куска.
    145 +#[derive(Debug, thiserror::Error)]
    146 +pub enum Error {
    147 + #[error(transparent)]
    148 + Cancelled(#[from] Cancelled),
    149 + #[error("Не читается звук: {0}")]
    150 + Read(#[from] io::Error),
    151 +}
    152 +
    153 +/// Расшифровывает дорожки. `progress` зовётся в начале и после каждого куска.
    74 154 /// По `cancel` неотправленные куски не отправляются, идущие обрываются.
    75 155 pub async fn run(
    76 − tracks: Vec<(Speaker, Vec<i16>)>,
    156 + tracks: Vec<(Speaker, Arc<dyn Samples>)>,
    77 157 stt: Arc<dyn Stt>,
    78 158 cfg: Config,
    79 159 cancel: &CancellationToken,
    80 − progress: impl Fn(usize, usize),
    81 −) -> Result<Outcome, Cancelled> {
    82 − let jobs: Vec<(Speaker, Chunk)> = tracks
    160 + progress: impl Fn(Update<'_>),
    161 +) -> Result<Outcome, Error> {
    162 + // Границы кусков — в отдельном потоке: это чтение всего звука с диска.
    163 + let planned = tokio::task::spawn_blocking(move || {
    164 + tracks
    165 + .into_iter()
    166 + .map(|(speaker, source)| {
    167 + let spans = spans_of(source.as_ref(), cfg.chunker)?;
    168 + Ok::<_, io::Error>((speaker, source, spans))
    169 + })
    170 + .collect::<Result<Vec<_>, _>>()
    171 + })
    172 + .await
    173 + .expect("нарезка упала")?;
    174 + let mut jobs: Vec<(Speaker, Arc<dyn Samples>, Span)> = planned
    83 175 .into_iter()
    84 − .flat_map(|(speaker, samples)| {
    85 − chunks_of(&samples, cfg.chunker)
    176 + .flat_map(|(speaker, source, spans)| {
    177 + spans
    86 178 .into_iter()
    87 − .map(move |c| (speaker.clone(), c))
    179 + .map(move |span| (speaker.clone(), source.clone(), span))
    88 180 })
    89 181 .collect();
    182 + // По времени, чтобы первыми распознавались и показывались ранние куски.
    183 + jobs.sort_by_key(|(_, _, span)| span.start_ms);
    90 184 let total = jobs.len();
    91 − progress(0, total);
    185 + progress(Update {
    186 + done: 0,
    187 + total,
    188 + fresh: &[],
    189 + });
    92 190
    93 191 let mut set = JoinSet::new();
    94 − let mut pending = jobs.into_iter();
    192 + let mut pending = jobs.into_iter().enumerate();
    95 193 let mut segments = Vec::new();
    194 + // Готовые куски, которые ждут, пока распознаются куски раньше них.
    195 + let mut waiting: BTreeMap<usize, Vec<Segment>> = BTreeMap::new();
    196 + let mut next_out = 0;
    96 197 let mut failed = 0;
    97 198 let mut last_error = None;
    98 199 let mut done = 0;
    99 200 loop {
    100 201 while set.len() < cfg.parallel.max(1) {
    101 − let Some((speaker, chunk)) = pending.next() else {
    202 + let Some((index, (speaker, source, span))) = pending.next() else {
    102 203 break;
    103 204 };
    104 205 let stt = stt.clone();
    105 206 let retry_after = cfg.retry_after;
    106 207 set.spawn(async move {
    107 − let wav = pcm::encode_wav(&chunk.samples);
    108 − let mut result = stt.transcribe(wav.clone()).await;
    109 − if result.is_err() {
    110 − tokio::time::sleep(retry_after).await;
    111 − result = stt.transcribe(wav).await;
    112 − }
    113 − (speaker, chunk.start_ms, result)
    208 + let start = ms_to_samples(span.start_ms);
    209 + let samples = tokio::task::spawn_blocking(move || source.read(start, span.samples))
    210 + .await
    211 + .expect("чтение упало");
    212 + let result = match samples {
    213 + Ok(samples) => {
    214 + let wav = pcm::encode_wav(&samples);
    215 + drop(samples);
    216 + let mut result = stt.transcribe(wav.clone()).await;
    217 + if result.is_err() {
    218 + tokio::time::sleep(retry_after).await;
    219 + result = stt.transcribe(wav).await;
    220 + }
    221 + result
    222 + }
    223 + Err(e) => Err(format!("не читается звук: {e}")),
    224 + };
    225 + (index, speaker, span.start_ms, result)
    114 226 });
    115 227 }
    116 228 let joined = tokio::select! {
    117 229 joined = set.join_next() => joined,
    118 230 _ = cancel.cancelled() => {
    119 231 set.abort_all();
    120 − return Err(Cancelled);
    232 + return Err(Cancelled.into());
    121 233 }
    122 234 };
    123 235 let Some(joined) = joined else {
    124 236 break;
    Разница обрезана: показаны первые 225 строк.
  • Показать разницуСкрыть разницу
    1 1 //! Реплики расшифровки и то, как они ложатся в заметку.
    2 2 //!
    3 3 //! Говорящий — только дорожка звука: «Я» (микрофон), «Собеседники» (звук компьютера)
    4 −//! или никто, если звук пришёл одним файлом. Узнавания людей по голосу нет и не
    5 −//! будет: это биометрия (см. README, «Согласие на запись»).
    4 +//! или «Спикер 1», если звук пришёл одним файлом и кто говорит, неизвестно.
    5 +//! Узнавания людей по голосу нет: это биометрия (см. README, «Согласие на запись»).
    6 6
    7 7 use serde::{Deserialize, Serialize};
    8 8
    16 16 }
    17 17
    18 18 impl Speaker {
    19 − pub fn label(&self) -> Option<&'static str> {
    19 + /// Подпись в расшифровке. Кто говорит в файле, неизвестно — «Спикер 1»
    20 + /// (решение владельца 01.10.2026: не определился — нумеруем).
    21 + pub fn label(&self) -> &'static str {
    20 22 match self {
    21 − Speaker::Me => Some("Я"),
    22 − Speaker::Others => Some("Собеседники"),
    23 − Speaker::Unknown => None,
    23 + Speaker::Me => "Я",
    24 + Speaker::Others => "Собеседники",
    25 + Speaker::Unknown => "Спикер 1",
    24 26 }
    25 27 }
    26 28
    57 59 continue;
    58 60 }
    59 61 out.push_str("\n\n");
    60 − match s.speaker.label() {
    61 − Some(who) => out.push_str(&format!(
    62 − "**{} {}:** {}",
    63 − format_timestamp(s.start_ms),
    64 − who,
    65 − s.text
    66 − )),
    67 − None => out.push_str(&format!("**{}** {}", format_timestamp(s.start_ms), s.text)),
    68 − }
    62 + out.push_str(&line(s));
    69 63 prev = Some(&s.speaker);
    70 64 }
    71 65 if segments.is_empty() {
    75 69 out
    76 70 }
    77 71
    72 +/// Реплика абзацем: «**00:03:21 Я:** текст». Так же её показывает интерфейс
    73 +/// по ходу расшифровки.
    74 +pub fn line(s: &Segment) -> String {
    75 + format!(
    76 + "**{} {}:** {}",
    77 + format_timestamp(s.start_ms),
    78 + s.speaker.label(),
    79 + s.text
    80 + )
    81 +}
    82 +
    78 83 pub fn format_timestamp(ms: u64) -> String {
    79 84 let total = ms / 1000;
    80 85 format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60)
    110 115 }
    111 116
    112 117 #[test]
    113 − fn single_file_has_no_speaker_names() {
    118 + fn unknown_speaker_is_speaker_one() {
    114 119 let md = to_markdown(&[
    115 120 seg(0, Speaker::Unknown, "Раз."),
    116 121 seg(20_000, Speaker::Unknown, "Два."),
    117 122 ]);
    118 − assert_eq!(md, "## Расшифровка\n\n**00:00:00** Раз. Два.\n");
    123 + assert_eq!(md, "## Расшифровка\n\n**00:00:00 Спикер 1:** Раз. Два.\n");
    119 124 }
    120 125
    121 126 #[test]
  • src/api.ts +5 −1
    Показать разницуСкрыть разницу
    118 118
    119 119 export const events = {
    120 120 recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
    121 − progress: (cb: (p: { meeting_id: string; done: number; total: number }) => void) => on("meet://progress", cb),
    121 + /** Ход расшифровки: `lines` — новые реплики по порядку времени, абзацами как в заметке. */
    122 + progress: (cb: (p: { meeting_id: string; done: number; total: number; lines: string[] }) => void) =>
    123 + on("meet://progress", cb),
    124 + /** Ответ ИИ пишется: `text` — новый кусок, его дописывают к показанному. */
    125 + answer: (cb: (p: { meeting_id: string; heading: string; text: string }) => void) => on("meet://answer", cb),
    122 126 };
    123 127
    124 128 export const files = {
  • src/dev-mock.ts +14 −8
    Показать разницуСкрыть разницу
    181 181 const d = find(args.id);
    182 182 if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
    183 183 if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл";
    184 − const total = 12;
    184 + // Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени.
    185 + const lines = TRANSCRIPT.replace(/^## .*\n+/, "").trim().split(/\n\s*\n/);
    186 + const total = lines.length;
    185 187 cancelled = false;
    186 − for (let i = 0; i <= total; i++) {
    188 + emit("meet://progress", { meeting_id: d.meeting.id, done: 0, total, lines: [] });
    189 + for (let i = 1; i <= total; i++) {
    190 + await wait(350);
    187 191 if (cancelled) throw "Расшифровка отменена";
    188 − emit("meet://progress", { meeting_id: d.meeting.id, done: i, total });
    189 − await wait(250);
    192 + emit("meet://progress", { meeting_id: d.meeting.id, done: i, total, lines: [lines[i - 1]] });
    190 193 }
    191 194 d.meeting.transcribed_at = new Date().toISOString();
    192 195 return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } as T;
    198 201 if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст";
    199 202 if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать";
    200 203 cancelled = false;
    201 − for (let i = 0; i < 8; i++) {
    202 − await wait(200);
    203 − if (cancelled) throw "Отменено";
    204 − }
    205 204 const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind];
    206 205 const block =
    207 206 c.kind === "custom"
    208 207 ? `## ${heading}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n`
    209 208 : ANSWERS[c.kind];
    209 + // Ответ пишется потоком: кусками по несколько знаков, как у модели.
    210 + const answer = block.replace(/^## .*\n+/, "");
    211 + for (let i = 0; i < answer.length; i += 6) {
    212 + await wait(25);
    213 + if (cancelled) throw "Отменено";
    214 + emit("meet://answer", { meeting_id: d.meeting.id, heading, text: answer.slice(i, i + 6) });
    215 + }
    210 216 return { note: upsert(d, heading, block), heading } as T;
    211 217 }
    212 218 case "recorder_status":
  • src/main.ts +6 −5
    Показать разницуСкрыть разницу
    3 3 import { renderHome } from "./views/home";
    4 4 import { renderMeeting } from "./views/meeting";
    5 5 import { renderSettings } from "./views/settings";
    6 −import { refreshSide, startSide } from "./side";
    6 +import { refreshNav, setCrumb, startNav } from "./nav";
    7 7 import { errorText, files } from "./api";
    8 8 import { esc } from "./dom";
    9 9 import { Scope } from "./scope";
    17 17 scope = new Scope();
    18 18 const [section, id, tab] = location.hash.replace(/^#\/?/, "").split("/");
    19 19 main.scrollTop = 0;
    20 − void refreshSide();
    20 + setCrumb(null);
    21 + void refreshNav();
    21 22 try {
    22 23 if (section === "m" && id) {
    23 24 await renderMeeting(main, scope, decodeURIComponent(id), tab ? decodeURIComponent(tab) : null);
    33 34
    34 35 async function start(): Promise<void> {
    35 36 await refreshRecording();
    36 − startSide();
    37 − // Запись началась, встала на паузу или закончилась — панель слева меняется.
    38 − onChange(() => void refreshSide());
    37 + startNav();
    38 + // Запись началась, встала на паузу или закончилась — шапка и меню меняются.
    39 + onChange(() => void refreshNav());
    39 40 // Аудиофайл, брошенный в окно с любого экрана, — новая запись.
    40 41 await files.onDrop(async (paths) => {
    41 42 for (const p of paths) await addAudio(p);
  • src/nav.ts +236
    Показать разницуСкрыть разницу
    1 +// Шапка и меню — как на skillok.org: кнопка ☰ открывает панель слева поверх
    2 +// экрана (разделы с иконками, список записей с поиском, «Настройки»), рядом —
    3 +// крошки «Skillok Meet / где я». Разметка, классы и поведение панели сняты с
    4 +// живого skillok.org (шапка `.hdr`, ящик `.drawer.hdr-nav`, меню `.mn`):
    5 +// закрывается крестиком, кликом мимо, Esc и переходом на другой экран.
    6 +
    7 +import { api, elapsed, files, type Meeting } from "./api";
    8 +import { esc } from "./dom";
    9 +import { addAudio, startRecording, state } from "./state";
    10 +
    11 +let meetings: Meeting[] = [];
    12 +let captureSupported = true;
    13 +/** Название открытой записи — для крошек. */
    14 +let crumb: string | null = null;
    15 +
    16 +const icon = (paths: string) =>
    17 + `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
    18 +
    19 +// Иконки — Lucide, как у skillok.org.
    20 +const ICONS = {
    21 + home: icon('<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
    22 + mic: icon('<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'),
    23 + file: icon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>'),
    24 + settings: icon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
    25 + search: icon('<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'),
    26 + plus: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'),
    27 + record: icon('<rect x="4" y="4" width="16" height="16"/>'),
    28 +};
    29 +
    30 +function route(): { section: string; id: string | null } {
    31 + const [section, id] = location.hash.replace(/^#\/?/, "").split("/");
    32 + return { section: section ?? "", id: section === "m" && id ? decodeURIComponent(id) : null };
    33 +}
    34 +
    35 +function link(href: string, label: string, ic: string, current: boolean, extra = ""): string {
    36 + return `<a class="mn-link" href="${href}"${current ? ` aria-current="page"` : ""}>
    37 + <span class="mn-icon">${ic}</span><span class="mn-label">${label}</span>${extra}</a>`;
    38 +}
    39 +
    40 +function drawerBody(): string {
    41 + const { section, id } = route();
    42 + const r = state.recording;
    43 + const rows = meetings
    44 + .map((m) => {
    45 + const mark = m.recording
    46 + ? `<span class="mn-count" data-strong>запись</span>`
    47 + : m.audio.length && !m.transcribed_at
    48 + ? `<span class="mn-dot" title="Не расшифровано"></span>`
    49 + : "";
    50 + return `<a class="mn-link" data-rec-row href="#/m/${encodeURIComponent(m.id)}" title="${esc(m.title)}"${
    51 + m.id === id ? ` aria-current="page"` : ""
    52 + }><span class="mn-label">${esc(m.title)}</span>${mark}</a>`;
    53 + })
    54 + .join("");
    55 + const record = r
    56 + ? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false,
    57 + `<span class="mn-count" data-strong data-drawer-timer>${elapsed(r.started_at)}</span>`)
    58 + : captureSupported
    59 + ? `<button type="button" class="mn-link" data-nav-record><span class="mn-icon">${ICONS.mic}</span><span class="mn-label">Записать встречу</span></button>`
    60 + : "";
    61 + return `
    62 + <div class="drawer-top">
    63 + <a class="drawer-home" href="#/">Skillok Meet</a>
    64 + <button type="button" class="drawer-close" data-drawer-close aria-label="Закрыть меню">✕</button>
    65 + </div>
    66 + <aside class="mn">
    67 + <div class="mn-group"><nav class="mn-list" aria-label="Меню">
    68 + ${link("#/", "Главная", ICONS.home, section === "")}
    69 + </nav></div>
    70 + <div class="mn-rule"></div>
    71 + <div class="mn-group"><nav class="mn-list" aria-label="Новая запись">
    72 + ${record}
    73 + <button type="button" class="mn-link" data-nav-pick><span class="mn-icon">${ICONS.file}</span><span class="mn-label">Добавить аудиофайл</span></button>
    74 + </nav></div>
    75 + <div class="mn-rule"></div>
    76 + <div class="mn-group drawer-repos">
    77 + <div class="mn-title drawer-group-row">
    78 + <span>Записи</span>
    79 + <button type="button" class="drawer-icon" data-rec-search title="Найти запись…" aria-label="Найти запись…">${ICONS.search}</button>
    80 + </div>
    81 + <input class="drawer-filter" type="search" data-rec-filter placeholder="Найти запись…" aria-label="Найти запись…" hidden>
    82 + ${rows || `<p class="drawer-empty">Записей пока нет.</p>`}
    83 + </div>
    84 + <div class="mn-rule"></div>
    85 + <div class="mn-group"><nav class="mn-list" aria-label="Программа">
    86 + ${link("#/settings", "Настройки", ICONS.settings, section === "settings")}
    87 + </nav></div>
    88 + </aside>`;
    89 +}
    90 +
    91 +function headerRight(): string {
    92 + const r = state.recording;
    93 + if (r) {
    94 + return `<a class="hdr-live" href="#/m/${encodeURIComponent(r.meeting_id)}">
    95 + <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"}
    96 + <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`;
    97 + }
    98 + return captureSupported ? `<button class="ui-btn ui-btn-sm" data-nav-record>Записать</button>` : "";
    99 +}
    100 +
    101 +function crumbs(): string {
    102 + const { section, id } = route();
    103 + const here = section === "settings" ? "Настройки" : id ? crumb ?? "" : "";
    104 + return `<a class="hdr-crumb${here ? "" : " hdr-crumb-strong"}" href="#/">Skillok Meet</a>${
    105 + here ? `<span class="hdr-slash">/</span><span class="hdr-crumb hdr-crumb-strong" aria-current="page">${esc(here)}</span>` : ""
    106 + }`;
    107 +}
    108 +
    109 +export function draw(): void {
    110 + const body = document.getElementById("drawer");
    111 + const filter = body?.querySelector<HTMLInputElement>("[data-rec-filter]");
    112 + const query = filter && !filter.hidden ? filter.value : null;
    113 + if (body) body.innerHTML = drawerBody();
    114 + document.getElementById("crumbs")!.innerHTML = crumbs();
    115 + document.getElementById("hdr-right")!.innerHTML = headerRight();
    116 + document.querySelectorAll("[data-nav-record]").forEach((b) => b.addEventListener("click", () => {
    117 + closeAll();
    118 + void startRecording();
    119 + }));
    120 + document.querySelector("[data-nav-pick]")?.addEventListener("click", async () => {
    121 + closeAll();
    122 + await addAudio(await files.pickAudio());
    123 + });
    124 + // Поиск остаётся открытым с тем же текстом, если список перерисовался.
    125 + if (query !== null) {
    126 + const f = document.querySelector<HTMLInputElement>("[data-rec-filter]")!;
    127 + f.hidden = false;
    128 + f.value = query;
    129 + applyFilter(query);
    130 + }
    131 +}
    132 +
    133 +function applyFilter(value: string): void {
    134 + const q = value.trim().toLowerCase();
    135 + document.querySelectorAll<HTMLElement>("[data-rec-row]").forEach((row) => {
    136 + row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q);
    137 + });
    138 +}
    139 +
    140 +/** Название открытой записи для крошек; `null` — открыт не экран записи. */
    141 +export function setCrumb(title: string | null): void {
    142 + crumb = title;
    143 + document.getElementById("crumbs")!.innerHTML = crumbs();
    144 +}
    145 +
    146 +/** Перечитать записи и перерисовать шапку и меню: после записи, расшифровки, правки. */
    147 +export async function refreshNav(): Promise<void> {
    148 + try {
    149 + const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]);
    150 + meetings = list;
    151 + captureSupported = view.capture_supported;
    152 + } catch {
    153 + // Меню — не главное: список останется прежним, ошибку покажет экран.
    154 + }
    155 + draw();
    156 +}
    157 +
    158 +/** Список записей, который уже знает меню, — главной не надо читать его заново. */
    159 +export function knownMeetings(): Meeting[] {
    160 + return meetings;
    161 +}
    162 +
    163 +// ── Поведение ящика — копия скрипта skillok.org ────────────────────────────
    164 +
    165 +const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
    166 +
    167 +function close(drawer: HTMLDetailsElement): void {
    168 + if (!drawer.open || drawer.classList.contains("is-closing")) return;
    169 + if (reduce) {
    170 + drawer.open = false;
    171 + return;
    172 + }
    173 + drawer.classList.add("is-closing");
    174 + const body = drawer.querySelector(":scope > .drawer-body");
    175 + let done = false;
    176 + const finish = () => {
    177 + if (done) return;
    178 + done = true;
    179 + drawer.classList.remove("is-closing");
    180 + drawer.open = false;
    181 + };
    182 + body?.addEventListener("animationend", finish, { once: true });
    183 + window.setTimeout(finish, 260);
    184 +}
    185 +
    186 +function closeAll(): void {
    187 + document.querySelectorAll<HTMLDetailsElement>(".drawer[open]").forEach(close);
    188 +}
    189 +
    190 +export function startNav(): void {
    191 + document.addEventListener("click", (event) => {
    192 + const target = event.target as HTMLElement;
    193 + const closer = target.closest("[data-drawer-close]");
    194 + if (closer) {
    195 + const own = closer.closest("details");
    196 + if (own) close(own);
    197 + return;
    198 + }
    199 + if (target.closest("[data-rec-search]")) {
    200 + const f = document.querySelector<HTMLInputElement>("[data-rec-filter]");
    201 + if (f) {
    202 + f.hidden = !f.hidden;
    203 + if (!f.hidden) f.focus();
    204 + else applyFilter("");
    205 + }
    206 + return;
    207 + }
    208 + const drawer = target.closest<HTMLDetailsElement>(".drawer");
    209 + if (!drawer) {
    210 + closeAll();
    211 + return;
    212 + }
    213 + const summary = target.closest("summary");
    214 + if (summary && summary.parentNode === drawer && drawer.open) {
    215 + event.preventDefault();
    216 + close(drawer);
    217 + return;
    218 + }
    219 + if (target === drawer) close(drawer);
    220 + });
    221 + document.addEventListener("input", (event) => {
    222 + const t = event.target as HTMLElement;
    223 + if (t.matches("[data-rec-filter]")) applyFilter((t as HTMLInputElement).value);
    224 + });
    225 + document.addEventListener("keydown", (event) => {
    226 + if (event.key === "Escape") closeAll();
    227 + });
    228 + // Переход на другой экран — ящик закрывается, как у ссылки на сайте.
    229 + window.addEventListener("hashchange", closeAll);
    230 + window.setInterval(() => {
    231 + if (!state.recording) return;
    232 + const t = elapsed(state.recording.started_at);
    233 + document.querySelectorAll("[data-hdr-timer], [data-drawer-timer]").forEach((el) => (el.textContent = t));
    234 + }, 1000);
    235 +}
    236 +
  • src/recording.ts +13 −8
    Показать разницуСкрыть разницу
    1 1 // Запись: панель с таймером, уровнями и кнопками — одна на главной и на
    2 −// экране записи. Метка «идёт запись» с любого экрана — в боковой панели (side.ts).
    2 +// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).
    3 3
    4 4 import { api, elapsed, events } from "./api";
    5 5 import { esc } from "./dom";
    9 9 export function recordingPanel(warning: string): string {
    10 10 const r = state.recording;
    11 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 12 return `
    16 13 <div class="rec" data-rec>
    17 14 <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
    19 16 }</p>
    20 17 ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""}
    21 18 <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
    22 − <div class="meters">${meter("Вы", "me")}${r.system ? meter("Собеседники", "others") : ""}</div>
    19 + <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе">
    20 + <span class="meter-bar"><span class="meter-fill" data-level></span></span>
    21 + </div>
    23 22 <div class="actions">
    24 23 <button class="ui-btn" data-rec-stop>Остановить</button>
    25 24 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
    55 54 }
    56 55 });
    57 56 root.querySelector("[data-rec-warning]")?.addEventListener("click", () => copy(warning, "Предупреждение скопировано — вставьте его в чат встречи"));
    57 + // Один общий уровень: громче из дорожек за последние полсекунды. Человеку
    58 + // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники»
    59 + // незачем (решение владельца 01.10.2026).
    60 + const last: Record<string, { dbfs: number; at: number }> = {};
    58 61 scope.on(
    59 62 events.recorder(({ event }) => {
    60 63 if (event.kind === "level") {
    61 − const key = event.data.speaker.kind === "me" ? "me" : "others";
    62 − const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`);
    63 − if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`;
    64 + const now = performance.now();
    65 + last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now };
    66 + const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs));
    67 + const fill = root.querySelector<HTMLElement>("[data-level]");
    68 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`;
    64 69 } else {
    65 70 toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`);
    66 71 }
  • src/side.ts −81
    Показать разницуСкрыть разницу
    1 −// Боковая панель, как библиотека у MacWhisper: «Записать» сверху, все записи
    2 −// списком, «Настройки» внизу. Видна с любого экрана; пока идёт запись, вместо
    3 −// кнопки — метка с таймером.
    4 −
    5 −import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "./api";
    6 −import { esc } from "./dom";
    7 −import { addAudio, startRecording, state } from "./state";
    8 −
    9 −const side = () => document.getElementById("side")!;
    10 −let meetings: Meeting[] = [];
    11 −let captureSupported = true;
    12 −
    13 −function currentId(): string | null {
    14 − const [section, id] = location.hash.replace(/^#\/?/, "").split("/");
    15 − return section === "m" && id ? decodeURIComponent(id) : null;
    16 −}
    17 −
    18 −function item(m: Meeting, current: string | null): string {
    19 − const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    20 − const mark = m.recording
    21 − ? `<span class="side-mark is-live"><span class="dot dot-live"></span>запись</span>`
    22 − : m.audio.length && !m.transcribed_at
    23 − ? `<span class="side-mark">не расшифровано</span>`
    24 − : "";
    25 − return `
    26 − <a class="side-item" href="#/m/${encodeURIComponent(m.id)}"${m.id === current ? ` aria-current="page"` : ""}>
    27 − <span class="side-title">${esc(m.title)}</span>
    28 − <span class="side-meta">${esc(meta)}${mark}</span>
    29 − </a>`;
    30 −}
    31 −
    32 −function draw(): void {
    33 − const r = state.recording;
    34 − const current = currentId();
    35 − const top = r
    36 − ? `<a class="side-live" href="#/m/${encodeURIComponent(r.meeting_id)}">
    37 − <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Идёт запись"}
    38 − <span class="mono-num" data-side-timer>${elapsed(r.started_at)}</span>
    39 − </a>`
    40 − : captureSupported
    41 − ? `<button class="ui-btn side-rec" data-side-record>Записать</button>`
    42 − : "";
    43 − side().innerHTML = `
    44 − <a class="brand" href="#/">
    45 − <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true">
    46 − <rect width="1024" height="1024" fill="var(--color-accent)"/>
    47 − <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    48 − </svg>
    49 − Skillok Meet
    50 − </a>
    51 − <div class="side-actions">
    52 − ${top}
    53 − <button class="link-btn" data-side-pick>Добавить аудиофайл</button>
    54 − </div>
    55 − <p class="side-head">Записи${meetings.length ? ` <span class="ui-nav-count">${meetings.length}</span>` : ""}</p>
    56 − <nav class="side-list">
    57 − ${meetings.length ? meetings.map((m) => item(m, current)).join("") : `<p class="side-empty">Здесь появятся ваши записи.</p>`}
    58 − </nav>
    59 − <a class="side-foot" href="#/settings"${location.hash.startsWith("#/settings") ? ` aria-current="page"` : ""}>Настройки</a>`;
    60 − side().querySelector("[data-side-record]")?.addEventListener("click", startRecording);
    61 − side().querySelector("[data-side-pick]")?.addEventListener("click", async () => addAudio(await files.pickAudio()));
    62 −}
    63 −
    64 −/** Перечитать записи и перерисовать панель: после записи, расшифровки, правки. */
    65 −export async function refreshSide(): Promise<void> {
    66 − try {
    67 − const [list, view] = await Promise.all([api.listMeetings(), api.getSettings()]);
    68 − meetings = list;
    69 − captureSupported = view.capture_supported;
    70 − } catch {
    71 − // Панель — не главное: список останется прежним, ошибку покажет экран.
    72 − }
    73 − draw();
    74 −}
    75 −
    76 −export function startSide(): void {
    77 − window.setInterval(() => {
    78 − const t = document.querySelector("[data-side-timer]");
    79 − if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
    80 − }, 1000);
    81 −}
  • src/styles/app.css +138 −55
    Показать разницуСкрыть разницу
    1 −/* Раскладка как у MacWhisper: библиотека записей слева, открытая запись справа.
    2 − Стиль — skillok.org: бумага, чернила, терракота, квадратные углы. Тема одна, светлая. */
    1 +/* Раскладка: шапка и меню как у skillok.org (кнопка ☰ открывает панель слева),
    2 + под ней — экран. Правила шапки, ящика и меню сняты с живого skillok.org
    3 + (`.hdr`, `.drawer.hdr-nav`, `.mn`) и пересчитаны на токены программы.
    4 + Стиль — бумага, чернила, терракота, квадратные углы. Тема одна, светлая. */
    3 5
    4 6 html, body { height: 100%; }
    5 −.shell { display: grid; grid-template-columns: 248px 1fr; height: 100%; }
    6 −
    7 −/* ── Боковая панель ── */
    8 −.side {
    9 − display: flex; flex-direction: column; min-height: 0;
    10 − background: var(--color-bg-secondary);
    11 − border-right: 1px solid var(--color-border);
    12 −}
    13 −.brand {
    14 − display: flex; align-items: center; gap: 10px; flex: none;
    15 − height: 52px; padding: 0 16px;
    16 − color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg);
    17 −}
    18 −.brand:hover { text-decoration: none; }
    19 −.brand-mark { width: 22px; height: 22px; }
    20 −.side-actions { display: grid; gap: 6px; justify-items: start; padding: 4px 16px 16px; }
    21 −.side-actions .side-rec { justify-self: stretch; min-height: 40px; }
    22 −.side-live {
    23 − display: flex; align-items: center; gap: 8px; justify-self: stretch;
    24 − min-height: 40px; padding: 0 12px;
    25 − font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text);
    26 − border: 2px solid var(--color-accent); background: var(--color-bg-elevated);
    27 −}
    28 −.side-live:hover { text-decoration: none; background: var(--background); }
    29 −.side-live .mono-num { margin-left: auto; }
    30 −.side-head {
    31 − display: flex; align-items: center; gap: 8px; padding: 8px 16px 6px;
    32 − font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
    33 − color: var(--color-text-secondary); border-top: 1px solid var(--color-border);
    34 −}
    35 −.side-list { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 8px; }
    36 −.side-item {
    37 − display: grid; gap: 2px; padding: 8px 16px;
    38 − color: var(--color-text-primary); box-shadow: inset 2px 0 0 transparent;
    39 −}
    40 −.side-item:hover { background: var(--color-bg-hover); text-decoration: none; }
    41 −.side-item[aria-current] { background: var(--color-bg-elevated); box-shadow: inset 2px 0 0 var(--color-accent); }
    42 −.side-title { font-size: var(--text-md); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    43 −.side-meta { display: flex; flex-wrap: wrap; gap: 0 8px; font-size: var(--text-xs); color: var(--color-text-secondary); }
    44 −.side-mark { display: inline-flex; align-items: center; gap: 6px; color: var(--color-accent-text); }
    45 −.side-empty { padding: 8px 16px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    46 −.side-foot {
    47 − flex: none; display: flex; align-items: center; height: 44px; padding: 0 16px;
    48 − color: var(--color-text-primary); font-size: var(--text-md);
    49 − border-top: 1px solid var(--color-border);
    50 −}
    51 −.side-foot:hover { background: var(--color-bg-hover); text-decoration: none; }
    52 −.side-foot[aria-current] { box-shadow: inset 2px 0 0 var(--color-accent); background: var(--color-bg-elevated); }
    53 −
    54 −.main { min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); }
    7 +body { display: flex; flex-direction: column; }
    8 +
    9 +/* ── Шапка ── */
    10 +.hdr { flex: none; position: relative; z-index: 100; background: var(--background); border-bottom: 1px solid var(--color-border); }
    11 +.hdr-row { display: flex; align-items: center; height: 64px; padding: 0 16px; gap: 8px; }
    12 +.hdr-spacer { flex: 1; }
    13 +.hdr-crumbs { display: flex; align-items: center; min-width: 0; flex-shrink: 1; font-size: var(--text-md); }
    14 +.hdr-slash { flex-shrink: 0; margin: 0 2px; color: var(--color-text-secondary); }
    15 +.hdr-crumb {
    16 + flex-shrink: 0; padding: 4px 8px; color: var(--color-text-primary); text-decoration: none;
    17 + transition: background-color 0.12s;
    18 +}
    19 +a.hdr-crumb:hover { background: var(--color-bg-hover); text-decoration: none; }
    20 +.hdr-crumb-strong { font-weight: 700; min-width: 0; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    21 +.hdr-right { display: flex; align-items: center; gap: 8px; flex: none; }
    22 +.hdr-live {
    23 + display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px;
    24 + font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); border: 1px solid var(--color-accent);
    25 +}
    26 +.hdr-live:hover { background: var(--color-bg-hover); text-decoration: none; }
    27 +
    28 +/* Кнопка ☰ */
    29 +.drawer { position: relative; }
    30 +.drawer > summary { list-style: none; cursor: pointer; }
    31 +.drawer > summary::-webkit-details-marker { display: none; }
    32 +.hdr-burger {
    33 + display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
    34 + box-sizing: border-box; height: 32px; min-width: 32px; padding: 0;
    35 + color: var(--color-text-secondary); border: 1px solid var(--color-border);
    36 + transition: background-color 0.12s, color 0.12s;
    37 +}
    38 +.hdr-burger:hover { color: var(--color-text-primary); background: var(--color-bg-hover); }
    39 +.hdr-burger-bar { display: block; width: 16px; height: 2px; background: currentColor; }
    40 +
    41 +/* Ящик слева поверх экрана */
    42 +.drawer.hdr-nav > .drawer-body {
    43 + position: fixed; z-index: 130; top: 0; left: 0; bottom: 0; width: min(320px, 90vw);
    44 + overflow-y: auto; padding: 16px;
    45 + background: var(--background); color: var(--color-text-primary);
    46 + box-shadow: rgba(10, 10, 10, 0.2) 0 8px 24px;
    47 + font-size: var(--text-md); line-height: 20px;
    48 +}
    49 +.drawer.hdr-nav[open]::before { content: ""; position: fixed; inset: 0; z-index: 129; background: rgba(10, 10, 10, 0.5); }
    50 +.drawer.hdr-nav[open] > .drawer-body { animation: drawer-slide 0.25s cubic-bezier(0.33, 1, 0.68, 1); }
    51 +.drawer.hdr-nav[open]::before { animation: drawer-fade 0.25s ease-out; }
    52 +.drawer.hdr-nav.is-closing > .drawer-body { animation: drawer-slide-out 0.2s cubic-bezier(0.32, 0, 0.67, 0) forwards; }
    53 +.drawer.hdr-nav.is-closing::before { animation: drawer-fade-out 0.2s ease-in forwards; }
    54 +@keyframes drawer-slide { from { transform: translateX(-100%); } to { transform: none; } }
    55 +@keyframes drawer-slide-out { from { transform: none; } to { transform: translateX(-100%); } }
    56 +@keyframes drawer-fade { from { opacity: 0; } to { opacity: 1; } }
    57 +@keyframes drawer-fade-out { from { opacity: 1; } to { opacity: 0; } }
    58 +@media (prefers-reduced-motion: reduce) {
    59 + .drawer.hdr-nav[open] > .drawer-body, .drawer.hdr-nav[open]::before,
    60 + .drawer.hdr-nav.is-closing > .drawer-body, .drawer.hdr-nav.is-closing::before { animation: none; }
    61 +}
    62 +.drawer-top { display: flex; align-items: center; justify-content: space-between; padding: 0 0 16px; }
    63 +.drawer-home { padding: 0 14px; font-size: var(--text-lg); font-weight: 700; color: var(--color-text-primary); }
    64 +.drawer-home:hover { text-decoration: none; }
    65 +.drawer-close, .drawer-icon {
    66 + display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0;
    67 + background: none; border: 0; color: var(--color-text-secondary);
    68 + font-family: var(--font-ui); font-size: var(--text-xl); line-height: 1; cursor: pointer;
    69 + transition: background-color 0.12s, color 0.12s;
    70 +}
    71 +.drawer-icon { width: 24px; height: 24px; }
    72 +.drawer-close:hover, .drawer-icon:hover { color: var(--color-text-primary); background: var(--color-bg-hover); }
    73 +.drawer-group-row { display: flex; align-items: center; justify-content: space-between; }
    74 +.drawer-group-row > span { flex: 1; }
    75 +.drawer-empty { margin: 0; padding: 4px 14px 6px; font-size: var(--text-xs); color: var(--color-text-secondary); }
    76 +.drawer-filter {
    77 + display: block; width: 100%; height: 32px; margin: 4px 0 8px; padding: 0 8px;
    78 + font-family: var(--font-ui); font-size: var(--text-md); color: var(--color-text-primary);
    79 + background: var(--background); border: 1px solid var(--color-border); outline: none;
    80 +}
    81 +.drawer-filter:focus { border-color: var(--color-accent); box-shadow: rgba(166, 83, 60, 0.25) 0 0 0 3px; }
    82 +.drawer-filter[hidden] { display: none; }
    83 +
    84 +/* Меню */
    85 +.mn { width: 100%; }
    86 +.mn-group + .mn-group { margin-top: 16px; }
    87 +.mn-title { margin: 0 0 4px; padding: 0 14px; font-size: var(--text-xs); font-weight: 700; color: var(--color-text-secondary); }
    88 +.mn-list { display: flex; flex-direction: column; }
    89 +.mn-link {
    90 + display: flex; align-items: center; gap: 10px; padding: 8px 14px;
    91 + font-size: var(--text-sm); color: var(--color-text-primary); text-decoration: none;
    92 + border-left: 3px solid transparent; transition: background 0.1s, color 0.1s;
    93 +}
    94 +.mn-link:hover { background: var(--color-bg-hover); text-decoration: none; }
    95 +.mn-link[aria-current] { border-left-color: var(--color-accent); background: var(--color-bg-secondary); font-weight: 700; }
    96 +.mn-link[hidden] { display: none; }
    97 +button.mn-link { width: 100%; background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; }
    98 +.mn-icon { flex-shrink: 0; display: inline-flex; color: var(--color-text-secondary); }
    99 +.mn-link[aria-current] .mn-icon { color: var(--color-accent); }
    100 +.mn-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    101 +.mn-count {
    102 + flex-shrink: 0; min-width: 18px; padding: 0 6px; line-height: 18px; text-align: center;
    103 + font-size: var(--text-xs); color: var(--color-text-secondary); background: var(--color-bg-secondary);
    104 +}
    105 +.mn-count[data-strong] { color: var(--color-on-accent); background: var(--color-accent); font-weight: 700; }
    106 +.mn-dot { flex-shrink: 0; width: 6px; height: 6px; background: var(--color-accent); }
    107 +.mn-rule { height: 1px; margin: 8px 14px; background: var(--color-border); }
    108 +
    109 +.main { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); }
    55 110 .home, .page { max-width: 720px; margin: 0 auto; display: grid; gap: 20px; }
    56 111 .meeting { max-width: 860px; margin: 0 auto; display: grid; gap: 20px; }
    57 112
    103 158 .hero.drop { padding: 24px 32px; gap: 4px; border-style: dashed; border-color: var(--color-border-light); background: transparent; }
    104 159 .hero.is-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); }
    105 160
    161 +/* ── Список записей на главной ── */
    162 +.records { display: grid; gap: 12px; }
    163 +.records-head { display: flex; align-items: center; justify-content: space-between; }
    164 +.records h2 { font-size: var(--text-lg); }
    165 +.record-list { border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    166 +.record {
    167 + display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status";
    168 + gap: 2px 16px; align-items: center; padding: 12px 16px;
    169 + color: var(--color-text-primary); border-top: 1px solid var(--color-border);
    170 +}
    171 +.record:first-child { border-top: 0; }
    172 +.record[hidden] { display: none; }
    173 +.record:hover { background: var(--color-bg-hover); text-decoration: none; }
    174 +.record-title { grid-area: title; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    175 +.record-meta { grid-area: meta; }
    176 +.status { grid-area: status; font-size: var(--text-sm); color: var(--color-text-secondary); text-align: right; }
    177 +.status-todo, .status-live { color: var(--color-accent-text); }
    178 +.status-live { font-weight: 700; }
    179 +
    180 +/* ── Идёт работа: расшифровка растёт на глазах ── */
    181 +.live { display: grid; gap: 10px; }
    182 +.live-head { display: flex; align-items: center; gap: 16px; }
    183 +.live-title { flex: 1; display: flex; align-items: center; gap: 8px; font-size: var(--text-lg); font-weight: 700; }
    184 +.live-title .spinner { margin-right: 4px; }
    185 +.live-pct { color: var(--color-accent-text); }
    186 +.live .pane { border-top: 1px solid var(--color-border); margin-top: 6px; }
    187 +.live-wait { padding: 4px 0; }
    188 +
    106 189 /* ── Запись идёт ── */
    107 190 .rec { display: grid; gap: 14px; justify-items: start; width: 100%; }
    108 191 .rec-state { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); }
    109 192 .rec-timer { font-size: var(--text-6xl); font-weight: 700; line-height: 1; }
    110 −.meters { display: grid; gap: 6px; width: min(420px, 100%); }
    111 −.meter { display: grid; grid-template-columns: 100px 1fr; align-items: center; gap: 12px; }
    193 +.meter { width: min(420px, 100%); }
    194 +.meter .meter-bar { display: block; }
    112 195 .meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; }
    113 196 .meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; }
    114 197
    158 241 .pane-empty > p { max-width: 60ch; }
    159 242 .pane-empty.ask .input { width: 100%; max-width: 560px; }
    160 243 .working { display: flex; align-items: center; font-size: var(--text-lg); }
    244 +.pane-tools .working { font-size: var(--text-sm); }
    161 245 .pane-edit { display: grid; gap: 12px; padding: 16px 20px; }
    162 246 .section-editor {
    163 247 width: 100%; min-height: 50vh; padding: 12px 14px;
    222 306 .toast.is-error { background: var(--color-error); color: #fff; }
    223 307
    224 308 @media (max-width: 900px) {
    225 − .shell { grid-template-columns: 208px 1fr; }
    226 309 .main { padding: 24px 20px 64px; }
    227 310 .bar { top: -24px; margin: -24px -20px 0; padding: 16px 20px 10px; }
    228 311 .hero, .card { padding: 24px 20px; }
  • src/views/home.ts +45 −3
    Показать разницуСкрыть разницу
    1 −// Главная: записать встречу или добавить файл. Список записей — в боковой панели.
    1 +// Главная: записать встречу или добавить файл, ниже — все записи с поиском,
    2 +// как список репозиториев на главной skillok.org.
    2 3
    3 −import { api, files, type SettingsView } from "../api";
    4 +import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api";
    5 +import { esc } from "../dom";
    4 6 import { bindRecording, recordingPanel } from "../recording";
    5 7 import type { Scope } from "../scope";
    6 8 import { addAudio, onChange, startRecording, state } from "../state";
    31 33 </section>`;
    32 34 }
    33 35
    36 +function status(m: Meeting): string {
    37 + if (m.recording) return `<span class="status status-live">Идёт запись</span>`;
    38 + if (!m.audio.length) return `<span class="status">Нет звука</span>`;
    39 + if (!m.transcribed_at) return `<span class="status status-todo">Не расшифровано</span>`;
    40 + return `<span class="status">Расшифровано</span>`;
    41 +}
    42 +
    43 +function list(meetings: Meeting[]): string {
    44 + if (!meetings.length) return "";
    45 + return `
    46 + <section class="records">
    47 + <div class="records-head"><h2>Записи</h2></div>
    48 + <input class="input records-filter" type="search" data-filter placeholder="Найти запись…" aria-label="Найти запись…">
    49 + <div class="record-list">
    50 + ${meetings
    51 + .map((m) => {
    52 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
    53 + return `
    54 + <a class="record" data-row href="#/m/${encodeURIComponent(m.id)}">
    55 + <span class="record-title">${esc(m.title)}</span>
    56 + <span class="record-meta small muted">${esc(meta)}</span>
    57 + ${status(m)}
    58 + </a>`;
    59 + })
    60 + .join("")}
    61 + </div>
    62 + <p class="small muted" data-nothing hidden>Ничего не нашлось.</p>
    63 + </section>`;
    64 +}
    65 +
    34 66 export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
    35 − const view = await api.getSettings();
    67 + const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]);
    36 68 if (!scope.alive) return;
    37 69
    38 70 const draw = () => {
    44 76 ? ""
    45 77 : `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>`
    46 78 }
    79 + ${list(meetings)}
    47 80 </div>`;
    81 + root.querySelector<HTMLInputElement>("[data-filter]")?.addEventListener("input", (e) => {
    82 + const q = (e.target as HTMLInputElement).value.trim().toLowerCase();
    83 + let shown = 0;
    84 + root.querySelectorAll<HTMLElement>("[data-row]").forEach((row) => {
    85 + row.hidden = q !== "" && !row.textContent!.toLowerCase().includes(q);
    86 + if (!row.hidden) shown++;
    87 + });
    88 + root.querySelector<HTMLElement>("[data-nothing]")!.hidden = shown > 0;
    89 + });
    48 90 root.querySelector("[data-record]")?.addEventListener("click", startRecording);
    49 91 root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
    50 92 // После «Остановить» переход к записи делает подписка ниже.
  • src/views/meeting.ts +57 −23
    Показать разницуСкрыть разницу
    13 13 import { plain, render, sectionBody, sections, toggleTask, upsertSection } from "../markdown";
    14 14 import { bindRecording, recordingPanel } from "../recording";
    15 15 import type { Scope } from "../scope";
    16 −import { refreshSide } from "../side";
    16 +import { refreshNav, setCrumb } from "../nav";
    17 17 import { copy, onChange, state, toast } from "../state";
    18 18
    19 19 const TRANSCRIPT = "Расшифровка";
    74 74 */
    75 75 function transcriptView(body: string): string {
    76 76 const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
    77 + const rows = transcriptRows(paras);
    78 + return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`;
    79 +}
    80 +
    81 +/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
    82 +function transcriptRows(paras: string[]): string | null {
    77 83 const rows = paras.map((p) => LINE.exec(p));
    78 − if (!rows.length || rows.some((r) => !r)) return `<div class="md">${render(body)}</div>`;
    79 − return `
    80 − <div class="tr">
    81 − ${rows
    82 − .map((r) => {
    83 − const [, time, who, text] = r!;
    84 − return `
    84 + if (!rows.length || rows.some((r) => !r)) return paras.length ? null : "";
    85 + return rows
    86 + .map((r) => {
    87 + const [, time, who, text] = r!;
    88 + return `
    85 89 <div class="tr-row">
    86 90 <span class="tr-time mono-num">${esc(time)}</span>
    87 91 <div class="tr-text">
    88 − ${who ? `<span class="tr-who${who === "Вы" ? " is-me" : ""}">${esc(who)}</span>` : ""}
    92 + ${who ? `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>` : ""}
    89 93 <p>${esc(text)}</p>
    90 94 </div>
    91 95 </div>`;
    92 − })
    93 − .join("")}
    94 − </div>`;
    96 + })
    97 + .join("");
    95 98 }
    96 99
    97 100 export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
    104 107 // Что сейчас идёт над записью: расшифровка или ответ ИИ (с какой вкладки).
    105 108 let job: { kind: "transcribe"; done: number; total: number } | { kind: "command"; tab: string } | null = null;
    106 109 let jobError: string | null = null;
    110 + // Что уже пришло по ходу работы: реплики расшифровки и текст ответа ИИ.
    111 + let liveLines: string[] = [];
    112 + let liveAnswer = "";
    107 113 let tab = wantTab ?? TRANSCRIPT;
    108 114 let editing = false;
    109 115
    134 140 if (job?.kind === "transcribe") {
    135 141 const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
    136 142 return `
    137 − <section class="card">
    138 − <h2>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}</h2>
    139 − <p class="big-pct mono-num" data-pct>${pct}%</p>
    143 + <section class="live">
    144 + <div class="live-head">
    145 + <p class="live-title"><span class="spinner"></span>Расшифровываю${dur ? ` ${esc(dur)} звука` : ""}
    146 + <span class="mono-num live-pct" data-pct>${pct}%</span></p>
    147 + <button class="link-btn" data-cancel>Отменить</button>
    148 + </div>
    140 149 <div class="progress"><span style="width:${pct}%"></span></div>
    141 − <p class="small muted" data-progress-note>${progressNote(job.done, job.total)}</p>
    142 − <div class="actions"><button class="link-btn" data-cancel>Отменить</button></div>
    150 + <p class="xs muted" data-progress-note>${progressNote(job.done, job.total)}</p>
    151 + <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(liveLines) ?? ""}</div>
    152 + <p class="live-wait small muted" data-live-wait ${liveLines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
    143 153 </section>`;
    144 154 }
    145 155 if (!hasKey) {
    180 190 if (busyHere) {
    181 191 const preset = PRESETS.find((p) => p.heading === tab);
    182 192 return `
    183 − <div class="pane-empty">
    184 − <p class="working"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
    193 + <div class="pane-tools">
    194 + <p class="working small"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
    195 + <span class="spacer"></span>
    185 196 <button class="link-btn" data-cancel>Отменить</button>
    186 − </div>`;
    197 + </div>
    198 + <div class="pane-body"><div class="md" data-answer>${liveAnswer ? render(liveAnswer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`;
    187 199 }
    188 200
    189 201 if (tab === CUSTOM_TAB) {
    270 282 </section>`;
    271 283
    272 284 const draw = () => {
    285 + setCrumb(d.meeting.title);
    273 286 const step = stepOf(d);
    274 287 if (!tabs().includes(tab)) tab = TRANSCRIPT;
    275 288 const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    305 318 if (job) return;
    306 319 job = { kind: "transcribe", done: 0, total: 0 };
    307 320 jobError = null;
    321 + liveLines = [];
    308 322 draw();
    309 323 try {
    310 324 const r = await api.transcribe(id);
    311 325 d.note = r.note;
    312 326 d.meeting = (await api.getMeeting(id)).meeting;
    313 327 tab = TRANSCRIPT;
    314 − void refreshSide();
    328 + void refreshNav();
    315 329 if (r.failed) toast(`Не распозналось кусков: ${r.failed}. ${r.last_error ?? ""}`);
    316 330 } catch (e) {
    317 331 jobError = errorText(e) === "Расшифровка отменена" ? null : errorText(e);
    324 338 if (job) return;
    325 339 job = { kind: "command", tab: forTab };
    326 340 jobError = null;
    341 + liveAnswer = "";
    327 342 tab = forTab;
    328 343 draw();
    329 344 try {
    361 376 title.addEventListener("change", async () => {
    362 377 try {
    363 378 d.meeting = await api.renameMeeting(id, title.value);
    364 − void refreshSide();
    379 + void refreshNav();
    365 380 } catch (e) {
    366 381 title.value = d.meeting.title;
    367 382 toast(e);
    477 492 draw();
    478 493
    479 494 scope.on(
    480 − events.progress(({ meeting_id, done, total }) => {
    495 + events.progress(({ meeting_id, done, total, lines }) => {
    481 496 if (meeting_id !== id || job?.kind !== "transcribe") return;
    482 497 job = { kind: "transcribe", done, total };
    498 + if (lines.length) {
    499 + liveLines.push(...lines);
    500 + root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(lines) ?? "");
    501 + root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
    502 + }
    483 503 const pct = total ? Math.round((done / total) * 100) : 0;
    484 504 const bar = root.querySelector<HTMLElement>(".progress > span");
    485 505 const big = root.querySelector("[data-pct]");
    489 509 if (note) note.textContent = total ? progressNote(done, total) : "В записи не нашлось речи.";
    490 510 }),
    491 511 );
    512 + // Ответ ИИ пишется — показываем по ходу. Перерисовка не чаще кадра.
    513 + let frame = 0;
    514 + scope.on(
    515 + events.answer(({ meeting_id, text }) => {
    516 + if (meeting_id !== id || job?.kind !== "command") return;
    517 + liveAnswer += text;
    518 + if (frame) return;
    519 + frame = requestAnimationFrame(() => {
    520 + frame = 0;
    521 + const el = root.querySelector("[data-answer]");
    522 + if (el) el.innerHTML = render(liveAnswer);
    523 + });
    524 + }),
    525 + );
    492 526 // Запись остановили из шапки или с главной — шаг меняется.
    493 527 const off = onChange(async () => {
    494 528 if (!scope.alive) return;