skillok meeting
18b6b167a6dc246530f63c582310140aebaca9b4
Перейти к файлу
Код
meeting src-taurisrccommands.rs
anc Меню как у 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> 3938872 30 сентября 2026 г.
commands.rs
//! Команды для интерфейса. Каждая — одно действие человека; сама программа
//! ничего не запускает. Ошибка уходит строкой и показывается как есть, поэтому
//! ошибки модулей написаны по-русски.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};

use chrono::{DateTime, Local};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tokio_util::sync::CancellationToken;

use meet_audio::pcm::{WavFile, WavWriter};
use meet_audio::source::{Capture, CaptureError};

use crate::ai::{self, Command};
use crate::meetings::{AudioTrack, Meeting, MeetingDetail, Store};
use crate::openrouter::{Client, KeyInfo, SttSegment};
use crate::recorder::{self, Session, Track};
use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};
use crate::transcribe::{self, BoxFuture, Stt};
use crate::transcript::{self, Speaker};

pub struct Recording {
    meeting_id: String,
    started_at: DateTime<Local>,
    system_error: Option<String>,
    session: Session,
}

pub struct AppState {
    pub settings_path: PathBuf,
    /// Хранилище в папке из настроек; меняется, когда человек выбирает другую.
    pub store: RwLock<Arc<Store>>,
    /// Папка записей по умолчанию — в данных программы.
    pub default_meetings_dir: PathBuf,
    pub secrets: Arc<dyn Secrets>,
    pub recording: Mutex<Option<Recording>>,
    /// Работа, которая сейчас идёт над встречей (расшифровка или ответ ИИ),
    /// и её отмена — по кнопке «Отменить».
    pub jobs: Mutex<HashMap<String, CancellationToken>>,
}

impl AppState {
    fn store(&self) -> Arc<Store> {
        self.store.read().unwrap_or_else(|p| p.into_inner()).clone()
    }

    fn settings(&self) -> Result<Settings, String> {
        Settings::load(&self.settings_path).map_err(|e| e.to_string())
    }

    fn key(&self) -> Result<String, String> {
        self.secrets
            .get(OPENROUTER_KEY)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| "Не подключён ИИ: добавьте ключ OpenRouter в настройках".into())
    }

    /// Начинает работу над встречей; вторая работа над той же встречей — отказ.
    /// Работа снимается, когда `Job` уходит из области видимости.
    fn start_job(&self, id: &str) -> Result<Job<'_>, String> {
        let mut jobs = self.jobs.lock().unwrap_or_else(|p| p.into_inner());
        if jobs.contains_key(id) {
            return Err("Над этой записью уже идёт работа — дождитесь её или отмените".into());
        }
        let token = CancellationToken::new();
        jobs.insert(id.to_string(), token.clone());
        Ok(Job {
            state: self,
            id: id.to_string(),
            token,
        })
    }

    fn is_recording(&self, id: &str) -> bool {
        self.recording
            .lock()
            .unwrap_or_else(|p| p.into_inner())
            .as_ref()
            .is_some_and(|r| r.meeting_id == id)
    }
}

struct Job<'a> {
    state: &'a AppState,
    id: String,
    token: CancellationToken,
}

impl Drop for Job<'_> {
    fn drop(&mut self) {
        self.state
            .jobs
            .lock()
            .unwrap_or_else(|p| p.into_inner())
            .remove(&self.id);
    }
}

fn title_now(prefix: &str) -> String {
    format!("{prefix} {}", Local::now().format("%d.%m, %H:%M"))
}

// ── Настройки и ключ ────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct SettingsView {
    settings: Settings,
    /// Ключ с закрытой серединой; `None` — ключа нет.
    openrouter_key: Option<String>,
    meetings_dir: String,
    default_meetings_dir: String,
    /// Эта машина умеет записывать звук (Windows, macOS).
    capture_supported: bool,
}

#[tauri::command]
pub fn get_settings(state: State<'_, AppState>) -> Result<SettingsView, String> {
    Ok(SettingsView {
        settings: state.settings()?,
        openrouter_key: state
            .secrets
            .get(OPENROUTER_KEY)
            .map_err(|e| e.to_string())?
            .map(|k| mask_key(&k)),
        meetings_dir: state.store().root().display().to_string(),
        default_meetings_dir: state.default_meetings_dir.display().to_string(),
        capture_supported: meet_audio::CAPTURE_SUPPORTED,
    })
}

