skillok meeting
ad47f897f99f8450c300100150f8c07c6061d85a
Перейти к файлу
Код
meeting src-taurisrccommands.rs
anc Ядро: встреча = заметка Markdown, всё по кнопке - Убрана автоматика: слежение за Zoom, окно согласия, трей, уведомления, автозапуск, живая расшифровка, разбор по JSON-схеме. - Встреча — note.md и дорожки звука; правка, дописывание, переименование, удаление. - «Добавить аудио»: чтение mp3, m4a, wav, aiff, flac, ogg (symphonia), потоковое приведение к 16 кГц моно. - «Расшифровать»: нарезка по паузам с обрезкой тишины в начале куска, до 4 кусков сразу, повтор, пометка нераспознанного, блок «Расшифровка». - Команды ИИ над заметкой: «Конспект», «Задачи», «Ревью», свой запрос; промпты файлами в prompts/. - «Записать» по кнопке: дорожки Zoom и микрофона в WAV, пауза, стоп. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> ad47f89 30 сентября 2026 г.
493 строки 16.6 КБ
commands.rs
//! Команды для интерфейса. Каждая — одно действие человека; сама программа
//! ничего не запускает. Ошибка уходит строкой и показывается как есть, поэтому
//! ошибки модулей написаны по-русски.

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

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

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>,
    zoom: bool,
    session: Session,
}

pub struct AppState {
    pub settings_path: PathBuf,
    pub store: Arc<Store>,
    pub secrets: Arc<dyn Secrets>,
    pub recording: Mutex<Option<Recording>>,
    /// Встречи, которые сейчас расшифровываются или обрабатываются ИИ.
    pub busy: Mutex<HashSet<String>>,
}

impl AppState {
    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())
    }

    /// Отмечает встречу занятой на время работы; вторая такая же работа — отказ.
    fn claim(&self, id: &str) -> Result<BusyGuard<'_>, String> {
        let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
        if !busy.insert(id.to_string()) {
            return Err("Со встречей уже идёт работа — дождитесь её".into());
        }
        Ok(BusyGuard {
            state: self,
            id: id.to_string(),
        })
    }

    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 BusyGuard<'a> {
    state: &'a AppState,
    id: String,
}

impl Drop for BusyGuard<'_> {
    fn drop(&mut self) {
        self.state
            .busy
            .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,
    /// Эта машина умеет записывать звук (Windows).
    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(),
        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 create_meeting(state: State<'_, AppState>) -> Result<Meeting, String> {
    state
        .store
        .create(&title_now("Заметка"), Local::now())
        .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 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 _busy = state.claim(&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,
}

fn read_track(path: &Path) -> Result<Vec<i16>, String> {
    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
    // Свои дорожки записи читаются напрямую, чужие файлы — декодером.
    match meet_audio::pcm::decode_wav(&bytes) {
        Ok(samples) => Ok(samples),
        Err(_) => meet_audio::decode::decode_file(path).map_err(|e| e.to_string()),
    }
}

#[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 _busy = state.claim(&id)?;
    let key = state.key()?;
    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 tracks = tokio::task::spawn_blocking(move || {
        tracks
            .into_iter()
            .map(|(s, p)| read_track(&p).map(|a| (s, a)))
            .collect::<Result<Vec<_>, _>>()
    })
    .await
    .map_err(|e| e.to_string())??;

    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(), |done, total| {
        let _ = app.emit(
            "meet://progress",
            Progress {
                meeting_id: &id,
                done,
                total,
            },
        );
    })
    .await;
    if outcome.failed > 0 && outcome.failed == outcome.segments.len() {
        // Не распознался ни один кусок — в заметку нечего класть, причина важнее.
        return Err(outcome
            .last_error
            .unwrap_or_else(|| "распознавание не удалось".into()));
    }
    let note = state
        .store
        .append_to_note(&id, &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,
    })
}

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

/// Команда ИИ над заметкой; ответ дописывается в её конец. Возвращает заметку.
#[tauri::command]
pub async fn run_command(state: State<'_, AppState>, id: String, command: Command) -> Result<String, String> {
    let _busy = state.claim(&id)?;
    let key = state.key()?;
    let settings = state.settings()?;
    let note = state.store.get(&id).map_err(|e| e.to_string())?.note;
    let prepared = ai::prepare(&command, &note).map_err(|e| e.to_string())?;
    let (answer, _usage) = Client::new(key)
        .complete(&settings.ai_model, &prepared.system, &prepared.user)
        .await
        .map_err(|e| e.to_string())?;
    state
        .store
        .append_to_note(&id, &ai::to_block(&prepared.heading, &answer))
        .map_err(|e| e.to_string())
}

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

#[derive(Serialize)]
pub struct RecordingView {
    meeting_id: String,
    started_at: DateTime<Local>,
    paused: bool,
    /// Пишется звук Zoom, а не только микрофон.
    zoom: bool,
}

#[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(),
            zoom: r.zoom,
        })
}

/// Начать запись: микрофон и, если Zoom сейчас выводит звук, звук Zoom.
#[tauri::command]
pub fn recorder_start(app: AppHandle, state: State<'_, AppState>) -> Result<Meeting, String> {
    if !meet_audio::CAPTURE_SUPPORTED {
        return Err("Запись работает только в Windows. Здесь можно добавить готовый аудиофайл.".into());
    }
    let mut slot = state.recording.lock().unwrap_or_else(|p| p.into_inner());
    if slot.is_some() {
        return Err("Запись уже идёт".into());
    }
    let zoom_pid = meet_audio::active_zoom_process();
    let meeting = state
        .store
        .create(
            &title_now(if zoom_pid.is_some() {
                "Встреча в Zoom,"
            } else {
                "Запись,"
            }),
            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());
    if let Some(pid) = zoom_pid {
        add(Speaker::Others, meet_audio::open_process_loopback(pid));
    }
    if tracks.is_empty() {
        let _ = state.store.delete(&id);
        return Err(errors.join("; "));
    }
    let zoom = tracks.iter().any(|t| t.speaker == Speaker::Others);
    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(),
        zoom,
        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())
}