diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdea3b..057e2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to Memiro AI. Versions follow [Semantic Versioning](https://semver.org/); installers for every version are on [GitHub Releases](https://github.com/bglglzd/auris/releases). +## [0.14.0] — 2026-10-07 + +### Added +- **Merge recordings into one conversation.** When a recording was stopped and continued, press «Merge recordings» above the list and click the parts in order — they are numbered 1, 2, 3… (or switch to «By time» to glue them oldest first). Memiro creates a new meeting from the parts (tracks stay aligned, a short pause between parts); if every part was transcribed, the transcripts are joined too — no need to transcribe again. The original recordings stay in the list. +- **AI correction of the transcript** (when AI is connected): after transcription and background refining, the AI fixes misheard words («над дубитом» → «на дубе том»), terms and punctuation by the meaning of the conversation. Only text is sent; every phrase stays in its place with its time and speaker; rewrites, translations and changes to phrases you edited are rejected. «✨» on the transcript runs it by hand, «Undo» returns the previous text. Switch: Settings → AI → «AI transcript correction». +- **Recognition on your AI server** (optional): Settings → Recognition → «Where to recognise» → «On the AI server». Audio is sent to your server (OpenAI and compatible, /audio/transcriptions; model whisper-1 by default, «Check server» button) in up to 10-minute parts; voice separation stays on the computer. By default recognition stays on this computer. +- **Folders in the meeting list.** Create a folder with the button above the list, then drag meetings into it or use «⋯ → To folder…». Folders collapse, can be renamed and deleted (meetings from a deleted folder stay in the list). Search shows only folders with matching meetings. + ## [0.13.8] — 2026-10-04 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 941e2d4..8020cf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -297,6 +297,29 @@ Ollama), `ai::pick_model` (настроенная → ближайшая по п - Экспорт (v0.8): одна кнопка «⬇ Экспорт» → `ExportModal`: Word (.docx — свой генератор `docx.ts`, без зависимостей), Markdown, TXT, субтитры SRT; в документ складываются стенограмма (опц. таймкоды) + выбранные ИИ-отчёты. +- **ИИ для качества расшифровки (v0.14.0)**: ИИ-корректура — `core/src/aifix.rs` + (`correct`: пачки по 40 реплик → JSON-массив строк той же длины; правка + принимается, если не пересказ (≤ 45 % изменённых символов), тот же язык, не + пустая; реплики `origin` не трогаются), команды `ai_correct_transcript` + (бэкап `transcript.before_ai.json`, событие `ai-fix-progress`), + `ai_correction_available`, `revert_ai_correction`; фронт — + `aiauto.ts::runAutoCorrect` (после `refine_transcript`, `aiAuto.correct`), + кнопка ✨ и плашка «Вернуть как было» в `MeetingView`. Распознавание на + сервере — `core/src/cloud_asr.rs` (`/audio/transcriptions`, multipart, + куски ≤ 10 мин в паузах, `verbose_json` → иначе `json`), `ServerAsr` в + `load_asr` при `TranscribeOptions.cloud` (`settings.whisper.engine = + "server"`, `serverModel`), проверка — `cloud_asr_check`. +- **Папки и объединение (v0.14.0)**: таблица `collections` + столбец + `meetings.collection` (миграция; имя `folder` занято каталогом встречи), + команды `list_collections`/`create_collection`/`rename_collection`/ + `delete_collection` (встречи остаются)/`set_meeting_collection`; фронт — + `src/library.ts` (`groupMeetings`, `mergeOrder`, `toggleSelected`), + `MeetingList` (папки, «⋯ → В папку…», перетаскивание, свёрнутые — + localStorage `3uxo.folders.closed`), `Sidebar` (кнопки над списком, панель + объединения 1/2/3 · «По времени»). Объединение — `core/src/merge.rs` + (`merge_meetings`: две дорожки дополняются тишиной до общей длины, 1 с паузы; + есть импорт — одна дорожка `audio.wav`; расшифровки сшиваются, если есть у + всех), команда `merge_meetings`; исходные записи остаются. - Встречи (v0.8.2): меню «⋯» в `MeetingList` (переименовать / заметки / удалить) → `MeetingEditDialog` (порталом в body: у сайдбара `backdrop-filter`); заметки — столбец `notes` в БД (миграция), команда `update_meeting_notes`, поле в diff --git a/core/src/aifix.rs b/core/src/aifix.rs new file mode 100644 index 0000000..3fa0155 --- /dev/null +++ b/core/src/aifix.rs @@ -0,0 +1,208 @@ +//! ИИ-корректура расшифровки (через сервер ИИ пользователя, по желанию). +//! +//! Локальное распознавание ошибается в словах («над дубитом» вместо «на дубе +//! том»), терминах и пунктуации. Чат-модель видит текст целиком и исправляет +//! такие ошибки — но только их: каждая реплика возвращается на своё место, +//! время и говорящий не меняются. Защита от «творчества» модели: правка +//! принимается, только если она близка к исходной реплике (не пересказ), не +//! меняет язык и не пустая. Реплики, которые пользователь дописал или +//! исправил сам, не трогаются. На сервер уходит только текст, звук — нет. + +use crate::ai::ChatBackend; +use crate::error::AppResult; +use crate::transcript::Transcript; + +/// Реплик в одном запросе (и не больше ~4000 символов). +const BATCH: usize = 40; +const BATCH_CHARS: usize = 4000; +/// Доля изменённых символов, выше которой правка считается пересказом. +const MAX_CHANGE: f32 = 0.45; + +const SYSTEM: &str = "Ты — корректор автоматической расшифровки речи. Тебе дают JSON-массив реплик \ +разговора по порядку. Исправь ТОЛЬКО ошибки распознавания: неверно услышанные или разорванные слова \ +(по смыслу соседних реплик), термины и имена (см. словарь, если он дан), пунктуацию и заглавные буквы. \ +НЕЛЬЗЯ: пересказывать, сокращать, дополнять, убирать слова-паразиты и повторы, переводить на другой язык, \ +объединять или делить реплики. Если реплика верна — верни её без изменений. Ответ — строго JSON-массив \ +строк той же длины и в том же порядке, без пояснений."; + +/// Итог корректуры: новая расшифровка и сколько реплик исправлено. +#[derive(Debug, Clone, PartialEq)] +pub struct Corrected { + pub transcript: Transcript, + pub changed: usize, + /// Правок, отклонённых как пересказ / смена языка. + pub rejected: usize, +} + +/// Расстояние Левенштейна по символам (для оценки размера правки). +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0; b.len() + 1]; + for i in 1..=a.len() { + cur[0] = i; + for j in 1..=b.len() { + let cost = usize::from(a[i - 1] != b[j - 1]); + cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// Принимаем ли правку модели: не пустая, тот же язык, не пересказ. +pub fn acceptable(original: &str, fixed: &str) -> bool { + let (o, f) = (original.trim(), fixed.trim()); + if f.is_empty() { + return false; + } + if o == f { + return true; + } + if let (Some(a), Some(b)) = (crate::langguard::dominant_script(o), crate::langguard::dominant_script(f)) { + if a != b { + return false; + } + } + let norm = |s: &str| s.to_lowercase(); + let d = edit_distance(&norm(o), &norm(f)) as f32; + let len = o.chars().count().max(f.chars().count()).max(1) as f32; + d / len <= MAX_CHANGE +} + +/// Достаёт JSON-массив строк из ответа модели (с блоком кода или без). +pub fn parse_lines(answer: &str) -> Option> { + let a = answer.find('[')?; + let b = answer.rfind(']')?; + if b <= a { + return None; + } + serde_json::from_str::>(&answer[a..=b]).ok() +} + +/// Корректура расшифровки. `glossary` — термины словаря (подсказка модели). +/// `on_progress(done, total)` — после каждого запроса. +pub fn correct( + backend: &dyn ChatBackend, + t: &Transcript, + glossary: Option<&str>, + on_progress: &dyn Fn(usize, usize), +) -> AppResult { + // Пачки подряд идущих реплик (контекст для модели). + let mut batches: Vec> = Vec::new(); + let mut start = 0; + let mut chars = 0; + for (i, s) in t.segments.iter().enumerate() { + chars += s.text.chars().count(); + if i + 1 - start >= BATCH || chars >= BATCH_CHARS { + batches.push(start..i + 1); + start = i + 1; + chars = 0; + } + } + if start < t.segments.len() { + batches.push(start..t.segments.len()); + } + let mut out = t.clone(); + let (mut changed, mut rejected) = (0, 0); + let total = batches.len(); + for (k, range) in batches.into_iter().enumerate() { + let lines: Vec<&str> = t.segments[range.clone()].iter().map(|s| s.text.as_str()).collect(); + let mut user = String::new(); + if let Some(g) = glossary.map(str::trim).filter(|g| !g.is_empty()) { + user.push_str("Словарь (так пишутся термины и имена): "); + user.push_str(&g.replace('\n', ", ")); + user.push_str("\n\n"); + } + user.push_str(&serde_json::to_string(&lines).unwrap_or_default()); + let answer = backend.chat(SYSTEM, &user)?; + // Ответ не той длины — пачку не трогаем (иначе реплики съедут). + if let Some(fixed) = parse_lines(&answer).filter(|f| f.len() == lines.len()) { + for (i, f) in range.clone().zip(fixed) { + let seg = &mut out.segments[i]; + if seg.is_user() || seg.text.trim() == f.trim() { + continue; + } + if acceptable(&seg.text, &f) { + seg.text = f.trim().to_string(); + changed += 1; + } else { + rejected += 1; + } + } + } + on_progress(k + 1, total); + } + Ok(Corrected { transcript: out, changed, rejected }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transcript::TranscriptSegment; + + struct Fake(Vec); + impl ChatBackend for Fake { + fn chat(&self, _s: &str, _u: &str) -> AppResult { + Ok(format!("```json\n{}\n```", serde_json::to_string(&self.0).unwrap())) + } + } + + fn seg(t: &str, origin: Option<&str>) -> TranscriptSegment { + TranscriptSegment { speaker: "me".into(), start_secs: 0.0, end_secs: 1.0, text: t.into(), origin: origin.map(Into::into) } + } + + #[test] + fn fixes_misheard_words_but_not_rewrites() { + let t = Transcript { + segments: vec![ + seg("Золотая цепь над дубитом", None), + seg("заведём задачу в жиру", None), + seg("ну короче я в общем думаю что надо", None), + seg("Это я поправил сам", Some("edited")), + seg("Давайте начнём", None), + ], + }; + let fake = Fake(vec![ + "Золотая цепь на дубе том".into(), + "Заведём задачу в Jira.".into(), + // Пересказ — отклоняется. + "Предлагаю действовать".into(), + "Совсем другое".into(), + // Перевод — отклоняется. + "Let's start".into(), + ]); + let r = correct(&fake, &t, Some("Jira"), &|_, _| {}).unwrap(); + let texts: Vec<&str> = r.transcript.segments.iter().map(|s| s.text.as_str()).collect(); + assert_eq!( + texts, + ["Золотая цепь на дубе том", "Заведём задачу в Jira.", "ну короче я в общем думаю что надо", "Это я поправил сам", "Давайте начнём"] + ); + assert_eq!((r.changed, r.rejected), (2, 2)); + } + + #[test] + fn wrong_length_answer_changes_nothing() { + let t = Transcript { segments: vec![seg("раз", None), seg("два", None)] }; + let r = correct(&Fake(vec!["один".into()]), &t, None, &|_, _| {}).unwrap(); + assert_eq!(r.transcript, t); + assert_eq!(parse_lines("нет массива"), None); + } + + #[test] + fn batches_long_transcripts() { + let t = Transcript { segments: (0..95).map(|i| seg(&format!("реплика {i}"), None)).collect() }; + let calls = std::sync::Mutex::new(0); + struct Echo<'a>(&'a std::sync::Mutex); + impl ChatBackend for Echo<'_> { + fn chat(&self, _s: &str, u: &str) -> AppResult { + *self.0.lock().unwrap() += 1; + Ok(u.to_string()) + } + } + let r = correct(&Echo(&calls), &t, None, &|_, _| {}).unwrap(); + assert_eq!(*calls.lock().unwrap(), 3); + assert_eq!(r.changed, 0); + } +} diff --git a/core/src/cli_transcriber.rs b/core/src/cli_transcriber.rs index 42a1264..5ee4458 100644 --- a/core/src/cli_transcriber.rs +++ b/core/src/cli_transcriber.rs @@ -19,6 +19,20 @@ pub struct TranscribeOptions { /// Языки разговора («ru», «en»): распознавание не выходит за них. #[serde(default)] pub languages: Option>, + /// Распознавание на сервере ИИ пользователя (по желанию; звук уходит на + /// сервер). `None` — на этом компьютере. + #[serde(default)] + pub cloud: Option, +} + +/// Сервер распознавания речи (OpenAI-совместимый `/audio/transcriptions`). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct CloudAsr { + pub base_url: String, + pub api_key: String, + /// Модель распознавания (например, `whisper-1`); пусто — по умолчанию. + #[serde(default)] + pub model: String, } /// Which whisper CLI family is being used; drives argument construction. diff --git a/core/src/cloud_asr.rs b/core/src/cloud_asr.rs new file mode 100644 index 0000000..3870e8e --- /dev/null +++ b/core/src/cloud_asr.rs @@ -0,0 +1,235 @@ +//! Распознавание речи на сервере ИИ пользователя (по желанию, через его ключ): +//! OpenAI-совместимый `POST {base}/audio/transcriptions` (OpenAI Whisper / +//! gpt-4o-transcribe, Groq, локальные серверы с тем же API). +//! +//! Звук уходит на сервер — поэтому это отдельный явный выбор в настройках; +//! по умолчанию распознавание локальное. Запись отправляется кусками до +//! 10 минут (лимит размера файла у серверов — 25 МБ), разрезанными в паузах; +//! время реплик пересчитывается на всю запись. + +use std::io::Cursor; +use std::path::Path; + +use crate::ai::AiConfig; +use crate::error::{AppError, AppResult}; +use crate::transcript::Segment; + +const SR: usize = 16_000; +/// Кусок для отправки: ≤ 10 минут (16 кГц × 16 бит ≈ 19 МБ). +const CHUNK_SECS: usize = 600; + +/// Модель распознавания по умолчанию (есть у OpenAI и большинства совместимых). +pub const DEFAULT_MODEL: &str = "whisper-1"; + +/// WAV (16 кГц моно i16) в памяти. +fn wav_bytes(samples: &[f32]) -> AppResult> { + let spec = hound::WavSpec { channels: 1, sample_rate: SR as u32, bits_per_sample: 16, sample_format: hound::SampleFormat::Int }; + let mut cur = Cursor::new(Vec::new()); + { + let mut w = hound::WavWriter::new(&mut cur, spec).map_err(|e| AppError::Audio(e.to_string()))?; + for v in samples { + w.write_sample((v.clamp(-1.0, 1.0) * 32767.0) as i16).map_err(|e| AppError::Audio(e.to_string()))?; + } + w.finalize().map_err(|e| AppError::Audio(e.to_string()))?; + } + Ok(cur.into_inner()) +} + +/// Тело multipart/form-data: текстовые поля и файл `file`. +pub fn multipart(boundary: &str, fields: &[(&str, &str)], file: &[u8]) -> Vec { + let mut body = Vec::with_capacity(file.len() + 1024); + for (k, v) in fields { + body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").as_bytes()); + } + body.extend_from_slice( + format!("--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\nContent-Type: audio/wav\r\n\r\n") + .as_bytes(), + ); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body +} + +/// Реплики из ответа сервера: `verbose_json` с `segments` или просто `text` +/// (тогда одна реплика на весь кусок). Время сдвигается на `offset`. +pub fn parse_response(v: &serde_json::Value, offset: f64, chunk_secs: f64) -> Vec { + if let Some(segs) = v.get("segments").and_then(|s| s.as_array()) { + return segs + .iter() + .filter_map(|s| { + let text = s.get("text")?.as_str()?.trim().to_string(); + let a = s.get("start").and_then(|x| x.as_f64()).unwrap_or(0.0); + let b = s.get("end").and_then(|x| x.as_f64()).unwrap_or(a); + (!text.is_empty()).then(|| Segment { start_secs: offset + a, end_secs: offset + b.max(a), text }) + }) + .collect(); + } + match v.get("text").and_then(|t| t.as_str()).map(str::trim) { + Some(t) if !t.is_empty() => vec![Segment { start_secs: offset, end_secs: offset + chunk_secs, text: t.to_string() }], + _ => Vec::new(), + } +} + +fn http_err(e: ureq::Error) -> AppError { + match e { + ureq::Error::Status(404, _) | ureq::Error::Status(405, _) => AppError::Http( + "сервер ИИ не умеет распознавать речь (нет /audio/transcriptions) — выберите распознавание на этом компьютере".into(), + ), + ureq::Error::Status(code, resp) => { + let body = resp.into_string().unwrap_or_default(); + AppError::Http(format!("HTTP {code}: {}", body.chars().take(400).collect::())) + } + other => AppError::Http(other.to_string()), + } +} + +/// Отправляет кусок звука и возвращает реплики со временем записи. +fn send_chunk( + config: &AiConfig, + model: &str, + language: Option<&str>, + prompt: Option<&str>, + samples: &[f32], + offset: f64, +) -> AppResult> { + let boundary = format!("memiro-{}", uuid::Uuid::new_v4().simple()); + let mut fields: Vec<(&str, &str)> = vec![("model", model), ("response_format", "verbose_json")]; + if let Some(l) = language.map(str::trim).filter(|l| !l.is_empty() && *l != "auto") { + fields.push(("language", l)); + } + if let Some(p) = prompt.map(str::trim).filter(|p| !p.is_empty()) { + fields.push(("prompt", p)); + } + let body = multipart(&boundary, &fields, &wav_bytes(samples)?); + let url = format!("{}/audio/transcriptions", config.base_url.trim_end_matches('/')); + let send = |fields_body: &[u8]| { + ureq::post(&url) + .set("Authorization", &format!("Bearer {}", config.api_key)) + .set("Content-Type", &format!("multipart/form-data; boundary={boundary}")) + .timeout(std::time::Duration::from_secs(600)) + .send_bytes(fields_body) + }; + let resp = match send(&body) { + // Модель без verbose_json (gpt-4o-transcribe) — просим обычный json. + Err(ureq::Error::Status(400, r)) => { + let msg = r.into_string().unwrap_or_default(); + if !msg.contains("response_format") { + return Err(AppError::Http(format!("HTTP 400: {}", msg.chars().take(400).collect::()))); + } + let fields: Vec<(&str, &str)> = + fields.iter().map(|&(k, v)| if k == "response_format" { (k, "json") } else { (k, v) }).collect(); + send(&multipart(&boundary, &fields, &wav_bytes(samples)?)).map_err(http_err)? + } + other => other.map_err(http_err)?, + }; + let v: serde_json::Value = resp.into_json().map_err(|e| AppError::Http(e.to_string()))?; + Ok(parse_response(&v, offset, samples.len() as f64 / SR as f64)) +} + +/// Распознаёт WAV-файл (16 кГц моно) на сервере. `on_progress(done, total)`. +pub fn transcribe_wav( + config: &AiConfig, + model: &str, + language: Option<&str>, + prompt: Option<&str>, + wav: &Path, + on_progress: &dyn Fn(usize, usize), +) -> AppResult> { + let audio = crate::enhance::read_wav_f32(wav)?; + if audio.is_empty() { + return Ok(Vec::new()); + } + let chunks = crate::audio::quiet_chunks(&audio, CHUNK_SECS * SR, 20 * SR, SR / 5); + let mut out = Vec::new(); + for (k, &(a, b)) in chunks.iter().enumerate() { + let part = &audio[a..b]; + // Тишину не отправляем. + if part.iter().fold(0f32, |m, v| m.max(v.abs())) > 0.003 { + out.extend(send_chunk(config, model, language, prompt, part, a as f64 / SR as f64)?); + } + on_progress(k + 1, chunks.len()); + } + Ok(out) +} + +/// Проверка: умеет ли сервер распознавать речь (секунда тишины). +pub fn check(config: &AiConfig, model: &str) -> AppResult<()> { + send_chunk(config, model, None, None, &vec![0.0; SR], 0.0).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_multipart_with_fields_and_file() { + let body = multipart("b", &[("model", "whisper-1"), ("language", "ru")], b"RIFF"); + let s = String::from_utf8_lossy(&body); + assert!(s.starts_with("--b\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n")); + assert!(s.contains("name=\"language\"\r\n\r\nru\r\n")); + assert!(s.contains("filename=\"audio.wav\"\r\nContent-Type: audio/wav\r\n\r\nRIFF\r\n--b--\r\n")); + } + + #[test] + fn parses_segments_or_plain_text_with_offset() { + let v = serde_json::json!({"text": "все", "segments": [ + {"start": 0.0, "end": 2.5, "text": " Привет "}, + {"start": 2.5, "end": 4.0, "text": " "} + ]}); + let s = parse_response(&v, 600.0, 10.0); + assert_eq!(s, vec![Segment { start_secs: 600.0, end_secs: 602.5, text: "Привет".into() }]); + let plain = parse_response(&serde_json::json!({"text": "Только текст"}), 10.0, 5.0); + assert_eq!(plain, vec![Segment { start_secs: 10.0, end_secs: 15.0, text: "Только текст".into() }]); + assert!(parse_response(&serde_json::json!({}), 0.0, 1.0).is_empty()); + } + + #[test] + fn wav_in_memory_is_valid() { + let bytes = wav_bytes(&[0.0, 0.5, -0.5]).unwrap(); + let r = hound::WavReader::new(Cursor::new(bytes)).unwrap(); + assert_eq!(r.spec().sample_rate, 16_000); + assert_eq!(r.len(), 3); + } + + /// Настоящий HTTP-запрос к локальному «серверу»: заголовки, поля, файл. + #[test] + fn sends_real_request_to_server() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 65536]; + // Читаем до конца тела (по Content-Length). + loop { + let n = sock.read(&mut chunk).unwrap(); + buf.extend_from_slice(&chunk[..n]); + let text = String::from_utf8_lossy(&buf).to_string(); + if let Some(h) = text.find("\r\n\r\n") { + let len: usize = text[..h] + .lines() + .find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())) + .unwrap_or(0); + if buf.len() >= h + 4 + len { + break; + } + } + if n == 0 { + break; + } + } + let body = r#"{"segments":[{"start":0.1,"end":0.9,"text":" Проверка "}]}"#; + write!(sock, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.len(), body).unwrap(); + String::from_utf8_lossy(&buf).to_string() + }); + let cfg = AiConfig { base_url: format!("http://127.0.0.1:{port}/v1/"), api_key: "k".into(), model: String::new() }; + let segs = send_chunk(&cfg, "whisper-1", Some("ru"), Some("Jira"), &vec![0.1; 1600], 5.0).unwrap(); + let req = server.join().unwrap(); + assert!(req.starts_with("POST /v1/audio/transcriptions"), "{}", &req[..60]); + assert!(req.contains("Authorization: Bearer k") || req.contains("authorization: Bearer k")); + assert!(req.contains("name=\"language\"\r\n\r\nru")); + assert!(req.contains("name=\"prompt\"\r\n\r\nJira")); + assert_eq!(segs, vec![Segment { start_secs: 5.1, end_secs: 5.9, text: "Проверка".into() }]); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 55c9534..a4fab0b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -3,8 +3,10 @@ //! dependency so it builds and tests on any platform. pub mod ai; +pub mod aifix; pub mod audio; pub mod call_detector; +pub mod cloud_asr; pub mod cluster; pub mod cli_transcriber; pub mod decode; @@ -14,6 +16,7 @@ pub mod edit; pub mod enhance; pub mod error; pub mod langguard; +pub mod merge; pub mod misses; pub mod model; pub mod models; diff --git a/core/src/merge.rs b/core/src/merge.rs new file mode 100644 index 0000000..c82aa3d --- /dev/null +++ b/core/src/merge.rs @@ -0,0 +1,257 @@ +//! Объединение нескольких записей в один разговор: запись прервали и потом +//! продолжили — части сшиваются в новую встречу (исходные остаются). +//! +//! Записанные встречи (две дорожки) сшиваются по дорожкам: каждая часть +//! дополняется тишиной до общей длины, чтобы «Я» и «Собеседник» не +//! разъехались. Если среди частей есть импортированный файл (одна дорожка), +//! всё сводится в одну дорожку `audio.wav`. Между частями — секунда тишины. +//! Расшифровки, если они есть у всех частей, сшиваются со сдвигом времени — +//! повторно расшифровывать не нужно (правки пользователя целы). + +use std::path::Path; + +use crate::error::{AppError, AppResult}; +use crate::model::Meeting; +use crate::storage::Repo; +use crate::transcript::{Transcript, TranscriptSegment}; + +const SR: u32 = 16_000; +/// Пауза между частями, секунды. +pub const GAP_SECS: f64 = 1.0; + +/// Сшивает расшифровки частей: `parts` — (начало части в общей записи, текст). +pub fn join_transcripts(parts: &[(f64, Transcript)]) -> Transcript { + let segments = parts + .iter() + .flat_map(|(offset, t)| { + t.segments.iter().map(move |s| TranscriptSegment { + start_secs: s.start_secs + offset, + end_secs: s.end_secs + offset, + ..s.clone() + }) + }) + .collect(); + Transcript { segments } +} + +/// Заголовок объединённой встречи: первый «настоящий» заголовок части. +pub fn merged_title(titles: &[&str]) -> String { + titles + .iter() + .map(|t| t.trim()) + .find(|t| !t.is_empty() && *t != "Новая встреча") + .map(|t| format!("{t} (объединено)")) + .unwrap_or_else(|| "Объединённая встреча".to_string()) +} + +/// Отсчёты дорожки встречи (16 кГц моно i16); нет файла или он пуст — пусто. +fn read_track(dir: &Path, name: &str, tmp: &Path) -> Vec { + let src = dir.join(name); + if !src.exists() { + return Vec::new(); + } + // Через общий декодер — на случай другой частоты/формата. + if crate::decode::decode_to_wav_16k_mono(&src, tmp).is_err() { + return Vec::new(); + } + let out = hound::WavReader::open(tmp) + .ok() + .map(|r| r.into_samples::().filter_map(Result::ok).collect()) + .unwrap_or_default(); + let _ = std::fs::remove_file(tmp); + out +} + +fn writer(path: &Path) -> AppResult>> { + let spec = hound::WavSpec { channels: 1, sample_rate: SR, bits_per_sample: 16, sample_format: hound::SampleFormat::Int }; + hound::WavWriter::create(path, spec).map_err(|e| AppError::Audio(e.to_string())) +} + +fn put(w: &mut hound::WavWriter>, x: &[i16], len: usize) -> AppResult<()> { + for i in 0..len { + w.write_sample(x.get(i).copied().unwrap_or(0)).map_err(|e| AppError::Audio(e.to_string()))?; + } + Ok(()) +} + +/// Объединяет встречи `ids` (в этом порядке) в новую встречу `new_id`. +pub fn merge_meetings(repo: &Repo, data_root: &Path, ids: &[String], new_id: &str) -> AppResult { + if ids.len() < 2 { + return Err(AppError::InvalidInput("для объединения нужно хотя бы две записи".into())); + } + let parts: Vec = ids.iter().map(|id| repo.get(id)).collect::>()?; + let dir = crate::service::meeting_dir(data_root, new_id); + std::fs::create_dir_all(&dir)?; + let tmp = dir.join("part.tmp.wav"); + let two_tracks = parts.iter().all(|m| m.source == "recorded"); + let gap = (GAP_SECS * SR as f64) as usize; + let mut offsets = Vec::with_capacity(parts.len()); + let mut total = 0usize; + let result = (|| -> AppResult<()> { + if two_tracks { + let (mut wm, mut ws) = (writer(&dir.join("mic.wav"))?, writer(&dir.join("system.wav"))?); + for (k, m) in parts.iter().enumerate() { + let src = crate::service::meeting_dir(data_root, &m.id); + let mic = read_track(&src, "mic.wav", &tmp); + let sys = read_track(&src, "system.wav", &tmp); + let len = mic.len().max(sys.len()) + if k + 1 < parts.len() { gap } else { 0 }; + offsets.push(total as f64 / SR as f64); + put(&mut wm, &mic, len)?; + put(&mut ws, &sys, len)?; + total += len; + } + wm.finalize().map_err(|e| AppError::Audio(e.to_string()))?; + ws.finalize().map_err(|e| AppError::Audio(e.to_string()))?; + } else { + let mut wa = writer(&dir.join("audio.wav"))?; + for (k, m) in parts.iter().enumerate() { + let src = crate::service::meeting_dir(data_root, &m.id); + let mixed: Vec = if m.source == "recorded" { + let (mic, sys) = (read_track(&src, "mic.wav", &tmp), read_track(&src, "system.wav", &tmp)); + (0..mic.len().max(sys.len())) + .map(|i| { + let v = mic.get(i).copied().unwrap_or(0) as i32 + sys.get(i).copied().unwrap_or(0) as i32; + v.clamp(i16::MIN as i32, i16::MAX as i32) as i16 + }) + .collect() + } else { + read_track(&src, "audio.wav", &tmp) + }; + let len = mixed.len() + if k + 1 < parts.len() { gap } else { 0 }; + offsets.push(total as f64 / SR as f64); + put(&mut wa, &mixed, len)?; + total += len; + } + wa.finalize().map_err(|e| AppError::Audio(e.to_string()))?; + } + Ok(()) + })(); + if let Err(e) = result { + let _ = std::fs::remove_dir_all(&dir); + return Err(e); + } + + // Расшифровки — только если они есть у всех частей (и для двух дорожек: + // «Я»/«Собеседник» у сводной одной дорожки не различить — там тоже ок, + // имена голосов остаются как были). + let transcripts: Vec> = + parts.iter().map(|m| crate::service::load_transcript(data_root, &m.id).ok().flatten()).collect(); + let status = if transcripts.iter().all(Option::is_some) { + let joined = join_transcripts( + &offsets.iter().copied().zip(transcripts.into_iter().map(Option::unwrap)).collect::>(), + ); + crate::service::save_transcript(data_root, new_id, &joined)?; + "transcribed" + } else { + "recorded" + }; + let titles: Vec<&str> = parts.iter().map(|m| m.title.as_str()).collect(); + let notes: Vec<&str> = parts.iter().map(|m| m.notes.trim()).filter(|n| !n.is_empty()).collect(); + let first = &parts[0]; + let meeting = Meeting { + id: new_id.to_string(), + created_at: first.created_at.clone(), + title: merged_title(&titles), + participants: parts.iter().map(|m| m.participants.trim()).find(|p| !p.is_empty()).unwrap_or("").to_string(), + topic: first.topic.clone(), + duration_secs: (total as u64) / SR as u64, + folder: new_id.to_string(), + status: status.into(), + source: if two_tracks { "recorded".into() } else { "imported".into() }, + notes: notes.join("\n\n"), + collection: first.collection.clone(), + }; + repo.insert(&meeting)?; + Ok(meeting) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wav(path: &Path, secs: f64, amp: i16) { + let mut w = writer(path).unwrap(); + for _ in 0..(secs * SR as f64) as usize { + w.write_sample(amp).unwrap(); + } + w.finalize().unwrap(); + } + + fn meeting(id: &str, source: &str, title: &str, at: &str) -> Meeting { + Meeting { + id: id.into(), + created_at: at.into(), + title: title.into(), + participants: String::new(), + topic: String::new(), + duration_secs: 0, + folder: id.into(), + status: "recorded".into(), + source: source.into(), + notes: String::new(), + collection: String::new(), + } + } + + fn seg(sp: &str, a: f64, b: f64, t: &str) -> TranscriptSegment { + TranscriptSegment { speaker: sp.into(), start_secs: a, end_secs: b, text: t.into(), origin: None } + } + + #[test] + fn joins_parts_in_given_order_keeping_tracks_aligned() { + let root = tempfile::tempdir().unwrap(); + let repo = Repo::open_in_memory().unwrap(); + for (id, mic, sys) in [("a", 2.0, 1.0), ("b", 1.0, 3.0)] { + let d = crate::service::meeting_dir(root.path(), id); + std::fs::create_dir_all(&d).unwrap(); + wav(&d.join("mic.wav"), mic, 100); + wav(&d.join("system.wav"), sys, 200); + repo.insert(&meeting(id, "recorded", if id == "a" { "Планёрка" } else { "Новая встреча" }, "2026-10-01T10:00:00Z")).unwrap(); + } + crate::service::save_transcript(root.path(), "a", &Transcript { segments: vec![seg("me", 0.5, 1.5, "начало")] }).unwrap(); + crate::service::save_transcript(root.path(), "b", &Transcript { segments: vec![seg("them", 0.2, 0.8, "продолжение")] }).unwrap(); + + let m = merge_meetings(&repo, root.path(), &["a".into(), "b".into()], "ab").unwrap(); + assert_eq!(m.source, "recorded"); + assert_eq!(m.status, "transcribed"); + assert_eq!(m.title, "Планёрка (объединено)"); + let d = crate::service::meeting_dir(root.path(), "ab"); + let len = |f: &str| hound::WavReader::open(d.join(f)).unwrap().len(); + // Часть A: 2 с (по длинной дорожке) + 1 с паузы; часть B: 3 с. + assert_eq!(len("mic.wav"), 6 * SR); + assert_eq!(len("system.wav"), 6 * SR); + let t = crate::service::load_transcript(root.path(), "ab").unwrap().unwrap(); + assert_eq!(t.segments[1].text, "продолжение"); + assert!((t.segments[1].start_secs - 3.2).abs() < 1e-6); + // Исходные встречи на месте. + assert_eq!(repo.list().unwrap().len(), 3); + } + + #[test] + fn imported_part_makes_one_track() { + let root = tempfile::tempdir().unwrap(); + let repo = Repo::open_in_memory().unwrap(); + let d = crate::service::meeting_dir(root.path(), "r"); + std::fs::create_dir_all(&d).unwrap(); + wav(&d.join("mic.wav"), 1.0, 100); + wav(&d.join("system.wav"), 1.0, 100); + repo.insert(&meeting("r", "recorded", "", "2026-10-01T10:00:00Z")).unwrap(); + let d = crate::service::meeting_dir(root.path(), "i"); + std::fs::create_dir_all(&d).unwrap(); + wav(&d.join("audio.wav"), 2.0, 50); + repo.insert(&meeting("i", "imported", "", "2026-10-01T11:00:00Z")).unwrap(); + + let m = merge_meetings(&repo, root.path(), &["i".into(), "r".into()], "ir").unwrap(); + assert_eq!(m.source, "imported"); + assert_eq!(m.status, "recorded"); // расшифровок не было + assert_eq!(m.title, "Объединённая встреча"); + let r = hound::WavReader::open(crate::service::meeting_dir(root.path(), "ir").join("audio.wav")).unwrap(); + assert_eq!(r.len(), 4 * SR); + let s: Vec = r.into_samples::().map(Result::unwrap).collect(); + // Через декодер — с точностью до единицы округления. + assert!((s[0] - 50).abs() <= 2, "{}", s[0]); + let mid = s[(3.5 * SR as f64) as usize]; + assert!((mid - 200).abs() <= 2, "{mid}"); // «Я» + «Собеседник» сведены + assert!(merge_meetings(&repo, root.path(), &["i".into()], "x").is_err()); + } +} diff --git a/core/src/model.rs b/core/src/model.rs index 9e6fd8b..e20cf5d 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -29,6 +29,18 @@ pub struct Meeting { /// Заметки пользователя к встрече (свободный текст). Пусто — нет заметок. #[serde(default)] pub notes: String, + /// Папка в списке встреч (id из [`Collection`]); пусто — вне папок. + #[serde(default)] + pub collection: String, +} + +/// Папка списка встреч: пользователь собирает в неё встречи по теме. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Collection { + pub id: String, + pub name: String, + /// ISO-8601, UTC. + pub created_at: String, } /// Дефолт для `source` — на случай старых записей без этого поля. @@ -53,6 +65,7 @@ mod tests { status: "recorded".into(), source: "recorded".into(), notes: "".into(), + collection: String::new(), }; let json = serde_json::to_string(&m).unwrap(); let back: Meeting = serde_json::from_str(&json).unwrap(); diff --git a/core/src/service.rs b/core/src/service.rs index fce865a..f975e8b 100644 --- a/core/src/service.rs +++ b/core/src/service.rs @@ -143,6 +143,7 @@ pub fn stop_recording( status: "recorded".into(), source: "recorded".into(), notes: String::new(), + collection: String::new(), }; repo.insert(&meeting)?; Ok(meeting) @@ -208,6 +209,7 @@ pub fn recover_orphan_recordings( status: "recorded".into(), source: "recorded".into(), notes: String::new(), + collection: String::new(), }; repo.insert(&meeting)?; } @@ -280,6 +282,7 @@ pub fn import_to_meeting( status: "recorded".into(), source: "imported".into(), notes: String::new(), + collection: String::new(), }) } @@ -1043,6 +1046,7 @@ mod tests { status: "recorded".into(), source: "recorded".into(), notes: String::new(), + collection: String::new(), }) .unwrap(); } diff --git a/core/src/storage.rs b/core/src/storage.rs index 0232f5f..ce74e27 100644 --- a/core/src/storage.rs +++ b/core/src/storage.rs @@ -1,5 +1,5 @@ use crate::error::{AppError, AppResult}; -use crate::model::Meeting; +use crate::model::{Collection, Meeting}; use rusqlite::{params, Connection}; /// Хранилище встреч поверх SQLite. @@ -32,7 +32,16 @@ impl Repo { folder TEXT NOT NULL, status TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'recorded', - notes TEXT NOT NULL DEFAULT '' + notes TEXT NOT NULL DEFAULT '', + collection TEXT NOT NULL DEFAULT '' + )", + [], + )?; + conn.execute( + "CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL )", [], )?; @@ -54,6 +63,12 @@ impl Repo { [], )?; } + if !Self::column_exists(conn, "collection")? { + conn.execute( + "ALTER TABLE meetings ADD COLUMN collection TEXT NOT NULL DEFAULT ''", + [], + )?; + } Ok(()) } @@ -73,8 +88,8 @@ impl Repo { pub fn insert(&self, m: &Meeting) -> AppResult<()> { self.conn.execute( "INSERT INTO meetings - (id, created_at, title, participants, topic, duration_secs, folder, status, source, notes) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + (id, created_at, title, participants, topic, duration_secs, folder, status, source, notes, collection) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ m.id, m.created_at, @@ -85,7 +100,8 @@ impl Repo { m.folder, m.status, m.source, - m.notes + m.notes, + m.collection ], )?; Ok(()) @@ -94,7 +110,7 @@ impl Repo { /// Все встречи, новейшие сверху. pub fn list(&self) -> AppResult> { let mut stmt = self.conn.prepare( - "SELECT id, created_at, title, participants, topic, duration_secs, folder, status, source, notes + "SELECT id, created_at, title, participants, topic, duration_secs, folder, status, source, notes, collection FROM meetings ORDER BY created_at DESC", )?; let rows = stmt.query_map([], Self::row_to_meeting)?; @@ -107,7 +123,7 @@ impl Repo { pub fn get(&self, id: &str) -> AppResult { let mut stmt = self.conn.prepare( - "SELECT id, created_at, title, participants, topic, duration_secs, folder, status, source, notes + "SELECT id, created_at, title, participants, topic, duration_secs, folder, status, source, notes, collection FROM meetings WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], Self::row_to_meeting)?; @@ -194,8 +210,57 @@ impl Repo { status: row.get(7)?, source: row.get(8)?, notes: row.get(9)?, + collection: row.get(10)?, }) } + + // ── Папки списка встреч ──────────────────────────────────────────────── + + /// Все папки, по имени. + pub fn list_collections(&self) -> AppResult> { + let mut stmt = self.conn.prepare("SELECT id, name, created_at FROM collections ORDER BY name COLLATE NOCASE")?; + let rows = stmt.query_map([], |r| Ok(Collection { id: r.get(0)?, name: r.get(1)?, created_at: r.get(2)? }))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn insert_collection(&self, c: &Collection) -> AppResult<()> { + self.conn.execute( + "INSERT INTO collections (id, name, created_at) VALUES (?1, ?2, ?3)", + params![c.id, c.name, c.created_at], + )?; + Ok(()) + } + + pub fn rename_collection(&self, id: &str, name: &str) -> AppResult<()> { + let n = self.conn.execute("UPDATE collections SET name = ?1 WHERE id = ?2", params![name, id])?; + if n == 0 { + return Err(AppError::NotFound(id.to_string())); + } + Ok(()) + } + + /// Удаляет папку; встречи из неё остаются (переходят «вне папок»). + pub fn delete_collection(&self, id: &str) -> AppResult<()> { + self.conn.execute("UPDATE meetings SET collection = '' WHERE collection = ?1", params![id])?; + let n = self.conn.execute("DELETE FROM collections WHERE id = ?1", params![id])?; + if n == 0 { + return Err(AppError::NotFound(id.to_string())); + } + Ok(()) + } + + /// Кладёт встречу в папку (`""` — вне папок). + pub fn set_meeting_collection(&self, id: &str, collection: &str) -> AppResult<()> { + let n = self.conn.execute("UPDATE meetings SET collection = ?1 WHERE id = ?2", params![collection, id])?; + if n == 0 { + return Err(AppError::NotFound(id.to_string())); + } + Ok(()) + } } #[cfg(test)] @@ -214,6 +279,7 @@ mod tests { status: "recorded".into(), source: "recorded".into(), notes: String::new(), + collection: String::new(), } } @@ -318,4 +384,22 @@ mod tests { assert_eq!(m.notes, ""); assert_eq!(m.title, "t"); } + + #[test] + fn collections_hold_meetings_and_release_them_on_delete() { + let repo = Repo::open_in_memory().unwrap(); + repo.insert(&sample("m1", "2026-10-01T10:00:00Z")).unwrap(); + repo.insert(&sample("m2", "2026-10-02T10:00:00Z")).unwrap(); + let c = Collection { id: "c1".into(), name: "Проект".into(), created_at: "2026-10-03T00:00:00Z".into() }; + repo.insert_collection(&c).unwrap(); + repo.set_meeting_collection("m1", "c1").unwrap(); + assert_eq!(repo.get("m1").unwrap().collection, "c1"); + repo.rename_collection("c1", "Клиент").unwrap(); + assert_eq!(repo.list_collections().unwrap()[0].name, "Клиент"); + // Удаление папки встречи не удаляет — они выходят из неё. + repo.delete_collection("c1").unwrap(); + assert!(repo.list_collections().unwrap().is_empty()); + assert_eq!(repo.get("m1").unwrap().collection, ""); + assert_eq!(repo.list().unwrap().len(), 2); + } } diff --git a/package-lock.json b/package-lock.json index a703b5b..61a16b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memiro", - "version": "0.13.8", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memiro", - "version": "0.13.8", + "version": "0.14.0", "dependencies": { "@fontsource-variable/bricolage-grotesque": "^5.2.10", "@fontsource-variable/golos-text": "^5.2.8", diff --git a/package.json b/package.json index 7df7afa..b0ed3d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "memiro", "private": true, - "version": "0.13.8", + "version": "0.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 06469a8..a0a051b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -581,6 +581,7 @@ pub async fn transcribe( &model, options.language.clone(), allowed.clone(), + options.cloud.clone(), &vocab, &|frac| emit("download", frac * 100.0, 0, 0), )?; @@ -752,6 +753,8 @@ pub async fn transcribe( _ => transcript, }; service::save_transcript(&state.data_root, &id, &transcript)?; + // Новая расшифровка — прежняя ИИ-корректура больше не к чему откатывать. + let _ = std::fs::remove_file(service::meeting_dir(&state.data_root, &id).join(AI_FIX_BACKUP)); { let plan = plan.finish(); flog(&state.data_root, &format!("refine plan: {} window(s)", plan.total())); @@ -789,6 +792,34 @@ trait Asr { } } +/// Распознавание на сервере ИИ пользователя (звук уходит на сервер — только +/// по явному выбору в настройках). +#[cfg(any(feature = "whisper", feature = "parakeet"))] +struct ServerAsr { + config: AiConfig, + model: String, + language: Option, + prompt: Option, +} + +#[cfg(any(feature = "whisper", feature = "parakeet"))] +impl Asr for ServerAsr { + fn run( + &self, + wav: &Path, + progress: &dyn Fn(usize, usize), + ) -> AppResult> { + uxo_core::cloud_asr::transcribe_wav( + &self.config, + &self.model, + self.language.as_deref(), + self.prompt.as_deref(), + wav, + progress, + ) + } +} + #[cfg(feature = "whisper")] impl Asr for uxo_core::whisper::WhisperTranscriber { fn run( @@ -1355,9 +1386,27 @@ fn load_asr( model: &str, language: Option, allowed: Vec, + cloud: Option, vocab: &uxo_core::vocab::Vocabulary, on_download: &dyn Fn(f32), ) -> AppResult> { + if let Some(c) = cloud { + // Один язык разговора — подсказываем его серверу (иначе он + // определяет сам). + let language = match allowed.as_slice() { + [one] => Some(one.clone()), + _ => language.filter(|l| !l.trim().is_empty() && l != "auto" && allowed.iter().any(|a| a == l)), + }; + let model = if c.model.trim().is_empty() { uxo_core::cloud_asr::DEFAULT_MODEL.to_string() } else { c.model.trim().to_string() }; + flog(data_root, &format!("transcribe: server recognition, model {model}")); + let inner: Box = Box::new(ServerAsr { + config: AiConfig { base_url: c.base_url, api_key: c.api_key, model: String::new() }, + model, + language, + prompt: vocab.prompt(), + }); + return Ok(Box::new(VocabFix { inner, vocab: vocab.clone() })); + } let inner = load_engine(data_root, model, language, allowed, vocab, on_download)?; Ok(Box::new(VocabFix { inner, vocab: vocab.clone() })) } @@ -2025,6 +2074,156 @@ pub fn update_meeting_notes( state.repo.lock().unwrap().update_notes(&id, ¬es) } +/// Копия расшифровки до ИИ-корректуры (для «Вернуть как было»). +const AI_FIX_BACKUP: &str = "transcript.before_ai.json"; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AiFixResult { + pub changed: usize, + pub rejected: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct AiFixProgress { + id: String, + done: usize, + total: usize, +} + +/// ИИ-корректура расшифровки через сервер ИИ пользователя (только текст): +/// исправляет неверно услышанные слова, термины, пунктуацию; реплики, +/// правленые пользователем, не трогает. Прежний текст сохраняется для отката. +#[tauri::command] +pub async fn ai_correct_transcript( + app: AppHandle, + state: tauri::State<'_, AppState>, + id: String, + config: AiConfig, + glossary: Option, +) -> AppResult { + let data_root = state.data_root.clone(); + tauri::async_runtime::spawn_blocking(move || { + let t = service::load_transcript(&data_root, &id)? + .ok_or_else(|| AppError::InvalidState("нет расшифровки — сначала расшифруйте встречу".into()))?; + let backend = HttpChatBackend::new(config); + let started = std::time::Instant::now(); + let r = uxo_core::aifix::correct(&backend, &t, glossary.as_deref(), &|done, total| { + let _ = app.emit("ai-fix-progress", AiFixProgress { id: id.clone(), done, total }); + })?; + if r.changed > 0 { + // Перед ИИ-правкой с последней расшифровки — прежний текст. + let backup = service::meeting_dir(&data_root, &id).join(AI_FIX_BACKUP); + if !backup.exists() { + std::fs::write(&backup, serde_json::to_vec(&t)?)?; + } + // Пока шла корректура, расшифровку могли поправить — берём + // свежую и меняем только реплики, оставшиеся как были. + let fresh = service::load_transcript(&data_root, &id)?.unwrap_or(t.clone()); + let merged = if fresh == t { + r.transcript + } else { + let mut out = fresh.clone(); + for s in out.segments.iter_mut() { + if let Some(i) = t.segments.iter().position(|o| o == s) { + s.text = r.transcript.segments[i].text.clone(); + } + } + out + }; + service::save_transcript(&data_root, &id, &merged)?; + } + flog( + &data_root, + &format!( + "ai-fix {id}: {} phrase(s) of {}, rejected {}, {:.0}s", + r.changed, + t.segments.len(), + r.rejected, + started.elapsed().as_secs_f32() + ), + ); + Ok(AiFixResult { changed: r.changed, rejected: r.rejected }) + }) + .await + .map_err(|e| AppError::Audio(format!("ai-fix join: {e}")))? +} + +/// Есть ли текст до ИИ-корректуры (можно откатить). +#[tauri::command] +pub fn ai_correction_available(state: tauri::State, id: String) -> bool { + service::meeting_dir(&state.data_root, &id).join(AI_FIX_BACKUP).exists() +} + +/// Возвращает текст расшифровки до ИИ-корректуры. +#[tauri::command] +pub fn revert_ai_correction(state: tauri::State, id: String) -> AppResult { + let backup = service::meeting_dir(&state.data_root, &id).join(AI_FIX_BACKUP); + let t: Transcript = serde_json::from_slice(&std::fs::read(&backup)?)?; + service::save_transcript(&state.data_root, &id, &t)?; + let _ = std::fs::remove_file(backup); + Ok(t) +} + +/// Проверка: умеет ли сервер ИИ распознавать речь (секунда тишины). +#[tauri::command] +pub async fn cloud_asr_check(config: AiConfig, model: Option) -> AppResult<()> { + let model = model.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| uxo_core::cloud_asr::DEFAULT_MODEL.into()); + tauri::async_runtime::spawn_blocking(move || uxo_core::cloud_asr::check(&config, &model)) + .await + .map_err(|e| AppError::Audio(format!("asr check join: {e}")))? +} + +/// Объединяет записи `ids` (в этом порядке) в новую встречу — разговор, +/// который прерывали и продолжали. Исходные записи остаются. +#[tauri::command] +pub async fn merge_meetings(state: tauri::State<'_, AppState>, ids: Vec) -> AppResult { + let new_id = uuid::Uuid::new_v4().to_string(); + let repo = state.repo.lock().unwrap(); + let m = uxo_core::merge::merge_meetings(&repo, &state.data_root, &ids, &new_id)?; + flog(&state.data_root, &format!("merge: {} part(s) → {} ({} s, {})", ids.len(), m.id, m.duration_secs, m.status)); + Ok(m) +} + +/// Папки списка встреч. +#[tauri::command] +pub fn list_collections(state: tauri::State) -> AppResult> { + state.repo.lock().unwrap().list_collections() +} + +/// Новая папка (имя без пробелов по краям; пустое — «Новая папка»). +#[tauri::command] +pub fn create_collection(state: tauri::State, name: String) -> AppResult { + let name = name.trim(); + let c = uxo_core::model::Collection { + id: uuid::Uuid::new_v4().to_string(), + name: if name.is_empty() { "Новая папка".into() } else { name.to_string() }, + created_at: chrono::Utc::now().to_rfc3339(), + }; + state.repo.lock().unwrap().insert_collection(&c)?; + Ok(c) +} + +#[tauri::command] +pub fn rename_collection(state: tauri::State, id: String, name: String) -> AppResult<()> { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::InvalidState("имя папки не может быть пустым".into())); + } + state.repo.lock().unwrap().rename_collection(&id, name) +} + +/// Удаляет папку; встречи из неё остаются в списке. +#[tauri::command] +pub fn delete_collection(state: tauri::State, id: String) -> AppResult<()> { + state.repo.lock().unwrap().delete_collection(&id) +} + +/// Кладёт встречу в папку (пусто — вынуть из папки). +#[tauri::command] +pub fn set_meeting_collection(state: tauri::State, id: String, collection: String) -> AppResult<()> { + state.repo.lock().unwrap().set_meeting_collection(&id, &collection) +} + /// Проверка ИИ-сервера: доступен ли, какие модели отдаёт и какую использовать /// (модель на сервере могли обновить — тогда `changed` и новая `model`). #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 793c973..2a1699a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -480,6 +480,16 @@ pub fn run() { commands::ask_named, commands::save_binary_file, commands::update_meeting_notes, + commands::merge_meetings, + commands::ai_correct_transcript, + commands::ai_correction_available, + commands::revert_ai_correction, + commands::cloud_asr_check, + commands::list_collections, + commands::create_collection, + commands::rename_collection, + commands::delete_collection, + commands::set_meeting_collection, commands::ai_check, commands::set_notifications, commands::test_notification, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9ec5b82..4b73a00 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Memiro AI", "mainBinaryName": "Memiro", - "version": "0.13.8", + "version": "0.14.0", "identifier": "com.3uxo.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 3bbdb46..18c9079 100644 --- a/src/App.css +++ b/src/App.css @@ -4396,3 +4396,199 @@ details.adv[open] summary::before { opacity: 0.6; cursor: default; } + +/* ---------- Папки и объединение записей (v0.14) ---------- */ +.meetings-head { + display: flex; + align-items: center; + gap: 2px; +} +.meetings-head .section-label { + flex: 1; +} +.head-btn { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-faint); + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} +.head-btn svg { + width: 16px; + height: 16px; +} +.head-btn:hover:not(:disabled) { + background: var(--hover); + color: var(--text); +} +.head-btn:disabled { + opacity: 0.4; + cursor: default; +} +.merge-bar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex-direction: column; + gap: 8px; + margin: 2px 4px 8px; + padding: 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border-strong); + background: var(--surface); + box-shadow: var(--glass-shadow-soft); +} +.merge-actions { + flex-wrap: wrap; +} +.merge-actions .btn { + white-space: nowrap; + flex: 1 1 auto; +} +.merge-order .seg-btn { + white-space: nowrap; + padding: 5px 8px; +} +.merge-hint { + font-size: 12px; + color: var(--text-dim); +} +.merge-order { + align-self: flex-start; +} +.merge-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} +.m-pick { + position: absolute; + top: 10px; + right: 10px; + width: 22px; + height: 22px; + border-radius: 50%; + border: 1.5px solid var(--glass-border-strong); + display: grid; + place-items: center; + font-size: 12px; + font-weight: 700; + font-family: var(--font-mono); + color: #fff; +} +.m-pick.on { + border-color: transparent; + background: var(--brand-grad); +} +.m-item.picked { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.folder { + border-radius: var(--radius-sm); + transition: background 0.14s ease, box-shadow 0.14s ease; +} +.folder.drop, +.loose.drop { + background: color-mix(in srgb, var(--accent) 10%, transparent); + box-shadow: inset 0 0 0 1.5px var(--accent); +} +.folder-head { + position: relative; +} +.folder-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 34px 8px 8px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font-size: 13px; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.folder-toggle:hover { + background: var(--hover); +} +.folder-head:hover .m-more, +.folder-head.menu-open .m-more { + opacity: 1; +} +.folder-caret { + width: 10px; + color: var(--text-faint); + font-size: 11px; +} +.folder-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder-count { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-faint); +} +.folder-body { + padding-left: 12px; + border-left: 1px solid var(--glass-border); + margin-left: 13px; +} +.folder-empty { + font-size: 11.5px; + color: var(--text-faint); + padding: 4px 8px 8px; +} +.folder-input { + width: calc(100% - 8px); + margin: 2px 4px 6px; + padding: 7px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent); + background: var(--field-bg); + color: var(--text); + font: inherit; + font-size: 13px; + outline: none; +} +.m-menu-title { + font-size: 10.5px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-faint); + padding: 6px 10px 4px; +} +.m-menu-hint { + font-size: 11.5px; + color: var(--text-faint); + padding: 4px 10px 6px; + max-width: 220px; +} + +/* Где распознавать: модель сервера + проверка (v0.14). */ +.row-inline { + display: flex; + gap: 8px; + align-items: center; +} +.row-inline input { + flex: 1; + min-width: 0; +} +.hint.ok { + color: var(--teal); +} +.hint.warn { + color: var(--rec); +} diff --git a/src/App.tsx b/src/App.tsx index 6d5306a..eac89dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,8 @@ import { listen } from "@tauri-apps/api/event"; import { api } from "./api"; import { getSettings } from "./settings"; import { resolveProcesses } from "./autorecord"; -import type { Meeting, TranscribeState, TrackLevels } from "./types"; +import type { Collection, Meeting, TranscribeState, TrackLevels } from "./types"; +import { getLabels, setLabels } from "./labels"; import { Sidebar } from "./components/Sidebar"; import { MemiroMark } from "./components/MemiroMark"; import { MeetingView } from "./components/MeetingView"; @@ -14,7 +15,7 @@ import { findUpdate, UPDATE_INTERVAL_MS } from "./updater"; import type { UpdateInfo } from "./updater"; import { UpdateDialog } from "./components/UpdateDialog"; import type { MeetingPatch } from "./components/MeetingEditDialog"; -import { runAutoAi } from "./aiauto"; +import { runAutoAi, runAutoCorrect } from "./aiauto"; import { AI_MODEL_EVENT, syncServerModel } from "./aimodel"; import type { AiModelChange } from "./aimodel"; import { useAppMenu } from "./appmenu"; @@ -34,6 +35,9 @@ type ProgressEvent = { export default function App() { const [meetings, setMeetings] = useState([]); + const [collections, setCollections] = useState([]); + // Итог действия со списком (объединение, папки): текст и вид. + const [listNote, setListNote] = useState<{ text: string; warn?: boolean } | null>(null); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [selectedId, setSelectedId] = useState(null); @@ -52,6 +56,7 @@ export default function App() { const refresh = useCallback(async () => { setMeetings(await api.listMeetings()); + setCollections(await api.listCollections().catch(() => [])); const st = await api.recordingState(); setRecording(st.recording); setPaused(st.paused); @@ -79,6 +84,11 @@ export default function App() { }, []); // Модель на ИИ-сервере: сверяем при запуске и вместе с проверкой обновлений. const [modelNote, setModelNote] = useState(null); + useEffect(() => { + if (!listNote) return; + const t = setTimeout(() => setListNote(null), 9000); + return () => clearTimeout(t); + }, [listNote]); useEffect(() => { const on = (e: Event) => setModelNote((e as CustomEvent).detail); window.addEventListener(AI_MODEL_EVENT, on); @@ -218,7 +228,11 @@ export default function App() { await refresh(); // Трудные места (шум, перебивания) уточняются в фоне — расшифровка // уже готова и доступна. - void api.refineTranscript(id).catch(() => {}); + // Затем — ИИ-корректура текста (если ИИ подключён и она включена). + void api + .refineTranscript(id) + .catch(() => {}) + .then(() => runAutoCorrect(id)); // ИИ сам придумывает заголовок и подводит итоги (если подключён). const m = await api.getMeeting(id).catch(() => null); if (m) { @@ -286,6 +300,37 @@ export default function App() { await refresh(); }; + // ── Папки и объединение записей ── + const listAction = async (run: () => Promise, fail: string) => { + try { + await run(); + } catch (e) { + setListNote({ text: `${fail}: ${String(e)}`, warn: true }); + } + await refresh(); + }; + const handleMerge = async (ids: string[]) => { + try { + const merged = await api.mergeMeetings(ids); + // Имена голосов и режим «я один» — от исходных записей. + const labels = ids.reduce((acc, id) => ({ ...getLabels(id), ...acc }), {}); + if (Object.keys(labels).length) setLabels(merged.id, labels); + if (ids.every((id) => localStorage.getItem(`3uxo.solo.${id}`) === "1")) { + localStorage.setItem(`3uxo.solo.${merged.id}`, "1"); + } + await refresh(); + setSelectedId(merged.id); + setListNote({ + text: + merged.status === "transcribed" + ? `Объединено записей: ${ids.length}. Расшифровки сшиты — исходные записи остались в списке.` + : `Объединено записей: ${ids.length}. Расшифруйте новую встречу целиком — исходные записи остались в списке.`, + }); + } catch (e) { + setListNote({ text: `Не удалось объединить записи: ${String(e)}`, warn: true }); + } + }; + const selected = meetings.find((m) => m.id === selectedId) ?? null; return ( @@ -322,6 +367,12 @@ export default function App() { }} onDelete={handleDelete} onEdit={handleEdit} + collections={collections} + onCreateFolder={(name) => listAction(() => api.createCollection(name), "Не удалось создать папку")} + onRenameFolder={(id, name) => listAction(() => api.renameCollection(id, name), "Не удалось переименовать папку")} + onDeleteFolder={(id) => listAction(() => api.deleteCollection(id), "Не удалось удалить папку")} + onMove={(id, c) => listAction(() => api.setMeetingCollection(id, c), "Не удалось переложить встречу")} + onMerge={handleMerge} onOpenSettings={() => { setNavOpen(false); setShowSettings(true); @@ -413,6 +464,15 @@ export default function App() { )} + {listNote && ( +
+ {listNote.warn ? "!" : "✦"} + {listNote.text} + +
+ )} {modelNote && (
✦ diff --git a/src/aiauto.ts b/src/aiauto.ts index 289235f..ecdbcb3 100644 --- a/src/aiauto.ts +++ b/src/aiauto.ts @@ -65,3 +65,31 @@ export async function runAutoAi(meeting: Meeting): Promise { } return changed; } + +/// Событие «расшифровка встречи изменилась» (ИИ-корректура) — detail: { id, changed }. +export const TRANSCRIPT_EVENT = "memiro-transcript-changed"; + +/// ИИ-корректура расшифровки (только текст, через ключ пользователя): +/// исправляет неверно услышанные слова, термины и пунктуацию. Запускается +/// после фонового уточнения трудных мест (иначе они мешали бы друг другу). +/// Возвращает число исправленных реплик (или null, если не запускалась). +export async function runAutoCorrect(meetingId: string, force = false): Promise { + const s = getSettings(); + if (!isAiConfigured(s)) return null; + if (!force && s.aiAuto.correct === false) return null; + signal({ id: meetingId, busy: true, label: "Исправляю ошибки распознавания…" }); + try { + const glossary = [s.whisper.vocabulary, s.whisper.learned] + .map((v) => v?.trim()) + .filter(Boolean) + .join("\n"); + const r = await api.aiCorrectTranscript(meetingId, s.ai, glossary); + signal({ id: meetingId, busy: false }); + window.dispatchEvent(new CustomEvent(TRANSCRIPT_EVENT, { detail: { id: meetingId, changed: r.changed } })); + return r.changed; + } catch (e) { + logError("ai correct", e); + signal({ id: meetingId, busy: false, error: `ИИ-правка расшифровки: ${String(e)}` }); + return null; + } +} diff --git a/src/api.ts b/src/api.ts index 70a70c1..a7ce814 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ import { invoke, convertFileSrc } from "@tauri-apps/api/core"; import type { Meeting, + Collection, TrackFile, Transcript, AiConfig, @@ -18,7 +19,7 @@ import type { MissDiagnosis, } from "./types"; import { logError, logInfo } from "./log"; -import { conversationLanguages } from "./settings"; +import { conversationLanguages, getSettings, isAiConfigured } from "./settings"; /// invoke с логированием ошибок в диагностику. async function inv(cmd: string, args?: Record): Promise { @@ -65,9 +66,15 @@ export const api = { logInfo( `transcribe start id=${id} model=${whisper.model || "default"} speakers=${speakerCount ?? "auto"}${solo ? " solo" : ""}`, ); + // Распознавание на сервере ИИ — только по явному выбору и с ключом. + const s = getSettings(); + const cloud = + whisper.engine === "server" && isAiConfigured(s) + ? { base_url: s.ai.base_url, api_key: s.ai.api_key, model: whisper.serverModel?.trim() || "" } + : undefined; return inv("transcribe", { id, - options: whisperOptions(whisper), + options: { ...whisperOptions(whisper), cloud }, speakerCount: speakerCount ?? null, solo: solo ?? null, totalVoices: totalVoices ?? null, @@ -111,6 +118,24 @@ export const api = { /// Заметки к встрече. updateMeetingNotes: (id: string, notes: string): Promise => inv("update_meeting_notes", { id, notes }), + /// ИИ-корректура расшифровки (только текст уходит на сервер ИИ). + aiCorrectTranscript: (id: string, config: AiConfig, glossary?: string): Promise<{ changed: number; rejected: number }> => + inv("ai_correct_transcript", { id, config, glossary: glossary || null }), + aiCorrectionAvailable: (id: string): Promise => inv("ai_correction_available", { id }), + revertAiCorrection: (id: string): Promise => inv("revert_ai_correction", { id }), + /// Умеет ли сервер ИИ распознавать речь. + cloudAsrCheck: (config: AiConfig, model?: string): Promise => + inv("cloud_asr_check", { config, model: model || null }), + /// Объединяет записи (в этом порядке) в новую встречу; исходные остаются. + mergeMeetings: (ids: string[]): Promise => inv("merge_meetings", { ids }), + listCollections: (): Promise => inv("list_collections"), + createCollection: (name: string): Promise => inv("create_collection", { name }), + renameCollection: (id: string, name: string): Promise => inv("rename_collection", { id, name }), + /// Удаляет папку; встречи из неё остаются в списке. + deleteCollection: (id: string): Promise => inv("delete_collection", { id }), + /// Кладёт встречу в папку (пусто — вынуть из папки). + setMeetingCollection: (id: string, collection: string): Promise => + inv("set_meeting_collection", { id, collection }), /// Проверить ИИ-сервер: доступность, модели, актуальная модель. aiCheck: (config: AiConfig): Promise => inv("ai_check", { config }), diff --git a/src/components/MeetingList.tsx b/src/components/MeetingList.tsx index 7344540..f6faf69 100644 --- a/src/components/MeetingList.tsx +++ b/src/components/MeetingList.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import type { DragEvent } from "react"; import { createPortal } from "react-dom"; -import type { Meeting, TranscribeState } from "../types"; +import type { Collection, Meeting, TranscribeState } from "../types"; import { formatClock } from "../util"; +import { groupMeetings } from "../library"; import { ConfirmDialog } from "./ConfirmDialog"; import { MeetingEditDialog } from "./MeetingEditDialog"; import type { MeetingPatch } from "./MeetingEditDialog"; @@ -14,8 +16,22 @@ interface Props { onDelete: (id: string) => void; /// Сохранить правку встречи из меню «⋯» (название, участники, тема, заметки). onEdit?: (id: string, patch: MeetingPatch) => void | Promise; + /// Папки списка встреч. + collections?: Collection[]; + /// Идёт поиск: показываем только папки с найденными встречами. + searching?: boolean; + onMove?: (id: string, collection: string) => void | Promise; + onRenameFolder?: (id: string, name: string) => void | Promise; + onDeleteFolder?: (id: string) => void | Promise; + /// Режим объединения: клик по записи выбирает её; номера — порядок склейки. + picking?: boolean; + /// Номер записи в склейке (1, 2, 3…) или 0 — не выбрана. + pickNumber?: (id: string) => number; + onPick?: (id: string) => void; } +const OPEN_KEY = "3uxo.folders.closed"; + function shortDate(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; @@ -31,6 +47,14 @@ function notePreview(notes?: string): string { return line.trim(); } +function loadClosed(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(OPEN_KEY) ?? "[]") as string[]); + } catch { + return new Set(); + } +} + export function MeetingList({ meetings, activeId, @@ -38,20 +62,41 @@ export function MeetingList({ onSelect, onDelete, onEdit, + collections = [], + searching = false, + onMove, + onRenameFolder, + onDeleteFolder, + picking = false, + pickNumber, + onPick, }: Props) { const [confirmId, setConfirmId] = useState(null); const [menuId, setMenuId] = useState(null); + // Меню «⋯» встречи в режиме «В папку…». + const [moveMenu, setMoveMenu] = useState(false); + const [folderMenu, setFolderMenu] = useState(null); + const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null); + const [confirmFolder, setConfirmFolder] = useState(null); + const [closed, setClosed] = useState>(() => loadClosed()); + const [dropOn, setDropOn] = useState(null); const [edit, setEdit] = useState<{ id: string; focus: "title" | "notes" } | null>(null); const menuRef = useRef(null); // Меню закрывается кликом мимо и по Esc. useEffect(() => { - if (!menuId) return; + if (!menuId && !folderMenu) return; const onDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuId(null); + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuId(null); + setFolderMenu(null); + } }; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setMenuId(null); + if (e.key === "Escape") { + setMenuId(null); + setFolderMenu(null); + } }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); @@ -59,50 +104,122 @@ export function MeetingList({ document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; - }, [menuId]); + }, [menuId, folderMenu]); + + const toggleFolder = (id: string) => { + const next = new Set(closed); + if (next.has(id)) next.delete(id); + else next.add(id); + setClosed(next); + try { + localStorage.setItem(OPEN_KEY, JSON.stringify([...next])); + } catch { + /* без хранилища — только на сеанс */ + } + }; - if (meetings.length === 0) { - return

Пока нет записей

; + const { folders, loose } = groupMeetings(meetings, collections); + const shownFolders = searching ? folders.filter((f) => f.meetings.length > 0) : folders; + if (meetings.length === 0 && shownFolders.length === 0) { + return

{searching ? "Ничего не найдено" : "Пока нет записей"}

; } const editing = edit ? meetings.find((m) => m.id === edit.id) : undefined; - return ( - <> - {meetings.map((m) => { - const tr = progress?.[m.id]; - const note = notePreview(m.notes); - return ( -
- + {!picking && ( + + )} + {menuId === m.id && ( +
+ {moveMenu ? ( + <> +
В папку
+ {collections.map((c) => ( + + ))} + {collections.length === 0 &&
Папок пока нет — создайте её кнопкой над списком.
} + {m.collection && ( + )} - - {note && {note}} - - - {menuId === m.id && ( -
+ + ) : ( + <> + {onMove && ( + + )} + + )} +
+ )} +
+ ); + }; + + return ( + <> + {shownFolders.map(({ collection: c, meetings: inside }) => { + const open = searching || !closed.has(c.id); + return ( +
+
+ {renaming?.id === c.id ? ( + setRenaming({ id: c.id, name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Escape") setRenaming(null); + if (e.key === "Enter" && renaming.name.trim()) { + void onRenameFolder?.(c.id, renaming.name.trim()); + setRenaming(null); + } + }} + onBlur={() => { + if (renaming.name.trim() && renaming.name.trim() !== c.name) void onRenameFolder?.(c.id, renaming.name.trim()); + setRenaming(null); + }} + /> + ) : ( + + )} + {!picking && renaming?.id !== c.id && ( + + )} + {folderMenu === c.id && ( +
+ + +
+ )} +
+ {open && ( +
+ {inside.length === 0 ? ( +
Перетащите сюда встречу или выберите «⋯ → В папку…»
+ ) : ( + inside.map(renderMeeting) + )}
)}
); })} +
+ {loose.map(renderMeeting)} +
+ {/* Окна — порталом в body: у сайдбара backdrop-filter, и position:fixed внутри него позиционировался бы относительно сайдбара, а не окна. */} {editing && edit && createPortal( @@ -163,6 +381,18 @@ export function MeetingList({ />, document.body, )} + + {confirmFolder && createPortal( + { + void onDeleteFolder?.(confirmFolder.id); + setConfirmFolder(null); + }} + onCancel={() => setConfirmFolder(null)} + />, + document.body, + )} ); } diff --git a/src/components/MeetingView.tsx b/src/components/MeetingView.tsx index 49a55fc..2fa81ac 100644 --- a/src/components/MeetingView.tsx +++ b/src/components/MeetingView.tsx @@ -14,7 +14,8 @@ import { applyPolicy } from "../profanity"; import { findGaps, insertSegments, newSegmentAfter, newSegmentAt, setSegmentText, speakerNear } from "../transcriptedit"; import { learnedLabel, learnFromEdit, mergeLearned } from "../learn"; import type { Gap } from "../transcriptedit"; -import { conversationLanguages, getSettings, profanityPolicy, saveSettings, SETTINGS_EVENT } from "../settings"; +import { conversationLanguages, getSettings, isAiConfigured, profanityPolicy, saveSettings, SETTINGS_EVENT } from "../settings"; +import { runAutoCorrect, TRANSCRIPT_EVENT } from "../aiauto"; import { TranscriptView } from "./TranscriptView"; import { SpeakersPanel } from "./SpeakersPanel"; import { ExportModal } from "./ExportModal"; @@ -169,6 +170,52 @@ export function MeetingView({ meeting, transState, onTranscribe, onMetaSaved }: const [refine, setRefine] = useState<{ running: boolean; done: number; total: number; improved: number; pending: number }>( { running: false, done: 0, total: 0, improved: 0, pending: 0 }, ); + // ИИ-корректура расшифровки: идёт (сколько пачек готово), итог, можно ли откатить. + const [aiFix, setAiFix] = useState<{ running: boolean; done: number; total: number; changed: number | null; undo: boolean }>( + { running: false, done: 0, total: 0, changed: null, undo: false }, + ); + useEffect(() => { + let alive = true; + setAiFix({ running: false, done: 0, total: 0, changed: null, undo: false }); + const check = () => + api + .aiCorrectionAvailable(meeting.id) + .then((undo) => alive && setAiFix((a) => ({ ...a, undo }))) + .catch(() => {}); + void check(); + const un = listen<{ id: string; done: number; total: number }>("ai-fix-progress", (e) => { + if (e.payload.id !== meeting.id || !alive) return; + setAiFix((a) => ({ ...a, running: e.payload.done < e.payload.total, done: e.payload.done, total: e.payload.total })); + }).catch(() => () => {}); + const onChanged = (e: Event) => { + const d = (e as CustomEvent<{ id: string; changed: number }>).detail; + if (d.id !== meeting.id || !alive) return; + setAiFix((a) => ({ ...a, running: false, changed: d.changed })); + api.getTranscript(meeting.id).then((t) => alive && setTranscript(t)).catch(() => {}); + void check(); + }; + window.addEventListener(TRANSCRIPT_EVENT, onChanged); + return () => { + alive = false; + un.then((f) => f()).catch(() => {}); + window.removeEventListener(TRANSCRIPT_EVENT, onChanged); + }; + }, [meeting.id]); + const startAiFix = () => { + setAiFix((a) => ({ ...a, running: true, done: 0, total: 0, changed: null })); + void runAutoCorrect(meeting.id, true).then((n) => { + if (n === null) setAiFix((a) => ({ ...a, running: false })); + }); + }; + const undoAiFix = async () => { + try { + const t = await api.revertAiCorrection(meeting.id); + setTranscript(t); + setAiFix({ running: false, done: 0, total: 0, changed: null, undo: false }); + } catch (e) { + setError(String(e)); + } + }; useEffect(() => { let alive = true; const status = () => @@ -829,6 +876,21 @@ export function MeetingView({ meeting, transState, onTranscribe, onMetaSaved }: doneLabel="✓" title="Скопировать текст расшифровки без Markdown" /> + {isAiConfigured(getSettings()) && ( + + )} +
+ ) : aiFix.changed === 0 ? ( +
ИИ проверил расшифровку — ошибок не нашёл
+ ) : null} {refine.running ? (