#[tauri::command]
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Result<(), String> {
    settings.save(&state.settings_path).map_err(|e| e.to_string())
}

/// Ключ сохраняется, только если OpenRouter его принял.
#[tauri::command]
pub async fn set_openrouter_key(state: State<'_, AppState>, key: String) -> Result<KeyInfo, String> {
    let key = key.trim().to_string();
    if key.is_empty() {
        return Err("Вставьте ключ OpenRouter".into());
    }
    let info = Client::new(key.clone())
        .key_info()
        .await
        .map_err(|e| e.to_string())?;
    state
        .secrets
        .set(OPENROUTER_KEY, &key)
        .map_err(|e| e.to_string())?;
    Ok(info)
}

#[tauri::command]
pub async fn check_openrouter_key(state: State<'_, AppState>) -> Result<KeyInfo, String> {
    Client::new(state.key()?)
        .key_info()
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
pub fn delete_openrouter_key(state: State<'_, AppState>) -> Result<(), String> {
    state.secrets.delete(OPENROUTER_KEY).map_err(|e| e.to_string())
}

// ── Встречи и заметки ───────────────────────────────────────────────────────

#[tauri::command]
pub fn list_meetings(state: State<'_, AppState>) -> Result<Vec<Meeting>, String> {
    state.store().list().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn get_meeting(state: State<'_, AppState>, id: String) -> Result<MeetingDetail, String> {
    state.store().get(&id).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn save_note(state: State<'_, AppState>, id: String, text: String) -> Result<Meeting, String> {
    state.store().save_note(&id, &text).map_err(|e| e.to_string())
}

/// Убрать результат — раздел заметки с этим заголовком.
#[tauri::command]
pub fn remove_section(state: State<'_, AppState>, id: String, heading: String) -> Result<String, String> {
    state
        .store()
        .remove_section(&id, &heading)
        .map_err(|e| e.to_string())
}

/// Новая папка для записей: все записи переносятся туда, дальше пишутся туда
/// же. `None` — вернуть папку по умолчанию.
#[tauri::command]
pub fn set_storage_dir(state: State<'_, AppState>, path: Option<String>) -> Result<String, String> {
    if state
        .recording
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .is_some()
    {
        return Err("Сначала остановите запись".into());
    }
    if !state.jobs.lock().unwrap_or_else(|p| p.into_inner()).is_empty() {
        return Err("Дождитесь, пока закончится расшифровка или ответ ИИ".into());
    }
    let target = match path.as_deref().map(str::trim) {
        Some(p) if !p.is_empty() => PathBuf::from(p),
        _ => state.default_meetings_dir.clone(),
    };
    let mut settings = state.settings()?;
    let mut slot = state.store.write().unwrap_or_else(|p| p.into_inner());
    slot.move_all_to(&target).map_err(|e| e.to_string())?;
    settings.storage_dir = if target == state.default_meetings_dir {
        String::new()
    } else {
        target.display().to_string()
    };
    settings.save(&state.settings_path).map_err(|e| e.to_string())?;
    *slot = Arc::new(Store::new(&target));
    Ok(target.display().to_string())
}

/// Открыть папку с записями или с одной записью в Finder / Проводнике.
#[tauri::command]
pub fn open_folder(state: State<'_, AppState>, id: Option<String>) -> Result<(), String> {
    let store = state.store();
    let path = match id {
        Some(id) => store.folder(&id).map_err(|e| e.to_string())?,
        None => store.root().to_path_buf(),
    };
    std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
    tauri_plugin_opener::open_path(&path, None::<&str>).map_err(|e| e.to_string())
}

/// «Отменить» — расшифровку или ответ ИИ, что сейчас идёт над встречей.
#[tauri::command]
pub fn cancel_job(state: State<'_, AppState>, id: String) {
    if let Some(token) = state.jobs.lock().unwrap_or_else(|p| p.into_inner()).get(&id) {
        token.cancel();
    }
}

#[tauri::command]
pub fn rename_meeting(state: State<'_, AppState>, id: String, title: String) -> Result<Meeting, String> {
    state.store().rename(&id, &title).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn delete_meeting(state: State<'_, AppState>, id: String) -> Result<(), String> {
    if state.is_recording(&id) {
        return Err("Сначала остановите запись".into());
    }
    let _job = state.start_job(&id)?;
    state.store().delete(&id).map_err(|e| e.to_string())
}

/// Новая встреча из аудиофайла. Файл сразу читается целиком: так сразу видно,
/// что формат подходит, и известна длительность.
#[tauri::command]
pub async fn add_audio(state: State<'_, AppState>, path: String) -> Result<Meeting, String> {
    let source = PathBuf::from(&path);
    let decoded = {
        let source = source.clone();
        tokio::task::spawn_blocking(move || meet_audio::decode::decode_file(&source))
            .await
            .map_err(|e| e.to_string())?
            .map_err(|e| e.to_string())?
    };
    let duration_ms = meet_audio::pcm::samples_to_ms(decoded.len());
    let title = source
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("Аудио")
        .to_string();
    let meeting = state
        .store()
        .create(&title, Local::now())
        .map_err(|e| e.to_string())?;
    state
        .store()
        .attach_audio_file(&meeting.id, &source, duration_ms)
        .map_err(|e| e.to_string())
}

// ── Расшифровка ─────────────────────────────────────────────────────────────

struct OpenRouterStt {
    client: Client,
    model: String,
    language: Option<String>,
}

impl Stt for OpenRouterStt {
    fn transcribe(&self, wav: Vec<u8>) -> BoxFuture<'_, Result<Vec<SttSegment>, String>> {
        Box::pin(async move {
            self.client
                .transcribe(&self.model, &wav, self.language.as_deref())
                .await
                .map(|t| t.segments)
                .map_err(|e| e.to_string())
        })
    }
}

#[derive(Serialize)]
pub struct TranscribeResult {
    note: String,
    failed: usize,
    last_error: Option<String>,
}

#[derive(Serialize, Clone)]
struct Progress<'a> {
    meeting_id: &'a str,
    done: usize,
    total: usize,
    /// Новые реплики по порядку времени, абзацами как в заметке.
    lines: Vec<String>,
}

/// Дорожка для расшифровки, читаемая с диска по кускам. Свои дорожки записи —
/// WAV нашего формата; чужой файл (mp3, m4a…) один раз перегоняется в такой
/// WAV рядом, чтобы и двухчасовой файл не держать в памяти всю расшифровку.
/// Второй путь — файл-времянка, его надо удалить после.
fn open_track(path: &Path) -> Result<(WavFile, Option<PathBuf>), String> {
    if let Ok(f) = WavFile::open(path) {
        return Ok((f, None));
    }
    let samples = meet_audio::decode::decode_file(path).map_err(|e| e.to_string())?;
    let temp = path.with_extension("transcribe.wav");
    let mut w = WavWriter::create(&temp).map_err(|e| format!("{}: {e}", temp.display()))?;
    w.write(&samples).map_err(|e| e.to_string())?;
    w.flush().map_err(|e| e.to_string())?;
    drop(samples);
    let f = WavFile::open(&temp).map_err(|e| e.to_string())?;
    Ok((f, Some(temp)))
}

#[tauri::command]
pub async fn transcribe_meeting(
    app: AppHandle,
    state: State<'_, AppState>,
    id: String,
) -> Result<TranscribeResult, String> {
    if state.is_recording(&id) {
        return Err("Сначала остановите запись".into());
    }
    let key = state.key()?;
    let job = state.start_job(&id)?;
    let settings = state.settings()?;
    let meeting = state.store().get(&id).map_err(|e| e.to_string())?.meeting;
    if meeting.audio.is_empty() {
        return Err("В этой встрече нет звука — запишите встречу или добавьте аудиофайл".into());
    }
    let mut tracks: Vec<(Speaker, PathBuf)> = Vec::new();
    for AudioTrack { file, speaker, .. } in &meeting.audio {
        tracks.push((
            speaker.clone(),
            state.store().audio_path(&id, file).map_err(|e| e.to_string())?,
        ));
    }
    let opened = tokio::task::spawn_blocking(move || {
        tracks
            .into_iter()
            .map(|(s, p)| open_track(&p).map(|a| (s, a)))
            .collect::<Result<Vec<_>, _>>()
    })
    .await
    .map_err(|e| e.to_string())??;
    let mut temps = Vec::new();
    let tracks: Vec<(Speaker, Arc<dyn transcribe::Samples>)> = opened
        .into_iter()
        .map(|(speaker, (file, temp))| {
            temps.extend(temp);
            (speaker, Arc::new(file) as Arc<dyn transcribe::Samples>)
        })
        .collect();
    // Времянки удаляются при любом исходе, в том числе при отмене.
    let _cleanup = RemoveOnDrop(temps);

    let stt = Arc::new(OpenRouterStt {
        client: Client::new(key),
        model: settings.stt_model.clone(),
        language: settings.stt_language().map(str::to_string),
    });
    let outcome = transcribe::run(tracks, stt, transcribe::Config::default(), &job.token, |u| {
        let _ = app.emit(
            "meet://progress",
            Progress {
                meeting_id: &id,
                done: u.done,
                total: u.total,
                lines: u.fresh.iter().map(transcript::line).collect(),
            },
        );
    })
    .await
    .map_err(|e| e.to_string())?;
    if outcome.failed > 0 && outcome.failed == outcome.segments.len() {
        // Не распознался ни один кусок — в заметку нечего класть, причина важнее.
        return Err(outcome
            .last_error
            .unwrap_or_else(|| "распознавание не удалось".into()));
    }
    let note = state
        .store()
        .upsert_section(
            &id,
            transcript::HEADING,
            &transcript::to_markdown(&outcome.segments),
        )
        .map_err(|e| e.to_string())?;
    state
        .store()
        .mark_transcribed(&id, Local::now())
        .map_err(|e| e.to_string())?;
    Ok(TranscribeResult {
        note,
        failed: outcome.failed,
        last_error: outcome.last_error,
    })
}

struct RemoveOnDrop(Vec<PathBuf>);

impl Drop for RemoveOnDrop {
    fn drop(&mut self) {
        for p in &self.0 {
            let _ = std::fs::remove_file(p);
        }
    }
}

// ── Команды ИИ ──────────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct CommandResult {
    note: String,
    /// Заголовок раздела с ответом — его вкладку интерфейс откроет.
    heading: String,
}

/// Получить результат: ответ ИИ ложится разделом на место прежнего с тем же
/// заголовком. Прежний ответ модель не видит — иначе она его пересказывает.
#[derive(Serialize, Clone)]
struct AnswerPiece<'a> {
    meeting_id: &'a str,
    heading: &'a str,
    /// Новый кусок ответа; интерфейс дописывает его к уже показанному.
    text: &'a str,
}

