Этап 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>
abb5e06
· сравнить с родителем
-
.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> -
package-lock.json +1089
Показать разницуСкрыть разницу
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` и оставь списки пустыми. -
src-tauri/Cargo.lock +4873
Показать разницуСкрыть разницу
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 +} -
src/dev-mock.ts +141
Показать разницуСкрыть разницу
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, "&") 6 + .replace(/</g, "<") 7 + .replace(/>/g, ">") 8 + .replace(/"/g, """) 9 + .replace(/'/g, "'"); 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 +} -
src/styles/ui.css +135
Показать разницуСкрыть разницу
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); } -
src/views/meeting.ts +118
Показать разницуСкрыть разницу
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 +} -
tsconfig.json +16
Показать разницуСкрыть разницу
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 +});