anc
Простой путь: записать → расшифровать → получить результат
- Главная: «Записать» (или аудиофайл перетаскиванием) и записи со статусом.
Боковая панель и «Новая заметка» убраны.
- Экран записи: шаги «Аудио → Расшифровка → Результат», на экране только
текущий шаг и одна главная кнопка.
- Результаты вкладками: «Расшифровка», «Конспект», «Задачи», «Ревью»,
«Свой запрос»; копировать, как текст, править, сделать заново, удалить.
Повтор команды заменяет раздел, а не дописывает второй.
- Задача #9: без ключа кнопки ведут к подключению ИИ; у расшифровки и
ответа ИИ — прогресс и «Отменить» (CancellationToken в ядре); идущая
запись видна меткой в шапке на любом экране.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e75bd24
30 сентября 2026 г.
api.ts
// Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
// тем, что отдаёт serde на стороне Rust.
export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" };
export interface AudioTrack { file: string; speaker: Speaker; duration_ms: number }
export interface Meeting {
id: string;
title: string;
created_at: string;
updated_at: string;
audio: AudioTrack[];
recording: boolean;
transcribed_at: string | null;
}
export interface MeetingDetail { meeting: Meeting; note: string }
export interface Settings {
ai_model: string;
stt_model: string;
language: string;
warning_text: string;
}
export interface SettingsView {
settings: Settings;
openrouter_key: string | null;
meetings_dir: string;
capture_supported: boolean;
}
export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }
export type Command =
| { kind: "summary" }
| { kind: "tasks" }
| { kind: "review" }
| { kind: "custom"; text: string };
export interface RecordingView { meeting_id: string; started_at: string; paused: boolean; zoom: boolean }
export interface TranscribeResult { note: string; failed: number; last_error: string | null }
export interface CommandResult { note: string; heading: string }
export type RecorderEvent =
| { kind: "level"; data: { speaker: Speaker; dbfs: number } }
| { kind: "track_failed"; data: { speaker: Speaker; error: string } };
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;
pickAudio: () => Promise<string | null>;
onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>;
}
const AUDIO_EXTENSIONS = ["mp3", "m4a", "wav", "aiff", "aif", "flac", "ogg", "aac", "mp4", "mkv", "caf"];
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");
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
const { open } = await import("@tauri-apps/plugin-dialog");
return {
invoke,
listen: (event, cb) => listen(event, (e) => cb(e.payload as never)),
pickAudio: async () => {
const picked = await open({ multiple: false, filters: [{ name: "Аудио", extensions: AUDIO_EXTENSIONS }] });
return typeof picked === "string" ? picked : null;
},
onFileDrop: (cb) =>
getCurrentWebview().onDragDropEvent((e) => {
if (e.payload.type === "drop") cb(e.payload.paths);
}),
};
}
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 = {
recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb),
progress: (cb: (p: { meeting_id: string; done: number; total: number }) => void) => on("meet://progress", cb),
};
export const files = {
pickAudio: async () => (await getCore()).pickAudio(),
onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb),
isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""),
};
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 }),
saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }),
removeSection: (id: string, heading: string) => call<string>("remove_section", { id, heading }),
cancelJob: (id: string) => call<void>("cancel_job", { id }),
renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }),
deleteMeeting: (id: string) => call<void>("delete_meeting", { id }),
addAudio: (path: string) => call<Meeting>("add_audio", { path }),
transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }),
runCommand: (id: string, command: Command) => call<CommandResult>("run_command", { id, command }),
recorderStatus: () => call<RecordingView | null>("recorder_status"),
recorderStart: () => call<Meeting>("recorder_start"),
recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }),
recorderStop: () => call<Meeting>("recorder_stop"),
};
export function formatDuration(ms: number): string {
if (ms <= 0) return "";
if (ms < 60000) return "меньше минуты";
const min = Math.round(ms / 60000);
if (min < 60) return `${min} мин`;
return `${Math.floor(min / 60)} ч ${min % 60} мин`;
}
export function meetingDuration(m: Meeting): number {
return Math.max(0, ...m.audio.map((a) => a.duration_ms));
}
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 elapsed(since: string): string {
const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
const p = (n: number) => String(n).padStart(2, "0");
return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`;
}
export function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}