skillok meeting
393887285dfc8accae94edba2c7c575b098e705e
Перейти к файлу
Код
meeting srcdev-mock.ts
anc Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13) - Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 3938872 30 сентября 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");

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,
    },
    note,
  };
  details.unshift(d);
  return d;
}

const other: Speaker = { kind: "unknown" };
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;
}

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/);
      const total = lines.length;
      cancelled = false;
      emit("meet://progress", { meeting_id: d.meeting.id, done: 0, total, lines: [] });
      for (let i = 1; i <= total; i++) {
        await wait(350);
        if (cancelled) throw "Расшифровка отменена";
        emit("meet://progress", { meeting_id: d.meeting.id, done: i, total, lines: [lines[i - 1]] });
      }
      d.meeting.transcribed_at = new Date().toISOString();
      return { note: upsert(d, "Расшифровка", TRANSCRIPT), failed: 0, last_error: null } 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 "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, timer: 0 };
      r.timer = window.setInterval(() => {
        for (const speaker of [{ kind: "me" }, { kind: "others" }] as Speaker[]) {
          const dbfs = r.paused ? -90 : -60 + Math.random() * 45;
          emit("meet://recorder", { meeting_id: r.meeting_id, event: { kind: "level", data: { speaker, dbfs } } });
        }
      }, 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}`;
  }
}