Интерфейс: список встреч слева, заметка справа
- Боковая панель: «Записать», «Добавить аудио» (и перетаскивание файла), «Новая заметка», список встреч. - Экран встречи: название правится на месте, шаги «Расшифровать» и команды ИИ с ходом работы, панель записи с уровнями и текстом предупреждения для чата Zoom. - Заметка: «Текст» / «Правка», автосохранение, «Копировать всё», «Как текст», копирование раздела, живые галочки задач. - Сообщения внизу экрана вместо ошибок в тексте страницы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ad47f89
· сравнить с родителем
-
README.md +103 −179
Показать разницуСкрыть разницу
2 2 3 3 Рабочее название. Продукт Skillok. 4 4 5 −Программа для Windows, которая работает на компьютере пользователя. Сама включает запись, 6 −когда начинается встреча в Zoom, по ходу ведёт расшифровку, а после встречи делает 7 −конспект, ревью, решения и задачи. Итог можно сохранить в свой репозиторий на skillok.org. 5 +Программа для Windows: встреча → заметка. Звук встречи записывается по кнопке или 6 +добавляется готовым файлом, по кнопке расшифровывается, а дальше ИИ делает с заметкой 7 +то, что попросите: конспект, задачи, ревью или что угодно своё. Заметка — обычный 8 +Markdown: её можно править и копировать. 9 + 10 +**Программа ничего не делает сама.** Не следит за Zoom, не висит в трее, не запускается 11 +вместе с Windows. Открыли — сделали — закрыли. 8 12 9 13 **Нашего сервера нет.** Пользователь вводит свой ключ OpenRouter — и всё. Звук и 10 −расшифровки лежат у него на диске. Наружу уходят только запросы к моделям по его 11 −собственному ключу. 14 +заметки лежат у него на диске; наружу уходят только запросы к моделям по его ключу. 12 15 13 16 --- 14 17 15 −## Что делает 16 − 17 −1. **Замечает, что началась встреча в Zoom, и включает запись.** Кнопку жать не нужно. 18 −2. **Ведёт расшифровку по ходу встречи.** Реплики разделены на «я» (микрофон) и 19 − «собеседники» (звук Zoom). 20 −3. **Каждые несколько минут обновляет живой конспект.** 21 −4. **После встречи делает разбор:** 22 − - краткое содержание; 23 − - ревью встречи: чего достигли, что осталось открытым, где ушли в сторону; 24 − - решения; 25 − - задачи с исполнителем и сроком, если они прозвучали; 26 − - вопросы на следующую встречу. 27 −5. **Сохраняет итог в репозиторий на skillok.org** — одним нажатием или автоматически. 18 +## Как пользоваться 19 + 20 +1. **Звук.** 21 + - «Записать» — пишется звук Zoom и микрофон, пока не нажмёте «Остановить». 22 + Zoom нужно запустить до нажатия, иначе запишется только микрофон. 23 + - «Добавить аудио» или просто перетащить файл в окно: mp3, m4a, wav, aiff, flac, ogg — 24 + с диктофона, из облачной записи Zoom, откуда угодно. 25 +2. **«Расшифровать».** Расшифровка ложится в заметку разделом «Расшифровка»: время, 26 + говорящий («Я», «Собеседники» — у записи; у файла говорящих нет), текст. 27 +3. **ИИ.** Кнопки «Конспект», «Задачи», «Ревью» или своя просьба в поле рядом 28 + («напиши письмо клиенту по итогам», «выпиши все цифры»). Ответ дописывается в конец 29 + заметки своим разделом. ИИ читает заметку целиком — с вашими правками. 30 +4. **Заметка.** 31 + - «Текст» — просмотр, «Правка» — Markdown; изменения сохраняются сами. 32 + - «Копировать всё» — в Markdown, «Как текст» — без разметки, для мессенджера. 33 + - У каждого раздела своя «Копировать». 34 + - Галочки задач отмечаются прямо в просмотре. 28 35 29 36 --- 30 37 31 −## Как это работает 38 +## Как это устроено 32 39 33 40 ``` 34 − Zoom.exe ──звук──┐ 35 − ├─► захват (WASAPI) ─► куски 6–28 с ──► OpenRouter: Whisper ─► расшифровка 36 − микрофон ────────┘ │ 37 − ▼ 38 − OpenRouter: Claude ◄── конспект / разбор 39 − │ 40 − диск пользователя ◄─────────────────────────┤ 41 − skillok.org (репозиторий) ◄── по желанию ───┘ 41 + «Записать»: Zoom.exe ──┐ 42 + микрофон ──┴─► audio/others.wav, audio/me.wav 43 + «Добавить аудио»: файл ──────────► audio/<файл> 44 + 45 + «Расшифровать»: дорожки ─► 16 кГц моно ─► куски 6–28 с по паузам ─► OpenRouter: Whisper 46 + (до 4 кусков сразу) │ 47 + ▼ 48 + note.md: ## Расшифровка 49 + 50 + Команда ИИ: note.md ─► OpenRouter: Claude ─► note.md: ## Задачи / ## Конспект / … 42 51 ``` 43 52 44 −### Захват звука Zoom 53 +### Запись звука Zoom (Windows) 45 54 46 55 - Звук берём **только от процесса Zoom** (process loopback) и отдельно с микрофона. 47 56 Системные звуки и музыка в запись не попадают. 48 57 См. [Application loopback sample](https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/) 49 58 и [ActivateAudioInterfaceAsync](https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nf-mmdeviceapi-activateaudiointerfaceasync). 50 59 Нужна Windows 10 сборки 20438 или новее. 51 −- Встреча идёт, пока у `Zoom.exe` есть активная звуковая сессия вывода: во время встречи Zoom 52 − держит поток открытым, даже когда все молчат. Началом считаем 4 секунды такой сессии 53 − (звук уведомления — не встреча), концом — 20 секунд без неё (переподключение не рвёт запись). 54 −- API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на любой 55 − встрече и на любом плане Zoom, в том числе бесплатном и на чужой встрече. 60 +- Какой процесс писать, определяется в момент нажатия «Записать»: `Zoom.exe` с открытым 61 + потоком звука, то есть идущей встречей. 62 +- API Zoom, аккаунт разработчика и одобрение в Marketplace **не нужны**. Работает на 63 + любой встрече и на любом плане Zoom. 56 64 57 65 ### Расшифровка — Whisper через OpenRouter 58 66 59 −- Эндпоинт `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для 60 − текстовых моделей. Модели: `openai/whisper-large-v3-turbo` (по умолчанию), 61 − `openai/whisper-large-v3`, `openai/whisper-1`. 67 +- `POST https://openrouter.ai/api/v1/audio/transcriptions`, тот же ключ, что для текстовых 68 + моделей. По умолчанию `openai/whisper-large-v3-turbo`. 62 69 См. [документацию OpenRouter STT](https://openrouter.ai/docs/guides/overview/multimodal/stt). 63 −- Эндпоинт принимает файл целиком, потокового режима нет. Поэтому звук режем на куски 64 − от 6 до 28 секунд по паузам в речи, куски без речи не отправляем. Расшифровка отстаёт от живой речи на длину куска 65 − плюс время ответа. 66 −- Дорожки «я» и «собеседники» распознаём раздельно. Так реплики подписаны без узнавания 67 − людей по голосу. 68 − 69 −### Разбор — Claude через OpenRouter 70 − 71 −| Задача | Модель по умолчанию | Когда | 72 −|---|---|---| 73 −| Живой конспект | Claude Haiku 4.5 | каждые несколько минут | 74 −| Итоговый разбор: ревью, решения, задачи | Claude Opus 5.5 | после встречи | 75 − 76 −- Модели можно поменять в настройках на любую из доступных в OpenRouter. 77 −- Разбор приходит JSON строго по схеме: `summary`, `review`, `decisions[]`, 78 − `tasks[]` (текст, исполнитель, срок, цитата-основание), `open_questions[]`. 79 − Из JSON программа собирает Markdown. 80 −- У каждой задачи есть ссылка на момент расшифровки, где она прозвучала. 81 − 82 −### Сохранение в репозиторий Skillok 83 − 84 −- Пользователь создаёт токен доступа на skillok.org (Settings → Access tokens) 85 − и выбирает репозиторий. 86 −- Программа кладёт файлы через API Skillok: 87 − - `POST /api/v1/repositories/{id}/files` — создать файл; 88 − - `PUT /api/v1/repositories/{id}/files/{path}` — обновить файл; 89 − - заголовок `Authorization: Bearer <токен>`. 90 −- Раскладка в репозитории: 91 − 92 − ``` 93 − meetings/2026-10-01-планёрка/ 94 − summary.md — конспект, ревью, решения, задачи 95 − transcript.md — расшифровка с временем 96 − ``` 97 − 98 −- Звук в репозиторий не уходит. 99 − 100 −--- 70 +- Файл любой длины режется на куски по паузам: у эндпоинта предел 25 МБ и около минуты 71 + обработки на запрос. Куски без речи не отправляются — это дешевле, и Whisper не 72 + выдумывает фраз на тишине. Такие фразы («Продолжение следует…») дополнительно отсеиваются. 73 +- Неудачный кусок повторяется один раз; не распознавшийся остаётся в расшифровке пометкой. 101 74 102 −## Интерфейс — как у skillok.org 103 − 104 −Та же система, что на skillok.org: 105 − 106 −- бумажный фон, чернильный текст, терракотовый акцент; 107 −- шрифт iA Writer Quattro S, JetBrains Mono только для кода и времени в расшифровке; 108 −- кегль только из шкалы, не меньше 12px; 109 −- квадратные углы; 110 −- кнопки контурные, без заливки. 75 +### Команды ИИ — Claude через OpenRouter 111 76 112 −Токены стиля берём из дизайн-системы Skillok, новых цветов не заводим. 113 − 114 −**Экраны:** 115 − 116 −- **Встречи** — список: дата, длительность, статус разбора. 117 −- **Встреча** — вкладки «Расшифровка», «Конспект», «Задачи», кнопка «Сохранить в Skillok». 118 −- **Идёт запись** — маленькое окно или значок в трее: время, уровень звука, стоп и пауза. 119 −- **Настройки:** 120 − - ключ OpenRouter; 121 − - модели; 122 − - папка хранения; 123 − - токен Skillok и репозиторий; 124 − - автосохранение; 125 − - текст предупреждения участникам. 77 +- По умолчанию `anthropic/claude-opus-5.5`; модель меняется в настройках. 78 +- Промпты — файлами в `prompts/`: общий `base.md` и по одному на команду. 79 +- Ответ — Markdown без заголовка: заголовок раздела ставит программа. 126 80 127 81 --- 128 82 129 −## Стек 130 − 131 −**Tauri 2**: ядро на Rust, интерфейс на HTML/CSS/TS. 132 − 133 −- Интерфейс в HTML/CSS — поэтому стиль skillok.org переносится один в один. 134 −- Ядро на Rust: 135 − - захват звука через крейт `windows` (WASAPI, process loopback); 136 − - разбивка звука на куски по паузам в речи; 137 − - запросы к OpenRouter и Skillok. Разница обрезана: показаны первые 187 строк. -
index.html +5 −2
Показать разницуСкрыть разницу
18 18 Skillok Meet 19 19 </a> 20 20 <nav class="header-nav" aria-label="Разделы"> 21 − <a href="#/" data-nav="home">Встречи</a> 22 21 <a href="#/settings" data-nav="settings">Настройки</a> 23 22 </nav> 24 23 </header> 25 − <main id="app" class="page"></main> 24 + <div class="layout"> 25 + <aside id="sidebar" class="sidebar"></aside> 26 + <main id="app" class="main"></main> 27 + </div> 28 + <p id="toast" class="toast" role="status" hidden></p> 26 29 <script type="module" src="/src/main.ts"></script> 27 30 </body> 28 31 </html> -
package-lock.json +41 −1
Показать разницуСкрыть разницу
8 8 "name": "skillok-meet", 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 − "@tauri-apps/api": "^2.12.0" 11 + "@tauri-apps/api": "^2.12.0", 12 + "@tauri-apps/plugin-dialog": "^2.8.0", 13 + "dompurify": "^3.4.16", 14 + "marked": "^18.0.14" 12 15 }, 13 16 "devDependencies": { 14 17 "@tauri-apps/cli": "^2.12.0", 548 551 "node": ">= 10" 549 552 } 550 553 }, 554 + "node_modules/@tauri-apps/plugin-dialog": { 555 + "version": "2.8.0", 556 + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.0.tgz", 557 + "integrity": "sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==", 558 + "license": "MIT OR Apache-2.0", 559 + "dependencies": { 560 + "@tauri-apps/api": "^2.12.0" 561 + } 562 + }, 563 + "node_modules/@types/trusted-types": { 564 + "version": "2.0.7", 565 + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 566 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 567 + "license": "MIT", 568 + "optional": true 569 + }, 551 570 "node_modules/detect-libc": { 552 571 "version": "2.1.2", 553 572 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 558 577 "node": ">=8" 559 578 } 560 579 }, 580 + "node_modules/dompurify": { 581 + "version": "3.4.16", 582 + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", 583 + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", 584 + "license": "(MPL-2.0 OR Apache-2.0)", 585 + "optionalDependencies": { 586 + "@types/trusted-types": "^2.0.7" 587 + } 588 + }, 561 589 "node_modules/fdir": { 562 590 "version": "6.5.0", 563 591 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 864 892 "url": "https://opencollective.com/parcel" 865 893 } 866 894 }, 895 + "node_modules/marked": { 896 + "version": "18.0.14", 897 + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz", 898 + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", 899 + "license": "MIT", 900 + "bin": { 901 + "marked": "bin/marked.js" 902 + }, 903 + "engines": { 904 + "node": ">= 20" 905 + } 906 + }, 867 907 "node_modules/nanoid": { 868 908 "version": "3.3.19", 869 909 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", -
package.json +4 −1
Показать разницуСкрыть разницу
11 11 "tauri": "tauri" 12 12 }, 13 13 "dependencies": { 14 − "@tauri-apps/api": "^2.12.0" 14 + "@tauri-apps/api": "^2.12.0", 15 + "@tauri-apps/plugin-dialog": "^2.8.0", 16 + "dompurify": "^3.4.16", 17 + "marked": "^18.0.14" 15 18 }, 16 19 "devDependencies": { 17 20 "@tauri-apps/cli": "^2.12.0", -
src/api.ts +70 −73
Показать разницуСкрыть разницу
1 1 // Типы и вызовы команд ядра (src-tauri/src/commands.rs). Поля совпадают с 2 2 // тем, что отдаёт serde на стороне Rust. 3 3 4 −export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "named"; name: string }; 4 +export type Speaker = { kind: "me" } | { kind: "others" } | { kind: "unknown" }; 5 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 type MeetingState = "recording" | "done" | "interrupted"; 6 +export interface AudioTrack { file: string; speaker: Speaker; duration_ms: number } 14 7 15 8 export interface Meeting { 16 9 id: string; 17 10 title: string; 18 11 created_at: string; 19 − source: "import" | "recording"; 20 − duration_ms: number; 21 − segments: number; 22 − analysis: AnalysisMeta | null; 23 − state: MeetingState; 24 −} 25 − 26 −export interface Analysis { 27 − title: string; 28 − summary: string; 29 − review: { achieved: string[]; open: string[]; off_track: string[]; verdict: string }; 30 − decisions: { text: string; source_segment: number }[]; 31 − tasks: { text: string; owner: string | null; due: string | null; quote: string; source_segment: number }[]; 32 − open_questions: string[]; 12 + updated_at: string; 13 + audio: AudioTrack[]; 14 + recording: boolean; 15 + transcribed_at: string | null; 33 16 } 34 17 35 −export interface MeetingDetail { 36 − meeting: Meeting; 37 − transcript: Transcript; 38 − analysis: Analysis | null; 39 − live_summary: string | null; 40 −} 18 +export interface MeetingDetail { meeting: Meeting; note: string } 41 19 42 20 export interface Settings { 43 − live_model: string; 44 − analysis_model: string; 21 + ai_model: string; 45 22 stt_model: string; 46 − warning_text: string; 47 − always_warn_myself: boolean; 48 23 language: string; 49 − keep_audio: boolean; 50 − launch_at_login: boolean; 24 + warning_text: string; 51 25 } 52 26 53 27 export interface SettingsView { 57 31 capture_supported: boolean; 58 32 } 59 33 60 −/** Состояние записи — `Status` из src-tauri/src/controller.rs. */ 61 −export type RecorderStatus = 62 − | { kind: "unsupported" } 63 − | { kind: "watching" } 64 − | { kind: "awaiting_consent" } 65 − | { kind: "recording"; meeting_id: string; started_at: string; paused: boolean; zoom: boolean } 66 − | { kind: "finishing"; meeting_id: string; step: "transcribing" | "analyzing" }; 34 +export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean } 35 + 36 +export type Command = 37 + | { kind: "summary" } 38 + | { kind: "tasks" } 39 + | { kind: "review" } 40 + | { kind: "custom"; text: string }; 41 + 42 +export interface RecordingView { meeting_id: string; started_at: string; paused: boolean; zoom: boolean } 43 + 44 +export interface TranscribeResult { note: string; failed: number; last_error: string | null } 67 45 68 −/** Событие записи — `Event` из src-tauri/src/recorder.rs. */ 69 46 export type RecorderEvent = 70 − | { kind: "segments"; data: Segment[] } 71 47 | { kind: "level"; data: { speaker: Speaker; dbfs: number } } 72 − | { kind: "live_summary"; data: string } 73 − | { kind: "track_failed"; data: { speaker: Speaker; error: string } } 74 − | { kind: "stt_failed"; data: string }; 75 − 76 −export interface KeyInfo { label: string | null; limit_remaining: number | null; usage: number | null; is_free_tier: boolean } 48 + | { kind: "track_failed"; data: { speaker: Speaker; error: string } }; 77 49 78 50 type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>; 79 51 type Listen = <T>(event: string, cb: (payload: T) => void) => Promise<() => void>; 80 52 81 −interface Core { invoke: Invoke; listen: Listen } 53 +interface Core { 54 + invoke: Invoke; 55 + listen: Listen; 56 + pickAudio: () => Promise<string | null>; 57 + onFileDrop: (cb: (paths: string[]) => void) => Promise<() => void>; 58 +} 59 + 60 +const AUDIO_EXTENSIONS = ["mp3", "m4a", "wav", "aiff", "aif", "flac", "ogg", "aac", "mp4", "mkv", "caf"]; 82 61 83 62 let core: Promise<Core> | null = null; 84 63 87 66 if ("__TAURI_INTERNALS__" in window) { 88 67 const { invoke } = await import("@tauri-apps/api/core"); 89 68 const { listen } = await import("@tauri-apps/api/event"); 90 − return { invoke, listen: (event, cb) => listen(event, (e) => cb(e.payload as never)) }; 69 + const { getCurrentWebview } = await import("@tauri-apps/api/webview"); 70 + const { open } = await import("@tauri-apps/plugin-dialog"); 71 + return { 72 + invoke, 73 + listen: (event, cb) => listen(event, (e) => cb(e.payload as never)), 74 + pickAudio: async () => { 75 + const picked = await open({ multiple: false, filters: [{ name: "Аудио", extensions: AUDIO_EXTENSIONS }] }); 76 + return typeof picked === "string" ? picked : null; 77 + }, 78 + onFileDrop: (cb) => 79 + getCurrentWebview().onDragDropEvent((e) => { 80 + if (e.payload.type === "drop") cb(e.payload.paths); 81 + }), 82 + }; 91 83 } 92 84 if (import.meta.env.DEV || import.meta.env.VITE_MOCK === "1") { 93 85 // Только для просмотра интерфейса в обычном браузере: `npm run dev` 103 95 return (await getCore()).invoke<T>(cmd, args); 104 96 } 105 97 106 −/** Подписка на событие ядра; возвращает отписку. */ 107 98 export async function on<T>(event: string, cb: (payload: T) => void): Promise<() => void> { 108 99 return (await getCore()).listen<T>(event, cb); 109 100 } 110 101 111 102 export const events = { 112 − status: (cb: (s: RecorderStatus) => void) => on<RecorderStatus>("meet://status", cb), 113 103 recorder: (cb: (p: { meeting_id: string; event: RecorderEvent }) => void) => on("meet://recorder", cb), 114 − meetingChanged: (cb: (id: string) => void) => on<string>("meet://meeting-changed", cb), 104 + progress: (cb: (p: { meeting_id: string; done: number; total: number }) => void) => on("meet://progress", cb), 105 +}; 106 + 107 +export const files = { 108 + pickAudio: async () => (await getCore()).pickAudio(), 109 + onDrop: async (cb: (paths: string[]) => void) => (await getCore()).onFileDrop(cb), 110 + isAudio: (path: string) => AUDIO_EXTENSIONS.includes(path.split(".").pop()?.toLowerCase() ?? ""), 115 111 }; 116 112 117 113 export const api = { 122 118 deleteOpenrouterKey: () => call<void>("delete_openrouter_key"), 123 119 listMeetings: () => call<Meeting[]>("list_meetings"), 124 120 getMeeting: (id: string) => call<MeetingDetail>("get_meeting", { id }), 125 − importMeeting: (title: string, text: string) => call<Meeting>("import_meeting", { title, text }), 126 − analyzeMeeting: (id: string) => call<Meeting>("analyze_meeting", { id }), 127 − recorderStatus: () => call<RecorderStatus>("recorder_status"), 128 − recorderConsent: (record: boolean) => call<void>("recorder_consent", { record }), 121 + createMeeting: () => call<Meeting>("create_meeting"), 122 + saveNote: (id: string, text: string) => call<Meeting>("save_note", { id, text }), 123 + renameMeeting: (id: string, title: string) => call<Meeting>("rename_meeting", { id, title }), 124 + deleteMeeting: (id: string) => call<void>("delete_meeting", { id }), 125 + addAudio: (path: string) => call<Meeting>("add_audio", { path }), 126 + transcribe: (id: string) => call<TranscribeResult>("transcribe_meeting", { id }), 127 + runCommand: (id: string, command: Command) => call<string>("run_command", { id, command }), 128 + recorderStatus: () => call<RecordingView | null>("recorder_status"), 129 129 recorderStart: () => call<Meeting>("recorder_start"), 130 130 recorderPause: (paused: boolean) => call<void>("recorder_pause", { paused }), 131 − recorderStop: () => call<void>("recorder_stop"), 131 + recorderStop: () => call<Meeting>("recorder_stop"), 132 132 }; 133 133 134 −export function speakerLabel(s: Speaker): string { 135 − switch (s.kind) { 136 − case "me": return "Я"; 137 − case "others": return "Собеседники"; 138 − case "named": return s.name; 139 − } 140 −} 141 − 142 −export function formatTime(ms: number): string { 143 − const t = Math.floor(ms / 1000); 144 − const p = (n: number) => String(n).padStart(2, "0"); 145 − return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`; 146 −} 147 − 148 134 export function formatDuration(ms: number): string { 135 + if (ms <= 0) return ""; 149 136 if (ms < 60000) return "меньше минуты"; 150 137 const min = Math.round(ms / 60000); 151 138 if (min < 60) return `${min} мин`; 152 139 return `${Math.floor(min / 60)} ч ${min % 60} мин`; 153 140 } 154 141 142 +export function meetingDuration(m: Meeting): number { 143 + return Math.max(0, ...m.audio.map((a) => a.duration_ms)); 144 +} 145 + 155 146 export function formatDate(iso: string): string { 156 147 return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); 157 148 } 158 149 150 +export function elapsed(since: string): string { 151 + const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)); 152 + const p = (n: number) => String(n).padStart(2, "0"); 153 + return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`; 154 +} 155 + 159 156 export function errorText(e: unknown): string { 160 157 return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); 161 158 } -
src/dev-mock.ts +134 −179
Показать разницуСкрыть разницу
1 1 // Заглушка ядра для просмотра интерфейса в обычном браузере (`npm run dev`, 2 2 // `npm run build:mock`). В сборку приложения не попадает (см. api.ts). 3 −// 4 −// Изображает и запись: «встреча» идёт по репликам планёрки из fixtures, с 5 −// уровнями звука и живым конспектом. Модель здесь не вызывается — разбор 6 −// заготовлен руками. Для проверки экранов есть ручки в консоли: 7 −// __meetMock.meetingStarted() — «началась встреча в Zoom» 8 −// __meetMock.noKey() — ключ удалён 3 +// ИИ здесь не вызывается: расшифровка и ответы заготовлены руками. 9 4 10 5 import planerka from "../fixtures/transcripts/planerka.txt?raw"; 11 −import klient from "../fixtures/transcripts/klient.txt?raw"; 12 −import type { 13 − Analysis, KeyInfo, Meeting, MeetingDetail, RecorderStatus, Segment, Settings, SettingsView, Speaker, 14 −} from "./api"; 15 − 16 −function parse(text: string): Segment[] { 17 − const out: Segment[] = []; 18 − for (const raw of text.split("\n")) { 19 − const line = raw.trim(); 20 − if (!line) continue; 21 − const m = /^\[(\d{1,2}):(\d{2}):(\d{2})\]\s*([^:]+):\s*(.*)$/.exec(line); 22 − if (m) { 23 − const ms = ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000; 24 − out.push({ start_ms: ms, speaker: { kind: "named", name: m[4].trim() }, text: m[5] }); 25 − } else if (out.length) { 26 − out[out.length - 1].text += " " + line; 27 − } else { 28 − throw "строка без метки времени перед первой репликой"; 29 − } 30 − } 31 − if (!out.length) throw "в тексте нет ни одной реплики"; 32 − return out; 33 −} 34 − 35 −const planerkaAnalysis: Analysis = { 36 − title: "Планёрка: релиз, отчёт инвестору, отпуск", 37 − summary: 38 − "Релиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу. Дежурства на время отпуска Дениса закрыты только на первую неделю.", 39 − review: { 40 − achieved: ["Решили судьбу релиза и назначили ответственного за блокер", "Согласовали срок отчёта для инвестора"], 41 − open: ["Кто дежурит во вторую неделю отпуска Дениса", "Затронет ли переезд на новый биллинг партнёрские выгрузки"], 42 − off_track: ["Спор о новом биллинге посреди пункта про отчёт"], 43 − verdict: "Встреча короткая и полезная; спорные темы стоит сразу выносить в отдельный список, не открывая обсуждение.", 44 − }, 45 − decisions: [ 46 − { text: "Релиз переносится с четверга на следующий вторник", source_segment: 4 }, 47 − { text: "Биллинг обсуждаем на отдельной встрече", source_segment: 17 }, 48 − ], 49 − tasks: [ 50 − { text: "Починить падение экрана оплаты на Android 12", owner: "Денис", due: "пятница", quote: "Денис, падение на Android 12 — на тебе, срок — пятница.", source_segment: 7 }, 51 − { text: "Выгрузить партнёрские данные для отчёта", owner: "Марат", due: "до среды", quote: "Партнёрские данные у меня, выгружу до среды.", source_segment: 10 }, 52 − { text: "Отправить отчёт инвестору", owner: "Света", due: "в четверг", quote: "тогда отчёт отправлю в четверг.", source_segment: 11 }, 53 − { text: "Собрать встречу по новому биллингу", owner: "Марат", due: "на следующей неделе", quote: "Соберу, на следующей неделе.", source_segment: 18 }, 54 − ], 55 − open_questions: ["Кто прикрывает дежурства во вторую неделю отпуска Дениса?"], 56 −}; 6 +import type { Command, KeyInfo, Meeting, MeetingDetail, RecordingView, Settings, SettingsView, Speaker } from "./api"; 57 7 58 8 // ── Шина событий ──────────────────────────────────────────────────────────── 59 9 73 23 74 24 // ── Данные ────────────────────────────────────────────────────────────────── 75 25 26 +/** Расшифровка планёрки в том виде, в каком ядро кладёт её в заметку. */ 27 +const TRANSCRIPT = 28 + "## Расшифровка\n\n" + 29 + planerka 30 + .trim() 31 + .split("\n") 32 + .map((l) => l.replace(/^\[(\d\d:\d\d:\d\d)\] ([^:]+): /, "**$1 $2:** ")) 33 + .join("\n\n") + 34 + "\n"; 35 + 36 +const ANSWERS: Record<string, string> = { 37 + summary: 38 + "## Конспект\n\nРелиз мобильной версии сдвинули с четверга на вторник из-за падения экрана оплаты на Android 12. Отчёт для инвестора уйдёт в четверг, когда Марат выгрузит партнёрские данные. Переезд на новый биллинг вынесли на отдельную встречу.\n\n### Решения\n\n- Релиз переносится на следующий вторник (00:00:55)\n- Биллинг — отдельной встречей (00:02:40)\n", 39 + tasks: 40 + "## Задачи\n\n- [ ] Починить падение оплаты на Android 12 — Денис, пятница (00:01:08)\n- [ ] Выгрузить партнёрские данные — Марат, до среды (00:01:34)\n- [ ] Отправить отчёт инвестору — Света, четверг (00:01:40)\n- [ ] Собрать встречу по биллингу — Марат, на следующей неделе (00:02:45)\n\n### Открытые вопросы\n\n- Кто дежурит во вторую неделю отпуска Дениса?\n", 41 + review: 42 + "## Ревью\n\nВстреча короткая и по делу: по релизу и отчёту есть решения и ответственные. В сторону ушли на биллинге — спор начался посреди пункта про отчёт. Открытым остался вопрос дежурств.\n\nСтоила своего времени; спорные темы лучше сразу выносить в список на потом.\n", 43 +}; 44 + 76 45 const now = Date.now(); 77 46 const details: MeetingDetail[] = []; 78 47 const p2 = (n: number) => String(n).padStart(2, "0"); 81 50 return `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`; 82 51 } 83 52 84 −function add(title: string, segments: Segment[], minutesAgo: number, analysis: Analysis | null, state: Meeting["state"] = "done"): MeetingDetail { 53 +function add(title: string, minutesAgo: number, note: string, audio: Meeting["audio"], transcribed: boolean): MeetingDetail { 85 54 const created = new Date(now - minutesAgo * 60000); 86 − const meeting: Meeting = { 87 − id: makeId(created), 88 − title, 89 − created_at: created.toISOString(), 90 − source: state === "done" && minutesAgo > 60 ? "import" : "recording", 91 − duration_ms: segments.length ? segments[segments.length - 1].start_ms : 0, 92 − segments: segments.length, 93 − analysis: analysis && { 94 − model: "anthropic/claude-opus-5.5", 95 − at: created.toISOString(), 96 − usage: { prompt_tokens: 2140, completion_tokens: 810, cost: 0.0248 }, 97 − tasks: analysis.tasks.length, 55 + let id = makeId(created); 56 + while (details.some((d) => d.meeting.id === id)) id += "-2"; 57 + const d: MeetingDetail = { 58 + meeting: { 59 + id, title, created_at: created.toISOString(), updated_at: created.toISOString(), audio, 60 + recording: false, transcribed_at: transcribed ? created.toISOString() : null, 98 61 }, 99 − state, 62 + note, 100 63 }; 101 − const d: MeetingDetail = { meeting, transcript: { segments }, analysis, live_summary: null }; 102 64 details.unshift(d); 103 65 return d; 104 66 } 105 67 106 −add("Планёрка команды", parse(planerka), 60 * 26, planerkaAnalysis); 107 −add("Встреча с клиентом: сайт", parse(klient), 90, null); 68 +const other: Speaker = { kind: "unknown" }; 69 +add("Планёрка команды", 60 * 26, `${TRANSCRIPT}\n${ANSWERS.tasks}`, [{ file: "Планёрка.m4a", speaker: other, duration_ms: 195_000 }], true); 70 +add("Созвон с клиентом", 90, "", [{ file: "Созвон с клиентом.m4a", speaker: other, duration_ms: 2_760_000 }], false); 108 71 109 72 let settings: Settings = { 110 − live_model: "anthropic/claude-haiku-4.5", 111 − analysis_model: "anthropic/claude-opus-5.5", 73 + ai_model: "anthropic/claude-opus-5.5", 112 74 stt_model: "openai/whisper-large-v3-turbo", 75 + language: "ru", 113 76 warning_text: 114 77 "Коллеги, я записываю встречу, чтобы сделать расшифровку и список задач. Запись остаётся у меня и никуда не публикуется. Если кто-то против — скажите, я выключу.", 115 − always_warn_myself: false, 116 − language: "ru", 117 − keep_audio: false, 118 − launch_at_login: true, 119 78 }; 120 79 let key: string | null = "sk-or-v1…demo"; 80 +let captureSupported = true; 121 81 const info: KeyInfo = { label: "просмотр без ядра", limit_remaining: 4.21, usage: 0.79, is_free_tier: false }; 122 82 123 −// ── Имитация записи ───────────────────────────────────────────────────────── 124 − 125 −let status: RecorderStatus = { kind: "watching" }; 126 −let timers: number[] = []; 83 +let recording: (RecordingView & { timer: number }) | null = null; 127 84 128 −function setStatus(s: RecorderStatus): void { 129 − status = s; 130 − emit("meet://status", s); 131 −} 85 +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); 132 86 133 −function startRecording(zoom: boolean): Meeting { 134 − const d = new Date(); 135 − const d0 = add(`${zoom ? "Встреча в Zoom," : "Запись,"} ${p2(d.getDate())}.${p2(d.getMonth() + 1)}, ${p2(d.getHours())}:${p2(d.getMinutes())}`, [], 0, null, "recording"); 136 − const id = d0.meeting.id; 137 − setStatus({ kind: "recording", meeting_id: id, started_at: d.toISOString(), paused: false, zoom }); 138 − const script = parse(planerka); 139 − let i = 0; 140 − timers.push(window.setInterval(() => { 141 − if (status.kind !== "recording" || status.paused || i >= script.length) return; 142 − const src = script[i++]; 143 − const speaker: Speaker = i % 3 === 1 ? { kind: "me" } : { kind: "others" }; 144 − const seg: Segment = { start_ms: Date.now() - Date.parse(status.started_at), speaker, text: src.text }; 145 − d0.transcript.segments.push(seg); 146 − d0.meeting.segments++; 147 − d0.meeting.duration_ms = seg.start_ms; 148 − emit("meet://recorder", { meeting_id: id, event: { kind: "segments", data: [seg] } }); 149 − if (i === 6) { 150 − d0.live_summary = "- Обсуждают релиз мобильной версии\n- Договорились: релиз переносится на вторник\n- Задача: Денис чинит оплату на Android 12 до пятницы"; 151 − emit("meet://recorder", { meeting_id: id, event: { kind: "live_summary", data: d0.live_summary } }); 152 − } 153 − }, 1800)); 154 − timers.push(window.setInterval(() => { 155 − if (status.kind !== "recording") return; 156 − const quiet = status.paused; 157 − for (const speaker of [{ kind: "me" }, ...(zoom ? [{ kind: "others" }] : [])] as Speaker[]) { 158 − const dbfs = quiet ? -90 : -60 + Math.random() * 45; 159 − emit("meet://recorder", { meeting_id: id, event: { kind: "level", data: { speaker, dbfs } } }); 160 − } 161 − }, 200)); 162 − return d0.meeting; 87 +function find(id: unknown): MeetingDetail { 88 + const d = details.find((x) => x.meeting.id === id); 89 + if (!d) throw `встреча «${id}» не найдена`; 90 + return d; 163 91 } 164 92 165 −function stopRecording(): void { 166 − if (status.kind !== "recording") throw "Запись не идёт"; 167 − const id = status.meeting_id; 168 − timers.forEach(clearInterval); 169 − timers = []; 170 − const d = details.find((x) => x.meeting.id === id)!; 171 − setStatus({ kind: "finishing", meeting_id: id, step: "transcribing" }); 172 − setTimeout(() => { 173 − d.meeting.state = "done"; 174 − emit("meet://meeting-changed", id); 175 − if (!d.transcript.segments.length) { 176 − setStatus({ kind: "watching" }); 177 − return; 178 − } 179 − setStatus({ kind: "finishing", meeting_id: id, step: "analyzing" }); 180 − setTimeout(() => { 181 − d.analysis = { ...planerkaAnalysis, decisions: [], tasks: planerkaAnalysis.tasks.map((t) => ({ ...t, source_segment: 0 })) }; 182 − d.meeting.analysis = { model: settings.analysis_model, at: new Date().toISOString(), usage: { prompt_tokens: 900, completion_tokens: 600, cost: 0.0156 }, tasks: 4 }; 183 − emit("meet://meeting-changed", id); 184 − setStatus({ kind: "watching" }); 185 − }, 2500); 186 − }, 1200); 93 +function append(d: MeetingDetail, block: string): string { 94 + d.note = (d.note.trimEnd() ? d.note.trimEnd() + "\n\n" : "") + block.trim() + "\n"; 95 + return d.note; 187 96 } 188 97 189 98 (window as unknown as { __meetMock: object }).__meetMock = { 190 − meetingStarted: () => status.kind === "watching" && setStatus({ kind: "awaiting_consent" }), 191 − meetingEnded: () => (status.kind === "awaiting_consent" ? setStatus({ kind: "watching" }) : status.kind === "recording" && stopRecording()), 192 − noKey: () => { key = null; setStatus(status); }, 193 − unsupported: () => setStatus({ kind: "unsupported" }), 99 + noKey: () => (key = null), 100 + unsupported: () => (captureSupported = false), 194 101 }; 195 102 196 −const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); Разница обрезана: показаны первые 227 строк. -
src/main.ts +17 −18
Показать разницуСкрыть разницу
1 −// Маршрутизация по хешу: #/ (главная), #/meeting/<id>/<вкладка>[/<реплика>], #/settings. 1 +// Оболочка: боковая панель со встречами слева, экран справа. 2 +// Маршруты: #/ — начало, #/m/<id> — встреча, #/settings — настройки. 2 3 3 −import { renderHome } from "./views/home"; 4 −import { renderMeeting, type Tab } from "./views/meeting"; 4 +import { renderNote } from "./views/note"; 5 5 import { renderSettings } from "./views/settings"; 6 +import { renderStart } from "./views/start"; 7 +import { mountSidebar } from "./views/sidebar"; 6 8 import { errorText } from "./api"; 7 9 import { esc } from "./dom"; 8 10 import { Scope } from "./scope"; 9 11 10 −const app = document.getElementById("app")!; 12 +const main = document.getElementById("app")!; 11 13 let scope = new Scope(); 12 14 13 15 async function route(): Promise<void> { 14 16 scope.dispose(); 15 17 scope = new Scope(); 16 − const [section, id, tab, target] = location.hash.replace(/^#\/?/, "").split("/"); 17 − for (const a of document.querySelectorAll<HTMLAnchorElement>("[data-nav]")) { 18 − const current = a.dataset.nav === (section === "settings" ? "settings" : "home"); 19 − if (current) a.setAttribute("aria-current", "page"); 20 − else a.removeAttribute("aria-current"); 21 − } 18 + const [section, id] = location.hash.replace(/^#\/?/, "").split("/"); 19 + const settingsLink = document.querySelector("[data-nav=settings]"); 20 + if (section === "settings") settingsLink?.setAttribute("aria-current", "page"); 21 + else settingsLink?.removeAttribute("aria-current"); 22 22 try { 23 − if (section === "meeting" && id) { 24 − await renderMeeting(app, scope, decodeURIComponent(id), (tab as Tab) || "transcript", target ? Number(target) : null); 25 − } else if (section === "settings") { 26 − await renderSettings(app); 27 − } else { 28 − await renderHome(app, scope); 29 − } 23 + if (section === "m" && id) await renderNote(main, scope, decodeURIComponent(id)); 24 + else if (section === "settings") await renderSettings(main); 25 + else await renderStart(main); 30 26 } catch (e) { 31 − app.innerHTML = `<p class="error">${esc(errorText(e))}</p>`; 27 + main.innerHTML = `<p class="error">${esc(errorText(e))}</p>`; 32 28 } 33 29 } 34 30 31 +mountSidebar(document.getElementById("sidebar")!).catch((e) => { 32 + document.getElementById("sidebar")!.innerHTML = `<p class="error">${esc(errorText(e))}</p>`; 33 +}); 35 34 window.addEventListener("hashchange", route); 36 35 route(); -
src/markdown.ts +68
Показать разницуСкрыть разницу
1 +// Markdown заметки: отображение и деление на разделы. 2 +// 3 +// Заметка — единственный источник текста. В режиме просмотра она делится на 4 +// разделы по заголовкам второго уровня (`## Расшифровка`, `## Задачи`), и у 5 +// каждого раздела своя кнопка «Копировать» — копируется исходный Markdown 6 +// раздела, а не HTML. 7 + 8 +import { marked } from "marked"; 9 +import DOMPurify from "dompurify"; 10 + 11 +marked.setOptions({ gfm: true, breaks: false }); 12 + 13 +export function render(md: string): string { 14 + return DOMPurify.sanitize(marked.parse(md, { async: false })); 15 +} 16 + 17 +export interface Section { 18 + /** Заголовок раздела без `## `; у текста до первого заголовка — пусто. */ 19 + heading: string; 20 + /** Исходный Markdown раздела вместе с заголовком. */ 21 + source: string; 22 +} 23 + 24 +export function sections(md: string): Section[] { 25 + const out: Section[] = []; 26 + let current: Section = { heading: "", source: "" }; 27 + let fence = false; 28 + for (const line of md.split("\n")) { 29 + if (/^(```|~~~)/.test(line)) fence = !fence; 30 + const h = !fence && /^## (.+)$/.exec(line); 31 + if (h) { 32 + if (current.source.trim()) out.push(current); 33 + current = { heading: h[1].trim(), source: "" }; 34 + } 35 + current.source += line + "\n"; 36 + } 37 + if (current.source.trim()) out.push(current); 38 + for (const s of out) s.source = s.source.trim() + "\n"; 39 + return out; 40 +} 41 + 42 +/** Текст без разметки — для вставки туда, где Markdown не понимают. */ 43 +export function plain(md: string): string { 44 + const div = document.createElement("div"); 45 + div.innerHTML = render(md); 46 + return (div.innerText || div.textContent || "").replace(/\n{3,}/g, "\n\n").trim(); 47 +} 48 + 49 +/** 50 + * Переключает n-ю по счёту галочку списка задач (`- [ ]` ↔ `- [x]`) в 51 + * исходнике. Порядок тот же, что у галочек в отображении: код в ``` не в счёт. 52 + */ 53 +export function toggleTask(md: string, n: number): string { 54 + let fence = false; 55 + let seen = 0; 56 + return md 57 + .split("\n") 58 + .map((line) => { 59 + if (/^(```|~~~)/.test(line)) fence = !fence; 60 + if (fence) return line; 61 + const m = /^(\s*(?:[-*+]|\d+[.)])\s+)\[( |x|X)\]/.exec(line); 62 + if (!m) return line; 63 + if (seen++ !== n) return line; 64 + const mark = m[2] === " " ? "x" : " "; 65 + return m[1] + `[${mark}]` + line.slice(m[0].length); 66 + }) 67 + .join("\n"); 68 +} -
src/state.ts +45
Показать разницуСкрыть разницу
1 +// Общее состояние интерфейса: идёт ли запись и сообщения об ошибках. 2 +// Боковая панель и экран заметки подписываются на изменения. 3 + 4 +import { api, errorText, type RecordingView } from "./api"; 5 + 6 +type Listener = () => void; 7 +const listeners = new Set<Listener>(); 8 + 9 +export const state = { 10 + recording: null as RecordingView | null, 11 +}; 12 + 13 +export function onChange(cb: Listener): () => void { 14 + listeners.add(cb); 15 + return () => listeners.delete(cb); 16 +} 17 + 18 +export function changed(): void { 19 + listeners.forEach((cb) => cb()); 20 +} 21 + 22 +export async function refreshRecording(): Promise<void> { 23 + state.recording = await api.recorderStatus(); 24 + changed(); 25 +} 26 + 27 +/** Сообщение внизу экрана: ошибка или «Скопировано». Исчезает само. */ 28 +export function toast(message: unknown, kind: "error" | "ok" = "error"): void { 29 + const el = document.getElementById("toast"); 30 + if (!el) return; 31 + el.textContent = kind === "error" ? errorText(message) : String(message); 32 + el.className = `toast is-${kind}`; 33 + el.hidden = false; 34 + window.clearTimeout(Number(el.dataset.timer)); 35 + el.dataset.timer = String(window.setTimeout(() => (el.hidden = true), kind === "error" ? 8000 : 2000)); 36 +} 37 + 38 +export async function copy(text: string, what = "Скопировано"): Promise<void> { 39 + try { 40 + await navigator.clipboard.writeText(text); 41 + toast(what, "ok"); 42 + } catch (e) { 43 + toast(e); 44 + } 45 +} -
src/styles/app.css +163 −75
Показать разницуСкрыть разницу
1 −/* Раскладка приложения: шапка как у skillok.org и одна колонка контента. */ 1 +/* Раскладка: шапка как у skillok.org, слева встречи, справа заметка. */ 2 + 3 +html, body { height: 100%; } 4 +body { display: flex; flex-direction: column; } 2 5 3 6 .header { 4 − position: sticky; top: 0; z-index: 10; 7 + flex: none; 5 8 display: flex; align-items: center; gap: 24px; 6 − height: 56px; padding: 0 24px; 9 + height: 52px; padding: 0 20px; 7 10 background: var(--background); 8 11 border-bottom: 1px solid var(--color-border); 9 12 } 10 13 .brand { display: flex; align-items: center; gap: 10px; color: var(--color-text-primary); font-weight: 700; font-size: var(--text-lg); } 11 14 .brand:hover { text-decoration: none; } 12 −.brand-mark { width: 24px; height: 24px; } 15 +.brand-mark { width: 22px; height: 22px; } 13 16 .header-nav { display: flex; gap: 4px; margin-left: auto; } 14 17 .header-nav a { 15 18 display: inline-flex; align-items: center; height: 32px; padding: 0 10px; 18 21 .header-nav a:hover { background: var(--color-bg-hover); text-decoration: none; } 19 22 .header-nav a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-accent); } 20 23 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 −} 24 +.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 280px 1fr; } 25 +.main { min-width: 0; overflow-y: auto; padding: 28px 32px 64px; } 58 26 59 −/* ── Карточка состояния (главный экран, задача #1) ── */ 60 −.status-card { 61 − display: grid; gap: 12px; 62 − padding: 24px; 63 − border: 1px solid var(--color-border); 27 +/* ── Боковая панель ── */ 28 +.sidebar { 29 + display: flex; flex-direction: column; min-height: 0; 30 + border-right: 1px solid var(--color-border); 64 31 background: var(--color-bg-elevated); 65 32 } 66 −.status-card.is-attention, .status-card.is-recording { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); } 67 −.status-kicker { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); } 68 −.status-title { font-size: var(--text-3xl); } 69 −.status-text { max-width: 60ch; color: var(--kv-body); } 33 +.side-actions { display: grid; gap: 4px; padding: 16px; border-bottom: 1px solid var(--color-border); } 34 +.side-main { width: 100%; margin-bottom: 6px; } 35 +.side-link { 36 + justify-self: start; padding: 4px 0; 37 + font: inherit; font-size: var(--text-md); color: var(--color-text-primary); 38 + background: none; border: 0; cursor: pointer; text-align: left; 39 +} 40 +.side-link::before { content: "+ "; color: var(--color-accent-text); } 41 +.side-link:hover { color: var(--color-accent-text); } 42 +.side-stop::before { content: "■ "; color: var(--color-accent); } 43 +.side-note { margin: 0 0 6px; } 44 +.rec-now { 45 + display: flex; align-items: center; gap: 8px; padding: 6px 0; 46 + color: var(--color-text-primary); font-size: var(--text-sm); 47 +} 48 +.rec-now .mono-num { margin-left: auto; } 49 +.rec-now:hover { text-decoration: none; color: var(--color-accent-text); } 50 + 51 +.side-list { flex: 1; overflow-y: auto; padding: 6px 0; } 52 +.side-item { 53 + display: grid; gap: 2px; padding: 10px 16px; 54 + color: var(--color-text-primary); border-left: 2px solid transparent; 55 +} 56 +.side-item:hover { background: var(--color-bg-hover); text-decoration: none; } 57 +.side-item.is-active { background: var(--color-bg-secondary); border-left-color: var(--color-accent); } 58 +.side-title { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 59 +.side-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } 60 +.side-empty { padding: 12px 16px; } 61 +.badge { 62 + display: inline-block; padding: 0 5px; line-height: 18px; 63 + font-size: var(--text-xs); border: 1px solid var(--color-border-light); color: var(--color-text-secondary); 64 +} 65 +.badge-live { border-color: var(--color-accent); color: var(--color-accent-text); } 66 + 67 +/* ── Общие мелочи ── */ 68 +.actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } 69 +.spacer { flex: 1; } 70 70 .mono-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } 71 +.page { max-width: 760px; display: grid; gap: 24px; } 72 +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } 73 + 74 +.link-btn { 75 + padding: 0 2px; min-height: 28px; 76 + font: inherit; font-size: var(--text-sm); 77 + color: var(--color-accent-text); background: none; border: 0; cursor: pointer; 78 + text-decoration: underline; text-underline-offset: 3px; 79 +} 80 +.link-btn:hover { color: var(--color-text-primary); } 81 +.link-btn[disabled] { opacity: 0.5; cursor: default; } 82 +.link-danger { color: var(--color-error); } 83 +a.ui-btn, a.ui-btn:hover { text-decoration: none; } 84 + 85 +/* Быстрая команда ИИ — лёгкая рамка, не кнопка первого ряда. */ 86 +.chip { 87 + min-height: 28px; padding: 0 10px; 88 + font: inherit; font-size: var(--text-sm); color: var(--color-text-primary); 89 + background: var(--color-bg-elevated); border: 1px solid var(--color-border-light); cursor: pointer; 90 +} 91 +.chip:hover { border-color: var(--kv-ink); } 92 +.chip:active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-accent); } 93 +.chip[disabled] { opacity: 0.45; cursor: not-allowed; } 71 94 72 95 /* Индикатор — квадрат: круг в продукте только у аватаров. */ 73 −.dot { display: inline-block; width: 8px; height: 8px; background: var(--color-text-secondary); } 74 −.dot-accent { background: var(--color-accent); } 96 +.dot { display: inline-block; flex: none; width: 8px; height: 8px; background: var(--color-text-secondary); } 75 97 .dot-live { background: var(--color-accent); animation: blink 1.4s steps(2, jump-none) infinite; } 76 98 .dot-live.is-paused { animation: none; background: var(--color-text-secondary); } 77 99 @keyframes blink { 50% { opacity: 0.25; } } 78 100 @media (prefers-reduced-motion: reduce) { .dot-live { animation: none; } } 79 101 80 −.warning-text { 81 − margin: 0; padding: 12px 16px; max-width: 72ch; 82 − border-left: 2px solid var(--color-accent); 83 − background: var(--color-bg-secondary); 102 +/* ── Заметка ── */ 103 +.note { max-width: 820px; display: grid; gap: 20px; } 104 +.note-head { display: grid; gap: 4px; } 105 +.title-input { 106 + width: 100%; padding: 2px 0; margin: 0; 107 + font: inherit; font-size: var(--text-3xl); font-weight: 700; line-height: 1.25; 108 + color: var(--color-text-primary); background: transparent; 109 + border: 0; border-bottom: 1px solid transparent; 84 110 } 111 +.title-input:hover { border-bottom-color: var(--color-border); } 112 +.title-input:focus { outline: none; border-bottom-color: var(--color-accent); } 85 113 114 +.panel { display: grid; gap: 10px; padding: 16px 20px; border: 1px solid var(--color-border); background: var(--color-bg-elevated); } 115 +.panel-live { border-color: var(--color-accent); box-shadow: inset 3px 0 0 var(--color-accent); } 116 +.panel-kicker { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--color-text-secondary); } 117 +.panel-timer { font-size: var(--text-3xl); font-weight: 700; } 86 118 .meters { display: grid; gap: 6px; max-width: 420px; } 87 119 .meter { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 12px; } 88 120 .meter-bar { height: 8px; background: var(--color-bg-secondary); overflow: hidden; } 89 121 .meter-fill { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.18s linear; } 90 122 91 −.live-lines { display: grid; gap: 4px; max-width: 72ch; font-size: var(--text-sm); min-height: 60px; } 92 −.live-lines .who { font-weight: 700; } 123 +.steps { display: grid; gap: 10px; } 124 +.step-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } 125 +.ai-row { gap: 8px; } 126 +.ask { display: flex; gap: 8px; } 127 +.ask .input { flex: 1; min-height: 28px; padding: 3px 10px; font-size: var(--text-sm); } 93 128 94 −/* Второстепенное действие — ссылкой, а не второй кнопкой (одна контурная кнопка на экран). */ 95 −.link-btn { 96 − padding: 0 4px; min-height: 32px; 97 − font: inherit; font-size: var(--text-sm); 98 − color: var(--color-accent-text); background: none; border: 0; cursor: pointer; 99 − text-decoration: underline; text-underline-offset: 3px; 129 +.editor { border: 1px solid var(--color-border); background: var(--color-bg-elevated); } 130 +.editor-bar { 131 + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; 132 + padding: 0 12px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary); 100 133 } 101 −.link-btn:hover { color: var(--color-text-primary); } 102 −.link-btn[disabled] { opacity: 0.5; cursor: default; } 103 −a.ui-btn { text-decoration: none; } 104 −a.ui-btn:hover { text-decoration: none; } 134 +.editor-bar .ui-underline-nav { box-shadow: none; } 135 +.note-editor { 136 + display: block; width: 100%; min-height: 60vh; padding: 16px 20px; 137 + font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.65; 138 + color: var(--color-text-primary); background: transparent; 139 + border: 0; resize: vertical; 140 +} 141 +.note-editor:focus { outline: none; } 142 +.note-editor[readonly] { opacity: 0.6; } 143 +.note-view { padding: 4px 0; } 144 +.note-empty { padding: 32px 20px; text-align: center; display: grid; gap: 6px; } 145 + 146 +.note-section { position: relative; padding: 12px 20px; border-top: 1px solid var(--kv-line-2); } 147 +.note-section:first-child { border-top: 0; } 148 +.section-copy { position: absolute; top: 12px; right: 16px; opacity: 0; transition: opacity 0.12s; } 149 +.note-section:hover .section-copy, .section-copy:focus-visible { opacity: 1; } 150 + 151 +.note-foot { padding-top: 8px; } 152 + 153 +/* Markdown в заметке */ 154 +.md { max-width: 72ch; display: grid; gap: 10px; } 155 +.md > * { margin: 0; } 156 +.md h1 { font-size: var(--text-2xl); } 157 +.md h2 { font-size: var(--text-xl); padding-right: 96px; } 158 +.md h3 { font-size: var(--text-lg); margin-top: 6px; } 159 +.md ul, .md ol { padding-left: 22px; display: grid; gap: 4px; } 160 +.md li > p { margin: 0; } 161 +.md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -22px; } 162 +.md input[type="checkbox"] { margin: 0 8px 0 0; accent-color: var(--color-accent); vertical-align: -1px; } 163 +.md code { padding: 0 4px; background: var(--color-bg-secondary); } 164 +.md pre { padding: 12px; background: var(--color-bg-secondary); overflow-x: auto; } 165 +.md pre code { padding: 0; background: none; } 166 +.md blockquote { padding: 4px 14px; border-left: 2px solid var(--color-border-light); color: var(--color-text-secondary); } 167 +.md strong { font-weight: 700; } 168 +.md a { word-break: break-word; } 169 +.md table { border-collapse: collapse; } 170 +.md th, .md td { padding: 4px 10px; border: 1px solid var(--color-border); text-align: left; } 171 + 172 +/* ── Начальный экран ── */ 173 +.start { max-width: 640px; display: grid; gap: 20px; padding-top: 24px; } 174 +.start h1 { font-size: var(--text-3xl); } 175 +.start-steps { margin: 0; padding-left: 22px; display: grid; gap: 10px; color: var(--kv-body); } 176 +.drop-zone { 177 + display: grid; gap: 4px; justify-items: center; padding: 28px 16px; 178 + font: inherit; color: var(--color-text-primary); cursor: pointer; 179 + background: var(--color-bg-elevated); border: 1px dashed var(--color-border-light); 180 +} 181 +.drop-zone:hover { border-color: var(--color-accent); } 182 +.start-key { padding: 10px 14px; border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); } 105 183 106 −.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } 184 +.advanced { display: grid; gap: 12px; } 185 +.advanced > summary { cursor: pointer; color: var(--color-accent-text); } 186 +.advanced[open] > summary { margin-bottom: 12px; } 187 +select.input { appearance: auto; } 107 188 108 −/* Расшифровка: свои реплики отмечены полосой слева. */ 109 −.segment.is-me { box-shadow: inset 2px 0 0 var(--color-border-light); } 110 −.segment.is-me.is-target { box-shadow: inset 2px 0 0 var(--color-accent); } 189 +/* ── Сообщение внизу ── */ 190 +.toast { 191 + position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20; 192 + max-width: min(640px, calc(100% - 32px)); margin: 0; padding: 10px 16px; 193 + font-size: var(--text-sm); color: var(--background); background: var(--kv-ink); 194 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); 195 +} 196 +.toast.is-error { background: var(--color-error); color: #fff; } 111 197 112 −.live-summary .box-head { align-items: baseline; } 113 −.pre { white-space: pre-wrap; } 114 −select.input { appearance: auto; } 198 +@media (max-width: 760px) { 199 + .layout { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } 200 + .sidebar { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--color-border); } 201 + .main { padding: 20px 16px 48px; } 202 +} -
src/views/home.ts −277
Показать разницуСкрыть разницу
1 −// Главный экран: что программа делает прямо сейчас (задача #1) и список встреч. 2 −// 3 −// Карточка состояния отвечает на один вопрос — «что происходит и что будет 4 −// дальше». В каждом состоянии одно главное действие. 5 − 6 −import { 7 − api, errorText, events, formatDate, formatDuration, speakerLabel, 8 − type Meeting, type RecorderStatus, type Segment, type SettingsView, type Speaker, 9 −} from "../api"; 10 −import { $, busy, esc, plural } from "../dom"; 11 −import type { Scope } from "../scope"; 12 − 13 −function elapsed(since: string): string { 14 − const t = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)); 15 − const p = (n: number) => String(n).padStart(2, "0"); 16 − return t >= 3600 ? `${Math.floor(t / 3600)}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}` : `${p(Math.floor(t / 60))}:${p(t % 60)}`; 17 −} 18 − 19 −function meter(label: string, key: string): string { 20 − return ` 21 − <div class="meter"> 22 − <span class="meter-label small">${esc(label)}</span> 23 − <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span> 24 − </div>`; 25 −} 26 − 27 −function card(status: RecorderStatus, view: SettingsView): string { 28 − const hasKey = Boolean(view.openrouter_key); 29 − if (status.kind === "unsupported") { 30 − return ` 31 − <section class="status-card"> 32 − <h2 class="status-title">Запись встреч работает в Windows</h2> 33 − <p class="status-text">На этом компьютере можно разобрать готовую расшифровку: вставьте её, и ИИ сделает конспект и задачи.</p> 34 − <div class="actions"><button class="ui-btn" data-open-import>Импортировать расшифровку</button></div> 35 − </section>`; 36 − } 37 − if (!hasKey && (status.kind === "watching" || status.kind === "awaiting_consent")) { 38 − return ` 39 − <section class="status-card"> 40 − <h2 class="status-title">Подключите ИИ, чтобы записывать встречи</h2> 41 − <p class="status-text">Расшифровка и разбор идут через ваш ключ OpenRouter. Без него встречу не записать.</p> 42 − <div class="actions"><a class="ui-btn" href="#/settings">Подключить</a></div> 43 − </section>`; 44 − } 45 − switch (status.kind) { 46 − case "watching": 47 − return ` 48 − <section class="status-card"> 49 − <p class="status-kicker"><span class="dot"></span>Слежу за Zoom</p> 50 − <h2 class="status-title">Жду встречу</h2> 51 − <p class="status-text">${ 52 − view.settings.always_warn_myself 53 − ? "Как только начнётся встреча в Zoom, запись включится сама." 54 − : "Как только начнётся встреча в Zoom, я попрошу предупредить участников и начну запись." 55 − }</p> 56 − <div class="actions"><button class="link-btn" data-start>Записать вручную</button></div> 57 − </section>`; 58 − case "awaiting_consent": 59 − return ` 60 − <section class="status-card is-attention"> 61 − <p class="status-kicker"><span class="dot dot-accent"></span>Началась встреча в Zoom</p> 62 − <h2 class="status-title">Предупредите участников о записи</h2> 63 − <blockquote class="warning-text" data-warning>${esc(view.settings.warning_text)}</blockquote> 64 − <div class="actions"> 65 − <button class="ui-btn" data-consent="yes">Предупредил, записывать</button> 66 − <button class="link-btn" data-copy>Скопировать текст</button> 67 − <button class="link-btn" data-consent="no">Не записывать</button> 68 − </div> 69 − </section>`; 70 − case "recording": 71 − return ` 72 − <section class="status-card is-recording"> 73 − <p class="status-kicker"><span class="dot dot-live${status.paused ? " is-paused" : ""}"></span>${ 74 − status.paused ? "Запись на паузе" : status.zoom ? "Записываю встречу в Zoom" : "Записываю с микрофона" 75 − }</p> 76 − <h2 class="status-title mono-num" data-timer>${elapsed(status.started_at)}</h2> 77 − <div class="meters"> 78 − ${meter("Я", "me")} 79 − ${status.zoom ? meter("Собеседники", "others") : ""} 80 − </div> 81 − <div class="live-lines" data-live><p class="muted small">Реплики появятся здесь через несколько секунд после начала разговора.</p></div> 82 − <div class="actions"> 83 − <button class="ui-btn" data-stop>Остановить</button> 84 − <button class="link-btn" data-pause>${status.paused ? "Продолжить" : "Пауза"}</button> 85 − <a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть расшифровку</a> 86 − </div> 87 − </section>`; 88 − case "finishing": 89 − return ` 90 − <section class="status-card"> 91 − <p class="status-kicker"><span class="dot dot-accent"></span>Запись остановлена</p> 92 − <h2 class="status-title">${status.step === "transcribing" ? "Дописываю расшифровку…" : "Разбираю встречу…"}</h2> 93 − <p class="status-text">${ 94 − status.step === "transcribing" 95 − ? "Распознаю последние реплики. Потом ИИ сделает конспект и задачи." 96 − : "ИИ пишет конспект, решения и задачи. Обычно это меньше минуты — пришлю уведомление." 97 − }</p> 98 − <div class="actions"><a class="link-btn" href="#/meeting/${encodeURIComponent(status.meeting_id)}">Открыть встречу</a></div> 99 − </section>`; 100 − } 101 −} 102 − 103 −function status(m: Meeting): string { 104 − if (m.state === "recording") return `<span class="label label-accent">идёт запись</span>`; 105 − if (m.state === "interrupted" && !m.analysis) return `<span class="label">прервана</span>`; 106 − if (!m.analysis) return `<span class="label">не разобрана</span>`; 107 − const n = m.analysis.tasks; 108 − return `<span class="label label-accent">${n} ${plural(n, "задача", "задачи", "задач")}</span>`; 109 −} 110 − 111 −function list(meetings: Meeting[]): string { 112 − if (!meetings.length) { 113 − return `<p class="empty">Здесь появятся записанные встречи.</p>`; 114 − } 115 − return meetings 116 − .map( 117 − (m) => ` 118 − <a class="row meeting-row" href="#/meeting/${encodeURIComponent(m.id)}"> 119 − <span class="title">${esc(m.title)}</span> 120 − <span class="when small muted">${esc(formatDate(m.created_at))} · ${esc(formatDuration(m.duration_ms))}</span> 121 − <span class="status">${status(m)}</span> 122 − </a>`, 123 − ) 124 − .join(""); 125 −} 126 − 127 −function importForm(): string { 128 − return ` 129 − <form class="box" data-import hidden> 130 − <div class="box-head"><h2>Импорт расшифровки</h2></div> 131 − <div class="box-body"> 132 − <div class="field"> 133 − <label for="imp-title">Название</label> 134 − <input id="imp-title" class="input" name="title" autocomplete="off" required> 135 − </div> 136 − <div class="field"> 137 − <label for="imp-text">Расшифровка</label> 138 − <textarea id="imp-text" class="textarea mono" name="text" rows="10" required 139 − placeholder="[00:00:05] Анна: Начнём с отчёта."></textarea> 140 − <p class="field-hint">Каждая реплика — с новой строки: [чч:мм:сс] Имя: текст.</p> 141 − </div> 142 − <p class="error" data-import-error hidden></p> 143 − <div class="actions"> 144 − <button class="ui-btn" type="submit">Создать встречу</button> 145 − <button class="link-btn" type="button" data-close-import>Отмена</button> 146 − </div> 147 − </div> 148 − </form>`; 149 −} 150 − 151 −function speakerKey(s: Speaker): string { 152 − return s.kind === "me" ? "me" : "others"; 153 −} 154 − 155 −export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> { 156 − const [recorder, initialView, meetings] = await Promise.all([api.recorderStatus(), api.getSettings(), api.listMeetings()]); 157 − if (!scope.alive) return; 158 − let view = initialView; 159 − 160 − root.innerHTML = ` 161 − <div data-card>${card(recorder, view)}</div> 162 − <p class="error" data-error hidden></p> 163 − ${importForm()} 164 − <section class="section"> 165 − <div class="section-head"> 166 − <h2>Встречи</h2> 167 − <button class="link-btn" data-open-import>Импортировать расшифровку</button> 168 − </div> 169 − <div class="box" data-list>${list(meetings)}</div> 170 − </section>`; 171 − 172 − const error = $(root, "[data-error]"); 173 − const fail = (e: unknown) => { 174 − error.textContent = errorText(e); 175 − error.hidden = false; 176 − }; 177 − let current = recorder; 178 − const live: Segment[] = []; 179 − 180 − const bindCard = () => { 181 − const box = $(root, "[data-card]"); 182 − box.querySelector<HTMLButtonElement>("[data-start]")?.addEventListener("click", (e) => 183 − busy(e.currentTarget as HTMLButtonElement, "Запускаю…", () => api.recorderStart()).catch(fail), 184 − ); 185 − box.querySelectorAll<HTMLButtonElement>("[data-consent]").forEach((b) => 186 − b.addEventListener("click", () => api.recorderConsent(b.dataset.consent === "yes").catch(fail)), 187 − ); 188 − box.querySelector<HTMLButtonElement>("[data-copy]")?.addEventListener("click", async (e) => { 189 − const b = e.currentTarget as HTMLButtonElement; 190 − await navigator.clipboard.writeText(view.settings.warning_text).catch(() => undefined); 191 − b.textContent = "Скопировано"; 192 − }); 193 − box.querySelector<HTMLButtonElement>("[data-stop]")?.addEventListener("click", (e) => 194 − busy(e.currentTarget as HTMLButtonElement, "Останавливаю…", () => api.recorderStop()).catch(fail), 195 − ); 196 − box.querySelector<HTMLButtonElement>("[data-pause]")?.addEventListener("click", () => 197 − api.recorderPause(current.kind === "recording" && !current.paused).catch(fail), 198 − ); 199 − box.querySelector("[data-open-import]")?.addEventListener("click", openImport); 200 − renderLive(); 201 − }; 202 − 203 − const renderLive = () => { 204 − const el = root.querySelector("[data-live]"); 205 − if (!el || !live.length) return; 206 − el.innerHTML = live 207 − .slice(-3) 208 − .map((s) => `<p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>`) 209 − .join(""); 210 − }; 211 − 212 − const refreshList = async () => { 213 − const fresh = await api.listMeetings(); 214 − if (scope.alive) $(root, "[data-list]").innerHTML = list(fresh); 215 − }; 216 − 217 − // Импорт 218 − const form = $(root, "[data-import]") as HTMLFormElement; 219 − const importError = $(root, "[data-import-error]"); 220 − function openImport() { 221 − form.hidden = false; 222 − $(form, "#imp-title").focus(); 223 − } 224 − root.querySelectorAll("[data-open-import]").forEach((b) => b.addEventListener("click", openImport)); 225 − $(root, "[data-close-import]").addEventListener("click", () => { 226 − form.hidden = true; 227 − importError.hidden = true; 228 − }); 229 − form.addEventListener("submit", async (event) => { 230 − event.preventDefault(); 231 − const data = new FormData(form); 232 − try { 233 − const m = await busy($(form, "[type=submit]") as HTMLButtonElement, "Создаю…", () => 234 − api.importMeeting(String(data.get("title")), String(data.get("text"))), 235 − ); 236 − location.hash = `#/meeting/${encodeURIComponent(m.id)}`; 237 − } catch (e) { 238 − importError.textContent = errorText(e); 239 − importError.hidden = false; 240 − } 241 − }); 242 − 243 − bindCard(); 244 − 245 − scope.on( 246 − events.status(async (s) => { 247 − const changedMeeting = s.kind !== current.kind; 248 − current = s; 249 − if (s.kind !== "recording") live.length = 0; 250 − // Ключ и «предупреждаю сам» могли поменяться, пока экран открыт. 251 − view = await api.getSettings(); 252 − if (!scope.alive) return; 253 − error.hidden = true; 254 − $(root, "[data-card]").innerHTML = card(s, view); 255 − bindCard(); 256 − if (changedMeeting) await refreshList(); 257 − }), 258 − ); 259 − scope.on( 260 − events.recorder(({ event }) => { 261 − if (event.kind === "segments") { 262 − live.push(...event.data); 263 − renderLive(); 264 − } else if (event.kind === "level") { 265 − const fill = root.querySelector<HTMLElement>(`[data-level="${speakerKey(event.data.speaker)}"]`); 266 − if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`; 267 − } else if (event.kind === "track_failed") { 268 − fail(event.data.error); 269 − } 270 − }), 271 − ); 272 − scope.on(events.meetingChanged(() => refreshList())); 273 − scope.every(1000, () => { 274 − const t = root.querySelector("[data-timer]"); 275 − if (t && current.kind === "recording") t.textContent = elapsed(current.started_at); 276 − }); 277 −} -
src/views/meeting.ts −190
Показать разницуСкрыть разницу
1 −// Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора. 2 −// Пока идёт запись, расшифровка и живой конспект обновляются по событиям ядра. 3 − 4 −import { 5 − api, errorText, events, formatDate, formatDuration, formatTime, speakerLabel, 6 − type Analysis, type MeetingDetail, type Segment, 7 −} from "../api"; 8 −import { $, busy, esc, list } from "../dom"; 9 −import type { Scope } from "../scope"; 10 − 11 −export type Tab = "transcript" | "summary" | "tasks"; 12 − 13 −function jump(d: MeetingDetail, index: number): string { 14 − const seg = d.transcript.segments[index]; 15 − if (!seg) return ""; 16 − return `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`; 17 −} 18 − 19 −function segmentRow(s: Segment, i: number, target: number | null): string { 20 − return ` 21 − <div class="segment${i === target ? " is-target" : ""}${s.speaker.kind === "me" ? " is-me" : ""}" id="seg-${i}"> 22 − <span class="at mono">${formatTime(s.start_ms)}</span> 23 − <p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p> 24 − </div>`; 25 −} 26 − 27 −function transcriptTab(d: MeetingDetail, target: number | null): string { 28 − if (!d.transcript.segments.length) { 29 − return `<section class="box" data-segments><p class="empty">${ 30 − d.meeting.state === "recording" 31 − ? "Реплики появятся здесь через несколько секунд после начала разговора." 32 − : "В записи не прозвучало ни одной реплики." 33 − }</p></section>`; 34 − } 35 − return `<section class="box" data-segments>${d.transcript.segments.map((s, i) => segmentRow(s, i, target)).join("")}</section>`; 36 −} 37 − 38 −function liveSummary(d: MeetingDetail): string { 39 − if (!d.live_summary) return ""; 40 − return ` 41 − <section class="box live-summary"> 42 − <div class="box-head"><h2>Живой конспект</h2><span class="xs muted">обновляется каждые несколько минут</span></div> 43 − <div class="box-body"><p class="pre">${esc(d.live_summary)}</p></div> 44 − </section>`; 45 −} 46 − 47 −function notAnalyzed(d: MeetingDetail): string { 48 − const why = 49 − d.meeting.state === "recording" 50 − ? "Разбор появится сам, когда встреча закончится." 51 − : "Разбора ещё нет. Нажмите «Разобрать» — ИИ сделает конспект, решения и задачи."; 52 − return `${liveSummary(d)}<section class="box"><p class="empty">${why}</p></section>`; 53 −} 54 − 55 −function summaryTab(d: MeetingDetail, a: Analysis): string { 56 − const r = a.review; 57 − const review = [ 58 − r.achieved.length ? `<h3>Чего добились</h3>${list(r.achieved)}` : "", 59 − r.open.length ? `<h3>Осталось открытым</h3>${list(r.open)}` : "", 60 − r.off_track.length ? `<h3>Где ушли в сторону</h3>${list(r.off_track)}` : "", 61 − `<p><strong>Итог:</strong> ${esc(r.verdict)}</p>`, 62 − ].join(""); 63 − return ` 64 − <section class="prose"> 65 − <p>${esc(a.summary)}</p> 66 − <div class="section"><h2>Ревью</h2>${review}</div> 67 − <div class="section"><h2>Решения</h2>${ 68 − a.decisions.length 69 − ? `<ul>${a.decisions.map((x) => `<li>${esc(x.text)} ${jump(d, x.source_segment)}</li>`).join("")}</ul>` 70 − : `<p class="muted">Явных решений не было.</p>` 71 − }</div> 72 − ${a.open_questions.length ? `<div class="section"><h2>Открытые вопросы</h2>${list(a.open_questions)}</div>` : ""} 73 − </section>`; 74 −} 75 − 76 −function tasksTab(d: MeetingDetail, a: Analysis): string { 77 − if (!a.tasks.length) return `<section class="box"><p class="empty">Задач не прозвучало.</p></section>`; 78 − return `<section class="box">${a.tasks 79 − .map( 80 − (t) => ` 81 − <div class="task"> 82 − <p class="text">${esc(t.text)}</p> 83 − <p class="small muted">${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}</p> 84 − <p class="quote">${esc(t.quote)}</p> 85 − </div>`, 86 − ) 87 − .join("")}</section>`; 88 −} 89 − 90 −function cost(d: MeetingDetail): string { 91 − const a = d.meeting.analysis; 92 − if (!a) return ""; 93 − const price = a.usage.cost != null ? ` · $${a.usage.cost.toFixed(4)}` : ""; 94 − return `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`; 95 −} 96 − 97 −export async function renderMeeting( 98 − root: HTMLElement, 99 − parent: Scope, 100 − id: string, 101 − tab: Tab, 102 − target: number | null = null, 103 −): Promise<void> { 104 − const scope = parent.child(); 105 − const d = await api.getMeeting(id); 106 − if (!scope.alive) return; 107 − const rerender = (t: Tab = tab, at: number | null = null) => { 108 − scope.dispose(); 109 − return renderMeeting(root, parent, id, t, at); 110 − }; 111 − const a = d.analysis; 112 − const recording = d.meeting.state === "recording"; 113 − const base = `#/meeting/${encodeURIComponent(id)}`; 114 − const tabLink = (key: Tab, label: string, count?: number) => 115 − `<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${ 116 − count !== undefined ? ` <span class="ui-nav-count">${count}</span>` : "" 117 − }</a>`; 118 − 119 − const body = 120 − tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed(d) : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a); 121 − const stateLabel = recording 122 − ? ` <span class="label label-accent">идёт запись</span>` 123 − : d.meeting.state === "interrupted" 124 − ? ` <span class="label" title="Программа закрылась посреди записи; всё, что успело распознаться, сохранено">прервана</span>` 125 − : ""; 126 − 127 − root.innerHTML = ` 128 − <div class="page-head"> 129 − <div> 130 − <h1>${esc(d.meeting.title)}</h1> 131 − <p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · <span data-count>${d.meeting.segments}</span> реплик${stateLabel}</p> 132 − </div> 133 − <div class="actions"> 134 − ${recording ? "" : `<button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>`} 135 − </div> 136 − </div> 137 − <p class="error" data-error hidden></p> 138 − <nav class="ui-underline-nav" aria-label="Встреча"> 139 − ${tabLink("transcript", "Расшифровка", d.meeting.segments)} 140 − ${tabLink("summary", "Конспект")} 141 − ${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)} 142 − </nav> 143 − ${body} 144 − ${cost(d)}`; 145 − 146 − if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" }); 147 − 148 − const error = $(root, "[data-error]"); 149 − root.querySelector<HTMLButtonElement>("[data-analyze]")?.addEventListener("click", async (event) => { 150 − error.hidden = true; 151 − try { 152 − await busy(event.currentTarget as HTMLButtonElement, "Разбираю…", () => api.analyzeMeeting(id)); 153 − if (tab === "summary") await rerender("summary"); 154 − else location.hash = `${base}/summary`; 155 − } catch (e) { 156 − error.textContent = errorText(e); 157 − error.hidden = false; 158 − } 159 − }); 160 − 161 − // Живая расшифровка: новые реплики дописываются на место по времени, лента 162 − // едет вниз сама, пока человек не прокрутил вверх. 163 − const segments = d.transcript.segments; 164 − scope.on( 165 − events.recorder(({ meeting_id, event }) => { 166 − if (meeting_id !== id) return; 167 − if (event.kind === "segments") { 168 − segments.push(...event.data); 169 − segments.sort((x, y) => x.start_ms - y.start_ms); 170 − const count = root.querySelector("[data-count]"); 171 − if (count) count.textContent = String(segments.length); 172 − const box = root.querySelector<HTMLElement>("[data-segments]"); 173 − if (tab !== "transcript" || !box) return; 174 − const atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80; 175 − box.innerHTML = segments.map((s, i) => segmentRow(s, i, null)).join(""); 176 − if (atBottom) window.scrollTo({ top: document.body.scrollHeight }); 177 − } else if (event.kind === "live_summary" && tab !== "transcript" && !a) { 178 − rerender(); 179 − } else if (event.kind === "stt_failed") { 180 − error.textContent = `Кусок записи не распознался: ${event.data}. Звук сохранён.`; 181 − error.hidden = false; 182 − } 183 − }), 184 − ); 185 − scope.on( 186 − events.meetingChanged((changed) => { 187 − if (changed === id) rerender(tab, target); 188 − }), 189 − ); 190 −} -
src/views/note.ts +360
Показать разницуСкрыть разницу
1 +// Экран встречи: заметка в Markdown и шаги над ней. 2 +// 3 +// 1. Звук — записан или добавлен файлом. 4 +// 2. «Расшифровать» — расшифровка ложится в заметку. 5 +// 3. Команды ИИ — «Конспект», «Задачи», «Ревью» или свой запрос; ответ 6 +// дописывается в конец заметки. 7 +// 8 +// Всё, что в заметке, можно править и копировать: целиком или по разделам. 9 + 10 +import { 11 + api, elapsed, events, formatDate, formatDuration, meetingDuration, 12 + type Command, type MeetingDetail, type Speaker, 13 +} from "../api"; 14 +import { $, esc } from "../dom"; 15 +import { plain, render, sections, toggleTask } from "../markdown"; 16 +import type { Scope } from "../scope"; 17 +import { changed, copy, onChange, state, toast } from "../state"; 18 +import { refreshSidebar, stopRecording } from "./sidebar"; 19 + 20 +type Mode = "view" | "edit"; 21 +let mode: Mode = "view"; 22 + 23 +const SAVE_DELAY_MS = 600; 24 + 25 +function audioLine(d: MeetingDetail): string { 26 + const m = d.meeting; 27 + if (m.recording) return "идёт запись"; 28 + if (!m.audio.length) return "без звука"; 29 + const who = (s: Speaker) => (s.kind === "me" ? "я" : s.kind === "others" ? "собеседники" : ""); 30 + const names = m.audio.map((a) => (a.speaker.kind === "unknown" ? a.file : who(a.speaker))).join(", "); 31 + return `звук: ${names}`; 32 +} 33 + 34 +function recordingPanel(d: MeetingDetail, warning: string): string { 35 + const r = state.recording; 36 + if (!r || r.meeting_id !== d.meeting.id) return ""; 37 + const meter = (label: string, key: string) => ` 38 + <div class="meter"><span class="meter-label small">${label}</span> 39 + <span class="meter-bar"><span class="meter-fill" data-level="${key}"></span></span></div>`; 40 + return ` 41 + <section class="panel panel-live"> 42 + <p class="panel-kicker"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${ 43 + r.paused ? "Запись на паузе" : r.zoom ? "Записываю Zoom и микрофон" : "Записываю микрофон — Zoom не найден" 44 + }</p> 45 + <p class="panel-timer mono-num" data-timer>${elapsed(r.started_at)}</p> 46 + <div class="meters">${meter("Я", "me")}${r.zoom ? meter("Собеседники", "others") : ""}</div> 47 + <p class="small muted">Предупредите участников о записи: 48 + <button class="link-btn" data-copy-warning>скопировать текст для чата Zoom</button></p> 49 + <div class="actions"> 50 + <button class="ui-btn" data-stop>Остановить</button> 51 + <button class="link-btn" data-pause>${r.paused ? "Продолжить" : "Пауза"}</button> 52 + </div> 53 + <template data-warning>${esc(warning)}</template> 54 + </section>`; 55 +} 56 + 57 +function steps(d: MeetingDetail): string { 58 + const m = d.meeting; 59 + if (m.recording) return ""; 60 + const hasAudio = m.audio.length > 0; 61 + const transcribe = hasAudio 62 + ? m.transcribed_at 63 + ? `<button class="link-btn" data-transcribe>Расшифровать заново</button>` 64 + : `<button class="ui-btn" data-transcribe>Расшифровать</button>` 65 + : ""; 66 + return ` 67 + <section class="steps"> 68 + ${ 69 + hasAudio && !m.transcribed_at 70 + ? `<div class="step-row">${transcribe}<span class="small muted" data-progress>${ 71 + meetingDuration(m) >= 10 * 60_000 ? `Звук ${formatDuration(meetingDuration(m))} — расшифровка займёт несколько минут.` : "" 72 + }</span></div>` 73 + : "" 74 + } 75 + <div class="step-row ai-row"> 76 + <span class="small muted">ИИ:</span> 77 + <button class="chip" data-cmd="summary">Конспект</button> 78 + <button class="chip" data-cmd="tasks">Задачи</button> 79 + <button class="chip" data-cmd="review">Ревью</button> 80 + ${hasAudio && m.transcribed_at ? `<span class="spacer"></span>${transcribe}` : ""} 81 + </div> 82 + <form class="ask" data-ask> 83 + <input class="input" name="text" autocomplete="off" placeholder="Или своё: «напиши письмо клиенту по итогам», «выпиши все цифры»" aria-label="Что сделать с заметкой"> 84 + <button class="chip" type="submit">Сделать</button> 85 + </form> 86 + <p class="small muted" data-busy hidden></p> 87 + </section>`; 88 +} 89 + 90 +function viewHtml(note: string): string { 91 + const parts = sections(note); 92 + if (!parts.length) { 93 + return `<div class="note-empty"> 94 + <p>Заметка пустая.</p> 95 + <p class="small muted">Расшифруйте звук, попросите ИИ или <button class="link-btn" data-to-edit>напишите сами</button>.</p> 96 + </div>`; 97 + } 98 + return parts 99 + .map( 100 + (s, i) => ` 101 + <section class="note-section" data-section="${i}"> 102 + <button class="section-copy link-btn" data-copy-section="${i}" title="Копировать раздел в Markdown">Копировать</button> 103 + <div class="md">${render(s.source)}</div> 104 + </section>`, 105 + ) 106 + .join(""); 107 +} 108 + 109 +export async function renderNote(root: HTMLElement, scope: Scope, id: string): Promise<void> { 110 + const [d, view] = await Promise.all([api.getMeeting(id), api.getSettings()]); 111 + if (!scope.alive) return; 112 + let note = d.note; 113 + let dirty = false; 114 + let saveTimer = 0; 115 + let working = false; 116 + 117 + const draw = () => { 118 + // Перерисовка — только когда работа закончилась; флаг снимается здесь, 119 + // иначе после удачной расшифровки следующая команда молча не запускалась бы. 120 + working = false; 121 + root.innerHTML = ` 122 + <article class="note"> 123 + <header class="note-head"> 124 + <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название встречи" spellcheck="false"> 125 + <p class="small muted">${esc(formatDate(d.meeting.created_at))}${ 126 + formatDuration(meetingDuration(d.meeting)) ? ` · ${esc(formatDuration(meetingDuration(d.meeting)))}` : "" 127 + } · ${esc(audioLine(d))}</p> 128 + </header> 129 + ${recordingPanel(d, view.settings.warning_text)} 130 + ${steps(d)} 131 + <section class="editor"> 132 + <div class="editor-bar"> 133 + <nav class="ui-underline-nav" aria-label="Вид заметки"> 134 + <a href="#" data-mode="view"${mode === "view" ? ` aria-current="page"` : ""}>Текст</a> 135 + <a href="#" data-mode="edit"${mode === "edit" ? ` aria-current="page"` : ""}>Правка</a> 136 + </nav> 137 + <span class="xs muted" data-save-state></span> 138 + <span class="spacer"></span> 139 + <button class="link-btn" data-copy-all>Копировать всё</button> 140 + <button class="link-btn" data-copy-plain title="Без разметки — для мессенджера или письма">Как текст</button> 141 + </div> 142 + ${ 143 + mode === "edit" 144 + ? `<textarea class="note-editor" data-editor spellcheck="true" placeholder="Пишите в Markdown: # заголовок, - список, **жирный**">${esc(note)}</textarea>` 145 + : `<div class="note-view" data-view>${viewHtml(note)}</div>` 146 + } 147 + </section> 148 + <footer class="note-foot"><button class="link-btn link-danger" data-delete>Удалить встречу</button></footer> 149 + </article>`; 150 + bind(); 151 + }; 152 + 153 + const saveState = (text: string) => { 154 + const el = root.querySelector("[data-save-state]"); 155 + if (el) el.textContent = text; 156 + }; 157 + 158 + const flush = async () => { 159 + window.clearTimeout(saveTimer); 160 + if (!dirty) return; 161 + dirty = false; 162 + try { 163 + await api.saveNote(id, note); 164 + saveState("Сохранено"); 165 + } catch (e) { 166 + dirty = true; 167 + saveState("Не сохранено"); 168 + toast(e); 169 + } 170 + }; 171 + 172 + const setBusy = (text: string | null) => { 173 + working = text !== null; 174 + const el = root.querySelector<HTMLElement>("[data-busy]"); 175 + if (el) { 176 + el.hidden = text === null; 177 + el.textContent = text ?? ""; 178 + } 179 + root.querySelectorAll<HTMLButtonElement>("[data-transcribe], [data-cmd], [data-ask] button").forEach((b) => (b.disabled = working)); 180 + const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]"); 181 + if (editor) editor.readOnly = working; 182 + }; 183 + 184 + const runCommand = async (command: Command, label: string) => { 185 + if (working) return; 186 + await flush(); 187 + setBusy(`${label}: ИИ читает заметку и пишет ответ…`); 188 + try { 189 + note = await api.runCommand(id, command); 190 + mode = "view"; 191 + draw(); 192 + root.querySelector(".note-section:last-child")?.scrollIntoView({ behavior: "smooth", block: "start" }); 193 + } catch (e) { 194 + setBusy(null); 195 + toast(e); 196 + } 197 + }; 198 + 199 + const bind = () => { 200 + const title = $(root, "[data-title]") as HTMLInputElement; 201 + title.addEventListener("keydown", (e) => { 202 + if (e.key === "Enter") title.blur(); 203 + if (e.key === "Escape") { 204 + title.value = d.meeting.title; 205 + title.blur(); 206 + } 207 + }); 208 + title.addEventListener("change", async () => { 209 + try { 210 + d.meeting = await api.renameMeeting(id, title.value); 211 + await refreshSidebar(); 212 + } catch (e) { 213 + title.value = d.meeting.title; 214 + toast(e); 215 + } 216 + }); 217 + 218 + root.querySelectorAll<HTMLAnchorElement>("[data-mode]").forEach((a) => 219 + a.addEventListener("click", async (e) => { 220 + e.preventDefault(); 221 + await flush(); 222 + mode = a.dataset.mode as Mode; 223 + draw(); 224 + if (mode === "edit") root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus(); 225 + }), 226 + ); 227 + root.querySelector("[data-to-edit]")?.addEventListener("click", () => { 228 + mode = "edit"; 229 + draw(); 230 + root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus(); 231 + }); 232 + 233 + const editor = root.querySelector<HTMLTextAreaElement>("[data-editor]"); 234 + editor?.addEventListener("input", () => { 235 + note = editor.value; 236 + dirty = true; 237 + saveState("Изменено…"); 238 + window.clearTimeout(saveTimer); 239 + saveTimer = window.setTimeout(flush, SAVE_DELAY_MS); 240 + }); 241 + 242 + root.querySelector("[data-copy-all]")?.addEventListener("click", () => copy(note, "Заметка скопирована в Markdown")); 243 + root.querySelector("[data-copy-plain]")?.addEventListener("click", () => copy(plain(note), "Заметка скопирована текстом")); 244 + root.querySelectorAll<HTMLButtonElement>("[data-copy-section]").forEach((b) => 245 + b.addEventListener("click", () => { 246 + const s = sections(note)[Number(b.dataset.copySection)]; 247 + if (s) copy(s.source, s.heading ? `Раздел «${s.heading}» скопирован` : "Скопировано"); 248 + }), 249 + ); 250 + 251 + // Галочки задач в режиме просмотра — живые: отметка пишется в Markdown. 252 + root.querySelectorAll<HTMLInputElement>("[data-view] input[type=checkbox]").forEach((box, n) => { 253 + box.disabled = working; 254 + box.addEventListener("change", async () => { 255 + note = toggleTask(note, n); 256 + dirty = true; 257 + await flush(); 258 + }); 259 + }); 260 + 261 + root.querySelector("[data-transcribe]")?.addEventListener("click", async () => { 262 + if (working) return; 263 + if (d.meeting.transcribed_at && !confirm("Расшифровать заново? Новая расшифровка добавится в конец заметки, старая останется.")) return; 264 + await flush(); 265 + setBusy("Готовлю звук…"); 266 + try { 267 + const result = await api.transcribe(id); 268 + note = result.note; 269 + d.meeting = (await api.getMeeting(id)).meeting; 270 + mode = "view"; 271 + draw(); 272 + if (result.failed) toast(`Не распознано кусков: ${result.failed}. ${result.last_error ?? ""}`); 273 + await refreshSidebar(); 274 + } catch (e) { 275 + setBusy(null); 276 + toast(e); 277 + } 278 + }); 279 + 280 + root.querySelectorAll<HTMLButtonElement>("[data-cmd]").forEach((b) => 281 + b.addEventListener("click", () => runCommand({ kind: b.dataset.cmd as "summary" | "tasks" | "review" }, b.textContent ?? "")), 282 + ); 283 + root.querySelector<HTMLFormElement>("[data-ask]")?.addEventListener("submit", (e) => { 284 + e.preventDefault(); 285 + const input = (e.currentTarget as HTMLFormElement).elements.namedItem("text") as HTMLInputElement; 286 + const text = input.value.trim(); 287 + if (!text) { 288 + input.focus(); 289 + return; 290 + } 291 + runCommand({ kind: "custom", text }, "Свой запрос"); 292 + }); 293 + 294 + root.querySelector("[data-stop]")?.addEventListener("click", async () => { 295 + await stopRecording(); 296 + d.meeting = (await api.getMeeting(id)).meeting; 297 + draw(); 298 + }); 299 + root.querySelector("[data-pause]")?.addEventListener("click", async () => { 300 + if (!state.recording) return; 301 + try { 302 + await api.recorderPause(!state.recording.paused); 303 + state.recording.paused = !state.recording.paused; 304 + changed(); 305 + } catch (e) { 306 + toast(e); 307 + } 308 + }); 309 + root.querySelector("[data-copy-warning]")?.addEventListener("click", () => 310 + copy(view.settings.warning_text, "Текст предупреждения скопирован"), 311 + ); 312 + 313 + root.querySelector("[data-delete]")?.addEventListener("click", async () => { 314 + if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком? Это не отменить.`)) return; 315 + try { 316 + await api.deleteMeeting(id); 317 + await refreshSidebar(); 318 + location.hash = "#/"; 319 + } catch (e) { 320 + toast(e); 321 + } 322 + }); 323 + }; 324 + 325 + draw(); 326 + 327 + scope.on( 328 + events.progress(({ meeting_id, done, total }) => { 329 + if (meeting_id !== id) return; 330 + setBusy(total ? `Расшифровываю: ${done} из ${total} кусков` : "В записи не нашлось речи"); 331 + }), 332 + ); 333 + scope.on( 334 + events.recorder(({ meeting_id, event }) => { 335 + if (meeting_id !== id) return; 336 + if (event.kind === "level") { 337 + const key = event.data.speaker.kind === "me" ? "me" : "others"; 338 + const fill = root.querySelector<HTMLElement>(`[data-level="${key}"]`); 339 + if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((event.data.dbfs + 60) / 55) * 100))}%`; 340 + } else { 341 + toast(`Дорожка «${event.data.speaker.kind === "me" ? "я" : "собеседники"}» перестала писаться: ${event.data.error}`); 342 + } 343 + }), Разница обрезана: показаны первые 343 строк. -
src/views/settings.ts +27 −46
Показать разницуСкрыть разницу
1 −// Экран «Настройки»: ключ OpenRouter, модели, предупреждение участникам. 1 +// Экран «Настройки»: ключ OpenRouter, модели, язык, предупреждение участникам. 2 2 3 3 import { api, errorText, type KeyInfo, type SettingsView } from "../api"; 4 4 import { $, busy, esc } from "../dom"; 21 21 ? `<p>Сохранён: <code>${esc(view.openrouter_key)}</code></p> 22 22 <div class="actions"> 23 23 <button class="ui-btn" data-check-key>Проверить</button> 24 − <button class="ui-btn" data-delete-key>Удалить ключ</button> 24 + <button class="link-btn link-danger" data-delete-key>Удалить ключ</button> 25 25 </div>` 26 26 : `<form class="field" data-key-form> 27 27 <label for="or-key">Ключ</label> 28 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> 29 + <p class="field-hint">Через ключ идут расшифровка и команды ИИ, платите вы OpenRouter напрямую. Ключ проверяется при сохранении и хранится в хранилище паролей системы, не в файлах. <a href="https://openrouter.ai/keys" target="_blank" rel="noreferrer">Где взять ключ</a></p> 30 30 <div class="actions"><button class="ui-btn" type="submit">Сохранить ключ</button></div> 31 31 </form>` 32 32 } 39 39 const view = await api.getSettings(); 40 40 const s = view.settings; 41 41 root.innerHTML = ` 42 + <div class="page"> 42 43 <div class="page-head"><h1>Настройки</h1></div> 43 44 ${keyBox(view)} 44 45 45 46 <form class="box" data-settings> 46 − <div class="box-head"><h2>Запись и ИИ</h2></div> 47 + <div class="box-head"><h2>Расшифровка и ИИ</h2></div> 47 48 <div class="box-body"> 48 49 <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 50 <label for="lang">Язык встреч</label> 72 51 <select id="lang" class="input" name="language"> 73 52 ${[["ru", "Русский"], ["en", "Английский"], ["", "Определять самому"]] 74 53 .map(([v, t]) => `<option value="${v}"${s.language === v ? " selected" : ""}>${t}</option>`) 75 54 .join("")} 76 55 </select> 77 − <p class="field-hint">Короткие куски речи распознаются точнее, когда язык задан заранее.</p> 56 + <p class="field-hint">Когда язык задан, речь распознаётся точнее.</p> 78 57 </div> 79 − <label class="check"> 80 − <input type="checkbox" name="keep_audio"${s.keep_audio ? " checked" : ""}> 81 − <span>Хранить звук встреч после разбора. Без этой отметки звук удаляется, остаются текст и разбор.</span> 82 − </label> 83 − ${ 84 − view.capture_supported 85 − ? `<label class="check"> 86 − <input type="checkbox" name="launch_at_login"${s.launch_at_login ? " checked" : ""}> 87 − <span>Запускать вместе с Windows — иначе встречу, начатую до запуска программы, некому записать</span> 88 − </label>` 89 − : "" 90 − } 58 + <div class="field"> 59 + <label for="warn">Текст предупреждения участникам</label> 60 + <textarea id="warn" class="textarea" name="warning_text" rows="3" required>${esc(s.warning_text)}</textarea> 61 + <p class="field-hint">Во время записи его можно скопировать одной кнопкой и отправить в чат Zoom. Записывать разговор без ведома участников нельзя.</p> 62 + </div> 63 + <details class="advanced"> 64 + <summary class="small">Модели</summary> 65 + <div class="field"> 66 + <label for="m-ai">Модель для команд ИИ</label> 67 + <input id="m-ai" class="input" name="ai_model" value="${esc(s.ai_model)}" required spellcheck="false"> 68 + </div> 69 + <div class="field"> 70 + <label for="m-stt">Модель распознавания речи</label> 71 + <input id="m-stt" class="input" name="stt_model" value="${esc(s.stt_model)}" required spellcheck="false"> 72 + <p class="field-hint">Имена — как в каталоге OpenRouter.</p> 73 + </div> 74 + </details> 91 75 <div class="field"> 92 76 <span class="field-label">Папка встреч</span> 93 77 <code>${esc(view.meetings_dir)}</code> 95 79 <p data-settings-status hidden></p> 96 80 <div class="actions"><button class="ui-btn" type="submit">Сохранить</button></div> 97 81 </div> 98 − </form>`; 82 + </form> 83 + </div>`; 99 84 100 85 const keyStatus = $(root, "[data-key-status]"); 101 86 const show = (el: HTMLElement, text: string, ok: boolean) => { 142 127 try { 143 128 await busy($(form, "[type=submit]") as HTMLButtonElement, "Сохраняю…", () => 144 129 api.saveSettings({ 145 − analysis_model: String(data.get("analysis_model")).trim(), 146 − live_model: String(data.get("live_model")).trim(), 130 + ai_model: String(data.get("ai_model")).trim(), 147 131 stt_model: String(data.get("stt_model")).trim(), 148 − warning_text: String(data.get("warning_text")).trim(), 149 − always_warn_myself: data.get("always_warn_myself") === "on", 150 132 language: String(data.get("language") ?? ""), 151 − keep_audio: data.get("keep_audio") === "on", 152 − launch_at_login: view.capture_supported ? data.get("launch_at_login") === "on" : s.launch_at_login, 133 + warning_text: String(data.get("warning_text")).trim(), 153 134 }), 154 135 ); 155 136 show(status, "Сохранено", true); -
src/views/sidebar.ts +141
Показать разницуСкрыть разницу
1 +// Боковая панель: три действия и список встреч. 2 +// 3 +// Записать — запись Zoom и микрофона, пока не нажмёте «Стоп» 4 +// Добавить аудио — готовый файл с диктофона или из облака Zoom 5 +// Новая заметка — пустая заметка, текст можно вставить руками 6 + 7 +import { api, elapsed, files, formatDate, formatDuration, meetingDuration, type Meeting } from "../api"; 8 +import { esc } from "../dom"; 9 +import { changed, onChange, refreshRecording, state, toast } from "../state"; 10 + 11 +let root: HTMLElement; 12 +let meetings: Meeting[] = []; 13 +let captureSupported = true; 14 + 15 +function currentId(): string | null { 16 + const m = /^#\/m\/([^/]+)/.exec(location.hash); 17 + return m ? decodeURIComponent(m[1]) : null; 18 +} 19 + 20 +function badges(m: Meeting): string { 21 + if (m.recording) return `<span class="badge badge-live">запись</span>`; 22 + if (m.transcribed_at) return ""; 23 + if (m.audio.length) return `<span class="badge">не расшифровано</span>`; 24 + return ""; 25 +} 26 + 27 +function recordBlock(): string { 28 + const r = state.recording; 29 + if (r) { 30 + return ` 31 + <a class="rec-now" href="#/m/${encodeURIComponent(r.meeting_id)}"> 32 + <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span> 33 + <span>${r.paused ? "Пауза" : "Идёт запись"}</span> 34 + <span class="mono-num" data-rec-timer>${elapsed(r.started_at)}</span> 35 + </a> 36 + <button class="side-link side-stop" data-stop>Остановить запись</button>`; 37 + } 38 + return captureSupported 39 + ? `<button class="ui-btn side-main" data-record>Записать</button>` 40 + : `<p class="side-note xs muted">Запись работает в Windows. Здесь — готовые аудиофайлы.</p>`; 41 +} 42 + 43 +function draw(): void { 44 + const active = currentId(); 45 + root.innerHTML = ` 46 + <div class="side-actions"> 47 + ${recordBlock()} 48 + <button class="side-link" data-add-audio>Добавить аудио</button> 49 + <button class="side-link" data-new-note>Новая заметка</button> 50 + </div> 51 + <nav class="side-list" aria-label="Встречи"> 52 + ${ 53 + meetings.length 54 + ? meetings 55 + .map((m) => { 56 + const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · "); 57 + return ` 58 + <a class="side-item${m.id === active ? " is-active" : ""}" href="#/m/${encodeURIComponent(m.id)}"${ 59 + m.id === active ? ` aria-current="page"` : "" 60 + }> 61 + <span class="side-title">${esc(m.title)}</span> 62 + <span class="side-meta xs muted">${esc(meta)} ${badges(m)}</span> 63 + </a>`; 64 + }) 65 + .join("") 66 + : `<p class="side-empty small muted">Встреч пока нет.</p>` 67 + } 68 + </nav>`; 69 +} 70 + 71 +export async function refreshSidebar(): Promise<void> { 72 + meetings = await api.listMeetings(); 73 + draw(); 74 +} 75 + 76 +export async function addAudioFrom(path: string | null): Promise<void> { 77 + if (!path) return; 78 + if (!files.isAudio(path)) { 79 + toast("Это не аудиофайл — подойдут mp3, m4a, wav, aiff, flac, ogg"); 80 + return; 81 + } 82 + toast("Читаю файл…", "ok"); 83 + try { 84 + const m = await api.addAudio(path); 85 + await refreshSidebar(); 86 + location.hash = `#/m/${encodeURIComponent(m.id)}`; 87 + } catch (e) { 88 + toast(e); 89 + } 90 +} 91 + 92 +export async function startRecording(): Promise<void> { 93 + try { 94 + const m = await api.recorderStart(); 95 + await refreshRecording(); 96 + await refreshSidebar(); 97 + location.hash = `#/m/${encodeURIComponent(m.id)}`; 98 + } catch (e) { 99 + toast(e); 100 + } 101 +} 102 + 103 +export async function stopRecording(): Promise<void> { 104 + try { 105 + await api.recorderStop(); 106 + await refreshRecording(); 107 + await refreshSidebar(); 108 + changed(); 109 + } catch (e) { 110 + toast(e); 111 + } 112 +} 113 + 114 +export async function mountSidebar(el: HTMLElement): Promise<void> { 115 + root = el; 116 + captureSupported = (await api.getSettings()).capture_supported; 117 + await refreshRecording(); 118 + await refreshSidebar(); 119 + root.addEventListener("click", async (e) => { 120 + const t = (e.target as HTMLElement).closest<HTMLElement>("button"); 121 + if (!t) return; 122 + if (t.matches("[data-record]")) await startRecording(); 123 + else if (t.matches("[data-stop]")) await stopRecording(); 124 + else if (t.matches("[data-add-audio]")) await addAudioFrom(await files.pickAudio()); 125 + else if (t.matches("[data-new-note]")) { 126 + const m = await api.createMeeting(); 127 + await refreshSidebar(); 128 + location.hash = `#/m/${encodeURIComponent(m.id)}`; 129 + } 130 + }); 131 + window.addEventListener("hashchange", draw); 132 + onChange(draw); 133 + window.setInterval(() => { 134 + const t = root.querySelector("[data-rec-timer]"); 135 + if (t && state.recording) t.textContent = elapsed(state.recording.started_at); 136 + }, 1000); 137 + // Файл, брошенный в окно, — новая встреча. 138 + await files.onDrop(async (paths) => { 139 + for (const p of paths) await addAudioFrom(p); 140 + }); 141 +} -
Показать разницуСкрыть разницу
1 +// Экран без выбранной встречи: что можно сделать. 2 + 3 +import { api, files } from "../api"; 4 +import { addAudioFrom } from "./sidebar"; 5 + 6 +export async function renderStart(root: HTMLElement): Promise<void> { 7 + const view = await api.getSettings(); 8 + const noKey = !view.openrouter_key; 9 + root.innerHTML = ` 10 + <section class="start"> 11 + <h1>Встреча → заметка</h1> 12 + <ol class="start-steps"> 13 + <li><strong>Звук.</strong> ${ 14 + view.capture_supported 15 + ? "«Записать» слева — перед встречей в Zoom: запишутся Zoom и ваш микрофон. Или добавьте" 16 + : "Запись работает в Windows; здесь добавьте" 17 + } готовый аудиофайл — с диктофона или из облака Zoom.</li> 18 + <li><strong>Расшифровка.</strong> Когда нужно — «Расшифровать». Текст ляжет в заметку.</li> 19 + <li><strong>ИИ.</strong> «Конспект», «Задачи», «Ревью» или свой запрос. Всё в заметке можно править и копировать.</li> 20 + </ol> 21 + <button class="drop-zone" data-add-audio> 22 + <strong>Перетащите аудиофайл в окно</strong> 23 + <span class="small muted">или нажмите, чтобы выбрать: mp3, m4a, wav, aiff, flac, ogg</span> 24 + </button> 25 + ${ 26 + noKey 27 + ? `<p class="start-key small">Для расшифровки и ИИ нужен ключ OpenRouter — <a href="#/settings">подключить</a>.</p>` 28 + : "" 29 + } 30 + </section>`; 31 + root.querySelector("[data-add-audio]")?.addEventListener("click", async () => addAudioFrom(await files.pickAudio())); 32 +}