skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting src-taurisrccommands.rs
anc Этап 1: запись встречи в Zoom, живая расшифровка и разбор - crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом тишины, распознавание начала и конца встречи по звуковой сессии Zoom. - Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка, живой конспект раз в три минуты, по окончании — разбор сам; звук удаляется после разбора, если не включено хранение. - Контроллер: слежу за Zoom → прошу предупредить участников → пишу; запись вручную, пауза, стоп; прерванная запись помечается при запуске. - Трей, уведомления, крестик прячет окно, запуск вместе с Windows. - Интерфейс: главный экран с карточкой состояния (задача #1), живая расшифровка и конспект на экране встречи, новые настройки. - reqwest на системном TLS: под Windows без C-кода ring. - 67 тестов; код под Windows проверен компиляцией и clippy с Mac, на Windows не запускался. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 169ce64 30 сентября 2026 г.
commands.rs
//! Команды для интерфейса. Ошибка уходит строкой — её текст показывается
//! пользователю как есть, поэтому все ошибки модулей написаны по-русски.

use std::path::PathBuf;
use std::sync::Arc;

use chrono::Local;
use serde::Serialize;
use tauri::State;

use crate::controller::{Controller, Status};
use crate::meetings::{Meeting, MeetingDetail, Store};
use crate::openrouter::{Client, KeyInfo};
use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};

pub struct AppState {
    pub settings_path: PathBuf,
    pub store: Arc<Store>,
    pub secrets: Arc<dyn Secrets>,
    pub controller: Arc<Controller>,
}

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())
    }
}

/// Итоговый разбор встречи и запись его на диск. Один путь и для кнопки
/// «Разобрать», и для разбора, который запускается сам после записи.
pub async fn analyze(store: &Store, settings: &Settings, key: &str, id: &str) -> Result<Meeting, String> {
    let detail = store.get(id).map_err(|e| e.to_string())?;
    if detail.transcript.segments.is_empty() {
        return Err("В расшифровке нет ни одной реплики".into());
    }
    let model = &settings.analysis_model;
    let (analysis, usage) = Client::new(key)
        .analyze(model, &detail.meeting.title, &detail.transcript)
        .await
        .map_err(|e| e.to_string())?;
    store
        .save_analysis(id, &analysis, model, usage, Local::now())
        .map_err(|e| e.to_string())
}

#[derive(Serialize)]
pub struct SettingsView {
    settings: Settings,
    /// Ключ с закрытой серединой; `None` — ключа нет.
    openrouter_key: Option<String>,
    meetings_dir: String,
    /// Эта машина умеет записывать Zoom.
    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: state.controller.status() != Status::Unsupported,
    })
}

#[tauri::command]
pub fn save_settings(
    app: tauri::AppHandle,
    state: State<'_, AppState>,
    settings: Settings,
) -> Result<(), String> {
    settings.save(&state.settings_path).map_err(|e| e.to_string())?;
    crate::shell::apply_autostart(&app, settings.launch_at_login);
    Ok(())
}

/// Ключ сохраняется, только если 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 import_meeting(state: State<'_, AppState>, title: String, text: String) -> Result<Meeting, String> {
    state
        .store
        .import(&title, &text, Local::now())
        .map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn analyze_meeting(state: State<'_, AppState>, id: String) -> Result<Meeting, String> {
    let settings = state.settings()?;
    let key = state.key()?;
    analyze(&state.store, &settings, &key, &id).await
}

// ── Запись ──────────────────────────────────────────────────────────────────

#[tauri::command]
pub fn recorder_status(state: State<'_, AppState>) -> Status {
    state.controller.status()
}

#[tauri::command]
pub fn recorder_consent(state: State<'_, AppState>, record: bool) -> Result<(), String> {
    state.controller.consent(record)
}

#[tauri::command]
pub fn recorder_start(state: State<'_, AppState>) -> Result<Meeting, String> {
    state.controller.start_manual()
}

#[tauri::command]
pub fn recorder_pause(state: State<'_, AppState>, paused: bool) -> Result<(), String> {
    state.controller.pause(paused)
}

/// Остановка возвращается сразу: расшифровка и разбор идут дальше сами, а
/// интерфейс следит за ними по событиям состояния.
#[tauri::command]
pub fn recorder_stop(state: State<'_, AppState>) -> Result<(), String> {
    if !matches!(state.controller.status(), Status::Recording { .. }) {
        return Err("Запись не идёт".into());
    }
    let controller = state.controller.clone();
    tauri::async_runtime::spawn(async move {
        let _ = controller.stop().await;
    });
    Ok(())
}