#[tauri::command]
pub async fn run_command(
    app: AppHandle,
    state: State<'_, AppState>,
    id: String,
    command: Command,
) -> Result<CommandResult, String> {
    let key = state.key()?;
    let job = state.start_job(&id)?;
    let settings = state.settings()?;
    let note = state.store().get(&id).map_err(|e| e.to_string())?.note;
    let heading = ai::heading(&command).map_err(|e| e.to_string())?;
    let prepared =
        ai::prepare(&command, &crate::notes::remove(&note, &heading)).map_err(|e| e.to_string())?;
    let client = Client::new(key);
    let answer = tokio::select! {
        answer = client.complete(&settings.ai_model, &prepared.system, &prepared.user, |text| {
            let _ = app.emit("meet://answer", AnswerPiece { meeting_id: &id, heading: &prepared.heading, text });
        }) => answer.map_err(|e| e.to_string())?.0,
        _ = job.token.cancelled() => return Err("Отменено".into()),
    };
    let note = state
        .store()
        .upsert_section(&id, &prepared.heading, &ai::to_block(&prepared.heading, &answer))
        .map_err(|e| e.to_string())?;
    Ok(CommandResult {
        note,
        heading: prepared.heading,
    })
}

// ── Запись по кнопке ────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct RecordingView {
    meeting_id: String,
    started_at: DateTime<Local>,
    paused: bool,
    /// Пишется звук компьютера (собеседники), а не только микрофон.
    system: bool,
    /// Почему звук компьютера не пишется — чтобы подсказать, где разрешить.
    system_error: Option<String>,
}

