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 г.
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);
}