skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting srcapi.ts
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 г.
161 строка 6.2 КБ
api.ts
// Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
// тем, что отдаёт serde на стороне Rust.

export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "named"; name: string };

export interface Segment { start_ms: number; speaker: Speaker; text: string }
export interface Transcript { segments: Segment[] }

export interface Usage { prompt_tokens: number; completion_tokens: number; cost: number | null }

export interface AnalysisMeta { model: string; at: string; usage: Usage; tasks: number }

export type MeetingState = "recording" | "done" | "interrupted";

export interface Meeting {
  id: string;
  title: string;
  created_at: string;
  source: "import" | "recording";
  duration_ms: number;
  segments: number;
  analysis: AnalysisMeta | null;
  state: MeetingState;
}

export interface Analysis {
  title: string;
  summary: string;
  review: { achieved: string[]; open: string[]; off_track: string[]; verdict: string };
  decisions: { text: string; source_segment: number }[];
  tasks: { text: string; owner: string | null; due: string | null; quote: string; source_segment: number }[];
  open_questions: string[];
}

export interface MeetingDetail {
  meeting: Meeting;
  transcript: Transcript;
  analysis: Analysis | null;
  live_summary: string | null;
}

export interface Settings {
  live_model: string;
  analysis_model: string;
  stt_model: string;
  warning_text: string;
  always_warn_myself: boolean;
  language: string;
  keep_audio: boolean;
  launch_at_login: boolean;
}

export interface SettingsView {
  settings: Settings;
  openrouter_key: string | null;
  meetings_dir: string;
  capture_supported: boolean;
}

/** Состояние записи — `Status` из src-tauri/src/controller.rs. */
export type RecorderStatus =
  | { kind: "unsupported" }
  | { kind: "watching" }
  | { kind: "awaiting_consent" }
  | { kind: "recording"; meeting_id: string; started_at: string; paused: boolean; zoom: boolean }
  | { kind: "finishing"; meeting_id: string; step: "transcribing" | "analyzing" };

/** Событие записи — `Event` из src-tauri/src/recorder.rs. */
export type RecorderEvent =
  | { kind: "segments"; data: Segment[] }
  | { kind: "level"; data: { speaker: Speaker; dbfs: number } }
  | { kind: "live_summary"; data: string }
  | { kind: "track_failed"; data: { speaker: Speaker; error: string } }
  | { kind: "stt_failed"; data: string };

export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }

type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>;

interface Core { invoke: Invoke; listen: Listen }

let core: Promise<Core> | null = null;

function getCore(): Promise<Core> {
  core ??= (async (): Promise<Core> => {
    if ("__TAURI_INTERNALS__" in window) {
      const { invoke } = await import("@tauri-apps/api/core");
      const { listen } = await import("@tauri-apps/api/event");
      return { invoke, listen: (event, cb) => listen(event, (e) => cb(e.payload as never)) };
    }
    if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
      // Только для просмотра интерфейса в обычном браузере: `npm run dev`
      // или сборка `npm run build:mock`. В сборку приложения не попадает.
      return import("./dev-mock");
    }
    throw new Error("Skillok Meet запущен без ядра");
  })();
  return core;
}

async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
  return (await getCore()).invoke<T>(cmd, args);
}

/** Подписка на событие ядра; возвращает отписку. */
export async function on<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
  return (await getCore()).listen<T>(event, cb);
}

export const events = {
  status: (cb: (s: RecorderStatus) => void) => on<RecorderStatus>("meet://status", cb),
  recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
  meetingChanged: (cb: (id: string) => void) => on<string>("meet://meeting-changed", cb),
};

export const api = {
  getSettings: () => call<SettingsView>("get_settings"),
  saveSettings: (settings: Settings) => call<void>("save_settings", { settings }),
  setOpenrouterKey: (key: string) => call<KeyInfo>("set_openrouter_key", { key }),
  checkOpenrouterKey: () => call<KeyInfo>("check_openrouter_key"),
  deleteOpenrouterKey: () => call<void>("delete_openrouter_key"),
  listMeetings: () => call<Meeting[]>("list_meetings"),
  getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
  importMeeting: (title: string, text: string) => call<Meeting>("import_meeting", { title, text }),
  analyzeMeeting: (id: string) => call<Meeting>("analyze_meeting", { id }),
  recorderStatus: () => call<RecorderStatus>("recorder_status"),
  recorderConsent: (record: boolean) => call<void>("recorder_consent", { record }),
  recorderStart: () => call<Meeting>("recorder_start"),
  recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
  recorderStop: () => call<void>("recorder_stop"),
};

export function speakerLabel(s: Speaker): string {
  switch (s.kind) {
    case "me": return "Я";
    case "others": return "Собеседники";
    case "named": return s.name;
  }
}

export function formatTime(ms: number): string {
  const t = Math.floor(ms / 1000);
  const p = (n: number) => String(n).padStart(2, "0");
  return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`;
}

export function formatDuration(ms: number): string {
  if (ms < 60000) return "меньше минуты";
  const min = Math.round(ms / 60000);
  if (min < 60) return `${min} мин`;
  return `${Math.floor(min / 60)} ч ${min % 60} мин`;
}

export function formatDate(iso: string): string {
  return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}

export function errorText(e: unknown): string {
  return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}