skillok meeting
0fd9dec299001feea939bb2bf54891037292726b
Перейти к файлу
Код
meeting srcdev-mock.ts
anc Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, ms в событиях уровня, position_ms в recorder_status). - Живая «волна» последней минуты, «Микрофон / Звук компьютера — слышно / тишина», объём записи; «Остановить и расшифровать». - «★ Отметить момент»: отметка с подписью (Meeting.markers, add_marker / set_markers); видна в расшифровке и на проигрывателе. Проигрыватель (player.ts): - Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна» всей встречи (клик и протяжка — перемотка, отметки, текущее место), «★» — отметить место. Две дорожки записи играют вместе и подравниваются. Живёт дольше перерисовок экрана — звук не прерывается. - Звук читается с диска протоколом asset: только из папки записей (protocol-asset, scope на папку, media-src в CSP); «волна» считается один раз и кэшируется в peaks.json. - Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика подсвечивается; пробел — играть/пауза, ←/→ — 5 с. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> b82e259 2 октября 2026 г.
dev-mock.ts
// Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`,
// `npm run build:mock`). В сборку приложения не попадает (см. api.ts).
// ИИ здесь не вызывается: расшифровка и ответы заготовлены руками.

import planerka from "../fixtures/transcripts/planerka.txt?raw";
import { sections, upsertSection } from "./markdown";
import type { Command, KeyInfo, Meeting, MeetingDetail, RecordingView, Settings, SettingsView, Speaker } from "./api";

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

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 TRANSCRIPT =
  "## Расшифровка\n\n" +
  planerka
    .trim()
    .split("\n")
    .map((l) => l.replace(/^\[(\d\d:\d\d:\d\d)\] ([^:]+): /, "**$1 $2:** "))
    .join("\n\n") +
  "\n";

const ANSWERS: Record<string, string> = {
  summary:
    "## Конспект\n\nРелиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу.\n\n### Решения\n\n- Релиз переносится на следующий вторник (00:00:55)\n- Биллинг — отдельной встречей (00:02:40)\n",
  tasks:
    "## Задачи\n\n- [ ] Починить падение оплаты на Android 12 — Денис, пятница (00:01:08)\n- [ ] Выгрузить партнёрские данные — Марат, до среды (00:01:34)\n- [ ] Отправить отчёт инвестору — Света, четверг (00:01:40)\n- [ ] Собрать встречу по биллингу — Марат, на следующей неделе (00:02:45)\n\n### Открытые вопросы\n\n- Кто дежурит во вторую неделю отпуска Дениса?\n",
  review:
    "## Ревью\n\nВстреча короткая и по делу: по релизу и отчёту есть решения и ответственные. В сторону ушли на биллинге — спор начался посреди пункта про отчёт. Открытым остался вопрос дежурств.\n\nСтоила своего времени; спорные темы лучше сразу выносить в список на потом.\n",
};

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

/** Расшифровка с говорящими «Спикер N», как после разбора по голосам в ядре.
 * В заглушке голоса — имена из заготовки: `count` сводит их к стольким спикерам. */
function byVoices(count: number | null): { md: string; voices: number } {
  const names: string[] = [];
  const md = TRANSCRIPT.replace(/^\*\*(\d\d:\d\d:\d\d) ([^*:]+):\*\*/gm, (_, t: string, who: string) => {
    if (!names.includes(who)) names.push(who);
    const i = names.indexOf(who);
    return `**${t} Спикер ${count ? (i % count) + 1 : i + 1}:**`;
  });
  return { md, voices: count ? Math.min(count, names.length) : names.length };
}

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, minutesAgo: number, note: string, audio: Meeting["audio"], transcribed: boolean): MeetingDetail {
  const created = new Date(now - minutesAgo * 60000);
  let id = makeId(created);
  while (details.some((d) => d.meeting.id === id)) id += "-2";
  const d: MeetingDetail = {
    meeting: {
      id, title, created_at: created.toISOString(), updated_at: created.toISOString(), audio,
      recording: false, transcribed_at: transcribed ? created.toISOString() : null, markers: [],
    },
    note,
    voices: transcribed,
  };
  details.unshift(d);
  return d;
}

const other: Speaker = { kind: "unknown" };
let checkTimer = 0;
add("Планёрка команды", 60 * 26, `${TRANSCRIPT}\n${ANSWERS.tasks}`, [{ file: "Планёрка.m4a", speaker: other, duration_ms: 195_000 }], true);
add("Созвон с клиентом", 90, "", [{ file: "Созвон с клиентом.m4a", speaker: other, duration_ms: 2_760_000 }], false);

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

let recording: (RecordingView & { timer: number }) | null = null;

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

function find(id: unknown): MeetingDetail {
  const d = details.find((x) => x.meeting.id === id);
  if (!d) throw `встреча «${id}» не найдена`;
  return d;
}

function upsert(d: MeetingDetail, heading: string, block: string): string {
  d.note = upsertSection(d.note, heading, block);
  return d.note;
}

let cancelled = false;

