From 1bc545e3b7d388e2ee9d93ce914014caf1820649 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Mon, 28 Sep 2026 17:32:48 +0300 Subject: [PATCH 1/2] Update index.js --- botsrc/index.js | 109 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/botsrc/index.js b/botsrc/index.js index 97789f6..f023337 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -1425,10 +1425,12 @@ async function _cmdNew() { // Fire-and-forget: не await-им executeJavaScript, т.к. loadURL внутри // newChat() выгружает контекст и промис не вернётся. try { - win.webContents.executeJavaScript( - "try { window.electronAPI.newChat(); } catch (e) {} true", - true, - ).catch(() => {}); + win.webContents + .executeJavaScript( + "try { window.electronAPI.newChat(); } catch (e) {} true", + true, + ) + .catch(() => {}); } catch (_) {} // Небольшая пауза, чтобы окно успело начать навигацию, и сразу отвечаем. @@ -1632,7 +1634,10 @@ function _projectView(page) { slice.forEach((dir, i) => { const base = dir.split(/[\\/]/).filter(Boolean).pop() || dir; rows.push([ - { text: "📁 " + base, callback_data: "st_proj_p" + cur + "_" + (offset + i) }, + { + text: "📁 " + base, + callback_data: "st_proj_p" + cur + "_" + (offset + i), + }, ]); }); @@ -1640,10 +1645,16 @@ function _projectView(page) { if (pages > 1) { const nav = []; if (cur > 1) - nav.push({ text: _t("page.prev"), callback_data: "st_proj_pg_" + (cur - 1) }); + nav.push({ + text: _t("page.prev"), + callback_data: "st_proj_pg_" + (cur - 1), + }); nav.push({ text: cur + "/" + pages, callback_data: "st_proj_noop" }); if (cur < pages) - nav.push({ text: _t("page.next"), callback_data: "st_proj_pg_" + (cur + 1) }); + nav.push({ + text: _t("page.next"), + callback_data: "st_proj_pg_" + (cur + 1), + }); rows.push(nav); } @@ -1761,9 +1772,12 @@ async function _cmdSessions(chatId, page, editMessageId) { const projectDir = _projectDir(); // Проект не выбран: подсказываем, как выбрать его кнопками. if (!projectDir) - return reply(escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), { - replyMarkup: _projectKeyboard(), - }); + return reply( + escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), + { + replyMarkup: _projectKeyboard(), + }, + ); let sessions = []; try { const ctx = _activeContext(); @@ -1866,9 +1880,12 @@ async function _cmdLog(chatId, page, editMessageId) { const projectDir = _projectDir(); // Проект не выбран: подсказываем выбрать кнопками. if (!projectDir) - return reply(escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), { - replyMarkup: _projectKeyboard(), - }); + return reply( + escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), + { + replyMarkup: _projectKeyboard(), + }, + ); const gitDiff = require("../src/main/git-diff"); const r = await gitDiff.getLog(projectDir, 100); if (!r.success) @@ -2398,7 +2415,9 @@ async function _handleIncoming(chatId, text, msg) { // ---- Ожидание ручного ввода пути к проекту ---- if (_projectWaiting.has(chatId)) { _projectWaiting.delete(chatId); - const dir = String(raw || "").trim().replace(/^["']|["']$/g, ""); + const dir = String(raw || "") + .trim() + .replace(/^["']|["']$/g, ""); if (!dir) { await telegramBot.sendMessage(_t("project.cancelled")); return; @@ -2604,6 +2623,28 @@ function _techDetail(toolName, args) { // Максимальная длина результата в уведомлении. const TECH_RESULT_MAX = 600; +/** + * Убрать служебную XML-обёртку результата инструмента (//), + * которую возвращают WriteTool/ReadTool. В Telegram она выглядит как мусорный + * псевдо-XML; нам нужен только полезный текст (verb «Created file» или контент). + */ +function _stripResultWrapper(preview) { + let s = String(preview == null ? "" : preview).trim(); + if (!s) return ""; + // Формат: ..\nfile\n\n..\n + const contentMatch = s.match(/([\s\S]*?)<\/content>/i); + if (contentMatch) { + s = contentMatch[1].trim(); + } else { + // Нет , но есть / — вырезаем их построчно. + s = s + .replace(/[\s\S]*?<\/path>/gi, "") + .replace(/[\s\S]*?<\/type>/gi, "") + .trim(); + } + return s; +} + /** Отправить накопленное уведомление (или ничего, если пусто). */ function _techFlush() { const b = _techBatch; @@ -2625,16 +2666,27 @@ function _techFlush() { let body = ""; if (b.command) body = _techTrunc(b.command); else if (b.detail && _techTrunc(b.detail) !== b.detail) body = b.detail; - if (body) msg += "\n
" + escapeHtml(body) + "
"; - - // Результат выполнения — второй цитатой (если есть и не пустой). - const result = String(b.preview || "").replace(/\n{3,}/g, "\n\n").trim(); + if (body) + msg += "\n
" + escapeHtml(body) + "
"; + + // Результат выполнения — второй, сворачиваемой цитатой (если есть и не пустой). + // Предварительно убираем служебную XML-обёртку Write/Read (//). + let result = _stripResultWrapper(b.preview) + .replace(/\n{3,}/g, "\n\n") + .trim(); + // write/read возвращают в результате только verb («Created file»/«Updated file») + // или усечённый текст. Если тело файла есть в аргументах вызова — показываем + // фрагмент содержимого вместо служебного verb. + if (/^(Created|Updated) file$/.test(result)) { + const content = String((b.args && b.args.content) || ""); + if (content) result = content; + } if (result) { const shown = result.length > TECH_RESULT_MAX ? result.slice(0, TECH_RESULT_MAX) + "\n…(обрезано)" : result; - msg += "\n
" + escapeHtml(shown) + "
"; + msg += "\n
" + escapeHtml(shown) + "
"; } return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } @@ -2657,9 +2709,10 @@ function _techQueue(toolName, args, preview) { const icon = _techIcon(toolName); const detail = _techDetail(toolName, args); const name = String(toolName || "").toLowerCase(); - const command = name === "bash" || name === "pwsh" - ? String((args && args.command) || "") - : ""; + const command = + name === "bash" || name === "pwsh" + ? String((args && args.command) || "") + : ""; const result = String(preview || ""); if ( @@ -2680,6 +2733,7 @@ function _techQueue(toolName, args, preview) { detail, command, preview: result, + args, count: 1, }; if (prev) _techFlush(); @@ -2709,12 +2763,19 @@ async function notifyToolResult(toolName, ok, detail) { } const args = detail && typeof detail === "object" ? detail.args : null; - const preview = detail && typeof detail === "object" ? detail.preview : detail; + const preview = + detail && typeof detail === "object" ? detail.preview : detail; // Ошибка — шлём сразу, отдельным сообщением, с обрезанным текстом ошибки. if (!ok) { const icon = _techIcon(toolName); - let msg = "❌ " + icon.emoji + " " + icon.label + " " + _techTrunc(_techDetail(toolName, args)); + let msg = + "❌ " + + icon.emoji + + " " + + icon.label + + " " + + _techTrunc(_techDetail(toolName, args)); if (preview) msg += "\n" + _techTrunc(String(preview).replace(/\n{3,}/g, "\n\n")); return telegramBot.sendMessage(escapeHtml(msg), { parseMode: "HTML" }); From 47bb5fe5e232a1b6e45192a8c340022c211e3f43 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 29 Sep 2026 19:26:07 +0300 Subject: [PATCH 2/2] Add local Whisper voice transcription Introduce local Whisper-based voice transcription: add botsrc/whisper.js (downloadable whisper-cli + GGML models, ffmpeg conversion, transcription pipeline), and wire it into the Telegram bot (botsrc/index.js + botsrc/telegram.js) to handle voice/audio, send transcriptions to Telegram and optionally insert into DeepSeek chat. Expose IPC handlers (status/download/remove) and preload API methods, add settings UI and i18n keys, and default settings in settings-store. Minor formatting/tidy-ups in src/main/ipc.js and src/preload/api.js. Update CHANGELOG with feature description. --- CHANGELOG.md | 197 +++---------- botsrc/i18n.js | 40 ++- botsrc/index.js | 93 ++++++ botsrc/telegram.js | 8 +- botsrc/whisper.js | 496 ++++++++++++++++++++++++++++++++ src/main/ipc.js | 105 +++++-- src/main/settings-store.js | 6 + src/preload/api.js | 36 ++- src/preload/dom/settings-tab.js | 209 ++++++++++++++ src/preload/i18n/i18n.js | 69 +++++ 10 files changed, 1061 insertions(+), 198 deletions(-) create mode 100644 botsrc/whisper.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9656daa..cf6b55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [4.0.35] - 2026-09-29 + +### Added + +- 🎤 **Голосовые сообщения теперь превращаются в текст — и всё это прямо у тебя на компьютере!** 🎉 Никаких облаков и сторонних сервисов — расшифровкой занимается локальный Whisper: + - 🧠 новый модуль `botsrc/whisper.js`: сам скачает `whisper-cli.exe` (+ DLL) и GGML-модель, сконвертирует аудио через ffmpeg в WAV 16 kHz mono и вернёт чистый текст; + - ⚙️ в настройках (вкладка Telegram) появился блок «🎤 Распознавание голоса (Whisper)» — включаешь одной галочкой и настраиваешь под себя: + - размер модели: лёгкая / средняя / тяжёлая 🪶⚖️🏋️; + - язык распознавания: авто, RU, EN, UK, DE, FR, ES, ZH 🌍; + - при желании — свои пути к `whisper-cli.exe` и `ffmpeg`; + - ⬇️ кнопки «Скачать whisper-cli», «Скачать модель» и «Удалить Whisper» — со статусом (exe / модель / ffmpeg) и прогрессом загрузки, чтобы всё было наглядно; + - 💌 распознанный текст прилетает в Telegram отдельным сообщением, а если включён приём сообщений в чат (chatFeed) — аккуратно вставляется в поле ввода DeepSeek с пометкой о возможных неточностях; + - 🆘 если что-то не готово — бот подскажет по-человечески: нет `whisper-cli.exe`, не скачана модель или не найден `ffmpeg`; + - 📦 без внешних npm-зависимостей — только встроенный `fetch` (Node 18+/Electron), чтобы ничего лишнего не тянуть. + +### Changed + +- 💬 **Бот больше не игнорирует голосовые и аудио** — раньше они молча пропадали, теперь `msg.voice`/`msg.audio` честно распознаются как контент; в `/help` добавили пункт про распознавание голоса, чтобы ты знал о возможности :) +- 🧹 **Небольшая уборка форматирования в `src/main/ipc.js` и `src/preload/api.js`** — выровняли отступы в блоках `init-project`, `set-project-dir`, `list-sessions` и обработчиках инструментов. Логика не тронута, просто стало опрятнее. + +### Added (EN) + +- 🎤 **Voice messages turn into text — right on your machine!** 🎉 No clouds or third-party services — transcription runs on a local Whisper: + - 🧠 new module `botsrc/whisper.js`: downloads `whisper-cli.exe` (+ DLL) and a GGML model, converts audio via ffmpeg to 16 kHz mono WAV, and returns clean text; + - ⚙️ a new "🎤 Voice recognition (Whisper)" block in settings (Telegram tab) — flip one checkbox and tune it your way: + - model size: light / medium / heavy 🪶⚖️🏋️; + - recognition language: auto, RU, EN, UK, DE, FR, ES, ZH 🌍; + - optional custom paths to `whisper-cli.exe` and `ffmpeg`; + - ⬇️ "Download whisper-cli", "Download model", and "Remove Whisper" buttons — with a status line (exe / model / ffmpeg) and download progress, so everything stays clear; + - 💌 the transcribed text arrives in Telegram as a separate message and, when chat feed is enabled, is gently inserted into the DeepSeek chat input with a note about possible inaccuracies; + - 🆘 if something is missing, the bot explains it human-style: no `whisper-cli.exe`, model not downloaded, or `ffmpeg` not found; + - 📦 no external npm dependencies — built-in `fetch` only (Node 18+/Electron), nothing extra pulled in. + +### Changed (EN) + +- 💬 **The bot no longer ignores voice and audio** — they used to vanish silently; now `msg.voice`/`msg.audio` are properly treated as content, and `/help` mentions voice recognition so you know it's there :) +- 🧹 **A bit of formatting tidy-up in `src/main/ipc.js` and `src/preload/api.js`** — indentation aligned in the `init-project`, `set-project-dir`, `list-sessions` blocks and tool handlers. No logic changed, just neater. + ## [4.0.34] - 2026-09-26 ### Added @@ -81,162 +119,3 @@ ### Fixed - 🪓 **Остановка зависших процессов** — теперь кнопка завершения в Telegram корректно находит активный дочерний процесс и гарантированно гасит всё дерево процессов на Windows. - -## [4.0.32] - 2026-09-19 - -### Added - -- 🎨 **Свой цвет текста** — в разделе «Шрифт» появилась пипетка с HEX-полем, чтобы красить шрифт интерфейса и страницы в любой цвет: - - выбираешь цвет — он применяется мгновенно и к интерфейсу Cookie Code, и к странице DeepSeek; - - кнопка **«Сбросить»** возвращает системный цвет; - - панель Cookie Code исключена из перекраски — у неё своя цветовая схема, так что кастомный цвет её не ломает. -- 🔤 **Шрифт Ndot 47 из коробки** — в предустановленных появился точечно-матричный Ndot 47: выбирается одним кликом, как встроенный Anthropic Mono. -- 📊 **Точный учёт сообщений в статистике** — сообщения теперь считаются по появлению в DOM (.ds-message) с двойной дедупликацией: - - один DOM-узел учитывается ровно один раз (WeakSet); - - стабильный хэш (роль + позиция) уходит в главный процесс, поэтому повторный рендер диалога при SPA-навигации туда-обратно не раздувает счётчики; - - служебные сообщения Cookie Code (результаты инструментов, JS-сводки, контекст) больше не считаются пользовательскими. -- 📜 **Кодекс поведения** — добавлен bilingual CODE_OF_CONDUCT.md; в CONTRIBUTING.md появилась ссылка на него, а лицензия проекта исправлена на **MIT** (было указано GPL-3.0). - -### Changed - -- ⚡ **Превью в настройках больше не тормозят при пролистывании** — сетки фонов, шрифтов и чубриков перерисованы под лёгкий скролл: - - картинки превью грузятся лениво — полноразмерный data-URI подставляется только при приближении плитки к экрану, а не для всех сразу; - - невидимые плитки исключены из layout и отрисовки (content-visibility + contain); - - убраны дорогие hover-анимации (подъём и тень), которые заставляли браузер перерисовывать плитки на каждом кадре прокрутки. -- 🎨 **Кастомный скроллбар в настройках** — полоса прокрутки панели настроек оформлена в стиле темы: тонкий трек, скруглённый бегунок с подсветкой при наведении (webkit + scrollbar-width/color для Firefox). -- ✨ **Плавное появление окна инициализации** — first-time диалог теперь открывается и закрывается с анимацией (fade + лёгкий подъём/масштаб бокса) вместо резкого появления. - -### Fixed - -- 🐛 **Дашборд на домашней странице прячется, пока в поле ввода есть текст** — начал набирать сообщение — дашборд тут же уходит с глаз долой; очистил поле — возвращается. Реакция мгновенная, покрывает textarea и contenteditable, включая ввод через IME (compositionend). - -## [4.0.30] - 2026-09-18 - -### Added - -- 🧪 **Инструмент `inject_page_js` — JS прямо на странице (как F12)** — AI может выполнять код в main world текущей страницы и получать результат: - - по умолчанию работает в главном окне (там, где идёт чат), опционально — в любом окне, открытом через `open_browser_window`; - - доступны DOM, `window.*`, `localStorage`, `fetch` от имени страницы — удобно собирать данные со страницы; - - `window.electronAPI` в этом контексте недоступен (это честный main world); - - код — либо блок с `return` в начале, либо выражение (например IIFE); поддержка `await`, результат JSON-совместимый. -- 🎛 **Тумблер «Показывать дашборд»** — статистику можно копить, но сам дашборд на домашней странице скрыть. Настройки → «Система и безопасность» → «Статистика»: - - выключил — дашборд прячется мгновенно и больше не мешает; сбор статистики при этом продолжается; - - включил обратно — дашборд тут же возвращается на своё место. - -### Changed - -- ⚡ **Дашборд стал заметно легче** — рендер оптимизирован, чтобы не грузить страницу: - - DOM-скелет строится один раз, дальше обновляются только изменившиеся узлы (теплокарта трогает 1–2 ячейки вместо ~200); - - обновление данных пропускается, если сводка не менялась, и не тянется вовсе, пока дашборд скрыт; - - наблюдение за DOM дебаунсится, убраны дублирующиеся подписки — во время стрима ответа страница больше не дёргает дашборд сотни раз в секунду. - -### Fixed - -- 🐛 **Дашборд пропадал не сразу при входе в сессию** — при переходе в чат он висел ещё до 1.5 секунды. Причина: SPA-навигация идёт через `history.pushState`/`replaceState`, которые не вызывают `popstate`/`hashchange`. Теперь эти вызовы перехватываются, и дашборд скрывается мгновенно. - -## [4.0.28] - 2026-09-17 - -### Added - -- 🔤 **Шрифт на выбор — теперь и для интерфейса, и для самой страницы** — в настройках появилась отдельная секция «Шрифт»: - - встроенный **Anthropic Mono** — выбирается одним кликом; по умолчанию всё работает на системном шрифте, ничего не навязывается; - - **свои шрифты** — положи файлы `.ttf` / `.otf` / `.woff` / `.woff2` в папку (кнопка «Открыть папку шрифтов») и нажми «Обновить» — шрифт появится в сетке превью; - - **слайдер жирности** (100–900) — работает и для системного, и для любого кастомного шрифта; - - выбранный шрифт применяется мгновенно ко всему интерфейсу Cookie Code **и** к странице DeepSeek (иконки и эмодзи не ломаются). -- 🔁 **Перенос контекста в новый чат** — длинный диалог можно продолжить в свежем чате, не теряя наработок: - - кнопка **«Перенести контекст»** в панели Cookie Code; - - история текущего чата читается из DOM, **AI сжимает её в структурный конспект** (цель, решения, изменённые файлы, открытые вопросы); - - вместе с конспектом в новый чат переносится **промпт инициализации проекта** (дерево каталога + системный промпт), поэтому AI сразу «знает» проект; - - длинные истории автоматически подрезаются по оценке токенов (до 50 000). -- 🗂 **Настройки разбиты на категории** — вместо одной длинной простыни появились подвкладки: 🎨 Тема и стекло, 🪟 Панель Cookie, 🖼 Фон страницы, 🤖 Telegram-бот, ⚙️ Система и безопасность: - - иконки подвкладок — аккуратные SVG (без эмодзи), рядом с «Cookie Code» — иконка печенья; - - панель подвкладок прячется при прокрутке вниз и возвращается при прокрутке вверх. - -### Changed - -- **Иконки подвкладок настроек** — эмодзи заменены на инлайн-SVG, которые наследуют цвет кнопки (в т.ч. белый у активной). - -### Fixed - -- Перенос контекста: надёжное ожидание ответа AI (по появлению нового сообщения и стабилизации текста, а не только по кнопке «стоп») — больше не «зависает» на коротких ответах. -- Перенос контекста: промпт инициализации теперь **пересобирается в главном процессе** из сохранённого каталога проекта, поэтому не теряется при перезагрузке страницы. - -## [4.0.27] - 2026-09-17 - -### Added - -- 🐦 **Чубрики прилетели на поле ввода!** Теперь на панели с сообщениями живёт твой питомец — сидит на верхней границе поля и переезжает вместе с ним. Никаких слетающих координат при смене разрешения: место посадки хранится в долях поля :) - - два режима: **приклеен к полю** (по умолчанию) и **свободный** — можно мышкой перетащить куда угодно; - - размер меняется прямо на экране — потяни за синий уголок; - - посадить в нужную точку можно за секунду: жми **F8** и кликни туда, где должен сидеть чубрик. -- 🎨 **Выбор питомца из папки** — открываешь настройки → «Чубрики» → и видишь всех своих чубриков сеткой. Кликнул на любого — он сразу на поле. -- 📤 **Загрузить своего** — кнопка «Загрузить…» принимает любую картинку. Большие фото автоматом сжимаются до 600×600, чтобы не тормозить :3 -- 🎁 **2 чубрика из коробки** — не надо ничего искать, при первом запуске уже есть с кем поиграться. -- ✂️ **Вырезать фон у GIF** — если у гифки белый (или любой другой) фон, жми кнопку «🎨 Фон», пипеткой тыкай в ненужный цвет и жми «Применить». Фон исчезнет **во всех кадрах**, анимация останется ^_^ -- 🛠 **Debug-режим** — тумблер в настройках открывает горячие клавиши: - - **F8** — прицел (поставить чубрика в точку) - - **F9** — показать рамку поля ввода - - **F10** — переключить режим «приклеен / свободный» - - **F11** — сброс размера -- 🐦 **Чубрики в Telegram-боте** — теперь питомца можно менять прямо из телеги: /settings → 🐦 Чубрики. Выбрал — и он уже в приложении бегает! -- ⏱ **Свой таймаут для скриптов** — если AI запускает длинную задачу и она падает с «превышено 60 секунд», теперь можно поставить хоть 1000. Настройки → «Агент» → «Таймаут JS-скриптов». -- 📡 **Всё меняется на лету** — что бы ты ни менял в боте (пет, фон, размер, блюр панели), в приложении это сразу видно. Никаких перезапусков :D - -### Fixed - -- 🐛 **Чубрик не возвращался после выключения** — включаешь его обратно в настройках, а он невидимка. Починили — теперь появляется на своём месте. -- 🐛 **Текст в настройках рвался по буквам** — в узкой панели подписи типа «Выбрать пета» превращались в вертикальную мешанину. Теперь всё чинно. - -## [4.0.26] - 2026-09-17 - -### Added - -- **Telegram-бот: полноценный i18n (ru/en)** — все сообщения, меню и подписи бота переведены и вынесены в новый модуль `botsrc/i18n.js`: - - язык бота хранится отдельно от языка UI (ключ `telegramLanguage` в `cuckoo-settings.json`), по умолчанию — системный; - - при первом `/start` бот показывает выбор языка (кнопки 🇷🇺 Русский / 🇬🇧 English); - - меню команд `/` в Telegram регистрируется через `setMyCommands` на выбранном языке. -- **Telegram-бот: приём вложений** — можно отправить боту фото или документ, он скачает файл и прикрепит его в поле ввода активного чата: - - скачивание через `getFile` → `api.telegram.org/file/bot/` во временную папку `userData/tgtemp`; - - изображения вставляются как картинка (clipboard + `Ctrl+V`, как у `read_photo`), остальные файлы — через `DataTransfer` + `change`; - - MIME определяется по расширению, временный файл удаляется после прикрепления. -- **Telegram-бот: список сессий и переключение** — команды `/sessions` (постраничный список сессий проекта) и `/switch ` для переключения активной сессии. -- **Telegram-бот: пагинация длинных списков** — `/sessions`, `/log`, `/todos`, `/diff` разбиты на страницы по 10 элементов с кнопками «Назад / Вперёд». -- **Telegram-бот: новая команда `/todos`** — список задач активного окна с иконками статуса (`☐` / `◔` / `☑`) и пагинацией. -- **Telegram-бот: ограничение по User ID** — новая настройка `telegramAllowedUserId`: если задана, бот принимает сообщения только от этого пользователя (в дополнение к проверке `chat_id`). -- **Telegram-бот: игнор-лист уведомлений** — новая настройка `telegramToolNotifyIgnore` (список имён tool по одному в строке), уведомления о которых не отправляются. По умолчанию исключены «шумные» `read`, `readLines`, `glob`, `grep`, `todoWrite`. -- **Настройки: секция языка бота** — в меню `/settings` Telegram-бота добавлена отдельная страница «Язык» с выбором ru/en. - -### Changed - -- **Telegram-бот: меню настроек переведено на i18n и переструктурировано** — страницы (`ui`, `glass`, `agent`, `tg`, `lang`) собираются из локализованных ключей `label.*` / `settings.*`; лейблы кнопок и значения форматируются через `_t()`. -- **Рефакторинг `settings-tab.js`** — единый стиль (двойные кавычки, форматирование), переработаны привязки полей Telegram-настроек (добавлены `allowedUserId` и `notifyIgnore`), без изменения пользовательского поведения. -- **`botsrc/telegram.js`** — `configure()` принимает третий параметр `allowedUserId`; добавлены методы `setMyCommands()` и `downloadFile()`; long-polling пропускает сообщения без текста/фото/документа. -- **`settings-store.js`** — новые дефолты: `telegramLanguage`, `telegramAllowedUserId`, `telegramToolNotifyIgnore`; `telegramLanguage` подтягивается из системного языка, если пользователь его не выбирал. - -### Fixed - -- **Настройки Telegram: конфликт языка** — язык бота больше не перезаписывается языком UI; смена языка UI не влияет на язык бота (и наоборот). - -### Removed - -- Удалён вывод старых версий из CHANGELOG (оставлены только 4.0.26 и 4.0.25). - -## [4.0.25] - 2026-09-17 - -### Added - -- **Инструмент `attach_file` — загрузка файлов-вложений в чат** — AI может прикрепить локальный файл (PDF, Word, Excel, PPT, изображения и др. бинарные форматы) прямо в поле ввода: - - скрытый `input[type=file]` на странице чата заполняется через `DataTransfer` + событие `change`; - - лимит 30 МБ, авто-определение MIME по расширению (~50 типов, фолбэк `application/octet-stream`); - - JS-API `attachFile(filePath)` (в песочнице также принимает объект `{ filePath }`), объявление в `cuckoo-tools.d.ts`, запись в `tool-names.js`; - - окно определяется через callback `attachFile` (по аналогии с `pasteImage` у `read_photo`), а не через `BrowserWindow.fromId`. -- **Telegram-бот: индикатор «печатает…»** — во время генерации ответа AI в чат отправляется `sendChatAction('typing')` каждые ~4 секунды, статус гасится по завершении стрима. IPC-хэндлеры `telegram-typing-start` / `telegram-typing-stop`, обновлён `observer.js`. - -### Changed - -- **Задержка отправки — теперь в секундах** — в overlay-панели поля ввода переведены с миллисекунд на секунды (шаг 0.1): - - хранение в `localStorage` и `state` осталось в мс (обратная совместимость с `chat-input.js`); - - конвертация мс ↔ сек на границе UI, подпись `сек` / `sec`; - - валидация и тосты переписаны под новые единицы. -- **i18n для настроек задержки** — сообщения об ошибках и сохранении переведены на ru/en (ранее были захардкожены на китайском): ключи `overlay.delay.*`. -- Исправлена вёрстка строки задержки в overlay — подписи «до» и «сек» больше не переносятся по буквам (отдельный класс `.cuckoo-delay-label` вместо `.cuckoo-blur-label` с `space-between`). \ No newline at end of file diff --git a/botsrc/i18n.js b/botsrc/i18n.js index ca6e23c..0442a70 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -49,6 +49,7 @@ const STRINGS = { "help.features": "Возможности", "help.feat.notify": "• Уведомления о вызовах инструментов", "help.feat.feed": "• Приём входящих сообщений в чат DeepSeek", + "help.feat.voice": "• Распознавание голосовых (локальный Whisper)", "help.feat.approval": "• Подтверждение команд — кнопки «Разрешить / Отклонить»", "help.feat.questions": "• Вопросы от AI с вариантами ответа", @@ -178,7 +179,8 @@ const STRINGS = { "project.current": "📁 Текущий проект: {dir}", "project.none": "📁 Проект пока не выбран.", "project.manual": "⌨️ Ввести путь вручную", - "project.manualHint": "⌨️ Отправь абсолютный путь к папке проекта следующим сообщением.", + "project.manualHint": + "⌨️ Отправь абсолютный путь к папке проекта следующим сообщением.", "project.cancelled": "Отменено.", // ===== /sessions и /switch ===== @@ -215,6 +217,22 @@ const STRINGS = { "attach.tooBig": "⚠️ Файл слишком большой (макс. {max}MB).", "attach.unsupported": "⚠️ Пока поддерживаются только фото и документы.", + // ===== Голосовые сообщения (Whisper) ===== + "voice.disabled": "🎤 Распознавание голоса выключено в настройках.", + "voice.downloading": "⏳ Скачиваю голосовое…", + "voice.recognizing": "🎧 Распознаю речь…", + "voice.done": "📝 Распознано:\n
{text}
", + "voice.disclaimer": + "**[Распознано локальным Whisper — в тексте возможны неточности.]**", + "voice.empty": "🤷 Речь не распознана (тишина или неразборчиво).", + "voice.failed": "⚠️ Ошибка распознавания: {err}", + "voice.needExe": + "⚠️ whisper-cli.exe не установлен. Скачайте его в настройках Cookie Code (вкладка Telegram).", + "voice.needModel": + "⚠️ Модель Whisper не скачана. Выберите размер и скачайте модель в настройках Cookie Code.", + "voice.needFfmpeg": + "⚠️ ffmpeg не найден. Установите ffmpeg или укажите путь в настройках Cookie Code.", + // ===== Пагинация ===== "page.prev": "◀️ Назад", "page.next": "Вперёд ▶️", @@ -307,6 +325,7 @@ const STRINGS = { "help.features": "Features", "help.feat.notify": "• Notifications about tool calls", "help.feat.feed": "• Receive incoming messages into DeepSeek chat", + "help.feat.voice": "• Voice message transcription (local Whisper)", "help.feat.approval": "• Command approval — “Allow / Deny” buttons", "help.feat.questions": "• AI questions with answer options", "help.sync": "Settings are synced with the desktop app.", @@ -437,7 +456,8 @@ const STRINGS = { "project.current": "📁 Current project: {dir}", "project.none": "📁 No project selected yet.", "project.manual": "⌨️ Enter path manually", - "project.manualHint": "⌨️ Send the absolute path to the project folder as your next message.", + "project.manualHint": + "⌨️ Send the absolute path to the project folder as your next message.", "project.cancelled": "Cancelled.", // ===== /sessions and /switch ===== @@ -474,6 +494,22 @@ const STRINGS = { "attach.tooBig": "⚠️ File too large (max {max}MB).", "attach.unsupported": "⚠️ Only photos and documents are supported yet.", + // ===== Voice messages (Whisper) ===== + "voice.disabled": "🎤 Voice recognition is disabled in settings.", + "voice.downloading": "⏳ Downloading voice message…", + "voice.recognizing": "🎧 Recognizing speech…", + "voice.done": "📝 Transcribed:\n
{text}
", + "voice.disclaimer": + "**[Transcribed by local Whisper — the text may contain inaccuracies.]**", + "voice.empty": "🤷 No speech recognized (silence or unintelligible).", + "voice.failed": "⚠️ Recognition error: {err}", + "voice.needExe": + "⚠️ whisper-cli.exe is not installed. Download it in Cookie Code settings (Telegram tab).", + "voice.needModel": + "⚠️ Whisper model is not downloaded. Pick a size and download it in Cookie Code settings.", + "voice.needFfmpeg": + "⚠️ ffmpeg not found. Install ffmpeg or set its path in Cookie Code settings.", + // ===== Pagination ===== "page.prev": "◀️ Back", "page.next": "Next ▶️", diff --git a/botsrc/index.js b/botsrc/index.js index f023337..cb546f1 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -56,6 +56,11 @@ function _read() { : [], allowedUserId: String(s.telegramAllowedUserId || "").trim(), chatFeed: !!s.telegramChatFeed, + voiceEnabled: !!s.telegramVoiceEnabled, + voiceModel: s.telegramVoiceModel || "light", + whisperLang: s.telegramWhisperLang || "auto", + whisperExePath: String(s.telegramWhisperExePath || "").trim(), + whisperFfmpegPath: String(s.telegramWhisperFfmpegPath || "").trim(), approvalMode: s.toolApprovalMode || "off", lang: i18n.normalizeLang(s.telegramLanguage), }; @@ -2016,6 +2021,7 @@ async function _cmdHelp() { _t("help.features"), _t("help.feat.notify"), _t("help.feat.feed"), + _t("help.feat.voice"), _t("help.feat.approval"), _t("help.feat.questions"), "", @@ -2297,6 +2303,87 @@ async function _handleAttachment(chatId, msg) { } catch (_) {} } +/** Скачать голосовое из TG, распознать локальным Whisper → в чат. */ +async function _handleVoice(chatId, msg) { + const cfg = _read(); + const fs = require("fs"); + const path = require("path"); + const whisper = require("./whisper"); + + if (!cfg.voiceEnabled) { + await telegramBot.sendMessage(_t("voice.disabled")); + return; + } + + const v = msg.voice || msg.audio || {}; + const fileId = v.file_id; + if (!fileId) { + await telegramBot.sendMessage(_t("voice.failed", { err: "no file_id" })); + return; + } + + await telegramBot.sendMessage(_t("voice.downloading")); + const name = "voice_" + Date.now() + ".oga"; + const dest = path.join(_tgTempDir(), name); + const dl = await telegramBot.downloadFile(fileId, dest); + if (!dl.success) { + await telegramBot.sendMessage( + _t("voice.failed", { err: escapeHtml(dl.error) }), + ); + return; + } + + await telegramBot.sendChatAction("typing"); + await telegramBot.sendMessage(_t("voice.recognizing")); + + let res; + try { + res = await whisper.transcribe(dest, { + model: cfg.voiceModel, + lang: cfg.whisperLang, + exe: cfg.whisperExePath, + ffmpeg: cfg.whisperFfmpegPath, + }); + } catch (err) { + res = { success: false, error: err.message }; + } finally { + try { + fs.unlinkSync(dest); + } catch (_) {} + } + + if (!res.success) { + // Понятные подсказки для типовых ошибок. + let hint = res.error; + if (res.error === "needExe") hint = _t("voice.needExe"); + else if (res.error === "needModel") hint = _t("voice.needModel"); + else if (res.error === "needFfmpeg") hint = _t("voice.needFfmpeg"); + await telegramBot.sendMessage(_t("voice.failed", { err: hint })); + return; + } + + const text = String(res.text || "").trim(); + if (!text) { + await telegramBot.sendMessage(_t("voice.empty")); + return; + } + + await telegramBot.sendMessage(_t("voice.done", { text: escapeHtml(text) }), { + parseMode: "HTML", + }); + + if (!cfg.chatFeed) { + _log("info", "voice: chatFeed выключен, текст не отправлен в чат"); + return; + } + // Пометка о возможных неточностях распознавания (перед текстом). + const chatText = _t("voice.disclaimer") + "\n" + text; + const sent = await _sendToChat(chatText); + if (!sent.success) { + await telegramBot.sendMessage("⚠ " + (sent.error || "unknown")); + } +} + /** Входящее из TG → в чат DeepSeek (или команда). */ async function _handleIncoming(chatId, text, msg) { const cfg = _read(); @@ -2310,6 +2397,12 @@ async function _handleIncoming(chatId, text, msg) { // Аргументы команды (всё после первого слова). const cmdArgs = trimmed.split(/\s+/).slice(1).join(" ").trim(); + // ---- Голосовые/аудио → локальный Whisper ---- + if (msg && (msg.voice || msg.audio)) { + await _handleVoice(chatId, msg); + return; + } + // ---- Вложения (фото/документы) ---- // Подпись (caption) обрабатывается внутри _handleAttachment, поэтому // после вложения дальше не идём (иначе caption уйдёт вторым сообщением). diff --git a/botsrc/telegram.js b/botsrc/telegram.js index 6ed7e9b..e18c23f 100644 --- a/botsrc/telegram.js +++ b/botsrc/telegram.js @@ -437,7 +437,13 @@ class TelegramBot { continue; } } - const hasContent = !!(msg.text || msg.photo || msg.document); + const hasContent = !!( + msg.text || + msg.photo || + msg.document || + msg.voice || + msg.audio + ); if (!hasContent) continue; if (this.onMessage) { try { diff --git a/botsrc/whisper.js b/botsrc/whisper.js new file mode 100644 index 0000000..d16e577 --- /dev/null +++ b/botsrc/whisper.js @@ -0,0 +1,496 @@ +/** + * botsrc/whisper.js + * Локальное распознавание речи через whisper.cpp (whisper-cli.exe). + * + * Возможности: + * - ensureBinary(onProgress) — проверить/скачать whisper-cli.exe (+ DLL) + * - ensureModel(size, onProgress) — проверить/скачать GGML-модель + * - getStatus() — что установлено (exe, модели, размеры) + * - transcribe(oggPath, opts) — ffmpeg OGG→WAV 16k mono → whisper-cli → текст + * - downloadBinary()/downloadModel(size) — принудительная загрузка + * + * Хранение: /whisper/ (exe + dll) и /whisper/models/. + * ffmpeg ищется в PATH (можно переопределить в настройках). + * Без внешних npm-зависимостей — только global fetch (Node 18+/Electron). + */ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawn, execFile } = require("child_process"); + +// ==================== Константы ==================== + +// Ночная сборка whisper.cpp для Windows x64. Внутри zip — whisper-cli.exe + DLL. +const WHISPER_TAG = "b5130"; +const WHISPER_ZIP_URL = + "https://github.com/ggml-org/whisper.cpp/releases/download/" + + WHISPER_TAG + + "/whisper-bin-x64.zip"; + +// GGML-модели whisper.cpp (huggingface ggerganov/whisper.cpp). +const HF_BASE = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"; +const MODELS = { + light: { file: "ggml-base.bin", label: "Light (base, ~148 MB)" }, + medium: { file: "ggml-small.bin", label: "Medium (small, ~488 MB)" }, + heavy: { file: "ggml-medium.bin", label: "Heavy (medium, ~1.5 GB)" }, +}; + +// Имя exe в архиве сборки. +const WHISPER_EXE = "whisper-cli.exe"; + +let _dirCache = null; + +// ==================== Пути ==================== + +function _baseDir() { + if (_dirCache) return _dirCache; + let base; + try { + const { app } = require("electron"); + base = path.join(app.getPath("userData"), "whisper"); + } catch (_) { + base = path.join(os.tmpdir(), "cuckoo-whisper"); + } + _dirCache = base; + return base; +} + +function _binDir() { + return path.join(_baseDir(), "bin"); +} + +function _modelsDir() { + return path.join(_baseDir(), "models"); +} + +function _ensureDirs() { + fs.mkdirSync(_binDir(), { recursive: true }); + fs.mkdirSync(_modelsDir(), { recursive: true }); +} + +/** Путь к whisper-cli.exe (пользовательский из настроек или авто). */ +function exePath(custom) { + const c = String(custom || "").trim(); + if (c && fs.existsSync(c)) return c; + return path.join(_binDir(), WHISPER_EXE); +} + +/** Путь к файлу модели для размера size. */ +function modelPath(size) { + const m = MODELS[size] || MODELS.light; + return path.join(_modelsDir(), m.file); +} + +// ==================== Проверка ffmpeg ==================== + +/** + * Найти ffmpeg: сначала пользовательский путь, затем PATH. + * @returns {Promise<{ok:boolean, path?:string, error?:string}>} + */ +function findFfmpeg(custom) { + return new Promise((resolve) => { + const c = String(custom || "").trim(); + if (c && fs.existsSync(c)) return resolve({ ok: true, path: c }); + const probe = process.platform === "win32" ? "where" : "which"; + execFile(probe, ["ffmpeg"], { windowsHide: true }, (err, stdout) => { + if (err || !stdout) { + return resolve({ ok: false, error: "ffmpeg не найден в PATH" }); + } + const first = String(stdout).split(/\r?\n/)[0].trim(); + if (first && fs.existsSync(first)) + return resolve({ ok: true, path: first }); + resolve({ ok: false, error: "ffmpeg не найден в PATH" }); + }); + }); +} + +// ==================== Статус ==================== + +/** + * Текущее состояние: наличие exe, моделей, ffmpeg. + * @param {{exe?:string, ffmpeg?:string}} [opts] + */ +function getStatus(opts = {}) { + _ensureDirs(); + const exe = exePath(opts.exe); + const exeExists = fs.existsSync(exe); + const models = {}; + for (const key of Object.keys(MODELS)) { + const p = modelPath(key); + models[key] = { + file: MODELS[key].file, + label: MODELS[key].label, + path: p, + installed: fs.existsSync(p), + size: fs.existsSync(p) ? fs.statSync(p).size : 0, + }; + } + return { + baseDir: _baseDir(), + exePath: exe, + exeInstalled: exeExists, + ffmpegPath: String(opts.ffmpeg || "").trim(), + models, + }; +} + +// ==================== Загрузка ==================== + +/** + * Скачать URL в файл с отчётом о прогрессе. + * @param {string} url + * @param {string} dest + * @param {(p:{loaded:number,total:number,percent:number})=>void} [onProgress] + */ +async function _download(url, dest, onProgress) { + const res = await fetch(url); + if (!res.ok) throw new Error("HTTP " + res.status + " для " + url); + const total = Number(res.headers.get("content-length")) || 0; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const tmp = dest + ".part"; + const fileStream = fs.createWriteStream(tmp); + const reader = res.body.getReader(); + let loaded = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + loaded += value.length; + fileStream.write(Buffer.from(value)); + if (typeof onProgress === "function") { + try { + onProgress({ + loaded, + total, + percent: total ? Math.round((loaded / total) * 100) : 0, + }); + } catch (_) {} + } + } + } finally { + await new Promise((r) => fileStream.end(r)); + } + fs.renameSync(tmp, dest); + return { path: dest, size: loaded }; +} + +/** + * Распаковать zip (Windows: PowerShell Expand-Archive; иначе unzip). + */ +function _unzip(zipPath, destDir) { + return new Promise((resolve, reject) => { + fs.mkdirSync(destDir, { recursive: true }); + if (process.platform === "win32") { + const cmd = + "Expand-Archive -LiteralPath '" + + zipPath.replace(/'/g, "''") + + "' -DestinationPath '" + + destDir.replace(/'/g, "''") + + "' -Force"; + execFile( + "powershell", + ["-NoProfile", "-Command", cmd], + { windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, + (err, _o, stderr) => { + if (err) return reject(new Error(stderr || err.message)); + resolve(); + }, + ); + } else { + execFile("unzip", ["-o", zipPath, "-d", destDir], (err, _o, stderr) => { + if (err) return reject(new Error(stderr || err.message)); + resolve(); + }); + } + }); +} + +/** Рекурсивно найти файл по имени в каталоге. */ +function _findFile(dir, name) { + let out = null; + const walk = (d) => { + if (out) return; + let entries; + try { + entries = fs.readdirSync(d, { withFileTypes: true }); + } catch (_) { + return; + } + for (const e of entries) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.toLowerCase() === name.toLowerCase()) { + out = p; + return; + } + } + }; + walk(dir); + return out; +} + +// Защита от параллельных загрузок. +const _busy = new Map(); + +/** + * Скачать и распаковать whisper-cli.exe (+ DLL) в /whisper/bin. + * @param {(p:object)=>void} [onProgress] + */ +async function downloadBinary(onProgress) { + if (_busy.has("bin")) return _busy.get("bin"); + const job = (async () => { + _ensureDirs(); + const zip = path.join(_baseDir(), "whisper-bin-x64.zip"); + await _download(WHISPER_ZIP_URL, zip, onProgress); + const tmpDir = path.join(_baseDir(), "_unzip"); + await _unzip(zip, tmpDir); + const found = _findFile(tmpDir, WHISPER_EXE); + if (!found) throw new Error(WHISPER_EXE + " не найден в архиве"); + // Копируем exe и все DLL рядом. + const srcDir = path.dirname(found); + const binDir = _binDir(); + for (const f of fs.readdirSync(srcDir)) { + if (/\.(exe|dll)$/i.test(f)) { + fs.copyFileSync(path.join(srcDir, f), path.join(binDir, f)); + } + } + // Уборка временных файлов. + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (_) {} + try { + fs.unlinkSync(zip); + } catch (_) {} + return { success: true, exePath: exePath() }; + })(); + _busy.set("bin", job); + try { + return await job; + } finally { + _busy.delete("bin"); + } +} + +/** + * Скачать GGML-модель нужного размера. + * @param {string} size — light | medium | heavy + * @param {(p:object)=>void} [onProgress] + */ +async function downloadModel(size, onProgress) { + const key = MODELS[size] ? size : "light"; + if (_busy.has("model:" + key)) return _busy.get("model:" + key); + const job = (async () => { + _ensureDirs(); + const m = MODELS[key]; + const dest = modelPath(key); + await _download(HF_BASE + m.file, dest, onProgress); + return { success: true, model: key, path: dest }; + })(); + _busy.set("model:" + key, job); + try { + return await job; + } finally { + _busy.delete("model:" + key); + } +} + +// ==================== Распознавание ==================== + +/** ffmpeg: сконвертировать любой аудиофайл в WAV 16kHz mono PCM s16le. */ +function _toWav(ffmpegPath, inPath, outPath) { + return new Promise((resolve, reject) => { + const args = [ + "-y", + "-i", + inPath, + "-ar", + "16000", + "-ac", + "1", + "-c:a", + "pcm_s16le", + "-f", + "wav", + outPath, + ]; + execFile(ffmpegPath, args, { windowsHide: true }, (err, _o, stderr) => { + if (err) return reject(new Error("ffmpeg: " + (stderr || err.message))); + resolve(outPath); + }); + }); +} + +/** + * Запустить whisper-cli.exe и вернуть распознанный текст. + * @param {string} exe + * @param {string} wavPath + * @param {{model:string, lang?:string}} opts + * @returns {Promise} + */ +function _runWhisper(exe, wavPath, opts) { + return new Promise((resolve, reject) => { + const model = modelPath(opts.model); + if (!fs.existsSync(model)) + return reject(new Error("модель не найдена: " + model)); + const args = [ + "-m", + model, + "-f", + wavPath, + "-l", + String(opts.lang || "auto"), + "-nt", // без таймстампов + "-otxt", // (на случай если пишет txt рядом — не мешает) + ]; + const child = spawn(exe, args, { + windowsHide: true, + cwd: path.dirname(exe), + }); + let out = ""; + let err = ""; + child.stdout.on("data", (d) => (out += d.toString("utf-8"))); + child.stderr.on("data", (d) => (err += d.toString("utf-8"))); + child.on("error", (e) => reject(new Error("whisper-cli: " + e.message))); + child.on("close", (code) => { + if (code !== 0 && !out.trim()) { + return reject( + new Error("whisper-cli exit " + code + ": " + err.slice(-500)), + ); + } + resolve(out); + }); + }); +} + +/** Очистить вывод whisper-cli от служебных строк, оставить текст. */ +function _cleanOutput(raw) { + const lines = String(raw || "").split(/\r?\n/); + const text = []; + for (const line of lines) { + const s = line.trim(); + if (!s) continue; + // Пропускаем служебные строки whisper.cpp: + // "[00:00:00.000 --> ...]", "whisper_", "system_info:", "main:", и т.п. + if (/^\[/.test(s)) { + // Строка с таймстампом — берём часть после "]". + const idx = s.indexOf("]"); + if (idx >= 0) { + const t = s.slice(idx + 1).trim(); + if (t) text.push(t); + } + continue; + } + if ( + /^(whisper_|ggml_|system_info|main:|load_|encode|decode|output)/i.test(s) + ) + continue; + text.push(s); + } + return text.join(" ").replace(/\s+/g, " ").trim(); +} + +/** + * Полный пайплайн: OGG/OPUS → WAV → whisper-cli → текст. + * @param {string} audioPath — путь к аудиофайлу (oga/ogg/mp3/...) + * @param {{model?:string, lang?:string, exe?:string, ffmpeg?:string}} [opts] + * @returns {Promise<{success:boolean, text?:string, error?:string}>} + */ +async function transcribe(audioPath, opts = {}) { + try { + if (!audioPath || !fs.existsSync(audioPath)) + return { success: false, error: "аудиофайл не найден" }; + const exe = exePath(opts.exe); + if (!fs.existsSync(exe)) return { success: false, error: "needExe" }; + const key = MODELS[opts.model] ? opts.model : "light"; + if (!fs.existsSync(modelPath(key))) + return { success: false, error: "needModel" }; + + const ff = await findFfmpeg(opts.ffmpeg); + if (!ff.ok) return { success: false, error: "needFfmpeg" }; + + const wav = audioPath.replace(/\.[^.]+$/, "") + ".16k.wav"; + try { + await _toWav(ff.path, audioPath, wav); + const raw = await _runWhisper(exe, wav, { + model: key, + lang: opts.lang, + }); + const text = _cleanOutput(raw); + return { success: true, text }; + } finally { + try { + fs.unlinkSync(wav); + } catch (_) {} + } + } catch (err) { + return { success: false, error: err.message }; + } +} + +// ==================== Удаление ==================== + +/** + * Удалить установленный whisper-cli.exe и все DLL рядом. + * @returns {{success:boolean, removed?:string[], error?:string}} + */ +function removeBinary() { + try { + const binDir = _binDir(); + if (!fs.existsSync(binDir)) return { success: true, removed: [] }; + const removed = []; + for (const f of fs.readdirSync(binDir)) { + if (/\.(exe|dll)$/i.test(f)) { + try { + fs.unlinkSync(path.join(binDir, f)); + removed.push(f); + } catch (_) {} + } + } + return { success: true, removed }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Удалить GGML-модель указанного размера. + * @param {string} size — light | medium | heavy + */ +function removeModel(size) { + const key = MODELS[size] ? size : "light"; + try { + const p = modelPath(key); + if (fs.existsSync(p)) fs.unlinkSync(p); + // На случай оставшихся .part-файлов. + try { + fs.unlinkSync(p + ".part"); + } catch (_) {} + return { success: true, model: key }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** + * Удалить всё: бинарник и все модели. + */ +function removeAll() { + const res = { success: true, bin: removeBinary(), models: {} }; + for (const key of Object.keys(MODELS)) res.models[key] = removeModel(key); + return res; +} + +module.exports = { + MODELS, + WHISPER_TAG, + getStatus, + downloadBinary, + downloadModel, + removeBinary, + removeModel, + removeAll, + transcribe, + findFfmpeg, + exePath, + modelPath, +}; diff --git a/src/main/ipc.js b/src/main/ipc.js index 2459403..c2c6c35 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -401,32 +401,32 @@ function registerIpcHandlers() { }); // 初始化项目 - ipcMain.handle("init-project", async (event, { skipPrompt = false } = {}) => { - const ctx = windowState.getContextByWebContents(event.sender); - return initProject(skipPrompt, ctx); - }); + ipcMain.handle("init-project", async (event, { skipPrompt = false } = {}) => { + const ctx = windowState.getContextByWebContents(event.sender); + return initProject(skipPrompt, ctx); + }); - // Установить проект по уже известному пути (без диалога выбора папки). - // Используется Telegram-ботом при выборе проекта кнопкой. - ipcMain.handle("set-project-dir", async (event, { dir } = {}) => { - const ctx = windowState.getContextByWebContents(event.sender); - const { setProjectByDir } = require("./project-context"); - return await setProjectByDir(dir, ctx); - }); + // Установить проект по уже известному пути (без диалога выбора папки). + // Используется Telegram-ботом при выборе проекта кнопкой. + ipcMain.handle("set-project-dir", async (event, { dir } = {}) => { + const ctx = windowState.getContextByWebContents(event.sender); + const { setProjectByDir } = require("./project-context"); + return await setProjectByDir(dir, ctx); + }); - // 列出会话 - ipcMain.handle("list-sessions", async (event) => { - const ctx = windowState.getContextByWebContents(event.sender); - const store = ctx ? ctx.sessionStore : null; - if (!store || !store.state.selectedProjectDir) { - return { success: true, sessions: [] }; - } - const all = store.readSessionStore(); - const sessions = Object.keys(all).filter( - (id) => all[id] === store.state.selectedProjectDir, - ); - return { success: true, sessions }; - }); + // 列出会话 + ipcMain.handle("list-sessions", async (event) => { + const ctx = windowState.getContextByWebContents(event.sender); + const store = ctx ? ctx.sessionStore : null; + if (!store || !store.state.selectedProjectDir) { + return { success: true, sessions: [] }; + } + const all = store.readSessionStore(); + const sessions = Object.keys(all).filter( + (id) => all[id] === store.state.selectedProjectDir, + ); + return { success: true, sessions }; + }); // 列出项目文件(用于 @ 文件提及自动补全) // 返回 { success, files: [{ rel, abs }] },rel 为相对 projectDir 的路径,abs 为绝对路径。 @@ -1073,6 +1073,63 @@ function registerIpcHandlers() { } }); + // ===== Локальный Whisper (распознавание голосовых) ===== + ipcMain.handle("telegram-whisper-status", async (_event, opts = {}) => { + try { + const whisper = require("../../botsrc/whisper"); + return { success: true, status: whisper.getStatus(opts || {}) }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle( + "telegram-whisper-download", + async (_event, { target, model, onProgress } = {}) => { + try { + const whisper = require("../../botsrc/whisper"); + const report = (p) => { + try { + if (_event.sender && !_event.sender.isDestroyed()) { + _event.sender.send("telegram-whisper-progress", { + target, + model, + ...p, + }); + } + } catch (_) {} + }; + if (target === "model") { + const res = await whisper.downloadModel(model || "light", report); + return { success: true, result: res }; + } + const res = await whisper.downloadBinary(report); + return { success: true, result: res }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + ); + + ipcMain.handle( + "telegram-whisper-remove", + async (_event, { target, model } = {}) => { + try { + const whisper = require("../../botsrc/whisper"); + if (target === "all") + return { success: true, result: whisper.removeAll() }; + if (target === "model") + return { + success: true, + result: whisper.removeModel(model || "light"), + }; + return { success: true, result: whisper.removeBinary() }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + ); + ipcMain.handle( "telegram-approval-request", async (_event, { requestId, info } = {}) => { diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 403aaaf..fd8eed8 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -63,6 +63,12 @@ const DEFAULTS = { // и в компактных уведомлениях оно показывается как «📖 Reading ». telegramToolNotifyIgnore: ["glob", "grep", "todoWrite"], telegramChatFeed: false, // принимать сообщения из TG в чат DeepSeek + // ===== Распознавание голосовых (локальный Whisper, botsrc/whisper.js) ===== + telegramVoiceEnabled: false, // распознавать голосовые из TG локальным Whisper + telegramVoiceModel: "light", // размер модели: 'light' | 'medium' | 'heavy' + telegramWhisperLang: "auto", // язык для whisper (-l): 'auto' | 'ru' | 'en' | ... + telegramWhisperExePath: "", // путь к whisper-cli.exe (пусто — авто, /whisper/bin) + telegramWhisperFfmpegPath: "", // путь к ffmpeg (пусто — искать в PATH) // ===== Подтверждение tool-вызовов (approval gate) ===== // 'off' — выполнять всё автоматически (прежнее поведение) // 'risky' — спрашивать подтверждение только для рискованных инструментов diff --git a/src/preload/api.js b/src/preload/api.js index f14fe7a..a9abeb9 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -20,18 +20,18 @@ let electronAPI = { return ipcRenderer.invoke("get-file-icons"); }, updateProjectDir: () => { - return ipcRenderer.invoke("init-project", { skipPrompt: true }); - }, - // Установить проект по известному пути (без диалога). Используется TG-ботом. - setProjectDir: (dir) => { - return ipcRenderer.invoke("set-project-dir", { dir }); - }, - executeTool: (toolName, params, callId) => { - return ipcRenderer.invoke("execute-tool", { toolName, params, callId }); - }, - executeJs: (code, callId) => { - return ipcRenderer.invoke("execute-js", { code, callId }); - }, + return ipcRenderer.invoke("init-project", { skipPrompt: true }); + }, + // Установить проект по известному пути (без диалога). Используется TG-ботом. + setProjectDir: (dir) => { + return ipcRenderer.invoke("set-project-dir", { dir }); + }, + executeTool: (toolName, params, callId) => { + return ipcRenderer.invoke("execute-tool", { toolName, params, callId }); + }, + executeJs: (code, callId) => { + return ipcRenderer.invoke("execute-js", { code, callId }); + }, setPlanMode: (enabled) => { return ipcRenderer.invoke("set-plan-mode", { enabled }); }, @@ -50,6 +50,18 @@ let electronAPI = { ipcRenderer.invoke("telegram-notify-ai", { text }), telegramTypingStart: () => ipcRenderer.invoke("telegram-typing-start"), telegramTypingStop: () => ipcRenderer.invoke("telegram-typing-stop"), + telegramWhisperStatus: (opts) => + ipcRenderer.invoke("telegram-whisper-status", opts || {}), + telegramWhisperDownload: (target, model) => + ipcRenderer.invoke("telegram-whisper-download", { target, model }), + telegramWhisperRemove: (target, model) => + ipcRenderer.invoke("telegram-whisper-remove", { target, model }), + onTelegramWhisperProgress: (callback) => { + const listener = (_event, data) => callback(data); + ipcRenderer.on("telegram-whisper-progress", listener); + return () => + ipcRenderer.removeListener("telegram-whisper-progress", listener); + }, telegramApprovalRequest: (requestId, info) => ipcRenderer.invoke("telegram-approval-request", { requestId, info }), telegramApprovalCancel: (requestId) => diff --git a/src/preload/dom/settings-tab.js b/src/preload/dom/settings-tab.js index f0b884c..094be17 100644 --- a/src/preload/dom/settings-tab.js +++ b/src/preload/dom/settings-tab.js @@ -694,6 +694,73 @@ function buildContentHTML() { t("tg.btn.test") + "" + " " + + // ===== Распознавание голоса (Whisper) ===== + '
' + + t("tg.voice.title") + + "
" + + '
' + + t("tg.voice.hint") + + "
" + + ' ' + + '
' + + '
' + + t("tg.voice.model") + + "
" + + ' " + + "
" + + '
' + + '
' + + t("tg.voice.lang") + + "
" + + ' " + + "
" + + '
' + + '
' + + t("tg.voice.exePath") + + "
" + + ' ' + + "
" + + '
' + + '
' + + t("tg.voice.ffmpegPath") + + "
" + + ' ' + + "
" + + '
' + + '
' + + ' " + + ' " + + ' " + + "
" + "" + // ===== Статистика использования ===== '
' + @@ -2208,6 +2275,19 @@ async function refreshTelegramSettings() { if (enabledEl) enabledEl.checked = !!s.telegramEnabled; if (notifyEl) notifyEl.checked = !!s.telegramNotifyTools; if (feedEl) feedEl.checked = !!s.telegramChatFeed; + + // ===== Whisper (голосовые) ===== + const voiceEnabledEl = document.getElementById("cuckoo-tg-voice-enabled"); + const voiceModelEl = document.getElementById("cuckoo-tg-voice-model"); + const voiceLangEl = document.getElementById("cuckoo-tg-voice-lang"); + const voiceExeEl = document.getElementById("cuckoo-tg-voice-exe"); + const voiceFfmpegEl = document.getElementById("cuckoo-tg-voice-ffmpeg"); + if (voiceEnabledEl) voiceEnabledEl.checked = !!s.telegramVoiceEnabled; + if (voiceModelEl) voiceModelEl.value = s.telegramVoiceModel || "light"; + if (voiceLangEl) voiceLangEl.value = s.telegramWhisperLang || "auto"; + if (voiceExeEl) voiceExeEl.value = s.telegramWhisperExePath || ""; + if (voiceFfmpegEl) voiceFfmpegEl.value = s.telegramWhisperFfmpegPath || ""; + refreshWhisperStatus(); } catch (err) { console.error( "[Cookie Code] Не удалось загрузить настройки Telegram:", @@ -2234,6 +2314,16 @@ function bindTelegramSettings() { enabled: !!(document.getElementById("cuckoo-tg-enabled") || {}).checked, notify: !!(document.getElementById("cuckoo-tg-notify") || {}).checked, feed: !!(document.getElementById("cuckoo-tg-feed") || {}).checked, + voiceEnabled: !!(document.getElementById("cuckoo-tg-voice-enabled") || {}) + .checked, + voiceModel: + (document.getElementById("cuckoo-tg-voice-model") || {}).value || "light", + voiceLang: + (document.getElementById("cuckoo-tg-voice-lang") || {}).value || "auto", + voiceExe: + (document.getElementById("cuckoo-tg-voice-exe") || {}).value || "", + voiceFfmpeg: + (document.getElementById("cuckoo-tg-voice-ffmpeg") || {}).value || "", }); const save = async () => { @@ -2264,6 +2354,26 @@ function bindTelegramSettings() { v.notify, ); await window.electronAPI.setCuckooSetting("telegramChatFeed", v.feed); + await window.electronAPI.setCuckooSetting( + "telegramVoiceEnabled", + v.voiceEnabled, + ); + await window.electronAPI.setCuckooSetting( + "telegramVoiceModel", + v.voiceModel, + ); + await window.electronAPI.setCuckooSetting( + "telegramWhisperLang", + v.voiceLang, + ); + await window.electronAPI.setCuckooSetting( + "telegramWhisperExePath", + v.voiceExe.trim(), + ); + await window.electronAPI.setCuckooSetting( + "telegramWhisperFfmpegPath", + v.voiceFfmpeg.trim(), + ); const res = await window.electronAPI.telegramApply(); return res && res.success; } catch (err) { @@ -2311,6 +2421,105 @@ function bindTelegramSettings() { testBtn.textContent = t("tg.btn.test"); }, 2500); }); + + // ===== Whisper: скачивание exe/модели ===== + const dlExeBtn = document.getElementById("cuckoo-tg-voice-dl-exe"); + const dlModelBtn = document.getElementById("cuckoo-tg-voice-dl-model"); + + dlExeBtn?.addEventListener("click", async () => { + await save(); + dlExeBtn.disabled = true; + dlExeBtn.textContent = "..."; + const res = await window.electronAPI.telegramWhisperDownload("exe"); + dlExeBtn.disabled = false; + dlExeBtn.textContent = + res && res.success + ? "✅ " + t("tg.voice.dlExe") + : "❌ " + ((res && res.error) || "error"); + await refreshWhisperStatus(); + setTimeout(() => { + dlExeBtn.textContent = t("tg.voice.dlExe"); + }, 2500); + }); + + dlModelBtn?.addEventListener("click", async () => { + await save(); + const model = + (document.getElementById("cuckoo-tg-voice-model") || {}).value || "light"; + dlModelBtn.disabled = true; + dlModelBtn.textContent = "..."; + const res = await window.electronAPI.telegramWhisperDownload( + "model", + model, + ); + dlModelBtn.disabled = false; + dlModelBtn.textContent = + res && res.success + ? "✅ " + t("tg.voice.dlModel") + : "❌ " + ((res && res.error) || "error"); + await refreshWhisperStatus(); + setTimeout(() => { + dlModelBtn.textContent = t("tg.voice.dlModel"); + }, 2500); + }); + + const removeBtn = document.getElementById("cuckoo-tg-voice-remove"); + removeBtn?.addEventListener("click", async () => { + const ok = window.confirm(t("tg.voice.removeConfirm")); + if (!ok) return; + removeBtn.disabled = true; + removeBtn.textContent = "..."; + // Удаляем и бинарник, и все модели (полный сброс Whisper). + const res = await window.electronAPI.telegramWhisperRemove("all"); + removeBtn.disabled = false; + removeBtn.textContent = + res && res.success + ? "✅ " + t("tg.voice.remove") + : "❌ " + ((res && res.error) || "error"); + await refreshWhisperStatus(); + setTimeout(() => { + removeBtn.textContent = t("tg.voice.remove"); + }, 2500); + }); +} + +/** + * Обновить строку статуса Whisper (exe/модель/ffmpeg) под настройками. + */ +async function refreshWhisperStatus() { + const el = document.getElementById("cuckoo-tg-voice-status"); + if (!el) return; + try { + const exePath = (document.getElementById("cuckoo-tg-voice-exe") || {}) + .value; + const ffmpegPath = (document.getElementById("cuckoo-tg-voice-ffmpeg") || {}) + .value; + const model = + (document.getElementById("cuckoo-tg-voice-model") || {}).value || "light"; + const res = await window.electronAPI.telegramWhisperStatus({ + exe: exePath, + ffmpeg: ffmpegPath, + }); + if (!res || !res.success) { + el.textContent = "❌ " + ((res && res.error) || "error"); + return; + } + const st = res.status || {}; + const m = (st.models && st.models[model]) || {}; + const parts = [ + (st.exeInstalled ? "✅" : "❌") + " " + t("tg.voice.stExe"), + (m.installed ? "✅" : "❌") + + " " + + t("tg.voice.stModel") + + " (" + + model + + (m.size ? ", " + Math.round(m.size / 1048576) + " MB" : "") + + ")", + ]; + el.textContent = parts.join(" "); + } catch (err) { + el.textContent = "❌ " + err.message; + } } function bindResetButton() { diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 01fb597..6780af7 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -79,6 +79,75 @@ const KEYS = { "tg.btn.test": { ru: "Тестовое сообщение", en: "Test message" }, "tg.status.off": { ru: "Выключен", en: "Disabled" }, "tg.status.on": { ru: "Работает", en: "Running" }, + // ===== Распознавание голоса (Whisper) ===== + "tg.voice.title": { + ru: "🎤 Распознавание голоса (Whisper)", + en: "🎤 Voice recognition (Whisper)", + }, + "tg.voice.hint": { + ru: "Голосовые из Telegram распознаются локально и вставляются в поле ввода чата.", + en: "Telegram voice messages are transcribed locally and inserted into the chat input.", + }, + "tg.voice.enabled": { + ru: "Включить распознавание голосовых", + en: "Enable voice recognition", + }, + "tg.voice.model": { + ru: "Размер модели", + en: "Model size", + }, + "tg.voice.model.light": { + ru: "Лёгкая (base, ~148 МБ)", + en: "Light (base, ~148 MB)", + }, + "tg.voice.model.medium": { + ru: "Средняя (small, ~488 МБ)", + en: "Medium (small, ~488 MB)", + }, + "tg.voice.model.heavy": { + ru: "Тяжёлая (medium, ~1.5 ГБ)", + en: "Heavy (medium, ~1.5 GB)", + }, + "tg.voice.lang": { + ru: "Язык распознавания", + en: "Recognition language", + }, + "tg.voice.lang.auto": { + ru: "Авто-определение", + en: "Auto-detect", + }, + "tg.voice.exePath": { + ru: "Путь к whisper-cli.exe (пусто — авто)", + en: "whisper-cli.exe path (empty — auto)", + }, + "tg.voice.ffmpegPath": { + ru: "Путь к ffmpeg (пусто — из PATH)", + en: "ffmpeg path (empty — from PATH)", + }, + "tg.voice.dlExe": { + ru: "Скачать whisper-cli", + en: "Download whisper-cli", + }, + "tg.voice.dlModel": { + ru: "Скачать модель", + en: "Download model", + }, + "tg.voice.remove": { + ru: "Удалить Whisper", + en: "Remove Whisper", + }, + "tg.voice.removeConfirm": { + ru: "Удалить whisper-cli и все скачанные модели? Это освободит место на диске.", + en: "Delete whisper-cli and all downloaded models? This frees up disk space.", + }, + "tg.voice.stExe": { + ru: "whisper-cli", + en: "whisper-cli", + }, + "tg.voice.stModel": { + ru: "модель", + en: "model", + }, "overlay.btn.genDoc": { ru: "Создать описание", en: "Generate docs" }, "overlay.btn.genDoc.title": { ru: "Попросить AI сгенерировать описание проекта (CUCKOO.md)",