skillok meeting
d93e31a829c96025e18e4ceed0cf1552df5934c8
Перейти к файлу
Код
meeting srcdev-mock.ts
anc Этап 0: каркас Tauri 2, разбор встречи через OpenRouter, стиль skillok.org - Ядро на Rust: расшифровка и импорт текста, схема разбора со строгой проверкой ответа модели, клиент OpenRouter (проверка ключа, разбор), настройки и ключ в хранилище паролей ОС, встречи папками на диске. - Интерфейс: экраны «Встречи», «Встреча» (расшифровка, конспект, задачи), «Настройки»; токены, шрифты и кнопки как на skillok.org. - Промпт разбора prompts/analysis.md и проверочные расшифровки. - 30 тестов ядра; клиент OpenRouter проверяется против локальной заглушки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> d93e31a 30 сентября 2026 г.
141 строка 7.7 КБ
dev-mock.ts
// Заглушка ядра для `npm run dev` в обычном браузере: показать интерфейс без
// Tauri. В сборку приложения не попадает (см. api.ts). Разбор здесь не
// вызывает модель — у первой встречи он заготовлен руками.

import planerka from "../fixtures/transcripts/planerka.txt?raw";
import klient from "../fixtures/transcripts/klient.txt?raw";
import type { Analysis, Meeting, MeetingDetail, Segment, Settings, SettingsView, KeyInfo } 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 now = Date.now();
const details: MeetingDetail[] = [];

function add(title: string, text: string, minutesAgo: number, analysis: Analysis | null): MeetingDetail {
  const segments = parse(text);
  const created = new Date(now - minutesAgo * 60000);
  const p = (n: number) => String(n).padStart(2, "0");
  const id = `${created.getFullYear()}${p(created.getMonth() + 1)}${p(created.getDate())}-${p(created.getHours())}${p(created.getMinutes())}${p(created.getSeconds())}`;
  const meeting: Meeting = {
    id,
    title,
    created_at: created.toISOString(),
    source: "import",
    duration_ms: segments[segments.length - 1].start_ms,
    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,
    },
  };
  const d = { meeting, transcript: { segments }, analysis };
  details.unshift(d);
  return d;
}

add("Планёрка команды", planerka, 60 * 26, planerkaAnalysis);
add("Встреча с клиентом: сайт", 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,
};
let key: string | null = null;
const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: null, usage: 0, is_free_tier: false };

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(150);
  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, 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 find(args.id) as T;
    case "import_meeting": {
      const title = String(args.title).trim();
      if (!title) throw "название встречи не может быть пустым";
      let d: MeetingDetail;
      try {
        d = add(title, String(args.text), 0, null);
      } catch (e) {
        throw `расшифровка не разбирается: ${e}`;
      }
      return d.meeting as T;
    }
    case "analyze_meeting":
      find(args.id);
      await wait(600);
      throw "В просмотре без ядра разбор не запускается — откройте приложение через `npm run tauri dev`";
    default:
      throw `нет команды ${cmd}`;
  }
}