(window as unknown as { __meetMock: object }).__meetMock = {
  noKey: () => (key = null),
  unsupported: () => (captureSupported = false),
};

export async function pickFolder(): Promise<string | null> {
  return "/Users/demo/Documents/Записи встреч";
}

export async function pickAudio(): Promise<string | null> {
  return "/Users/demo/Загрузки/Интервью с кандидатом.m4a";
}

export async function onFileDrop(): Promise<() => void> {
  return () => undefined;
}

/** В заглушке «путь» к звуку — уже готовый адрес (blob:). */
export function fileSrc(path: string): string {
  return path;
}

/** Звук для проигрывателя в браузере: 90 секунд — реплики тоном разной
 * высоты с паузами, чтобы было что слушать и видеть на «волне». */
let demoAudio: string | null = null;
function demoTrack(): { url: string; peaks: number[]; ms: number } {
  const rate = 8_000;
  const seconds = 90;
  const n = rate * seconds;
  const data = new Int16Array(n);
  for (let i = 0; i < n; i++) {
    const t = i / rate;
    const phrase = Math.floor(t / 4);
    const on = t % 4 < 3.2;
    const f = [220, 330, 262, 392][phrase % 4];
    data[i] = on ? Math.round(Math.sin(2 * Math.PI * f * t) * 6000 * (0.5 + 0.5 * Math.sin(t * 3))) : 0;
  }
  if (!demoAudio) {
    const buf = new ArrayBuffer(44 + n * 2);
    const v = new DataView(buf);
    const str = (o: number, x: string) => [...x].forEach((c, k) => v.setUint8(o + k, c.charCodeAt(0)));
    str(0, "RIFF"); v.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt ");
    v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
    v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
    str(36, "data"); v.setUint32(40, n * 2, true);
    new Int16Array(buf, 44).set(data);
    demoAudio = URL.createObjectURL(new Blob([buf], { type: "audio/wav" }));
  }
  const peaks = Array.from({ length: 1600 }, (_, b) => {
    const t = (b / 1600) * seconds;
    return t % 4 < 3.2 ? 0.25 + 0.2 * (0.5 + 0.5 * Math.sin(t * 3)) : 0;
  });
  return { url: demoAudio, peaks, ms: seconds * 1000 };
}

