skillok meeting

Этап 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>

anc · 30 сентября 2026 г. d93e31a829c96025e18e4ceed0cf1552df5934c8
60 файлов
  • .gitignore +7
    Показать разницуСкрыть разницу
    1 +node_modules/
    2 +dist/
    3 +src-tauri/target/
    4 +src-tauri/gen/
    5 +.DS_Store
    6 +dist-mock/
    7 +.claude/
  • README.md +39 −3
    Показать разницуСкрыть разницу
    190 190
    191 191 ---
    192 192
    193 +## Разработка
    194 +
    195 +Нужны Rust (stable) и Node.js. На Windows ещё WebView2 — он уже есть в Windows 10/11.
    196 +
    197 +```
    198 +npm install
    199 +npm run tauri dev # приложение целиком: ядро + интерфейс
    200 +npm run dev # только интерфейс в браузере, http://localhost:1420, с заглушкой ядра
    201 +npm run typecheck # проверка типов интерфейса
    202 +cd src-tauri && cargo test
    203 +```
    204 +
    205 +Раскладка:
    206 +
    207 +```
    208 +src/ интерфейс: HTML/CSS/TS, стиль skillok.org (src/styles/tokens.css)
    209 +src/dev-mock.ts заглушка ядра — только для `npm run dev` в браузере
    210 +src-tauri/src/ ядро на Rust
    211 + transcript.rs реплики, импорт текста, Markdown расшифровки
    212 + analysis.rs схема разбора, проверка ответа модели, summary.md
    213 + openrouter.rs клиент OpenRouter: проверка ключа, разбор
    214 + settings.rs настройки; ключ — в хранилище паролей ОС (keyring)
    215 + meetings.rs встречи на диске, папка на встречу
    216 + commands.rs команды для интерфейса
    217 +prompts/analysis.md системный промпт разбора
    218 +fixtures/transcripts проверочные расшифровки
    219 +```
    220 +
    221 +Данные приложения: настройки — в папке конфигурации (`%APPDATA%\org.skillok.meet` на Windows),
    222 +встречи — в папке данных, подпапка `meetings`. Путь виден на экране «Настройки».
    223 +
    224 +---
    225 +
    193 226 ## План работ
    194 227
    195 228 ### Этап 0. Каркас
    196 229
    197 −- [ ] Проект Tauri 2, стиль skillok.org, экраны «Встречи», «Встреча», «Настройки».
    198 −- [ ] Ключ OpenRouter в Credential Manager, проверка ключа.
    199 −- [ ] Схема разбора (JSON), промпты, набор проверочных расшифровок.
    230 +- [x] Проект Tauri 2, стиль skillok.org, экраны «Встречи», «Встреча», «Настройки».
    231 +- [x] Ключ OpenRouter в хранилище паролей ОС (keyring), проверка ключа через `GET /key`.
    232 +- [x] Схема разбора (JSON), промпт `prompts/analysis.md`, проверочные расшифровки `fixtures/transcripts/`.
    233 +- [x] Импорт текста расшифровки и разбор по кнопке — вся цепочка разбора без захвата звука.
    234 +- [ ] Прогнать разбор на проверочных расшифровках настоящей моделью и поправить промпт (нужен ключ).
    235 +- [ ] Проверить хранилище ключа и окно на Windows.
    200 236
    Разница обрезана: показаны первые 50 строк.
  • Показать разницуСкрыть разницу
    1 +[00:00:02] Я: Добрый день, Андрей Викторович. Мы подготовили два варианта главной страницы, давайте пройдёмся.
    2 +[00:00:12] Андрей Викторович: Добрый. Да, я смотрел оба. Первый мне нравится больше, второй слишком тёмный.
    3 +[00:00:25] Я: Понял. Тогда берём первый вариант за основу.
    4 +[00:00:30] Андрей Викторович: Да. Но каталог надо вынести наверх, клиенты его не находят.
    5 +[00:00:41] Я: Сделаем. Покажу обновлённый макет в понедельник.
    6 +[00:00:48] Андрей Викторович: Хорошо. И ещё: нам нужна английская версия к выставке.
    7 +[00:00:56] Я: Когда выставка?
    8 +[00:00:58] Андрей Викторович: Пятнадцатого ноября.
    9 +[00:01:04] Я: Переводы текстов — с вашей стороны или заказываем мы?
    10 +[00:01:10] Андрей Викторович: Пока не знаю, уточню у маркетинга.
    11 +[00:01:18] Я: Хорошо. Без переводов мы английскую версию к пятнадцатому не успеем, это важно решить на этой неделе.
    12 +[00:01:27] Андрей Викторович: Понял, до пятницы дам ответ.
    13 +[00:01:35] Я: И по бюджету: английская версия в текущую смету не входит, я пришлю допсоглашение.
    14 +[00:01:44] Андрей Викторович: Присылайте, посмотрим.
    15 +[00:01:50] Я: Тогда договорились. Спасибо.
  • Показать разницуСкрыть разницу
    1 +[00:00:04] Ольга: Всем привет, начинаем планёрку. Сегодня три пункта: релиз мобильной версии, отчёт для инвестора и отпуск Дениса.
    2 +[00:00:21] Денис: Привет. По релизу: сборка готова, но на Android 12 падает экран оплаты. Воспроизвёл на двух телефонах.
    3 +[00:00:40] Ольга: Это блокер?
    4 +[00:00:43] Денис: Да, без оплаты релизить нельзя. Мне нужно два дня, чтобы разобраться.
    5 +[00:00:55] Ольга: Хорошо. Тогда релиз сдвигаем с четверга на следующий вторник. Все согласны?
    6 +[00:01:02] Марат: Согласен.
    7 +[00:01:03] Света: Да.
    8 +[00:01:08] Ольга: Денис, падение на Android 12 — на тебе, срок — пятница.
    9 +[00:01:12] Денис: Беру.
    10 +[00:01:20] Света: По отчёту для инвестора. Цифры за август я собрала, не хватает выручки по партнёрам.
    11 +[00:01:34] Марат: Партнёрские данные у меня, выгружу до среды.
    12 +[00:01:40] Света: Отлично, тогда отчёт отправлю в четверг.
    13 +[00:01:47] Марат: Кстати, а мы вообще будем переезжать на новый биллинг в этом квартале? Я слышал, что бухгалтерия опять недовольна.
    14 +[00:02:05] Ольга: Это отдельная тема, давайте не сейчас.
    15 +[00:02:08] Марат: Просто если переезжаем, то партнёрские выгрузки поменяются.
    16 +[00:02:15] Света: Там же ещё налоговая часть, я не уверена, что это вообще к нам.
    17 +[00:02:31] Марат: Ну как не к нам, мы же выгружаем.
    18 +[00:02:40] Ольга: Коллеги, давайте вынесем биллинг на отдельную встречу. Марат, соберёшь её?
    19 +[00:02:45] Марат: Соберу, на следующей неделе.
    20 +[00:02:52] Ольга: Последнее: Денис с двадцатого в отпуске на две недели. Кто прикрывает дежурства?
    21 +[00:03:05] Света: Я могу первую неделю.
    22 +[00:03:09] Ольга: А вторую?
    23 +[00:03:12] Марат: Вторую не могу, у меня конференция.
    24 +[00:03:18] Ольга: Ладно, вторую неделю решим позже. Всё, спасибо, расходимся.
  • Показать разницуСкрыть разницу
    1 +[00:00:03] Игорь: Алло, слышно?
    2 +[00:00:06] Нина: Да, слышно. А где остальные?
    3 +[00:00:10] Игорь: Не знаю, я ссылку всем кидал.
    4 +[00:00:25] Нина: Может, перенесём? Без Кати смысла нет.
    5 +[00:00:29] Игорь: Давай, я напишу в чат.
    6 +[00:00:33] Нина: Ок, пока.
  • index.html +28
    Показать разницуСкрыть разницу
    1 +<!doctype html>
    2 +<html lang="ru">
    3 +<head>
    4 + <meta charset="utf-8">
    5 + <meta name="viewport" content="width=device-width, initial-scale=1">
    6 + <title>Skillok Meet</title>
    7 + <link rel="stylesheet" href="/src/styles/tokens.css">
    8 + <link rel="stylesheet" href="/src/styles/ui.css">
    9 + <link rel="stylesheet" href="/src/styles/app.css">
    10 +</head>
    11 +<body>
    12 + <header class="header">
    13 + <a class="brand" href="#/meetings">
    14 + <svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true">
    15 + <rect width="1024" height="1024" fill="var(--color-accent)"/>
    16 + <path fill="var(--background)" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    17 + </svg>
    18 + Skillok Meet
    19 + </a>
    20 + <nav class="header-nav" aria-label="Разделы">
    21 + <a href="#/meetings" data-nav="meetings">Встречи</a>
    22 + <a href="#/settings" data-nav="settings">Настройки</a>
    23 + </nav>
    24 + </header>
    25 + <main id="app" class="page"></main>
    26 + <script type="module" src="/src/main.ts"></script>
    27 +</body>
    28 +</html>
  • Показать разницуСкрыть разницу
    1 +{
    2 + "name": "skillok-meet",
    3 + "version": "0.1.0",
    4 + "lockfileVersion": 3,
    5 + "requires": true,
    6 + "packages": {
    7 + "": {
    8 + "name": "skillok-meet",
    9 + "version": "0.1.0",
    10 + "dependencies": {
    11 + "@tauri-apps/api": "^2.12.0"
    12 + },
    13 + "devDependencies": {
    14 + "@tauri-apps/cli": "^2.12.0",
    15 + "typescript": "^6.0.0",
    16 + "vite": "^8.0.0"
    17 + }
    18 + },
    19 + "node_modules/@oxc-project/types": {
    20 + "version": "0.151.0",
    21 + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
    22 + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==",
    23 + "dev": true,
    24 + "license": "MIT",
    25 + "funding": {
    26 + "url": "https://github.com/sponsors/oxc-project"
    27 + }
    28 + },
    29 + "node_modules/@rolldown/binding-android-arm-eabi": {
    30 + "version": "1.2.11",
    31 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz",
    32 + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==",
    33 + "cpu": [
    34 + "arm"
    35 + ],
    36 + "dev": true,
    37 + "license": "MIT",
    38 + "optional": true,
    39 + "os": [
    40 + "android"
    41 + ],
    42 + "engines": {
    43 + "node": "^20.19.0 || >=22.12.0"
    44 + }
    45 + },
    46 + "node_modules/@rolldown/binding-android-arm64": {
    47 + "version": "1.2.11",
    48 + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz",
    49 + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==",
    50 + "cpu": [
    51 + "arm64"
    52 + ],
    53 + "dev": true,
    Разница обрезана: показаны первые 53 строк.
  • package.json +21
    Показать разницуСкрыть разницу
    1 +{
    2 + "name": "skillok-meet",
    3 + "private": true,
    4 + "version": "0.1.0",
    5 + "type": "module",
    6 + "scripts": {
    7 + "dev": "vite",
    8 + "build": "tsc --noEmit && vite build",
    9 + "build:mock": "VITE_MOCK=1 vite build --outDir dist-mock",
    10 + "typecheck": "tsc --noEmit",
    11 + "tauri": "tauri"
    12 + },
    13 + "dependencies": {
    14 + "@tauri-apps/api": "^2.12.0"
    15 + },
    16 + "devDependencies": {
    17 + "@tauri-apps/cli": "^2.12.0",
    18 + "typescript": "^6.0.0",
    19 + "vite": "^8.0.0"
    20 + }
    21 +}
  • Показать разницуСкрыть разницу
    1 +Ты разбираешь расшифровку рабочей встречи для человека, который на ней был. Он откроет разбор после встречи, чтобы вспомнить главное, увидеть, чем встреча закончилась, и не потерять договорённости. Разбор может уйти в его репозиторий и в трекер задач, поэтому он должен быть точным: лучше пропустить сомнительное, чем выдумать.
    2 +
    3 +Расшифровка сделана автоматически. Каждая реплика пронумерована: `#N [время] Говорящий: текст`. В ней бывают ошибки распознавания, оборванные фразы и повторы. Если говорящий подписан «Я», это владелец записи; «Собеседники» — все остальные участники одной дорожкой, без разделения по людям.
    4 +
    5 +Что заполнить:
    6 +
    7 +- `title` — тема встречи в нескольких словах, как её назвал бы участник.
    8 +- `summary` — о чём была встреча и чем закончилась, 3–6 предложений.
    9 +- `review` — честный взгляд со стороны:
    10 + - `achieved` — чего встреча добилась;
    11 + - `open` — что осталось нерешённым или отложено;
    12 + - `off_track` — где обсуждение ушло в сторону или ходило по кругу; пустой список, если такого не было;
    13 + - `verdict` — одно предложение: стоила ли встреча своего времени и что стоит сделать иначе в следующий раз.
    14 +- `decisions` — только то, о чём договорились явно. Предложение, которое никто не принял, — не решение.
    15 +- `tasks` — действия, которые кто-то взял на себя или которые кому-то поручили:
    16 + - `owner` — имя, как оно звучит в расшифровке; `null`, если исполнитель не прозвучал. «Я» — владелец записи, так и пиши «Я»;
    17 + - `due` — срок словами из расшифровки («до пятницы», «к 15 октября»); `null`, если срока не было. Не пересчитывай в даты;
    18 + - `quote` — короткая дословная цитата, на которой основана задача.
    19 +- `open_questions` — вопросы, которые прозвучали и остались без ответа, или которые надо поднять на следующей встрече.
    20 +
    21 +У каждого решения и задачи `source_segment` — номер реплики, где это прозвучало (число N из `#N`).
    22 +
    23 +Пиши на языке встречи, коротко и конкретно, без вводных слов и оценок участников как людей. Если встреча пустая или расшифровка нечитаема, скажи об этом в `summary` и оставь списки пустыми.
  • Показать разницуСкрыть разницу
    1 +# This file is automatically @generated by Cargo.
    2 +# It is not intended for manual editing.
    3 +version = 4
    4 +
    5 +[[package]]
    6 +name = "adler2"
    7 +version = "2.0.1"
    8 +source = "registry+https://github.com/rust-lang/crates.io-index"
    9 +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
    10 +
    11 +[[package]]
    12 +name = "aho-corasick"
    13 +version = "1.1.5"
    14 +source = "registry+https://github.com/rust-lang/crates.io-index"
    15 +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
    16 +dependencies = [
    17 + "memchr",
    18 +]
    19 +
    20 +[[package]]
    21 +name = "alloc-no-stdlib"
    22 +version = "3.0.0"
    23 +source = "registry+https://github.com/rust-lang/crates.io-index"
    24 +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
    25 +
    26 +[[package]]
    27 +name = "alloc-stdlib"
    28 +version = "0.3.0"
    29 +source = "registry+https://github.com/rust-lang/crates.io-index"
    30 +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
    31 +dependencies = [
    32 + "alloc-no-stdlib",
    33 +]
    34 +
    35 +[[package]]
    36 +name = "android_system_properties"
    37 +version = "0.1.6"
    38 +source = "registry+https://github.com/rust-lang/crates.io-index"
    39 +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
    40 +dependencies = [
    41 + "libc",
    42 +]
    43 +
    44 +[[package]]
    45 +name = "anyhow"
    46 +version = "1.0.104"
    47 +source = "registry+https://github.com/rust-lang/crates.io-index"
    48 +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
    49 +
    50 +[[package]]
    51 +name = "atk"
    52 +version = "0.18.2"
    53 +source = "registry+https://github.com/rust-lang/crates.io-index"
    54 +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
    Разница обрезана: показаны первые 54 строк.
  • Показать разницуСкрыть разницу
    1 +[package]
    2 +name = "skillok-meet"
    3 +version = "0.1.0"
    4 +description = "Skillok Meet — запись, расшифровка и разбор встреч Zoom"
    5 +edition = "2021"
    6 +rust-version = "1.90"
    7 +
    8 +[lib]
    9 +name = "skillok_meet_lib"
    10 +crate-type = ["staticlib", "cdylib", "rlib"]
    11 +
    12 +[build-dependencies]
    13 +tauri-build = { version = "2.7", features = [] }
    14 +
    15 +[dependencies]
    16 +tauri = { version = "2.12", features = [] }
    17 +serde = { version = "1", features = ["derive"] }
    18 +serde_json = "1"
    19 +thiserror = "2"
    20 +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
    21 +keyring = { version = "3.6", features = ["apple-native", "windows-native"] }
    22 +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
    23 +
    24 +[dev-dependencies]
    25 +tempfile = "3"
    26 +tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
  • Показать разницуСкрыть разницу
    1 +fn main() {
    2 + tauri_build::build()
    3 +}
  • Показать разницуСкрыть разницу
    1 +{
    2 + "$schema": "../gen/schemas/desktop-schema.json",
    3 + "identifier": "default",
    4 + "description": "Главное окно: только собственные команды приложения",
    5 + "windows": ["main"],
    6 + "permissions": ["core:default"]
    7 +}
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Показать разницуСкрыть разницу
    1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
    2 + <!-- Квадрат терракоты и «М» бумагой: цвета акцента и фона skillok.org. -->
    3 + <rect width="1024" height="1024" fill="#A6533C"/>
    4 + <path fill="#F4F4F2" d="M232 792V232h136l144 232 144-232h136v560H672V440L540 648h-56L352 440v352z"/>
    5 +</svg>
  • Показать разницуСкрыть разницу
    1 +max_width = 110
  • Показать разницуСкрыть разницу
    1 +//! Разбор встречи: что модель обязана вернуть, как её об этом просить и как
    2 +//! собрать из ответа `summary.md`.
    3 +//!
    4 +//! Модель отвечает JSON строго по [`schema`] (structured outputs OpenRouter,
    5 +//! `strict: true`). Схема и структура [`Analysis`] описывают одно и то же, и
    6 +//! тест `schema_matches_struct` держит их вместе: поле, добавленное в одно
    7 +//! место и забытое в другом, падает тестом, а не молча пропадает из разбора.
    8 +
    9 +use serde::{Deserialize, Serialize};
    10 +use serde_json::{json, Value};
    11 +
    12 +use crate::transcript::{format_timestamp, Transcript};
    13 +
    14 +/// Системный промпт разбора. Лежит файлом, чтобы его правили без Rust.
    15 +pub const SYSTEM_PROMPT: &str = include_str!("../../prompts/analysis.md");
    16 +
    17 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    18 +#[serde(deny_unknown_fields)]
    19 +pub struct Analysis {
    20 + pub title: String,
    21 + pub summary: String,
    22 + pub review: Review,
    23 + pub decisions: Vec<Decision>,
    24 + pub tasks: Vec<Task>,
    25 + pub open_questions: Vec<String>,
    26 +}
    27 +
    28 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    29 +#[serde(deny_unknown_fields)]
    30 +pub struct Review {
    31 + pub achieved: Vec<String>,
    32 + pub open: Vec<String>,
    33 + pub off_track: Vec<String>,
    34 + pub verdict: String,
    35 +}
    36 +
    37 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    38 +#[serde(deny_unknown_fields)]
    39 +pub struct Decision {
    40 + pub text: String,
    41 + pub source_segment: usize,
    42 +}
    43 +
    44 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    45 +#[serde(deny_unknown_fields)]
    46 +pub struct Task {
    47 + pub text: String,
    48 + pub owner: Option<String>,
    49 + pub due: Option<String>,
    50 + pub quote: String,
    51 + pub source_segment: usize,
    52 +}
    53 +
    54 +#[derive(Debug, thiserror::Error, PartialEq, Eq)]
    55 +pub enum InvalidAnalysis {
    56 + #[error("ответ модели не совпал со схемой: {0}")]
    57 + Shape(String),
    58 + #[error("{what} ссылается на реплику #{index}, а в расшифровке их {len}")]
    59 + SegmentOutOfRange {
    60 + what: &'static str,
    61 + index: usize,
    62 + len: usize,
    63 + },
    64 + #[error("пустое поле «{0}»")]
    65 + Blank(&'static str),
    66 +}
    67 +
    68 +/// JSON Schema ответа. Только то, что понимают строгие режимы провайдеров:
    69 +/// без `minimum`, `format` и прочих ограничений — их проверяет [`Analysis::validate`].
    70 +pub fn schema() -> Value {
    71 + let string = json!({ "type": "string" });
    72 + let strings = json!({ "type": "array", "items": { "type": "string" } });
    73 + let nullable = json!({ "anyOf": [{ "type": "string" }, { "type": "null" }] });
    74 + let segment = json!({ "type": "integer", "description": "Номер реплики N из #N" });
    75 + json!({
    76 + "type": "object",
    77 + "additionalProperties": false,
    78 + "required": ["title", "summary", "review", "decisions", "tasks", "open_questions"],
    79 + "properties": {
    80 + "title": string,
    81 + "summary": string,
    82 + "review": {
    83 + "type": "object",
    84 + "additionalProperties": false,
    85 + "required": ["achieved", "open", "off_track", "verdict"],
    86 + "properties": {
    87 + "achieved": strings,
    88 + "open": strings,
    89 + "off_track": strings,
    90 + "verdict": string
    91 + }
    92 + },
    93 + "decisions": {
    94 + "type": "array",
    95 + "items": {
    Разница обрезана: показаны первые 95 строк.
  • Показать разницуСкрыть разницу
    1 +//! Команды для интерфейса. Ошибка уходит строкой — её текст показывается
    2 +//! пользователю как есть, поэтому все ошибки модулей написаны по-русски.
    3 +
    4 +use std::path::PathBuf;
    5 +
    6 +use chrono::Local;
    7 +use serde::Serialize;
    8 +use tauri::State;
    9 +
    10 +use crate::meetings::{Meeting, MeetingDetail, Store};
    11 +use crate::openrouter::{Client, KeyInfo};
    12 +use crate::settings::{mask_key, Secrets, Settings, OPENROUTER_KEY};
    13 +
    14 +pub struct AppState {
    15 + settings_path: PathBuf,
    16 + store: Store,
    17 + secrets: Box<dyn Secrets>,
    18 +}
    19 +
    20 +impl AppState {
    21 + pub fn new(settings_path: PathBuf, store: Store, secrets: Box<dyn Secrets>) -> Self {
    22 + Self {
    23 + settings_path,
    24 + store,
    25 + secrets,
    26 + }
    27 + }
    28 +
    29 + fn settings(&self) -> Result<Settings, String> {
    30 + Settings::load(&self.settings_path).map_err(|e| e.to_string())
    31 + }
    32 +
    33 + fn client(&self) -> Result<Client, String> {
    34 + match self.secrets.get(OPENROUTER_KEY).map_err(|e| e.to_string())? {
    35 + Some(key) => Ok(Client::new(key)),
    36 + None => Err("Ключ OpenRouter не задан — добавьте его в настройках".into()),
    37 + }
    38 + }
    39 +}
    40 +
    41 +#[derive(Serialize)]
    42 +pub struct SettingsView {
    43 + settings: Settings,
    44 + /// Ключ с закрытой серединой; `None` — ключа нет.
    45 + openrouter_key: Option<String>,
    46 + meetings_dir: String,
    47 +}
    48 +
    49 +#[tauri::command]
    50 +pub fn get_settings(state: State<'_, AppState>) -> Result<SettingsView, String> {
    51 + Ok(SettingsView {
    52 + settings: state.settings()?,
    53 + openrouter_key: state
    54 + .secrets
    55 + .get(OPENROUTER_KEY)
    56 + .map_err(|e| e.to_string())?
    57 + .map(|k| mask_key(&k)),
    58 + meetings_dir: state.store.root().display().to_string(),
    59 + })
    60 +}
    61 +
    62 +#[tauri::command]
    63 +pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Result<(), String> {
    64 + settings.save(&state.settings_path).map_err(|e| e.to_string())
    65 +}
    66 +
    67 +/// Ключ сохраняется, только если OpenRouter его принял: нерабочий ключ в
    68 +/// хранилище — это ошибка, которую пользователь увидит лишь на первой встрече.
    69 +#[tauri::command]
    70 +pub async fn set_openrouter_key(state: State<'_, AppState>, key: String) -> Result<KeyInfo, String> {
    71 + let key = key.trim().to_string();
    72 + if key.is_empty() {
    73 + return Err("Вставьте ключ OpenRouter".into());
    74 + }
    75 + let info = Client::new(key.clone())
    76 + .key_info()
    77 + .await
    78 + .map_err(|e| e.to_string())?;
    79 + state
    80 + .secrets
    81 + .set(OPENROUTER_KEY, &key)
    82 + .map_err(|e| e.to_string())?;
    83 + Ok(info)
    84 +}
    85 +
    86 +#[tauri::command]
    87 +pub async fn check_openrouter_key(state: State<'_, AppState>) -> Result<KeyInfo, String> {
    88 + let client = state.client()?;
    89 + client.key_info().await.map_err(|e| e.to_string())
    90 +}
    91 +
    92 +#[tauri::command]
    93 +pub fn delete_openrouter_key(state: State<'_, AppState>) -> Result<(), String> {
    94 + state.secrets.delete(OPENROUTER_KEY).map_err(|e| e.to_string())
    95 +}
    Разница обрезана: показаны первые 95 строк.
  • Показать разницуСкрыть разницу
    1 +//! Skillok Meet — ядро приложения. Интерфейс зовёт команды отсюда через
    2 +//! `invoke`; всё, что трогает диск, сеть и ключи, живёт в Rust.
    3 +
    4 +pub mod analysis;
    5 +pub mod meetings;
    6 +pub mod openrouter;
    7 +pub mod settings;
    8 +pub mod transcript;
    9 +
    10 +mod commands;
    11 +
    12 +use tauri::Manager;
    13 +
    14 +pub fn run() {
    15 + tauri::Builder::default()
    16 + .setup(|app| {
    17 + let config_dir = app.path().app_config_dir()?;
    18 + let data_dir = app.path().app_data_dir()?;
    19 + app.manage(commands::AppState::new(
    20 + settings::settings_path(&config_dir),
    21 + meetings::Store::new(data_dir.join("meetings")),
    22 + Box::new(settings::Keyring),
    23 + ));
    24 + Ok(())
    25 + })
    26 + .invoke_handler(tauri::generate_handler![
    27 + commands::get_settings,
    28 + commands::save_settings,
    29 + commands::set_openrouter_key,
    30 + commands::check_openrouter_key,
    31 + commands::delete_openrouter_key,
    32 + commands::list_meetings,
    33 + commands::get_meeting,
    34 + commands::import_meeting,
    35 + commands::analyze_meeting,
    36 + ])
    37 + .run(tauri::generate_context!())
    38 + .expect("Skillok Meet не запустился");
    39 +}
  • Показать разницуСкрыть разницу
    1 +// Без консольного окна в сборке для Windows.
    2 +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
    3 +
    4 +fn main() {
    5 + skillok_meet_lib::run()
    6 +}
  • Показать разницуСкрыть разницу
    1 +//! Встречи на диске пользователя. Каждая — своя папка:
    2 +//!
    3 +//! ```text
    4 +//! <корень>/20260930-154300/
    5 +//! meeting.json — название, когда, откуда, модель и цена разбора
    6 +//! transcript.json — реплики
    7 +//! transcript.md — расшифровка для людей
    8 +//! analysis.json — разбор (после «Разобрать»)
    9 +//! summary.md — разбор для людей; это его сохраним в Skillok на этапе 2
    10 +//! ```
    11 +
    12 +use std::path::{Path, PathBuf};
    13 +
    14 +use chrono::{DateTime, Local};
    15 +use serde::{Deserialize, Serialize};
    16 +
    17 +use crate::analysis::Analysis;
    18 +use crate::openrouter::Usage;
    19 +use crate::transcript::{ParseError, Transcript};
    20 +
    21 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    22 +#[serde(rename_all = "snake_case")]
    23 +pub enum Source {
    24 + /// Текст расшифровки, который пользователь вставил сам.
    25 + Import,
    26 + /// Запись Zoom (этап 1).
    27 + Recording,
    28 +}
    29 +
    30 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    31 +pub struct Meeting {
    32 + pub id: String,
    33 + pub title: String,
    34 + pub created_at: DateTime<Local>,
    35 + pub source: Source,
    36 + pub duration_ms: u64,
    37 + pub segments: usize,
    38 + pub analysis: Option<AnalysisMeta>,
    39 +}
    40 +
    41 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    42 +pub struct AnalysisMeta {
    43 + pub model: String,
    44 + pub at: DateTime<Local>,
    45 + pub usage: Usage,
    46 + pub tasks: usize,
    47 +}
    48 +
    49 +#[derive(Debug, Clone, PartialEq, Serialize)]
    50 +pub struct MeetingDetail {
    51 + pub meeting: Meeting,
    52 + pub transcript: Transcript,
    53 + pub analysis: Option<Analysis>,
    54 +}
    55 +
    56 +#[derive(Debug, thiserror::Error)]
    57 +pub enum Error {
    58 + #[error("встреча «{0}» не найдена")]
    59 + NotFound(String),
    60 + #[error("название встречи не может быть пустым")]
    61 + BlankTitle,
    62 + #[error("расшифровка не разбирается: {0}")]
    63 + Parse(#[from] ParseError),
    64 + #[error("файл встречи: {0}")]
    65 + Io(#[from] std::io::Error),
    66 + #[error("файл встречи повреждён: {0}")]
    67 + Corrupt(#[from] serde_json::Error),
    68 +}
    69 +
    70 +pub struct Store {
    71 + root: PathBuf,
    72 +}
    73 +
    74 +impl Store {
    75 + pub fn new(root: impl Into<PathBuf>) -> Self {
    76 + Self { root: root.into() }
    77 + }
    78 +
    79 + pub fn root(&self) -> &Path {
    80 + &self.root
    81 + }
    82 +
    83 + /// Все встречи, свежие сверху. Папка с повреждённым `meeting.json`
    84 + /// пропускается, а не роняет весь список.
    85 + pub fn list(&self) -> Result<Vec<Meeting>, Error> {
    86 + let entries = match std::fs::read_dir(&self.root) {
    87 + Ok(e) => e,
    88 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
    89 + Err(e) => return Err(e.into()),
    90 + };
    91 + let mut out = Vec::new();
    92 + for entry in entries.flatten() {
    93 + let file = entry.path().join("meeting.json");
    94 + if let Ok(text) = std::fs::read_to_string(&file) {
    95 + if let Ok(m) = serde_json::from_str::<Meeting>(&text) {
    96 + out.push(m);
    97 + }
    98 + }
    99 + }
    100 + out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
    101 + Ok(out)
    Разница обрезана: показаны первые 101 строк.
  • Показать разницуСкрыть разницу
    1 +//! Клиент OpenRouter: проверка ключа и разбор встречи.
    2 +//!
    3 +//! Один ключ пользователя на всё: на этапе 1 через него же пойдёт расшифровка
    4 +//! (`/audio/transcriptions`). Документация:
    5 +//! - ключ: `GET /api/v1/key` — https://openrouter.ai/docs/api/reference/limits
    6 +//! - строгий JSON: `response_format.json_schema` —
    7 +//! https://openrouter.ai/docs/guides/features/structured-outputs
    8 +
    9 +use std::time::Duration;
    10 +
    11 +use serde::{Deserialize, Serialize};
    12 +use serde_json::{json, Value};
    13 +
    14 +use crate::analysis::{self, Analysis, InvalidAnalysis};
    15 +use crate::transcript::Transcript;
    16 +
    17 +pub const DEFAULT_BASE: &str = "https://openrouter.ai/api/v1";
    18 +
    19 +#[derive(Debug, thiserror::Error)]
    20 +pub enum Error {
    21 + #[error("OpenRouter не принял ключ — проверьте его в настройках")]
    22 + Unauthorized,
    23 + #[error("На счёте OpenRouter не хватает средств")]
    24 + PaymentRequired,
    25 + #[error("OpenRouter просит подождать: слишком много запросов")]
    26 + RateLimited,
    27 + #[error("OpenRouter ответил {status}: {message}")]
    28 + Http { status: u16, message: String },
    29 + #[error("Нет связи с OpenRouter: {0}")]
    30 + Network(String),
    31 + #[error("Модель не закончила ответ ({0}) — попробуйте ещё раз или другую модель")]
    32 + Incomplete(String),
    33 + #[error("Модель вернула негодный разбор: {0}")]
    34 + BadAnswer(#[from] InvalidAnalysis),
    35 +}
    36 +
    37 +/// Что показать в настройках после проверки ключа.
    38 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    39 +pub struct KeyInfo {
    40 + pub label: Option<String>,
    41 + /// Остаток лимита ключа в долларах; `None` — лимита нет.
    42 + pub limit_remaining: Option<f64>,
    43 + /// Потрачено по ключу за всё время, доллары.
    44 + pub usage: Option<f64>,
    45 + pub is_free_tier: bool,
    46 +}
    47 +
    48 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
    49 +pub struct Usage {
    50 + pub prompt_tokens: u64,
    51 + pub completion_tokens: u64,
    52 + /// Стоимость запроса в долларах, если OpenRouter её сообщил.
    53 + pub cost: Option<f64>,
    54 +}
    55 +
    56 +pub struct Client {
    57 + http: reqwest::Client,
    58 + base: String,
    59 + key: String,
    60 +}
    61 +
    62 +impl Client {
    63 + pub fn new(key: impl Into<String>) -> Self {
    64 + Self::with_base(DEFAULT_BASE, key)
    65 + }
    66 +
    67 + pub fn with_base(base: impl Into<String>, key: impl Into<String>) -> Self {
    68 + let http = reqwest::Client::builder()
    69 + .connect_timeout(Duration::from_secs(15))
    70 + // Итоговый разбор долгой встречи на большой модели идёт минуты.
    71 + .timeout(Duration::from_secs(600))
    72 + .build()
    73 + .expect("reqwest client");
    74 + Self {
    75 + http,
    76 + base: base.into().trim_end_matches('/').to_string(),
    77 + key: key.into(),
    78 + }
    79 + }
    80 +
    81 + pub async fn key_info(&self) -> Result<KeyInfo, Error> {
    82 + let body = self.send(self.http.get(format!("{}/key", self.base))).await?;
    83 + // Ответ завёрнут в `data`; держим и голый вид на случай смены формата.
    84 + let data = body.get("data").unwrap_or(&body);
    85 + Ok(KeyInfo {
    86 + label: data.get("label").and_then(Value::as_str).map(str::to_string),
    87 + limit_remaining: data.get("limit_remaining").and_then(Value::as_f64),
    88 + usage: data.get("usage").and_then(Value::as_f64),
    89 + is_free_tier: data.get("is_free_tier").and_then(Value::as_bool).unwrap_or(false),
    90 + })
    91 + }
    92 +
    93 + /// Итоговый разбор встречи моделью `model` строго по [`analysis::schema`].
    94 + pub async fn analyze(
    95 + &self,
    96 + model: &str,
    97 + title: &str,
    98 + transcript: &Transcript,
    99 + ) -> Result<(Analysis, Usage), Error> {
    100 + let request = json!({
    101 + "model": model,
    Разница обрезана: показаны первые 101 строк.
  • Показать разницуСкрыть разницу
    1 +//! Настройки и секреты.
    2 +//!
    3 +//! Настройки — `settings.json` в папке конфигурации приложения. Ключи — только
    4 +//! в хранилище ОС: Windows Credential Manager (на Mac, где идёт разработка, —
    5 +//! связка ключей). В файлы ключ не попадает никогда.
    6 +
    7 +use std::path::{Path, PathBuf};
    8 +
    9 +use serde::{Deserialize, Serialize};
    10 +
    11 +pub const KEYRING_SERVICE: &str = "org.skillok.meet";
    12 +pub const OPENROUTER_KEY: &str = "openrouter";
    13 +
    14 +pub const DEFAULT_WARNING: &str = "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. \
    15 +Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.";
    16 +
    17 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    18 +#[serde(default)]
    19 +pub struct Settings {
    20 + /// Живой конспект по ходу встречи (этап 1).
    21 + pub live_model: String,
    22 + /// Итоговый разбор после встречи.
    23 + pub analysis_model: String,
    24 + /// Распознавание речи (этап 1).
    25 + pub stt_model: String,
    26 + /// Текст, который пользователь отправляет участникам перед записью.
    27 + pub warning_text: String,
    28 + /// «Предупреждаю всегда сам» — не спрашивать отметку перед каждой записью.
    29 + pub always_warn_myself: bool,
    30 +}
    31 +
    32 +impl Default for Settings {
    33 + fn default() -> Self {
    34 + Self {
    35 + live_model: "anthropic/claude-haiku-4.5".into(),
    36 + analysis_model: "anthropic/claude-opus-5.5".into(),
    37 + stt_model: "openai/whisper-large-v3-turbo".into(),
    38 + warning_text: DEFAULT_WARNING.into(),
    39 + always_warn_myself: false,
    40 + }
    41 + }
    42 +}
    43 +
    44 +#[derive(Debug, thiserror::Error)]
    45 +pub enum Error {
    46 + #[error("не удалось прочитать или записать настройки: {0}")]
    47 + Io(#[from] std::io::Error),
    48 + #[error("файл настроек повреждён: {0}")]
    49 + Corrupt(#[from] serde_json::Error),
    50 + #[error("хранилище ключей ОС: {0}")]
    51 + Keyring(String),
    52 + #[error("поле «{0}» не может быть пустым")]
    53 + Blank(&'static str),
    54 +}
    55 +
    56 +impl Settings {
    57 + /// Нет файла — значения по умолчанию. Файл есть, но битый — ошибка, а не
    58 + /// молчаливый сброс: пользователь должен узнать, что его настройки пропали.
    59 + pub fn load(path: &Path) -> Result<Self, Error> {
    60 + match std::fs::read_to_string(path) {
    61 + Ok(text) => Ok(serde_json::from_str(&text)?),
    62 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
    63 + Err(e) => Err(e.into()),
    64 + }
    65 + }
    66 +
    67 + pub fn save(&self, path: &Path) -> Result<(), Error> {
    68 + self.validate()?;
    69 + if let Some(dir) = path.parent() {
    70 + std::fs::create_dir_all(dir)?;
    71 + }
    72 + let tmp = path.with_extension("json.tmp");
    73 + std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
    74 + std::fs::rename(&tmp, path)?;
    75 + Ok(())
    76 + }
    77 +
    78 + pub fn validate(&self) -> Result<(), Error> {
    79 + for (name, value) in [
    80 + ("модель конспекта", &self.live_model),
    81 + ("модель разбора", &self.analysis_model),
    82 + ("модель распознавания", &self.stt_model),
    83 + ("текст предупреждения", &self.warning_text),
    84 + ] {
    85 + if value.trim().is_empty() {
    86 + return Err(Error::Blank(name));
    87 + }
    88 + }
    89 + Ok(())
    90 + }
    91 +}
    92 +
    93 +/// Хранилище секретов. В приложении — [`Keyring`], в тестах — [`Memory`].
    94 +pub trait Secrets: Send + Sync {
    95 + fn get(&self, name: &str) -> Result<Option<String>, Error>;
    96 + fn set(&self, name: &str, value: &str) -> Result<(), Error>;
    97 + fn delete(&self, name: &str) -> Result<(), Error>;
    98 +}
    99 +
    100 +pub struct Keyring;
    101 +
    Разница обрезана: показаны первые 101 строк.
  • Показать разницуСкрыть разницу
    1 +//! Расшифровка встречи: реплики с временем и говорящим.
    2 +//!
    3 +//! На этапе 1 реплики будут приходить из распознавания двух дорожек: «я»
    4 +//! (микрофон) и «собеседники» (звук Zoom). Пока их даёт импорт текста — тот же
    5 +//! формат, которым написаны проверочные расшифровки в `fixtures/transcripts/`:
    6 +//!
    7 +//! ```text
    8 +//! [00:01:23] Анна: текст реплики
    9 +//! продолжение той же реплики на следующей строке
    10 +//! ```
    11 +//!
    12 +//! Имя говорящего — только то, что написано в тексте. Узнавания людей по голосу
    13 +//! нет и не будет: это биометрия (см. README, «Согласие на запись»).
    14 +
    15 +use serde::{Deserialize, Serialize};
    16 +
    17 +/// Кто говорит. `Me` и `Others` — две дорожки захвата; `Named` — имя из
    18 +/// импортированного текста.
    19 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    20 +#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
    21 +pub enum Speaker {
    22 + Me,
    23 + Others,
    24 + Named(String),
    25 +}
    26 +
    27 +impl Speaker {
    28 + pub fn label(&self) -> &str {
    29 + match self {
    30 + Speaker::Me => "Я",
    31 + Speaker::Others => "Собеседники",
    32 + Speaker::Named(name) => name,
    33 + }
    34 + }
    35 +}
    36 +
    37 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    38 +pub struct Segment {
    39 + /// Начало реплики от начала встречи, миллисекунды.
    40 + pub start_ms: u64,
    41 + pub speaker: Speaker,
    42 + pub text: String,
    43 +}
    44 +
    45 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
    46 +pub struct Transcript {
    47 + pub segments: Vec<Segment>,
    48 +}
    49 +
    50 +#[derive(Debug, thiserror::Error, PartialEq, Eq)]
    51 +pub enum ParseError {
    52 + #[error("строка {line}: нет метки времени вида [чч:мм:сс] перед первой репликой")]
    53 + TextBeforeFirstSegment { line: usize },
    54 + #[error("строка {line}: метка времени «{stamp}» не разбирается")]
    55 + BadTimestamp { line: usize, stamp: String },
    56 + #[error("строка {line}: после метки времени нет «Имя:»")]
    57 + NoSpeaker { line: usize },
    58 + #[error("строка {line}: время идёт назад")]
    59 + TimeGoesBack { line: usize },
    60 + #[error("в тексте нет ни одной реплики")]
    61 + Empty,
    62 +}
    63 +
    64 +impl Transcript {
    65 + /// Разбирает текст формата `[чч:мм:сс] Имя: реплика`. Строки без метки
    66 + /// продолжают предыдущую реплику; пустые строки пропускаются.
    67 + pub fn parse(text: &str) -> Result<Self, ParseError> {
    68 + let mut segments: Vec<Segment> = Vec::new();
    69 + for (index, raw) in text.lines().enumerate() {
    70 + let line = index + 1;
    71 + let trimmed = raw.trim();
    72 + if trimmed.is_empty() {
    73 + continue;
    74 + }
    75 + let Some(rest) = trimmed.strip_prefix('[') else {
    76 + let Some(last) = segments.last_mut() else {
    77 + return Err(ParseError::TextBeforeFirstSegment { line });
    78 + };
    79 + last.text.push(' ');
    80 + last.text.push_str(trimmed);
    81 + continue;
    82 + };
    83 + let Some((stamp, rest)) = rest.split_once(']') else {
    84 + return Err(ParseError::BadTimestamp {
    85 + line,
    86 + stamp: rest.to_string(),
    87 + });
    88 + };
    89 + let start_ms = parse_timestamp(stamp).ok_or_else(|| ParseError::BadTimestamp {
    90 + line,
    91 + stamp: stamp.to_string(),
    92 + })?;
    93 + let Some((name, said)) = rest.split_once(':') else {
    94 + return Err(ParseError::NoSpeaker { line });
    95 + };
    96 + let name = name.trim();
    97 + if name.is_empty() {
    98 + return Err(ParseError::NoSpeaker { line });
    99 + }
    100 + if segments.last().is_some_and(|prev| prev.start_ms > start_ms) {
    101 + return Err(ParseError::TimeGoesBack { line });
    Разница обрезана: показаны первые 101 строк.
  • Показать разницуСкрыть разницу
    1 +{
    2 + "$schema": "https://schema.tauri.app/config/2",
    3 + "productName": "Skillok Meet",
    4 + "version": "0.1.0",
    5 + "identifier": "org.skillok.meet",
    6 + "build": {
    7 + "beforeDevCommand": "npm run dev",
    8 + "devUrl": "http://localhost:1420",
    9 + "beforeBuildCommand": "npm run build",
    10 + "frontendDist": "../dist"
    11 + },
    12 + "app": {
    13 + "windows": [
    14 + {
    15 + "title": "Skillok Meet",
    16 + "width": 1100,
    17 + "height": 760,
    18 + "minWidth": 720,
    19 + "minHeight": 520
    20 + }
    21 + ],
    22 + "security": {
    23 + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:"
    24 + }
    25 + },
    26 + "bundle": {
    27 + "active": true,
    28 + "targets": ["nsis"],
    29 + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.png"]
    30 + }
    31 +}
  • src/api.ts +103
    Показать разницуСкрыть разницу
    1 +// Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с
    2 +// тем, что отдаёт serde на стороне Rust.
    3 +
    4 +export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "named"; name: string };
    5 +
    6 +export interface Segment { start_ms: number; speaker: Speaker; text: string }
    7 +export interface Transcript { segments: Segment[] }
    8 +
    9 +export interface Usage { prompt_tokens: number; completion_tokens: number; cost: number | null }
    10 +
    11 +export interface AnalysisMeta { model: string; at: string; usage: Usage; tasks: number }
    12 +
    13 +export interface Meeting {
    14 + id: string;
    15 + title: string;
    16 + created_at: string;
    17 + source: "import" | "recording";
    18 + duration_ms: number;
    19 + segments: number;
    20 + analysis: AnalysisMeta | null;
    21 +}
    22 +
    23 +export interface Analysis {
    24 + title: string;
    25 + summary: string;
    26 + review: { achieved: string[]; open: string[]; off_track: string[]; verdict: string };
    27 + decisions: { text: string; source_segment: number }[];
    28 + tasks: { text: string; owner: string | null; due: string | null; quote: string; source_segment: number }[];
    29 + open_questions: string[];
    30 +}
    31 +
    32 +export interface MeetingDetail { meeting: Meeting; transcript: Transcript; analysis: Analysis | null }
    33 +
    34 +export interface Settings {
    35 + live_model: string;
    36 + analysis_model: string;
    37 + stt_model: string;
    38 + warning_text: string;
    39 + always_warn_myself: boolean;
    40 +}
    41 +
    42 +export interface SettingsView { settings: Settings; openrouter_key: string | null; meetings_dir: string }
    43 +
    44 +export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean }
    45 +
    46 +type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
    47 +
    48 +let invokeImpl: Invoke | null = null;
    49 +
    50 +async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
    51 + if (!invokeImpl) {
    52 + if ("__TAURI_INTERNALS__" in window) {
    53 + invokeImpl = (await import("@tauri-apps/api/core")).invoke;
    54 + } else if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") {
    55 + // Только для просмотра интерфейса в обычном браузере: `npm run dev`
    56 + // или сборка `npm run build:mock`. В сборку приложения не попадает.
    57 + invokeImpl = (await import("./dev-mock")).invoke;
    58 + } else {
    59 + throw new Error("Skillok Meet запущен без ядра");
    60 + }
    61 + }
    62 + return invokeImpl<T>(cmd, args);
    63 +}
    64 +
    65 +export const api = {
    66 + getSettings: () => call<SettingsView>("get_settings"),
    67 + saveSettings: (settings: Settings) => call<void>("save_settings", { settings }),
    68 + setOpenrouterKey: (key: string) => call<KeyInfo>("set_openrouter_key", { key }),
    69 + checkOpenrouterKey: () => call<KeyInfo>("check_openrouter_key"),
    70 + deleteOpenrouterKey: () => call<void>("delete_openrouter_key"),
    71 + listMeetings: () => call<Meeting[]>("list_meetings"),
    72 + getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }),
    73 + importMeeting: (title: string, text: string) => call<Meeting>("import_meeting", { title, text }),
    74 + analyzeMeeting: (id: string) => call<Meeting>("analyze_meeting", { id }),
    75 +};
    76 +
    77 +export function speakerLabel(s: Speaker): string {
    78 + switch (s.kind) {
    79 + case "me": return "Я";
    80 + case "others": return "Собеседники";
    81 + case "named": return s.name;
    82 + }
    83 +}
    84 +
    85 +export function formatTime(ms: number): string {
    86 + const t = Math.floor(ms / 1000);
    87 + const p = (n: number) => String(n).padStart(2, "0");
    88 + return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`;
    89 +}
    90 +
    91 +export function formatDuration(ms: number): string {
    92 + const min = Math.round(ms / 60000);
    93 + if (min < 60) return `${min} мин`;
    94 + return `${Math.floor(min / 60)} ч ${min % 60} мин`;
    95 +}
    96 +
    97 +export function formatDate(iso: string): string {
    98 + return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
    99 +}
    100 +
    101 +export function errorText(e: unknown): string {
    102 + return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
    103 +}
  • Показать разницуСкрыть разницу
    1 +// Заглушка ядра для `npm run dev` в обычном браузере: показать интерфейс без
    2 +// Tauri. В сборку приложения не попадает (см. api.ts). Разбор здесь не
    3 +// вызывает модель — у первой встречи он заготовлен руками.
    4 +
    5 +import planerka from "../fixtures/transcripts/planerka.txt?raw";
    6 +import klient from "../fixtures/transcripts/klient.txt?raw";
    7 +import type { Analysis, Meeting, MeetingDetail, Segment, Settings, SettingsView, KeyInfo } from "./api";
    8 +
    9 +function parse(text: string): Segment[] {
    10 + const out: Segment[] = [];
    11 + for (const raw of text.split("\n")) {
    12 + const line = raw.trim();
    13 + if (!line) continue;
    14 + const m = /^\[(\d{1,2}):(\d{2}):(\d{2})\]\s*([^:]+):\s*(.*)$/.exec(line);
    15 + if (m) {
    16 + const ms = ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000;
    17 + out.push({ start_ms: ms, speaker: { kind: "named", name: m[4].trim() }, text: m[5] });
    18 + } else if (out.length) {
    19 + out[out.length - 1].text += " " + line;
    20 + } else {
    21 + throw "строка без метки времени перед первой репликой";
    22 + }
    23 + }
    24 + if (!out.length) throw "в тексте нет ни одной реплики";
    25 + return out;
    26 +}
    27 +
    28 +const planerkaAnalysis: Analysis = {
    29 + title: "Планёрка: релиз, отчёт инвестору, отпуск",
    30 + summary:
    31 + "Релиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу. Дежурства на время отпуска Дениса закрыты только на первую неделю.",
    32 + review: {
    33 + achieved: ["Решили судьбу релиза и назначили ответственного за блокер", "Согласовали срок отчёта для инвестора"],
    34 + open: ["Кто дежурит во вторую неделю отпуска Дениса", "Затронет ли переезд на новый биллинг партнёрские выгрузки"],
    35 + off_track: ["Спор о новом биллинге посреди пункта про отчёт"],
    36 + verdict: "Встреча короткая и полезная; спорные темы стоит сразу выносить в отдельный список, не открывая обсуждение.",
    37 + },
    38 + decisions: [
    39 + { text: "Релиз переносится с четверга на следующий вторник", source_segment: 4 },
    40 + { text: "Биллинг обсуждаем на отдельной встрече", source_segment: 17 },
    41 + ],
    42 + tasks: [
    43 + { text: "Починить падение экрана оплаты на Android 12", owner: "Денис", due: "пятница", quote: "Денис, падение на Android 12 — на тебе, срок — пятница.", source_segment: 7 },
    44 + { text: "Выгрузить партнёрские данные для отчёта", owner: "Марат", due: "до среды", quote: "Партнёрские данные у меня, выгружу до среды.", source_segment: 10 },
    45 + { text: "Отправить отчёт инвестору", owner: "Света", due: "в четверг", quote: "тогда отчёт отправлю в четверг.", source_segment: 11 },
    46 + { text: "Собрать встречу по новому биллингу", owner: "Марат", due: "на следующей неделе", quote: "Соберу, на следующей неделе.", source_segment: 18 },
    47 + ],
    48 + open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"],
    49 +};
    50 +
    51 +const now = Date.now();
    52 +const details: MeetingDetail[] = [];
    53 +
    54 +function add(title: string, text: string, minutesAgo: number, analysis: Analysis | null): MeetingDetail {
    55 + const segments = parse(text);
    56 + const created = new Date(now - minutesAgo * 60000);
    57 + const p = (n: number) => String(n).padStart(2, "0");
    58 + const id = `${created.getFullYear()}${p(created.getMonth() + 1)}${p(created.getDate())}-${p(created.getHours())}${p(created.getMinutes())}${p(created.getSeconds())}`;
    59 + const meeting: Meeting = {
    60 + id,
    61 + title,
    62 + created_at: created.toISOString(),
    63 + source: "import",
    64 + duration_ms: segments[segments.length - 1].start_ms,
    65 + segments: segments.length,
    66 + analysis: analysis && {
    67 + model: "anthropic/claude-opus-5.5",
    68 + at: created.toISOString(),
    69 + usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 },
    70 + tasks: analysis.tasks.length,
    71 + },
    72 + };
    73 + const d = { meeting, transcript: { segments }, analysis };
    74 + details.unshift(d);
    75 + return d;
    76 +}
    77 +
    78 +add("Планёрка команды", planerka, 60 * 26, planerkaAnalysis);
    79 +add("Встреча с клиентом: сайт", klient, 90, null);
    80 +
    81 +let settings: Settings = {
    82 + live_model: "anthropic/claude-haiku-4.5",
    83 + analysis_model: "anthropic/claude-opus-5.5",
    84 + stt_model: "openai/whisper-large-v3-turbo",
    85 + warning_text:
    86 + "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.",
    87 + always_warn_myself: false,
    88 +};
    89 +let key: string | null = null;
    90 +const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: null, usage: 0, is_free_tier: false };
    91 +
    92 +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
    93 +
    94 +export async function invoke<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
    95 + await wait(150);
    96 + const find = (id: unknown) => {
    97 + const d = details.find((x) => x.meeting.id === id);
    98 + if (!d) throw `встреча «${id}» не найдена`;
    99 + return d;
    100 + };
    101 + switch (cmd) {
    102 + case "get_settings":
    103 + return { settings, openrouter_key: key, meetings_dir: "(просмотр без ядра — на диск ничего не пишется)" } satisfies SettingsView as T;
    104 + case "save_settings":
    105 + settings = args.settings as Settings;
    Разница обрезана: показаны первые 105 строк.
  • src/dom.ts +33
    Показать разницуСкрыть разницу
    1 +// Маленькие помощники вёрстки: экранирование и сборка разметки строкой.
    2 +
    3 +export function esc(value: unknown): string {
    4 + return String(value ?? "")
    5 + .replace(/&/g, "&amp;")
    6 + .replace(/</g, "&lt;")
    7 + .replace(/>/g, "&gt;")
    8 + .replace(/"/g, "&quot;")
    9 + .replace(/'/g, "&#39;");
    10 +}
    11 +
    12 +export function list(items: string[]): string {
    13 + return `<ul>${items.map((i) => `<li>${esc(i)}</li>`).join("")}</ul>`;
    14 +}
    15 +
    16 +export function $(root: ParentNode, selector: string): HTMLElement {
    17 + const el = root.querySelector<HTMLElement>(selector);
    18 + if (!el) throw new Error(`нет элемента ${selector}`);
    19 + return el;
    20 +}
    21 +
    22 +/** Кнопка в ожидании: недоступна и с другой подписью, пока идёт `work`. */
    23 +export async function busy<T>(button: HTMLButtonElement, label: string, work: () => Promise<T>): Promise<T> {
    24 + const before = button.textContent;
    25 + button.disabled = true;
    26 + button.textContent = label;
    27 + try {
    28 + return await work();
    29 + } finally {
    30 + button.disabled = false;
    31 + button.textContent = before;
    32 + }
    33 +}
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Показать разницуСкрыть разницу
    1 +# iA Writer Typeface
    2 +
    3 +Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer"
    4 +
    5 +# Based on IBM Plex Typeface
    6 +
    7 +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
    8 +
    9 +# License
    10 +
    11 +This Font Software is licensed under the SIL Open Font License, Version 1.1.
    12 +This license is copied below, and is also available with a FAQ at:
    13 +http://scripts.sil.org/OFL
    14 +
    15 +-----------------------------------------------------------
    16 +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
    17 +-----------------------------------------------------------
    18 +
    19 +PREAMBLE
    20 +The goals of the Open Font License (OFL) are to stimulate worldwide
    21 +development of collaborative font projects, to support the font creation
    22 +efforts of academic and linguistic communities, and to provide a free and
    23 +open framework in which fonts may be shared and improved in partnership
    24 +with others.
    25 +
    26 +The OFL allows the licensed fonts to be used, studied, modified and
    27 +redistributed freely as long as they are not sold by themselves. The
    28 +fonts, including any derivative works, can be bundled, embedded,
    29 +redistributed and/or sold with any software provided that any reserved
    30 +names are not used by derivative works. The fonts and derivatives,
    31 +however, cannot be released under any other type of license. The
    32 +requirement for fonts to remain under this license does not apply
    33 +to any document created using the fonts or their derivatives.
    34 +
    35 +DEFINITIONS
    36 +"Font Software" refers to the set of files released by the Copyright
    37 +Holder(s) under this license and clearly marked as such. This may
    38 +include source files, build scripts and documentation.
    39 +
    40 +"Reserved Font Name" refers to any names specified as such after the
    41 +copyright statement(s).
    42 +
    43 +"Original Version" refers to the collection of Font Software components as
    44 +distributed by the Copyright Holder(s).
    45 +
    46 +"Modified Version" refers to any derivative made by adding to, deleting,
    47 +or substituting -- in part or in whole -- any of the components of the
    48 +Original Version, by changing formats or by porting the Font Software to a
    49 +new environment.
    50 +
    51 +"Author" refers to any designer, engineer, programmer, technical
    52 +writer or other person who contributed to the Font Software.
    53 +
    54 +PERMISSION & CONDITIONS
    55 +Permission is hereby granted, free of charge, to any person obtaining
    56 +a copy of the Font Software, to use, study, copy, merge, embed, modify,
    57 +redistribute, and sell modified and unmodified copies of the Font
    58 +Software, subject to the following conditions:
    59 +
    60 +1) Neither the Font Software nor any of its individual components,
    61 +in Original or Modified Versions, may be sold by itself.
    62 +
    63 +2) Original or Modified Versions of the Font Software may be bundled,
    64 +redistributed and/or sold with any software, provided that each copy
    65 +contains the above copyright notice and this license. These can be
    66 +included either as stand-alone text files, human-readable headers or
    67 +in the appropriate machine-readable metadata fields within text or
    68 +binary files as long as those fields can be easily viewed by the user.
    69 +
    70 +3) No Modified Version of the Font Software may use the Reserved Font
    71 +Name(s) unless explicit written permission is granted by the corresponding
    72 +Copyright Holder. This restriction only applies to the primary font name as
    73 +presented to the users.
    74 +
    75 +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    76 +Software shall not be used to promote, endorse or advertise any
    77 +Modified Version, except to acknowledge the contribution(s) of the
    78 +Copyright Holder(s) and the Author(s) or with their explicit written
    79 +permission.
    80 +
    81 +5) The Font Software, modified or unmodified, in part or in whole,
    82 +must be distributed entirely under this license, and must not be
    83 +distributed under any other license. The requirement for fonts to
    84 +remain under this license does not apply to any document created
    85 +using the Font Software.
    86 +
    87 +TERMINATION
    88 +This license becomes null and void if any of the above conditions are
    89 +not met.
    90 +
    91 +DISCLAIMER
    92 +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    93 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
    94 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
    95 +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
    96 +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    97 +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
    98 +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    99 +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
    100 +OTHER DEALINGS IN THE FONT SOFTWARE.
    \ No newline at end of file
  • Показать разницуСкрыть разницу
    1 +Copyright 2020 The JetBrains Mono Project Authors
    2 +(https://github.com/JetBrains/JetBrainsMono)
    3 +
    4 +This Font Software is licensed under the SIL Open Font License, Version 1.1.
    5 +This license is copied below, and is also available with a FAQ at:
    6 +https://scripts.sil.org/OFL
    7 +
    8 +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
    9 +-----------------------------------------------------------
    10 +
    11 +PREAMBLE
    12 +The goals of the Open Font License (OFL) are to stimulate worldwide
    13 +development of collaborative font projects, to support the font creation
    14 +efforts of academic and linguistic communities, and to provide a free and
    15 +open framework in which fonts may be shared and improved in partnership
    16 +with others.
    17 +
    18 +The OFL allows the licensed fonts to be used, studied, modified and
    19 +redistributed freely as long as they are not sold by themselves. The
    20 +fonts, including any derivative works, can be bundled, embedded,
    21 +redistributed and/or sold with any software provided that any reserved
    22 +names are not used by derivative works. The fonts and derivatives,
    23 +however, cannot be released under any other type of license. The
    24 +requirement for fonts to remain under this license does not apply
    25 +to any document created using the fonts or their derivatives.
    26 +
    27 +DEFINITIONS
    28 +"Font Software" refers to the set of files released by the Copyright
    29 +Holder(s) under this license and clearly marked as such. This may
    30 +include source files, build scripts and documentation.
    31 +
    32 +"Reserved Font Name" refers to any names specified as such after the
    33 +copyright statement(s).
    34 +
    35 +"Original Version" refers to the collection of Font Software components as
    36 +distributed by the Copyright Holder(s).
    37 +
    38 +"Modified Version" refers to any derivative made by adding to, deleting,
    39 +or substituting -- in part or in whole -- any of the components of the
    40 +Original Version, by changing formats or by porting the Font Software to a
    41 +new environment.
    42 +
    43 +"Author" refers to any designer, engineer, programmer, technical
    44 +writer or other person who contributed to the Font Software.
    45 +
    46 +PERMISSION & CONDITIONS
    47 +Permission is hereby granted, free of charge, to any person obtaining
    48 +a copy of the Font Software, to use, study, copy, merge, embed, modify,
    49 +redistribute, and sell modified and unmodified copies of the Font
    50 +Software, subject to the following conditions:
    51 +
    52 +1) Neither the Font Software nor any of its individual components,
    53 +in Original or Modified Versions, may be sold by itself.
    54 +
    55 +2) Original or Modified Versions of the Font Software may be bundled,
    56 +redistributed and/or sold with any software, provided that each copy
    57 +contains the above copyright notice and this license. These can be
    58 +included either as stand-alone text files, human-readable headers or
    59 +in the appropriate machine-readable metadata fields within text or
    60 +binary files as long as those fields can be easily viewed by the user.
    61 +
    62 +3) No Modified Version of the Font Software may use the Reserved Font
    63 +Name(s) unless explicit written permission is granted by the corresponding
    64 +Copyright Holder. This restriction only applies to the primary font name as
    65 +presented to the users.
    66 +
    67 +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
    68 +Software shall not be used to promote, endorse or advertise any
    69 +Modified Version, except to acknowledge the contribution(s) of the
    70 +Copyright Holder(s) and the Author(s) or with their explicit written
    71 +permission.
    72 +
    73 +5) The Font Software, modified or unmodified, in part or in whole,
    74 +must be distributed entirely under this license, and must not be
    75 +distributed under any other license. The requirement for fonts to
    76 +remain under this license does not apply to any document created
    77 +using the Font Software.
    78 +
    79 +TERMINATION
    80 +This license becomes null and void if any of the above conditions are
    81 +not met.
    82 +
    83 +DISCLAIMER
    84 +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    85 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
    86 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
    87 +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
    88 +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    89 +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
    90 +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    91 +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
    92 +OTHER DEALINGS IN THE FONT SOFTWARE.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • Двоичный файл — показывать нечего.
  • src/main.ts +32
    Показать разницуСкрыть разницу
    1 +// Маршрутизация по хешу: #/meetings, #/meeting/<id>/<вкладка>[/<реплика>], #/settings.
    2 +
    3 +import { renderMeetings } from "./views/meetings";
    4 +import { renderMeeting, type Tab } from "./views/meeting";
    5 +import { renderSettings } from "./views/settings";
    6 +import { errorText } from "./api";
    7 +import { esc } from "./dom";
    8 +
    9 +const app = document.getElementById("app")!;
    10 +
    11 +async function route(): Promise<void> {
    12 + const [section, id, tab, target] = location.hash.replace(/^#\/?/, "").split("/");
    13 + for (const a of document.querySelectorAll<HTMLAnchorElement>("[data-nav]")) {
    14 + const current = a.dataset.nav === (section === "meeting" ? "meetings" : section || "meetings");
    15 + if (current) a.setAttribute("aria-current", "page");
    16 + else a.removeAttribute("aria-current");
    17 + }
    18 + try {
    19 + if (section === "meeting" && id) {
    20 + await renderMeeting(app, decodeURIComponent(id), (tab as Tab) || "transcript", target ? Number(target) : null);
    21 + } else if (section === "settings") {
    22 + await renderSettings(app);
    23 + } else {
    24 + await renderMeetings(app);
    25 + }
    26 + } catch (e) {
    27 + app.innerHTML = `<p class="error">${esc(errorText(e))}</p>`;
    28 + }
    29 +}
    30 +
    31 +window.addEventListener("hashchange", route);
    32 +route();
  • Показать разницуСкрыть разницу
    1 +/* Раскладка приложения: шапка как у skillok.org и одна колонка контента. */
    2 +
    3 +.header {
    4 + position: sticky; top: 0; z-index: 10;
    5 + display: flex; align-items: center; gap: 24px;
    6 + height: 56px; padding: 0 24px;
    7 + background: var(--background);
    8 + border-bottom: 1px solid var(--color-border);
    9 +}
    10 +.brand { display: flex; align-items: center; gap: 10px; color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg); }
    11 +.brand:hover { text-decoration: none; }
    12 +.brand-mark { width: 24px; height: 24px; }
    13 +.header-nav { display: flex; gap: 4px; margin-left: auto; }
    14 +.header-nav a {
    15 + display: inline-flex; align-items: center; height: 32px; padding: 0 10px;
    16 + color: var(--color-text-primary); font-size: var(--text-md);
    17 +}
    18 +.header-nav a:hover { background: var(--color-bg-hover); text-decoration: none; }
    19 +.header-nav a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    20 +
    21 +.page { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; display: grid; gap: 24px; }
    22 +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
    23 +.page-head .meta { margin-top: 6px; }
    24 +.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
    25 +
    26 +/* Список встреч */
    27 +.meeting-row { cursor: pointer; color: var(--color-text-primary); }
    28 +.meeting-row:hover { text-decoration: none; }
    29 +.meeting-row:hover .title { color: var(--color-accent-text); }
    30 +.meeting-row .title { font-weight: 700; flex: 1; min-width: 0; }
    31 +.meeting-row .when { width: 200px; white-space: nowrap; }
    32 +.meeting-row .status { width: 170px; text-align: right; }
    33 +
    34 +/* Расшифровка */
    35 +.segment { display: grid; grid-template-columns: 72px 1fr; gap: 12px; padding: 8px 16px; border-top: 1px solid var(--kv-line-2); }
    36 +.segment:first-child { border-top: 0; }
    37 +.segment .at { color: var(--color-text-secondary); padding-top: 2px; }
    38 +.segment .who { font-weight: 700; }
    39 +.segment.is-target { background: var(--color-bg-secondary); box-shadow: inset 2px 0 0 var(--color-accent); }
    40 +
    41 +/* Конспект и задачи */
    42 +.prose { display: grid; gap: 12px; max-width: 72ch; }
    43 +.prose ul { margin: 0; padding-left: 20px; display: grid; gap: 4px; }
    44 +.section { display: grid; gap: 8px; }
    45 +.task { display: grid; gap: 4px; padding: 12px 16px; border-top: 1px solid var(--color-border); }
    46 +.task:first-child { border-top: 0; }
    47 +.task .text { font-weight: 700; }
    48 +.task .quote { border-left: 2px solid var(--color-border-light); padding-left: 10px; color: var(--color-text-secondary); font-size: var(--text-sm); }
    49 +.jump { font-family: var(--font-mono); font-size: var(--text-xs); }
    50 +
    51 +.empty { padding: 40px 16px; text-align: center; color: var(--color-text-secondary); }
    52 +.busy { color: var(--color-text-secondary); font-size: var(--text-sm); }
    53 +
    54 +@media (max-width: 640px) {
    55 + .meeting-row { flex-wrap: wrap; }
    56 + .meeting-row .when, .meeting-row .status { width: auto; text-align: left; }
    57 +}
  • Показать разницуСкрыть разницу
    1 +/* Токены стиля — копия системы skillok.org
    2 + (src/web-backend/crates/web/templates/base.html и ui.html в репозитории Skillok).
    3 + Правила те же: новых цветов не заводить, кегль только из шкалы --text-*,
    4 + пол 12px, углы квадратные, JetBrains Mono — только для кода и времени. */
    5 +
    6 +@font-face {
    7 + font-family: 'iA Writer Quattro S';
    8 + font-style: normal;
    9 + font-weight: 100 600;
    10 + font-display: swap;
    11 + src: url('../fonts/iAWriterQuattroS-Regular.woff2') format('woff2');
    12 +}
    13 +@font-face {
    14 + font-family: 'iA Writer Quattro S';
    15 + font-style: italic;
    16 + font-weight: 100 600;
    17 + font-display: swap;
    18 + src: url('../fonts/iAWriterQuattroS-Italic.woff2') format('woff2');
    19 +}
    20 +@font-face {
    21 + font-family: 'iA Writer Quattro S';
    22 + font-style: normal;
    23 + font-weight: 700;
    24 + font-display: swap;
    25 + src: url('../fonts/iAWriterQuattroS-Bold.woff2') format('woff2');
    26 +}
    27 +@font-face {
    28 + font-family: 'JetBrains Mono';
    29 + font-style: normal;
    30 + font-weight: 400;
    31 + font-display: swap;
    32 + src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
    33 +}
    34 +@font-face {
    35 + font-family: 'JetBrains Mono';
    36 + font-style: normal;
    37 + font-weight: 700;
    38 + font-display: swap;
    39 + src: url('../fonts/JetBrainsMono-Bold.woff2') format('woff2');
    40 +}
    41 +
    42 +:root {
    43 + --font-ui: 'iA Writer Quattro S', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
    44 + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
    45 +
    46 + --text-xs: 12px; /* мета, подписи полей, значки */
    47 + --text-sm: 13px; /* вторичный текст, кнопки, вкладки */
    48 + --text-md: 14px; /* основной текст экрана */
    49 + --text-lg: 16px; /* крупный абзац, подзаголовок */
    50 + --text-xl: 18px;
    51 + --text-2xl: 20px;
    52 + --text-3xl: 24px; /* заголовок экрана */
    53 +
    54 + --background: #F4F4F2;
    55 + --kv-ink: #0A0A0A;
    56 + --kv-body: #2A2A2A;
    57 + --kv-muted: #8B8B8B;
    58 + --kv-line: rgba(10, 10, 10, 0.12);
    59 + --kv-line-2: rgba(10, 10, 10, 0.06);
    60 +
    61 + --color-text-primary: var(--kv-ink);
    62 + --color-text-secondary: #666666;
    63 + --color-border: var(--kv-line);
    64 + --color-border-light: rgba(10, 10, 10, 0.32);
    65 + --color-accent-text: #8C4531;
    66 + --color-accent: #A6533C;
    67 + --color-on-accent: #FFFFFF;
    68 + --color-bg-secondary: #EBEBE8;
    69 + --color-bg-elevated: #FAFAF8;
    70 + --color-bg-hover: rgba(10, 10, 10, 0.06);
    71 + --color-error: #C81E1E;
    72 +
    73 + color-scheme: light;
    74 +}
    75 +
    76 +/* Тёмная палитра — как на skillok.org: чернила и бумага меняются местами. */
    77 +@media (prefers-color-scheme: dark) {
    78 + :root {
    79 + --background: #121212;
    80 + --kv-ink: #F2F2F0;
    81 + --kv-body: #D6D6D2;
    82 + --kv-line: rgba(242, 242, 240, 0.14);
    83 + --kv-line-2: rgba(242, 242, 240, 0.07);
    84 + --color-text-secondary: #A0A09C;
    85 + --color-accent-text: #E08A6B;
    86 + --color-bg-secondary: #1C1C1C;
    87 + --color-bg-elevated: #242424;
    88 + --color-border-light: rgba(242, 242, 240, 0.32);
    89 + --color-bg-hover: rgba(255, 255, 255, 0.06);
    90 + --color-error: #F87171;
    91 + color-scheme: dark;
    92 + }
    93 +}
  • Показать разницуСкрыть разницу
    1 +/* Компоненты — по образцу ui.html skillok.org: контурная кнопка, ряд вкладок
    2 + с подчёркиванием, поля и строки списка. Квадратные углы везде. */
    3 +
    4 +*, *::before, *::after { box-sizing: border-box; font-variant-ligatures: no-contextual; }
    5 +
    6 +html, body { margin: 0; }
    7 +body {
    8 + background: var(--background);
    9 + color: var(--color-text-primary);
    10 + font-family: var(--font-ui);
    11 + font-size: var(--text-md);
    12 + line-height: 1.5;
    13 + -webkit-font-smoothing: antialiased;
    14 +}
    15 +
    16 +a { color: var(--color-accent-text); text-decoration: none; }
    17 +a:hover { text-decoration: underline; }
    18 +
    19 +h1, h2, h3 { margin: 0; font-weight: 700; line-height: 1.25; }
    20 +h1 { font-size: var(--text-3xl); }
    21 +h2 { font-size: var(--text-lg); }
    22 +h3 { font-size: var(--text-md); }
    23 +p { margin: 0; }
    24 +
    25 +code, .mono { font-family: var(--font-mono); font-size: var(--text-xs); }
    26 +
    27 +/* ── Кнопка: одна, контурная (как «Войти» в шапке skillok.org) ── */
    28 +.ui-btn {
    29 + display: inline-flex;
    30 + align-items: center;
    31 + justify-content: center;
    32 + gap: 8px;
    33 + min-height: 32px;
    34 + max-width: 100%;
    35 + padding: 0 14px;
    36 + font-family: var(--font-ui);
    37 + font-size: var(--text-sm);
    38 + font-weight: 700;
    39 + letter-spacing: 0.06em;
    40 + text-transform: uppercase;
    41 + line-height: 20px;
    42 + color: var(--kv-ink);
    43 + background: transparent;
    44 + border: 2px solid var(--kv-ink);
    45 + border-radius: 0;
    46 + cursor: pointer;
    47 + transition: background 0.15s, color 0.15s, border-color 0.15s;
    48 + white-space: normal;
    49 + text-wrap: balance;
    50 + flex-shrink: 0;
    51 +}
    52 +.ui-btn:hover { background: var(--kv-ink); color: var(--background); }
    53 +.ui-btn:active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-accent); }
    54 +.ui-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
    55 +.ui-btn[disabled] { opacity: 0.45; cursor: not-allowed; }
    56 +.ui-btn[disabled]:hover { background: transparent; color: var(--kv-ink); }
    57 +.ui-btn-sm { min-height: 28px; padding: 0 10px; font-size: var(--text-xs); }
    58 +
    59 +/* ── Ряд вкладок ── */
    60 +.ui-underline-nav {
    61 + display: flex;
    62 + flex-wrap: wrap;
    63 + gap: 0 8px;
    64 + box-shadow: inset 0 -1px 0 var(--color-border);
    65 +}
    66 +.ui-underline-nav > a {
    67 + display: inline-flex;
    68 + align-items: center;
    69 + gap: 8px;
    70 + height: 32px;
    71 + padding: 0 8px;
    72 + font-size: var(--text-md);
    73 + color: var(--color-text-primary);
    74 + text-decoration: none;
    75 + white-space: nowrap;
    76 +}
    77 +.ui-underline-nav > a:hover { background: var(--color-bg-hover); }
    78 +.ui-underline-nav > a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-accent); }
    79 +.ui-nav-count {
    80 + display: inline-block;
    81 + min-width: 20px;
    82 + padding: 0 6px;
    83 + line-height: 20px;
    84 + text-align: center;
    85 + font-size: var(--text-xs);
    86 + font-weight: 500;
    87 + color: var(--color-text-secondary);
    88 + background: var(--color-bg-secondary);
    89 +}
    90 +.ui-underline-nav > a[aria-current="page"] .ui-nav-count { color: var(--color-text-primary); }
    91 +
    92 +/* ── Поля ── */
    93 +.field { display: grid; gap: 6px; }
    94 +.field > label, .field-label { font-size: var(--text-sm); font-weight: 700; }
    95 +.field-hint { font-size: var(--text-xs); color: var(--color-text-secondary); }
    96 +.input, .textarea {
    97 + width: 100%;
    98 + padding: 6px 10px;
    99 + font: inherit;
    100 + font-size: var(--text-md);
    101 + color: var(--color-text-primary);
    102 + background: var(--color-bg-elevated);
    103 + border: 1px solid var(--color-border-light);
    104 + border-radius: 0;
    105 +}
    106 +.input:focus, .textarea:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; border-color: transparent; }
    107 +.textarea { min-height: 120px; resize: vertical; line-height: 1.5; }
    108 +.textarea.mono { font-size: var(--text-sm); }
    109 +.check { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-md); cursor: pointer; }
    110 +.check input { margin-top: 4px; accent-color: var(--color-accent); }
    111 +
    112 +/* ── Рамка-блок и список ── */
    113 +.box { border: 1px solid var(--color-border); background: var(--color-bg-elevated); }
    114 +.box-head {
    115 + display: flex; align-items: center; justify-content: space-between; gap: 12px;
    116 + padding: 10px 16px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary);
    117 +}
    118 +.box-body { padding: 16px; display: grid; gap: 16px; }
    119 +.row {
    120 + display: flex; align-items: baseline; gap: 16px;
    121 + padding: 12px 16px; border-top: 1px solid var(--color-border);
    122 +}
    123 +.row:first-child { border-top: 0; }
    124 +.row:hover { background: var(--color-bg-hover); }
    125 +
    126 +.muted { color: var(--color-text-secondary); }
    127 +.small { font-size: var(--text-sm); }
    128 +.xs { font-size: var(--text-xs); }
    129 +.error { color: var(--color-error); font-size: var(--text-sm); }
    130 +.ok { color: var(--color-accent-text); font-size: var(--text-sm); }
    131 +.label {
    132 + display: inline-block; padding: 0 6px; line-height: 20px;
    133 + font-size: var(--text-xs); border: 1px solid var(--color-border-light); color: var(--color-text-secondary);
    134 +}
    135 +.label-accent { border-color: var(--color-accent); color: var(--color-accent-text); }
  • Показать разницуСкрыть разницу
    1 +// Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора.
    2 +
    3 +import { api, errorText, formatDate, formatDuration, formatTime, speakerLabel, type Analysis, type MeetingDetail } from "../api";
    4 +import { $, busy, esc, list } from "../dom";
    5 +
    6 +export type Tab = "transcript" | "summary" | "tasks";
    7 +
    8 +function jump(d: MeetingDetail, index: number): string {
    9 + const seg = d.transcript.segments[index];
    10 + if (!seg) return "";
    11 + return `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`;
    12 +}
    13 +
    14 +function transcriptTab(d: MeetingDetail, target: number | null): string {
    15 + return `<section class="box">${d.transcript.segments
    16 + .map(
    17 + (s, i) => `
    18 + <div class="segment${i === target ? " is-target" : ""}" id="seg-${i}">
    19 + <span class="at mono">${formatTime(s.start_ms)}</span>
    20 + <p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>
    21 + </div>`,
    22 + )
    23 + .join("")}</section>`;
    24 +}
    25 +
    26 +function notAnalyzed(): string {
    27 + return `<section class="box"><p class="empty">Разбора ещё нет.</p></section>`;
    28 +}
    29 +
    30 +function summaryTab(d: MeetingDetail, a: Analysis): string {
    31 + const r = a.review;
    32 + const review = [
    33 + r.achieved.length ? `<h3>Чего добились</h3>${list(r.achieved)}` : "",
    34 + r.open.length ? `<h3>Осталось открытым</h3>${list(r.open)}` : "",
    35 + r.off_track.length ? `<h3>Где ушли в сторону</h3>${list(r.off_track)}` : "",
    36 + `<p><strong>Итог:</strong> ${esc(r.verdict)}</p>`,
    37 + ].join("");
    38 + return `
    39 + <section class="prose">
    40 + <p>${esc(a.summary)}</p>
    41 + <div class="section"><h2>Ревью</h2>${review}</div>
    42 + <div class="section"><h2>Решения</h2>${
    43 + a.decisions.length
    44 + ? `<ul>${a.decisions.map((x) => `<li>${esc(x.text)} ${jump(d, x.source_segment)}</li>`).join("")}</ul>`
    45 + : `<p class="muted">Явных решений не было.</p>`
    46 + }</div>
    47 + ${a.open_questions.length ? `<div class="section"><h2>Открытые вопросы</h2>${list(a.open_questions)}</div>` : ""}
    48 + </section>`;
    49 +}
    50 +
    51 +function tasksTab(d: MeetingDetail, a: Analysis): string {
    52 + if (!a.tasks.length) return `<section class="box"><p class="empty">Задач не прозвучало.</p></section>`;
    53 + return `<section class="box">${a.tasks
    54 + .map(
    55 + (t) => `
    56 + <div class="task">
    57 + <p class="text">${esc(t.text)}</p>
    58 + <p class="small muted">${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}</p>
    59 + <p class="quote">${esc(t.quote)}</p>
    60 + </div>`,
    61 + )
    62 + .join("")}</section>`;
    63 +}
    64 +
    65 +function cost(d: MeetingDetail): string {
    66 + const a = d.meeting.analysis;
    67 + if (!a) return "";
    68 + const price = a.usage.cost != null ? ` · $${a.usage.cost.toFixed(4)}` : "";
    69 + return `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`;
    70 +}
    71 +
    72 +export async function renderMeeting(root: HTMLElement, id: string, tab: Tab, target: number | null = null): Promise<void> {
    73 + const d = await api.getMeeting(id);
    74 + const a = d.analysis;
    75 + const base = `#/meeting/${encodeURIComponent(id)}`;
    76 + const tabLink = (key: Tab, label: string, count?: number) =>
    77 + `<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${
    78 + count !== undefined ? ` <span class="ui-nav-count">${count}</span>` : ""
    79 + }</a>`;
    80 +
    81 + const body =
    82 + tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed() : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
    83 +
    84 + root.innerHTML = `
    85 + <div class="page-head">
    86 + <div>
    87 + <h1>${esc(d.meeting.title)}</h1>
    88 + <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · ${d.meeting.segments} реплик</p>
    89 + </div>
    90 + <div class="actions">
    91 + <button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>
    92 + </div>
    93 + </div>
    94 + <p class="error" data-error hidden></p>
    95 + <nav class="ui-underline-nav" aria-label="Встреча">
    96 + ${tabLink("transcript", "Расшифровка", d.meeting.segments)}
    97 + ${tabLink("summary", "Конспект")}
    98 + ${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)}
    99 + </nav>
    100 + ${body}
    101 + ${cost(d)}`;
    102 +
    103 + if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });
    104 +
    105 + const button = $(root, "[data-analyze]") as HTMLButtonElement;
    106 + const error = $(root, "[data-error]");
    107 + button.addEventListener("click", async () => {
    108 + error.hidden = true;
    109 + try {
    110 + await busy(button, "Разбираю…", () => api.analyzeMeeting(id));
    111 + location.hash = `${base}/summary`;
    112 + if (tab === "summary") await renderMeeting(root, id, "summary");
    113 + } catch (e) {
    114 + error.textContent = errorText(e);
    115 + error.hidden = false;
    116 + }
    117 + });
    118 +}
  • Показать разницуСкрыть разницу
    1 +// Экран «Встречи»: список и импорт расшифровки.
    2 +
    3 +import { api, errorText, formatDate, formatDuration, type Meeting } from "../api";
    4 +import { $, busy, esc } from "../dom";
    5 +
    6 +function status(m: Meeting): string {
    7 + if (!m.analysis) return `<span class="label">не разобрана</span>`;
    8 + const n = m.analysis.tasks;
    9 + return `<span class="label label-accent">разобрана · ${n} ${plural(n, "задача", "задачи", "задач")}</span>`;
    10 +}
    11 +
    12 +export function plural(n: number, one: string, few: string, many: string): string {
    13 + const d = n % 10, h = n % 100;
    14 + if (d === 1 && h !== 11) return one;
    15 + if (d >= 2 && d <= 4 && (h < 12 || h > 14)) return few;
    16 + return many;
    17 +}
    18 +
    19 +export async function renderMeetings(root: HTMLElement): Promise<void> {
    20 + const meetings = await api.listMeetings();
    21 + root.innerHTML = `
    22 + <div class="page-head">
    23 + <h1>Встречи</h1>
    24 + <div class="actions"><button class="ui-btn" data-open-import>Импорт расшифровки</button></div>
    25 + </div>
    26 +
    27 + <form class="box" data-import hidden>
    28 + <div class="box-head"><h2>Импорт расшифровки</h2></div>
    29 + <div class="box-body">
    30 + <div class="field">
    31 + <label for="imp-title">Название</label>
    32 + <input id="imp-title" class="input" name="title" autocomplete="off" required>
    33 + </div>
    34 + <div class="field">
    35 + <label for="imp-text">Расшифровка</label>
    36 + <textarea id="imp-text" class="textarea mono" name="text" rows="10" required
    37 + placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea>
    38 + <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p>
    39 + </div>
    40 + <p class="error" data-import-error hidden></p>
    41 + <div class="actions">
    42 + <button class="ui-btn" type="submit">Создать встречу</button>
    43 + <button class="ui-btn" type="button" data-close-import>Отмена</button>
    44 + </div>
    45 + </div>
    46 + </form>
    47 +
    48 + <section class="box">
    49 + ${
    50 + meetings.length
    51 + ? meetings
    52 + .map(
    53 + (m) => `
    54 + <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}">
    55 + <span class="title">${esc(m.title)}</span>
    56 + <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span>
    57 + <span class="status">${status(m)}</span>
    58 + </a>`,
    59 + )
    60 + .join("")
    61 + : `<p class="empty">Встреч пока нет.</p>`
    62 + }
    63 + </section>`;
    64 +
    65 + const form = $(root, "[data-import]") as HTMLFormElement;
    66 + const error = $(root, "[data-import-error]");
    67 + $(root, "[data-open-import]").addEventListener("click", () => {
    68 + form.hidden = false;
    69 + $(form, "#imp-title").focus();
    70 + });
    71 + $(root, "[data-close-import]").addEventListener("click", () => {
    72 + form.hidden = true;
    73 + error.hidden = true;
    74 + });
    75 + form.addEventListener("submit", async (event) => {
    76 + event.preventDefault();
    77 + const data = new FormData(form);
    78 + const submit = $(form, "[type=submit]") as HTMLButtonElement;
    79 + try {
    80 + const m = await busy(submit, "Создаю…", () => api.importMeeting(String(data.get("title")), String(data.get("text"))));
    81 + location.hash = `#/meeting/${encodeURIComponent(m.id)}`;
    82 + } catch (e) {
    83 + error.textContent = errorText(e);
    84 + error.hidden = false;
    85 + }
    86 + });
    87 +}
  • Показать разницуСкрыть разницу
    1 +// Экран «Настройки»: ключ OpenRouter, модели, предупреждение участникам.
    2 +
    3 +import { api, errorText, type KeyInfo, type SettingsView } from "../api";
    4 +import { $, busy, esc } from "../dom";
    5 +
    6 +function keyInfoText(info: KeyInfo): string {
    7 + const parts = ["Ключ работает"];
    8 + if (info.limit_remaining != null) parts.push(`остаток лимита $${info.limit_remaining.toFixed(2)}`);
    9 + if (info.usage != null) parts.push(`потрачено $${info.usage.toFixed(2)}`);
    10 + if (info.is_free_tier) parts.push("бесплатный уровень");
    11 + return parts.join(" · ");
    12 +}
    13 +
    14 +function keyBox(view: SettingsView): string {
    15 + return `
    16 + <section class="box" data-key-box>
    17 + <div class="box-head"><h2>Ключ OpenRouter</h2></div>
    18 + <div class="box-body">
    19 + ${
    20 + view.openrouter_key
    21 + ? `<p>Сохранён: <code>${esc(view.openrouter_key)}</code></p>
    22 + <div class="actions">
    23 + <button class="ui-btn" data-check-key>Проверить</button>
    24 + <button class="ui-btn" data-delete-key>Удалить ключ</button>
    25 + </div>`
    26 + : `<form class="field" data-key-form>
    27 + <label for="or-key">Ключ</label>
    28 + <input id="or-key" class="input" name="key" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…" required>
    29 + <p class="field-hint">Ключ проверяется в OpenRouter и хранится в хранилище паролей Windows, не в файлах. Через него идут расшифровка и разбор.</p>
    30 + <div class="actions"><button class="ui-btn" type="submit">Сохранить ключ</button></div>
    31 + </form>`
    32 + }
    33 + <p data-key-status hidden></p>
    34 + </div>
    35 + </section>`;
    36 +}
    37 +
    38 +export async function renderSettings(root: HTMLElement): Promise<void> {
    39 + const view = await api.getSettings();
    40 + const s = view.settings;
    41 + root.innerHTML = `
    42 + <div class="page-head"><h1>Настройки</h1></div>
    43 + ${keyBox(view)}
    44 +
    45 + <form class="box" data-settings>
    46 + <div class="box-head"><h2>Модели и запись</h2></div>
    47 + <div class="box-body">
    48 + <div class="field">
    49 + <label for="m-analysis">Модель итогового разбора</label>
    50 + <input id="m-analysis" class="input" name="analysis_model" value="${esc(s.analysis_model)}" required spellcheck="false">
    51 + </div>
    52 + <div class="field">
    53 + <label for="m-live">Модель живого конспекта</label>
    54 + <input id="m-live" class="input" name="live_model" value="${esc(s.live_model)}" required spellcheck="false">
    55 + </div>
    56 + <div class="field">
    57 + <label for="m-stt">Модель распознавания речи</label>
    58 + <input id="m-stt" class="input" name="stt_model" value="${esc(s.stt_model)}" required spellcheck="false">
    59 + <p class="field-hint">Имена моделей — как в каталоге OpenRouter.</p>
    60 + </div>
    61 + <div class="field">
    62 + <label for="warn">Предупреждение участникам</label>
    63 + <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea>
    64 + <p class="field-hint">Перед записью этот текст можно скопировать в чат Zoom. Записывать разговор без ведома участников нельзя.</p>
    65 + </div>
    66 + <label class="check">
    67 + <input type="checkbox" name="always_warn_myself"${s.always_warn_myself ? " checked" : ""}>
    68 + <span>Я всегда предупреждаю участников сам — не спрашивать перед каждой записью</span>
    69 + </label>
    70 + <div class="field">
    71 + <span class="field-label">Папка встреч</span>
    72 + <code>${esc(view.meetings_dir)}</code>
    73 + </div>
    74 + <p data-settings-status hidden></p>
    75 + <div class="actions"><button class="ui-btn" type="submit">Сохранить</button></div>
    76 + </div>
    77 + </form>`;
    78 +
    79 + const keyStatus = $(root, "[data-key-status]");
    80 + const show = (el: HTMLElement, text: string, ok: boolean) => {
    81 + el.textContent = text;
    82 + el.className = ok ? "ok" : "error";
    83 + el.hidden = false;
    84 + };
    85 +
    86 + const keyForm = root.querySelector<HTMLFormElement>("[data-key-form]");
    87 + keyForm?.addEventListener("submit", async (event) => {
    88 + event.preventDefault();
    89 + const key = String(new FormData(keyForm).get("key"));
    90 + try {
    91 + const info = await busy($(keyForm, "[type=submit]") as HTMLButtonElement, "Проверяю…", () => api.setOpenrouterKey(key));
    92 + await renderSettings(root);
    93 + show($(root, "[data-key-status]"), keyInfoText(info), true);
    94 + } catch (e) {
    95 + show(keyStatus, errorText(e), false);
    96 + }
    97 + });
    98 + root.querySelector<HTMLButtonElement>("[data-check-key]")?.addEventListener("click", async (event) => {
    99 + try {
    100 + const info = await busy(event.currentTarget as HTMLButtonElement, "Проверяю…", () => api.checkOpenrouterKey());
    101 + show(keyStatus, keyInfoText(info), true);
    102 + } catch (e) {
    103 + show(keyStatus, errorText(e), false);
    104 + }
    105 + });
    106 + root.querySelector<HTMLButtonElement>("[data-delete-key]")?.addEventListener("click", async () => {
    107 + if (!confirm("Удалить ключ OpenRouter с этого компьютера?")) return;
    108 + try {
    109 + await api.deleteOpenrouterKey();
    110 + await renderSettings(root);
    111 + } catch (e) {
    112 + show(keyStatus, errorText(e), false);
    113 + }
    114 + });
    115 +
    116 + const form = $(root, "[data-settings]") as HTMLFormElement;
    117 + form.addEventListener("submit", async (event) => {
    118 + event.preventDefault();
    119 + const data = new FormData(form);
    120 + const status = $(root, "[data-settings-status]");
    121 + try {
    122 + await busy($(form, "[type=submit]") as HTMLButtonElement, "Сохраняю…", () =>
    123 + api.saveSettings({
    124 + analysis_model: String(data.get("analysis_model")).trim(),
    125 + live_model: String(data.get("live_model")).trim(),
    126 + stt_model: String(data.get("stt_model")).trim(),
    127 + warning_text: String(data.get("warning_text")).trim(),
    128 + always_warn_myself: data.get("always_warn_myself") === "on",
    129 + }),
    130 + );
    131 + show(status, "Сохранено", true);
    132 + } catch (e) {
    133 + show(status, errorText(e), false);
    134 + }
    135 + });
    136 +}
  • Показать разницуСкрыть разницу
    1 +{
    2 + "compilerOptions": {
    3 + "target": "ES2022",
    4 + "module": "ESNext",
    5 + "moduleResolution": "Bundler",
    6 + "lib": ["ES2022", "DOM", "DOM.Iterable"],
    7 + "strict": true,
    8 + "noUnusedLocals": true,
    9 + "noUnusedParameters": true,
    10 + "noFallthroughCasesInSwitch": true,
    11 + "skipLibCheck": true,
    12 + "isolatedModules": true,
    13 + "types": ["vite/client"]
    14 + },
    15 + "include": ["src"]
    16 +}
  • Показать разницуСкрыть разницу
    1 +import { defineConfig } from "vite";
    2 +
    3 +// Порт закреплён: его же ждёт `devUrl` в src-tauri/tauri.conf.json.
    4 +export default defineConfig({
    5 + clearScreen: false,
    6 + server: { port: 1420, strictPort: true },
    7 + build: { target: "es2022", outDir: "dist" },
    8 +});