#[tauri::command]
pub fn recorder_status(state: State<'_, AppState>) -> Option<RecordingView> {
    state
        .recording
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .as_ref()
        .map(|r| RecordingView {
            meeting_id: r.meeting_id.clone(),
            started_at: r.started_at,
            paused: r.session.is_paused(),
            system: r.system_error.is_none(),
            system_error: r.system_error.clone(),
        })
}

/// Начать запись: микрофон и весь звук компьютера — голоса собеседников в
/// любом сервисе встреч.
#[tauri::command]
pub fn recorder_start(app: AppHandle, state: State<'_, AppState>) -> Result<Meeting, String> {
    if !meet_audio::CAPTURE_SUPPORTED {
        return Err("На этой системе запись не поддерживается — можно добавить готовый аудиофайл.".into());
    }
    let mut slot = state.recording.lock().unwrap_or_else(|p| p.into_inner());
    if slot.is_some() {
        return Err("Запись уже идёт".into());
    }
    let meeting = state
        .store()
        .create(&title_now("Встреча"), Local::now())
        .map_err(|e| e.to_string())?;
    let id = meeting.id.clone();

    let mut tracks = Vec::new();
    let mut errors = Vec::new();
    let mut add = |speaker: Speaker, capture: Result<Box<dyn Capture>, CaptureError>| {
        let path = state
            .store()
            .audio_path(&id, speaker.track_file())
            .map_err(|e| e.to_string());
        match (capture, path) {
            (Ok(capture), Ok(path)) => tracks.push(Track {
                speaker,
                capture,
                path,
            }),
            (Err(e), _) => errors.push(e.to_string()),
            (_, Err(e)) => errors.push(e),
        }
    };
    add(Speaker::Me, meet_audio::open_microphone());
    add(Speaker::Others, meet_audio::open_system_audio());
    if tracks.is_empty() {
        let _ = state.store().delete(&id);
        return Err(errors.join("; "));
    }
    let system_error = (!tracks.iter().any(|t| t.speaker == Speaker::Others)).then(|| {
        errors
            .last()
            .cloned()
            .unwrap_or_else(|| "звук компьютера недоступен".into())
    });
    let audio: Vec<AudioTrack> = tracks
        .iter()
        .map(|t| AudioTrack {
            file: t.speaker.track_file().into(),
            speaker: t.speaker.clone(),
            duration_ms: 0,
        })
        .collect();
    let emit_id = id.clone();
    let session = Session::start(
        tracks,
        Arc::new(move |e: recorder::Event| {
            let _ = app.emit(
                "meet://recorder",
                serde_json::json!({ "meeting_id": emit_id, "event": e }),
            );
        }),
    )?;
    let meeting = state
        .store()
        .set_recording(&id, true, audio)
        .map_err(|e| e.to_string())?;
    *slot = Some(Recording {
        meeting_id: id,
        started_at: Local::now(),
        system_error,
        session,
    });
    Ok(meeting)
}

#[tauri::command]
pub fn recorder_pause(state: State<'_, AppState>, paused: bool) -> Result<(), String> {
    match state.recording.lock().unwrap_or_else(|p| p.into_inner()).as_ref() {
        Some(r) => {
            r.session.set_paused(paused);
            Ok(())
        }
        None => Err("Запись не идёт".into()),
    }
}

#[tauri::command]
pub async fn recorder_stop(state: State<'_, AppState>) -> Result<Meeting, String> {
    let recording = state
        .recording
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .take()
        .ok_or("Запись не идёт")?;
    let written = tokio::task::spawn_blocking(move || recording.session.stop())
        .await
        .map_err(|e| e.to_string())?;
    let audio: Vec<AudioTrack> = written
        .into_iter()
        .map(|w| AudioTrack {
            file: w.speaker.track_file().into(),
            speaker: w.speaker,
            duration_ms: w.duration_ms,
        })
        .collect();
    state
        .store()
        .set_recording(&recording.meeting_id, false, audio)
        .map_err(|e| e.to_string())
}