export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
  await wait(60);
  switch (cmd) {
    case "get_settings":
      return {
        settings, openrouter_key: key, capture_supported: captureSupported,
        meetings_dir: settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings",
        default_meetings_dir: "~/Library/Application Support/org.skillok.meet/meetings",
      } 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 "save_note":
      find(args.id).note = String(args.text);
      return find(args.id).meeting as T;
    case "rename_meeting": {
      const t = String(args.title).trim();
      if (!t) throw "название не может быть пустым";
      find(args.id).meeting.title = t;
      return find(args.id).meeting as T;
    }
    case "remove_section": {
      const d = find(args.id);
      const rest = sections(d.note).filter((x) => x.heading !== args.heading);
      d.note = rest.length ? rest.map((x) => x.source.trim()).join("\n\n") + "\n" : "";
      return d.note as T;
    }
    case "set_storage_dir":
      if (recording) throw "Сначала остановите запись";
      settings.storage_dir = (args.path as string | null) ?? "";
      return (settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings") as T;
    case "open_folder":
      return undefined as T;
    case "cancel_job":
      cancelled = true;
      return undefined as T;
    case "delete_meeting":
      details.splice(details.indexOf(find(args.id)), 1);
      return undefined as T;
    case "add_audio": {
      const path = String(args.path);
      const name = path.split("/").pop()!;
      await wait(400);
      return add(name.replace(/\.[^.]+$/, ""), 0, "", [{ file: name, speaker: other, duration_ms: 1_380_000 }], false).meeting as T;
    }
    case "transcribe_meeting": {
      const d = find(args.id);
      if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
      if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл";
      // Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени.
      // У файла имени говорящего по ходу нет — оно появится после разбора по голосам.
      const lines = TRANSCRIPT.replace(/^## .*\n+/, "")
        .trim()
        .split(/\n\s*\n/)
        .map((l) => l.replace(/^\*\*(\d\d:\d\d:\d\d) [^*:]+:\*\*/, "**$1**"));
      const total = lines.length;
      cancelled = false;
      emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: 0, total, lines: [] });
      for (let i = 1; i <= total; i++) {
        await wait(300);
        if (cancelled) throw "Расшифровка отменена";
        emit("meet://progress", { meeting_id: d.meeting.id, stage: "speech", done: i, total, lines: [lines[i - 1]] });
      }
      for (let i = 0; i <= total; i += 4) {
        await wait(200);
        if (cancelled) throw "Расшифровка отменена";
        emit("meet://progress", { meeting_id: d.meeting.id, stage: "voices", done: Math.min(i, total), total, lines: [] });
      }
      d.meeting.transcribed_at = new Date().toISOString();
      d.voices = true;
      const v = byVoices(null);
      return { note: upsert(d, "Расшифровка", v.md), failed: 0, last_error: null, voices: v.voices, voices_error: null } as T;
    }
    case "regroup_speakers": {
      const d = find(args.id);
      if (!d.voices) throw "Для этой записи нет данных о голосах — расшифруйте её заново";
      await wait(300);
      const v = byVoices((args.count as number | null) ?? null);
      return { note: upsert(d, "Расшифровка", v.md), voices: v.voices } as T;
    }
    case "run_command": {
      const d = find(args.id);
      const c = args.command as Command;
      if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках";
      if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст";
      if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать";
      cancelled = false;
      const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind];
      const block =
        c.kind === "custom"
          ? `## ${heading}\n\n(В просмотре без ядра ИИ не вызывается — здесь будет ответ на «${c.text.trim()}».)\n`
          : ANSWERS[c.kind];
      // Ответ пишется потоком: кусками по несколько знаков, как у модели.
      const answer = block.replace(/^## .*\n+/, "");
      for (let i = 0; i < answer.length; i += 6) {
        await wait(25);
        if (cancelled) throw "Отменено";
        emit("meet://answer", { meeting_id: d.meeting.id, heading, text: answer.slice(i, i + 6) });
      }
      return { note: upsert(d, heading, block), heading } as T;
    }
    case "audio_check_start": {
      if (!captureSupported) throw "На этой системе запись не поддерживается";
      window.clearInterval(checkTimer);
      const began = Date.now();
      // Микрофон «слышит» сразу, звук компьютера — через 3 секунды, как если
      // бы человек включил видео.
      checkTimer = window.setInterval(() => {
        const others = Date.now() - began > 3_000;
        for (const [speaker, on] of [[{ kind: "me" }, true], [{ kind: "others" }, others]] as [Speaker, boolean][]) {
          const dbfs = on ? -40 + Math.random() * 25 : -90;
          emit("meet://recorder", { meeting_id: "", event: { kind: "level", data: { speaker, dbfs, ms: 0 } } });
        }
      }, 200);
      return { mic_error: null, system_error: null } as T;
    }
    case "audio_check_stop":
      window.clearInterval(checkTimer);
      return undefined as T;
    case "add_marker": {
      const d = find(args.id);
      const ms = (args.ms as number | null) ?? (recording && recording.meeting_id === args.id ? recording.position_ms : null);
      if (ms == null) throw "Запись не идёт — отметьте место в проигрывателе";
      d.meeting.markers = [...d.meeting.markers, { ms, text: String(args.text ?? "") }].sort((a, b) => a.ms - b.ms);
      return d.meeting as T;
    }
    case "set_markers": {
      const d = find(args.id);
      d.meeting.markers = (args.markers as Meeting["markers"]).slice().sort((a, b) => a.ms - b.ms);
      return d.meeting as T;
    }
    case "playback": {
      const d = find(args.id);
      if (d.meeting.recording) throw "Запись ещё идёт";
      if (!d.meeting.audio.length) throw "Файлов звука этой записи нет на диске";
      const t = demoTrack();
      return { tracks: [{ path: t.url, speaker: { kind: "unknown" } }], duration_ms: t.ms, peaks: t.peaks } as T;
    }
    case "recorder_status":
      return (recording && { ...recording, timer: undefined }) as T;
    case "recorder_start": {
      if (!captureSupported) throw "На этой системе запись не поддерживается — можно добавить готовый аудиофайл.";
      if (recording) throw "Запись уже идёт";
      const d = new Date();
      const m = add(`Встреча ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, 0, "", [], false);
      m.meeting.recording = true;
      const r = { meeting_id: m.meeting.id, started_at: d.toISOString(), paused: false, system: true, system_error: null, position_ms: 0, timer: 0 };
      window.clearInterval(checkTimer);
      // Собеседники «молчат» с 6-й по 12-ю секунду — видно предупреждение о тишине.
      window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "silent", data: { speaker: { kind: "others" }, seconds: 60 } } }), 6_000);
      window.setTimeout(() => emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "heard", data: { speaker: { kind: "others" } } } }), 12_000);
      r.timer = window.setInterval(() => {
        for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) {
          const dbfs = r.paused ? -90 : -60 + Math.random() * 45;
          if (!r.paused && speaker.kind === "me") r.position_ms += 200;
          emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "level", data: { speaker, dbfs, ms: r.position_ms } } });
        }
      }, 200);
      recording = r;
      return m.meeting as T;
    }
    case "recorder_pause":
      if (!recording) throw "Запись не идёт";
      recording.paused = Boolean(args.paused);
      return undefined as T;
    case "recorder_stop": {
      if (!recording) throw "Запись не идёт";
      clearInterval(recording.timer);
      const d = find(recording.meeting_id);
      const ms = Date.now() - Date.parse(recording.started_at);
      d.meeting.recording = false;
      d.meeting.audio = [
        { file: "me.wav", speaker: { kind: "me" }, duration_ms: ms },
        { file: "others.wav", speaker: { kind: "others" }, duration_ms: ms },
      ];
      recording = null;
      await wait(300);
      return d.meeting as T;
    }
    default:
      throw `нет команды ${cmd}`;
  }
}