skillok meeting
ad47f897f99f8450c300100150f8c07c6061d85a
Перейти к файлу
Код
meeting srcdev-mock.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 г.
dev-mock.ts
// Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
// `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
//
// Изображает и запись: «встреча» идёт по репликам планёрки из fixtures, с
// уровнями звука и живым конспектом. Модель здесь не вызывается — разбор
// заготовлен руками. Для проверки экранов есть ручки в консоли:
//   __meetMock.meetingStarted()  — «началась встреча в Zoom»
//   __meetMock.noKey()           — ключ удалён

import planerka from "../fixtures/transcripts/planerka.txt?raw";
import klient from "../fixtures/transcripts/klient.txt?raw";
import type {
  Analysis, KeyInfo, Meeting, MeetingDetail, RecorderStatus, Segment, Settings, SettingsView, Speaker,
} from "./api";

function parse(text: string): Segment[] {
  const out: Segment[] = [];
  for (const raw of text.split("\n")) {
    const line = raw.trim();
    if (!line) continue;
    const m = /^\[(\d{1,2}):(\d{2}):(\d{2})\]\s*([^:]+):\s*(.*)$/.exec(line);
    if (m) {
      const ms = ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000;
      out.push({ start_ms: ms, speaker: { kind: "named", name: m[4].trim() }, text: m[5] });
    } else if (out.length) {
      out[out.length - 1].text += " " + line;
    } else {
      throw "строка без метки времени перед первой репликой";
    }
  }
  if (!out.length) throw "в тексте нет ни одной реплики";
  return out;
}

const planerkaAnalysis: Analysis = {
  title: "Планёрка: релиз, отчёт инвестору, отпуск",
  summary:
    "Релиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу. Дежурства на время отпуска Дениса закрыты только на первую неделю.",
  review: {
    achieved: ["Решили судьбу релиза и назначили ответственного за блокер", "Согласовали срок отчёта для инвестора"],
    open: ["Кто дежурит во вторую неделю отпуска Дениса", "Затронет ли переезд на новый биллинг партнёрские выгрузки"],
    off_track: ["Спор о новом биллинге посреди пункта про отчёт"],
    verdict: "Встреча короткая и полезная; спорные темы стоит сразу выносить в отдельный список, не открывая обсуждение.",
  },
  decisions: [
    { text: "Релиз переносится с четверга на следующий вторник", source_segment: 4 },
    { text: "Биллинг обсуждаем на отдельной встрече", source_segment: 17 },
  ],
  tasks: [
    { text: "Починить падение экрана оплаты на Android 12", owner: "Денис", due: "пятница", quote: "Денис, падение на Android 12 — на тебе, срок — пятница.", source_segment: 7 },
    { text: "Выгрузить партнёрские данные для отчёта", owner: "Марат", due: "до среды", quote: "Партнёрские данные у меня, выгружу до среды.", source_segment: 10 },
    { text: "Отправить отчёт инвестору", owner: "Света", due: "в четверг", quote: "тогда отчёт отправлю в четверг.", source_segment: 11 },
    { text: "Собрать встречу по новому биллингу", owner: "Марат", due: "на следующей неделе", quote: "Соберу, на следующей неделе.", source_segment: 18 },
  ],
  open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"],
};

// ── Шина событий ────────────────────────────────────────────────────────────

const handlers = new Map<string, Set<(p: unknown) => void>>();

function emit(event: string, payload: unknown): void {
  handlers.get(event)?.forEach((h) => h(payload));
}

export async function listen<T>(event: string, cb: (payload: T) => void): Promise<() => void> {
  const set = handlers.get(event) ?? new Set();
  handlers.set(event, set);
  const h = cb as (p: unknown) => void;
  set.add(h);
  return () => set.delete(h);
}

// ── Данные ──────────────────────────────────────────────────────────────────

const now = Date.now();
const details: MeetingDetail[] = [];
const p2 = (n: number) => String(n).padStart(2, "0");

function makeId(d: Date): string {
  return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
}

function add(title: string, segments: Segment[], minutesAgo: number, analysis: Analysis | null, state: Meeting["state"] = "done"): MeetingDetail {
  const created = new Date(now - minutesAgo * 60000);
  const meeting: Meeting = {
    id: makeId(created),
    title,
    created_at: created.toISOString(),
    source: state === "done" && minutesAgo > 60 ? "import" : "recording",
    duration_ms: segments.length ? segments[segments.length - 1].start_ms : 0,
    segments: segments.length,
    analysis: analysis && {
      model: "anthropic/claude-opus-5.5",
      at: created.toISOString(),
      usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 },
      tasks: analysis.tasks.length,
    },
    state,
  };
  const d: MeetingDetail = { meeting, transcript: { segments }, analysis, live_summary: null };
  details.unshift(d);
  return d;
}

add("Планёрка команды", parse(planerka), 60 * 26, planerkaAnalysis);
add("Встреча с клиентом: сайт", parse(klient), 90, null);

let settings: Settings = {
  live_model: "anthropic/claude-haiku-4.5",
  analysis_model: "anthropic/claude-opus-5.5",
  stt_model: "openai/whisper-large-v3-turbo",
  warning_text:
    "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
  always_warn_myself: false,
  language: "ru",
  keep_audio: false,
  launch_at_login: true,
};
let key: string | null = "sk-or-v1…demo";
const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false };

// ── Имитация записи ─────────────────────────────────────────────────────────

let status: RecorderStatus = { kind: "watching" };
let timers: number[] = [];

function setStatus(s: RecorderStatus): void {
  status = s;
  emit("meet://status", s);
}

