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 г.
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}`;
}
}