diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6b55b..05409fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## [4.0.36] - 2026-10-04 + +### Added + +- 📸 **Команда `/screen` в Telegram — скриншот активного окна** — бот делает снимок текущего окна и присылает его в чат: + - новая команда `/screen` (+ подсказка в `/help`), зарегистрирована в меню команд для RU/EN; + - если активного окна нет — понятное сообщение «Нет активного окна»; + - при ошибке захвата бот возвращает причину (`screen.error`). +- ✅ **Утверждение плана через Telegram** — план из режима плана теперь можно согласовать или отклонить прямо из бота: + - сообщение «📋 План на утверждение» с inline-кнопками «✅ Согласовать» / «❌ Отказать»; + - решение синхронизируется между ботом и окном приложения (состояние диалога плана общее); + - повторное нажатие по закрытому запросу отвечает «Запрос уже закрыт». +- 🧹 **Перенос контекста: удаление промежуточного чата-суммаризатора** — чат, в котором модель сжимала историю, больше не остаётся висеть: + - sessionId промежуточного чата запоминается и удаляется уже из финального чата после переноса; + - удаление идёт через DOM сайдбара и **не зависит от языка интерфейса** (пункт меню и кнопка определяются по структурным признакам, а не по тексту). + +### Changed + +- 🌐 **Локализация списка MCP в оверлее** — статусы («Подключён» / «Не подключён» / «Отключён»), пустой список и тосты действий переведены через i18n (RU/EN) вместо захардкоженных строк. +- 💬 **Диалоги вопросов и режима плана** — улучшена обработка закрытия/отмены (`ask-user-question`, `exit-plan-mode`), обновлён рендеринг markdown для новых сценариев утверждения. + +### Fixed + +- 🎨 **Кастомизация: фон и покраска не применяются при выключенной кастомизации** — если кастомизация отключена, фон/blur/эффекты не навязываются, а inline-стили `background-image` снимаются с ``/`
`: + - добавлен флаг состояния, синхронизируемый при загрузке настроек и по изменению из другого окна/бота — фон больше не «возвращается» обратно; + - при выключении сбрасываются blur, RGB-ник и стекло ввода. +- 🖼️ **Экспорт в PDF/DOCX: чистка SVG и служебных элементов** — перед выгрузкой ответа удаляются декоративные SVG-иконки и сервисные узлы, чтобы в документ попадал только полезный контент. +- 🔗 **Привязка перенесённого чата к проекту** — после переноса контекста новый чат автоматически привязывается к тому же проекту: + - `projectDir` переносится через контекст-порт и устанавливается как `pendingProjectDir` нового чата; + - добавлен fallback `lastProjectDir` в session-store и чтение персистентного маппинга, чтобы системный промпт проекта собирался даже при сброшенном `selectedProjectDir`. + +### Added (EN) + +- 📸 **Telegram `/screen` command — screenshot of the active window** — the bot captures the current window and sends it to the chat: + - new `/screen` command (+ hint in `/help`), registered in the command menu for RU/EN; + - if there is no active window, a clear "No active window" message is returned; + - on capture failure the bot reports the reason (`screen.error`). +- ✅ **Plan approval via Telegram** — a plan from plan mode can now be approved or denied straight from the bot: + - a "📋 Plan for approval" message with inline "✅ Approve" / "❌ Deny" buttons; + - the decision is synchronized between the bot and the app window (shared plan dialog state); + - pressing a button on an already-closed request answers "Request already closed". +- 🧹 **Context transfer: removing the intermediate summarizer chat** — the chat where the model compressed the history no longer lingers: + - the summarizer chat's sessionId is remembered and deleted from the final chat after the transfer; + - deletion goes through the sidebar DOM and **does not depend on the UI language** (menu item and button are matched by structural traits, not by text). + +### Changed (EN) + +- 🌐 **Localized MCP list in the overlay** — statuses ("Connected" / "Disconnected" / "Disabled"), the empty state, and action toasts now go through i18n (RU/EN) instead of hardcoded strings. +- 💬 **Question and plan-mode dialogs** — improved close/cancel handling (`ask-user-question`, `exit-plan-mode`) and markdown rendering for the new approval scenarios. + +### Fixed (EN) + +- 🎨 **Customization: background and styling are not applied when customization is off** — when customization is disabled, background/blur/effects are not forced, and inline `background-image` styles are removed from ``/``: + - a state flag is synchronized on settings load and on changes from another window/bot — the background no longer snaps back; + - disabling resets blur, RGB username, and input glass. +- 🖼️ **PDF/DOCX export: stripping SVG and service elements** — before exporting a response, decorative SVG icons and service nodes are removed so only useful content lands in the document. +- 🔗 **Binding a transferred chat to the project** — after a context transfer, the new chat is automatically bound to the same project: + - `projectDir` is carried through the context port and set as the new chat's `pendingProjectDir`; + - added a `lastProjectDir` fallback in session-store and reading of the persistent mapping, so the project system prompt is built even when `selectedProjectDir` is reset. + ## [4.0.35] - 2026-09-29 ### Added diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 0442a70..b138418 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -44,6 +44,7 @@ const STRINGS = { "help.cmd.show": "/show <hash> — diff коммита", "help.cmd.files": "/files <hash> — файлы коммита", "help.cmd.todos": "/todos — список задач активного окна", + "help.cmd.screen": "/screen — скриншот активного окна", "help.cmd.cancel": "/cancel — отменить ввод / подтверждение", "help.cmd.help": "/help — эта справка", "help.features": "Возможности", @@ -262,6 +263,23 @@ const STRINGS = { "approval.deniedShort": "❌ Отклонено", "approval.closed": "Запрос уже закрыт", + // ===== Утверждение плана ===== + "plan.title": "📋 План на утверждение", + "plan.ask": "Согласовать план?", + "plan.approve": "✅ Согласовать", + "plan.deny": "❌ Отказать", + "plan.approved": "План согласован", + "plan.denied": "План отклонён", + "plan.approvedShort": "✅ Согласован", + "plan.deniedShort": "❌ Отклонён", + "plan.closed": "Запрос уже закрыт", + + // ===== Скриншот ===== + "screen.capturing": "📸 Делаю скриншот окна...", + "screen.caption": "📸 Скриншот активного окна", + "screen.noWindow": "Нет активного окна", + "screen.error": "Не удалось сделать скриншот: {err}", + // ===== Долгий процесс ===== "longproc.title": "⏳ Долгий процесс", "longproc.body": "Команда выполняется более {sec} с:", @@ -320,6 +338,7 @@ const STRINGS = { "help.cmd.show": "/show <hash> — commit diff", "help.cmd.files": "/files <hash> — commit files", "help.cmd.todos": "/todos — tasks of the active window", + "help.cmd.screen": "/screen — screenshot of the active window", "help.cmd.cancel": "/cancel — cancel input / approval", "help.cmd.help": "/help — this help", "help.features": "Features", @@ -539,6 +558,23 @@ const STRINGS = { "approval.deniedShort": "❌ Denied", "approval.closed": "Request already closed", + // ===== Plan approval ===== + "plan.title": "📋 Plan for approval", + "plan.ask": "Approve this plan?", + "plan.approve": "✅ Approve", + "plan.deny": "❌ Deny", + "plan.approved": "Plan approved", + "plan.denied": "Plan denied", + "plan.approvedShort": "✅ Approved", + "plan.deniedShort": "❌ Denied", + "plan.closed": "Request already closed", + + // ===== Screenshot ===== + "screen.capturing": "📸 Capturing window screenshot...", + "screen.caption": "📸 Active window screenshot", + "screen.noWindow": "No active window", + "screen.error": "Failed to capture screenshot: {err}", + // ===== Long process ===== "longproc.title": "⏳ Long-running process", "longproc.body": "Command has been running for over {sec}s:", diff --git a/botsrc/index.js b/botsrc/index.js index cb546f1..53b5d13 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -198,6 +198,58 @@ function _activeSenderId() { return null; } +/** + * Жёстко сфокусировать активное окно, сделать скриншот и отпустить. + * Возвращает путь к временному PNG или { error }. + */ +async function _captureActiveWindow() { + const win = windowState.getMainWindow(); + if (!win || win.isDestroyed()) return { error: "noWindow" }; + const { app } = require("electron"); + const fs = require("fs"); + const path = require("path"); + + // Запоминаем прежнее состояние, чтобы вернуть его после снимка. + const wasMinimized = win.isMinimized(); + const wasAlwaysOnTop = win.isAlwaysOnTop(); + const wasVisible = win.isVisible(); + + try { + if (wasMinimized) win.restore(); + if (!wasVisible) win.show(); + // «Жёстко» держим окно поверх и в фокусе, пока идёт захват. + win.setAlwaysOnTop(true, "screen-saver"); + win.show(); + win.focus(); + if (typeof win.moveTop === "function") win.moveTop(); + try { + win.webContents.focus(); + } catch (_) {} + + // Даём окну отрисоваться/подняться. + await new Promise((r) => setTimeout(r, 350)); + + const image = await win.webContents.capturePage(); + const png = image.toPNG(); + const outPath = path.join( + app.getPath("temp"), + "cuckoo-screen-" + Date.now() + ".png", + ); + fs.writeFileSync(outPath, png); + return { path: outPath }; + } catch (err) { + return { error: err.message }; + } finally { + // Отпускаем: снимаем always-on-top и возвращаем прежнее состояние. + try { + win.setAlwaysOnTop(wasAlwaysOnTop); + } catch (_) {} + try { + if (wasMinimized) win.minimize(); + } catch (_) {} + } +} + /** Текст со списком задач (для /todos и уведомления о завершении). */ function formatTodos(todos) { const items = Array.isArray(todos) ? todos : []; @@ -226,6 +278,8 @@ const _pendingQuestions = new Map(); const _callbackTokens = new Map(); const _pendingApprovals = new Map(); const _approvalCallbackTokens = new Map(); +const _pendingPlanApprovals = new Map(); +const _planCallbackTokens = new Map(); /** * Реестр уведомлений о долгих процессах. * key = token ('k" + body + "" : "") + + "\n" + + _t("plan.ask"); + const res = await telegramBot.sendMessage(msg, { + parseMode: "HTML", + replyMarkup: { + inline_keyboard: [ + [ + { text: _t("plan.approve"), callback_data: token }, + { text: _t("plan.deny"), callback_data: token + "d" }, + ], + ], + }, + }); + if (!res.success) { + _pendingPlanApprovals.delete(id); + _planCallbackTokens.delete(token); + return res; + } + entry.messageId = res.messageId || null; + _planCallbackTokens.set(token + "d", { requestId: id, approved: false }); + return promise; +} + +async function _handlePlanCallback(ref, cbq) { + const entry = _pendingPlanApprovals.get(ref.requestId); + if (!entry) { + await telegramBot.answerCallbackQuery(cbq.id, { + text: _t("plan.closed"), + }); + return; + } + _pendingPlanApprovals.delete(ref.requestId); + _planCallbackTokens.delete(entry.token); + _planCallbackTokens.delete(entry.token + "d"); + await telegramBot.answerCallbackQuery(cbq.id, { + text: ref.approved ? _t("plan.approved") : _t("plan.denied"), + }); + if (entry.messageId) { + const status = ref.approved + ? _t("plan.approvedShort") + : _t("plan.deniedShort"); + await telegramBot.editMessageText( + entry.messageId, + _t("plan.title") + "\n" + status, + { parseMode: "HTML", replyMarkup: { inline_keyboard: [] } }, + ); + } + // Уведомляем main, чтобы окно закрыло диалог и сняло/не снимало режим плана. + try { + const { resolvePlanApprovalFromTelegram } = require("../src/main/ipc"); + if (typeof resolvePlanApprovalFromTelegram === "function") { + resolvePlanApprovalFromTelegram(ref.requestId, ref.approved); + } + } catch (_) {} + if (typeof entry.resolve === "function") { + entry.resolve({ success: true, approved: ref.approved }); + } +} + +/** Отменить запрос плана, если решение уже принято в окне приложения. */ +function cancelPlanApproval(requestId) { + const id = String(requestId || ""); + const entry = _pendingPlanApprovals.get(id); + if (!entry) return { success: true, skipped: true }; + _pendingPlanApprovals.delete(id); + _planCallbackTokens.delete(entry.token); + _planCallbackTokens.delete(entry.token + "d"); + if (typeof entry.resolve === "function") + entry.resolve({ success: false, skipped: true }); + return { success: true }; +} + /** * Уведомить о долгоиграющем процессе и прислать кнопку «Убить процесс». * Вызывается из bash/pwsh инструментов, если команда не завершилась за N сек. @@ -1341,6 +1499,43 @@ async function _cmdStop() { } /** /status — окно, проект, процессы, версия, polling. */ + +/** /screen — жёстко сфокусировать активное окно, сделать скриншот и отправить в TG. */ +async function _cmdScreen() { + const cfg = _read(); + if (!cfg.enabled || !cfg.token || !cfg.chatId) + return { success: false, skipped: true }; + + const win = windowState.getMainWindow(); + if (!win || win.isDestroyed()) { + await telegramBot.sendMessage(_t("screen.noWindow")); + return { success: false, error: "noWindow" }; + } + + await telegramBot.sendMessage(_t("screen.capturing")); + const res = await _captureActiveWindow(); + if (res.error === "noWindow") { + await telegramBot.sendMessage(_t("screen.noWindow")); + return { success: false, error: "noWindow" }; + } + if (res.error || !res.path) { + await telegramBot.sendMessage( + _t("screen.error", { err: escapeHtml(res.error || "unknown") }), + { parseMode: "HTML" }, + ); + return { success: false, error: res.error }; + } + + const send = await telegramBot.sendPhoto(res.path, { + caption: _t("screen.caption"), + }); + // Удаляем временный файл. + try { + require("fs").unlinkSync(res.path); + } catch (_) {} + return send; +} + async function _cmdStatus() { const cfg = _read(); const lines = [_t("status.title"), ""]; @@ -2015,6 +2210,7 @@ async function _cmdHelp() { _t("help.cmd.show"), _t("help.cmd.files"), _t("help.cmd.todos"), + _t("help.cmd.screen"), _t("help.cmd.cancel"), _t("help.cmd.help"), "", @@ -2498,6 +2694,10 @@ async function _handleIncoming(chatId, text, msg) { await _cmdTodos(chatId, 1); return; } + if (cmd === "/screen" || cmd === "/screenshot") { + await _cmdScreen(); + return; + } // ---- Ожидание ввода значения настройки ---- if (_settingsWaiting.has(chatId)) { @@ -2942,6 +3142,7 @@ async function _registerCommands() { switch: "переключить сессию", diagnostics: "диагностика", todos: "список задач", + screen: "скриншот окна", cancel: "отменить ввод", }, en: { @@ -2959,6 +3160,7 @@ async function _registerCommands() { switch: "switch session", diagnostics: "diagnostics", todos: "todo list", + screen: "window screenshot", cancel: "cancel", }, }[lang]; @@ -3044,6 +3246,8 @@ module.exports = { notifyAllDone, requestApproval, cancelApproval, + requestPlanApproval, + cancelPlanApproval, notifyLongProcess, askQuestion, setOnQuestionAnswered, diff --git a/src/main/context-port.js b/src/main/context-port.js index d3d2568..c45b29f 100644 --- a/src/main/context-port.js +++ b/src/main/context-port.js @@ -26,6 +26,11 @@ function set(webContentsId, data) { // Промпт инициализации проекта (дерево каталога + системный промпт), // переносится вместе с контекстом, чтобы новый чат «знал» проект. initPrompt: data.initPrompt || "", + // Каталог проекта — чтобы привязать новый чат к тому же проекту + // (sessionStore.state.pendingProjectDir) после переноса. + projectDir: data.projectDir || "", + // sessionId промежуточного чата-суммаризатора — удаляется после переноса. + intermediateSessionId: data.intermediateSessionId || "", createdAt: Date.now(), }); } diff --git a/src/main/ipc.js b/src/main/ipc.js index c2c6c35..a50e755 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -103,9 +103,52 @@ function requestExitPlanMode(sender, plan) { try { sender.send("exit-plan-mode", { requestId, plan: String(plan || "") }); } catch (_) {} + // Дублируем план в Telegram (если бот включён). Кто ответит первым — + // окно или TG — тот и резолвит promise. + try { + const bot = require("../../botsrc"); + if (bot && typeof bot.requestPlanApproval === "function") { + bot.requestPlanApproval(requestId, String(plan || "")).catch(() => {}); + } + } catch (_) {} }); } +/** + * Резолв плана, утверждённого в Telegram (вызывается из botsrc). + * @returns {boolean} true, если нашли ожидающий план. + */ +function resolvePlanApprovalFromTelegram(requestId, approved) { + for (const [key, pending] of pendingPlanApprovals) { + if (key.endsWith(":" + requestId)) { + pendingPlanApprovals.delete(key); + try { + const senderId = Number(key.split(":")[0]); + const wc = require("electron").webContents.fromId(senderId); + if (wc && !wc.isDestroyed()) { + // Просим окно закрыть диалог. + wc.send("exit-plan-mode-resolved", { + requestId, + approved: !!approved, + }); + // Если согласовано — снимаем режим плана для сессии окна. + if (approved && wc.session) { + const ctx = windowState.getContextByWebContents(wc); + const sessionId = + ctx && ctx.sessionStore + ? ctx.sessionStore.state.currentSessionId || null + : null; + planMode.clearPlanMode(senderId, sessionId); + } + } + } catch (_) {} + pending.resolve({ approved: !!approved }); + return true; + } + } + return false; +} + /** * Текущий sessionId окна (из его sessionStore). Может быть null (новый чат). * @param {Electron.IpcMainEvent|Electron.IpcMainInvokeEvent} event @@ -306,6 +349,13 @@ function registerIpcHandlers() { pendingPlanApprovals.delete(key); if (approved) planMode.clearPlanMode(event.sender.id, sessionIdOf(event)); pending.resolve({ approved: !!approved }); + // Убираем кнопки в Telegram — решение уже принято в окне. + try { + const bot = require("../../botsrc"); + if (bot && typeof bot.cancelPlanApproval === "function") { + bot.cancelPlanApproval(requestId); + } + } catch (_) {} }, ); @@ -537,6 +587,60 @@ function registerIpcHandlers() { } }); + // Удалить чат из списка (DeepSeek и др. — через DOM сайдбара). + // Инжектим скрипт в main world: находим ссылку a[href*="/a/chat/s/