function startRecording(zoom: boolean): Meeting {
  const d = new Date();
  const d0 = add(`${zoom ? "Встреча в Zoom," : "Запись,"} ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, [], 0, null, "recording");
  const id = d0.meeting.id;
  setStatus({ kind: "recording", meeting_id: id, started_at: d.toISOString(), paused: false, zoom });
  const script = parse(planerka);
  let i = 0;
  timers.push(window.setInterval(() => {
    if (status.kind !== "recording" || status.paused || i >= script.length) return;
    const src = script[i++];
    const speaker: Speaker = i % 3 === 1 ? { kind: "me" } : { kind: "others" };
    const seg: Segment = { start_ms: Date.now() - Date.parse(status.started_at), speaker, text: src.text };
    d0.transcript.segments.push(seg);
    d0.meeting.segments++;
    d0.meeting.duration_ms = seg.start_ms;
    emit("meet://recorder", { meeting_id: id, event: { kind: "segments", data: [seg] } });
    if (i === 6) {
      d0.live_summary = "- Обсуждают релиз мобильной версии\n- Договорились: релиз переносится на вторник\n- Задача: Денис чинит оплату на Android 12 до пятницы";
      emit("meet://recorder", { meeting_id: id, event: { kind: "live_summary", data: d0.live_summary } });
    }
  }, 1800));
  timers.push(window.setInterval(() => {
    if (status.kind !== "recording") return;
    const quiet = status.paused;
    for (const speaker of [{ kind: "me" }, ...(zoom ? [{ kind: "others" }] : [])] as Speaker[]) {
      const dbfs = quiet ? -90 : -60 + Math.random() * 45;
      emit("meet://recorder", { meeting_id: id, event: { kind: "level", data: { speaker, dbfs } } });
    }
  }, 200));
  return d0.meeting;
}

function stopRecording(): void {
  if (status.kind !== "recording") throw "Запись не идёт";
  const id = status.meeting_id;
  timers.forEach(clearInterval);
  timers = [];
  const d = details.find((x) => x.meeting.id === id)!;
  setStatus({ kind: "finishing", meeting_id: id, step: "transcribing" });
  setTimeout(() => {
    d.meeting.state = "done";
    emit("meet://meeting-changed", id);
    if (!d.transcript.segments.length) {
      setStatus({ kind: "watching" });
      return;
    }
    setStatus({ kind: "finishing", meeting_id: id, step: "analyzing" });
    setTimeout(() => {
      d.analysis = { ...planerkaAnalysis, decisions: [], tasks: planerkaAnalysis.tasks.map((t) => ({ ...t, source_segment: 0 })) };
      d.meeting.analysis = { model: settings.analysis_model, at: new Date().toISOString(), usage: { prompt_tokens: 900, completion_tokens: 600, cost: 0.0156 }, tasks: 4 };
      emit("meet://meeting-changed", id);
      setStatus({ kind: "watching" });
    }, 2500);
  }, 1200);
}

(window as unknown as { __meetMock: object }).__meetMock = {
  meetingStarted: () => status.kind === "watching" && setStatus({ kind: "awaiting_consent" }),
  meetingEnded: () => (status.kind === "awaiting_consent" ? setStatus({ kind: "watching" }) : status.kind === "recording" && stopRecording()),
  noKey: () => { key = null; setStatus(status); },
  unsupported: () => setStatus({ kind: "unsupported" }),
};

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
  await wait(80);
  const find = (id: unknown) => {
    const d = details.find((x) => x.meeting.id === id);
    if (!d) throw `встреча «${id}» не найдена`;
    return d;
  };
  switch (cmd) {
    case "get_settings":
      return {
        settings, openrouter_key: key, capture_supported: status.kind !== "unsupported",
        meetings_dir: "(просмотр без ядра — на диск ничего не пишется)",
      } satisfies SettingsView as T;
    case "save_settings":
      settings = args.settings as Settings;
      return undefined as T;
    case "set_openrouter_key": {
      const k = String(args.key).trim();
      if (!k) throw "Вставьте ключ OpenRouter";
      key = k.length > 12 ? `${k.slice(0, 8)}…${k.slice(-4)}` : "•".repeat(k.length);
      return info as T;
    }
    case "check_openrouter_key":
      if (!key) throw "Ключ OpenRouter не задан — добавьте его в настройках";
      return info as T;
    case "delete_openrouter_key":
      key = null;
      return undefined as T;
    case "list_meetings":
      return details.map((d) => d.meeting) as T;
    case "get_meeting":
      return structuredClone(find(args.id)) as T;
    case "import_meeting": {
      const title = String(args.title).trim();
      if (!title) throw "название встречи не может быть пустым";
      let segs: Segment[];
      try {
        segs = parse(String(args.text));
      } catch (e) {
        throw `расшифровка не разбирается: ${e}`;
      }
      return add(title, segs, 0, null).meeting as T;
    }
    case "analyze_meeting":
      find(args.id);
      await wait(600);
      throw "В просмотре без ядра разбор не запускается — откройте приложение через `npm run tauri dev`";
    case "recorder_status":
      return status as T;
    case "recorder_consent":
      if (status.kind !== "awaiting_consent") throw "Сейчас нечего подтверждать";
      if (args.record) startRecording(true);
      else setStatus({ kind: "watching" });
      return undefined as T;
    case "recorder_start":
      if (!key) throw "Сначала подключите ИИ: добавьте ключ OpenRouter в настройках";
      if (status.kind !== "watching" && status.kind !== "awaiting_consent") throw "Запись уже идёт";
      return startRecording(status.kind === "awaiting_consent") as T;
    case "recorder_pause":
      if (status.kind !== "recording") throw "Запись не идёт";
      setStatus({ ...status, paused: Boolean(args.paused) });
      return undefined as T;
    case "recorder_stop":
      stopRecording();
      return undefined as T;
    default:
      throw `нет команды ${cmd}`;
  }
}