diff --git a/CHANGELOG.md b/CHANGELOG.md index 6467538..9656daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## [4.0.34] - 2026-09-26 + +### Added + +- 📤 **Инструмент `attach_telegram` — отправка файлов в Telegram** — AI может прикрепить любой локальный файл и отправить его прямо в чат с ботом: + - JS-API `attachTelegram(filePath, caption?, comment?)`: `caption` — подпись к файлу, `comment` — то, что AI хочет добавить от себя (зачем файл, что внутри, на что обратить внимание); + - если задан только `comment`, он уходит подписью; если заданы оба — `comment` дописывается к `caption` отдельной строкой; + - лимит 45 МБ (запас под лимит Telegram в 50 МБ), подпись и комментарий обрезаются до 1024 символов; + - отправка через уже настроенного бота (`telegramBot.sendDocument`) — новых зависимостей нет; + - файлы больше лимита, отсутствующий файл или ненастроенный бот возвращают понятную ошибку; + - объявление в `cuckoo-tools.d.ts`, обёртка `attachTelegram` в песочнице `JsRunner`. +- 📁 **Telegram-бот: выбор проекта кнопками** — новая команда `/projects` (алиасы `/project`, `/proj`) открывает список проектов inline-клавиатурой: + - список собирается из сохранённых связок «сессия → проект» активного профиля, плюс текущий проект; + - постраничный вывод по 8 проектов с кнопками «◀️ Назад / Вперёд ▶️» и счётчиком страниц; + - выбор проекта переключает окно на **существующую сессию** этого проекта (та же навигация, что и `/switch`) — новый чат не создаётся; + - кнопка «⌨️ Ввести путь вручную» принимает абсолютный путь к папке следующим сообщением; + - если проект не выбран, команды `/diff`, `/log`, `/sessions` показывают сообщение вместе с клавиатурой выбора. + +### Changed + +- 📜 **`/log` и `/sessions` редактируют сообщение, а не плодят новые** — при повторном вызове и при листании страниц бот обновляет уже отправленное сообщение (`editMessageText`); если сообщение было удалено вручную — отправляется новое. +- 🆕 **`/new` сообщает результат** — вместо тишины или `⚠️ undefined` бот отвечает явно: «🆕 Новый чат открыт», «⚠️ Нет активного окна…» или «❌ Не удалось открыть новый чат: \<причина\>». +- 🌐 **Язык бота больше не навязывается** — `readSettings()` отдаёт ровно то, что записано в файле настроек, без подстановки языка системы; при `/start` бот больше не требует принудительно выбрать язык, а сразу показывает справку. + +### Fixed + +- 🐛 **`/new` не отвечал** — `newChat()` делает `loadURL` на домашнюю страницу и выгружает JS-контекст окна, поэтому `executeJavaScript` с ожиданием результата зависал навсегда. Теперь вызов «выстрелил-и-забыл», а ответ приходит сразу. +- 🐛 **Пагинация выбора проекта падала** — `ReferenceError: msgId is not defined` в обработчике callback-кнопок; id сообщения теперь берётся из `cbq.message`, а при неудачном редактировании есть фолбэк на отправку нового сообщения. +- 🐛 **Выбор проекта не применялся в приложении** — установка проекта по известному пути не переключала окно на его сессию. Добавлен IPC `set-project-dir` (+ `setProjectDir` в preload и `setProjectByDir` в main): поиск сессии по папке проекта и навигация на неё. +- 🐛 **Ошибка `setProjectDir недоступен в окне`** — метода не существовало; добавлен полный путь IPC → preload → main. + +### Removed + +- 🧹 **Принудительная фиксация языка DeepSeek** — удалён модуль `src/preload/dom/deepseek-language.js`, который переключал Language на «Система» и блокировал селект (`pointer-events:none`, `aria-disabled`, гашение `onMouseDown`/`onKeyDown`). Язык интерфейса DeepSeek снова выбирается пользователем свободно. + +### Added (EN) + +- 📤 **Tool `attach_telegram` — send files to Telegram** — the AI can attach any local file and send it straight to the chat with the bot: + - JS API `attachTelegram(filePath, caption?, comment?)`: `caption` is the file caption, `comment` is an extra note the AI adds itself (why the file, what's inside, what to look at); + - if only `comment` is given, it becomes the caption; if both are given, `comment` is appended to `caption` on a separate line; + - 45 MB limit (headroom under Telegram's 50 MB cap), caption and comment are truncated to 1024 chars; + - sent through the already-configured bot (`telegramBot.sendDocument`) — no new dependencies; + - oversized files, a missing file, or an unconfigured bot return a clear error; + - declared in `cuckoo-tools.d.ts`, wrapped as `attachTelegram` in the `JsRunner` sandbox. +- 📁 **Telegram bot: pick a project with buttons** — the new `/projects` command (aliases `/project`, `/proj`) opens a project list as an inline keyboard: + - the list is built from saved "session → project" mappings of the active profile plus the current project; + - paged output, 8 projects per page, with "◀️ Back / Next ▶️" buttons and a page counter; + - picking a project switches the window to the **existing session** of that project (same navigation as `/switch`) — no new chat is created; + - the "⌨️ Enter path manually" button accepts an absolute folder path as the next message; + - if no project is selected, `/diff`, `/log`, and `/sessions` show the message together with the project picker. + +### Changed (EN) + +- 📜 **`/log` and `/sessions` edit the message instead of posting new ones** — on repeat calls and when paging, the bot updates the message it already sent (`editMessageText`); if the message was deleted manually, a new one is sent. +- 🆕 **`/new` reports the result** — instead of silence or `⚠️ undefined`, the bot answers explicitly: "🆕 New chat opened", "⚠️ No active window…", or "❌ Failed to open a new chat: \\". +- 🌐 **The bot language is no longer forced** — `readSettings()` returns exactly what is stored in the settings file, with no system-language substitution; on `/start` the bot no longer demands a language choice and just shows help. + +### Fixed (EN) + +- 🐛 **`/new` did not respond** — `newChat()` calls `loadURL` to the home page, which unloads the window's JS context, so `executeJavaScript` awaiting a result hung forever. The call is now fire-and-forget and the reply comes back immediately. +- 🐛 **Project picker pagination crashed** — `ReferenceError: msgId is not defined` in the callback-button handler; the message id is now taken from `cbq.message`, and a failed edit falls back to sending a new message. +- 🐛 **Project selection did not apply in the app** — setting a project by a known path did not switch the window to its session. Added the IPC `set-project-dir` (plus `setProjectDir` in preload and `setProjectByDir` in main): it finds the session by project folder and navigates to it. +- 🐛 **"setProjectDir is unavailable in the window" error** — the method did not exist; the full IPC → preload → main path was added. + +### Removed (EN) + +- 🧹 **Forced DeepSeek language lock** — deleted the `src/preload/dom/deepseek-language.js` module, which switched Language to "System" and locked the select (`pointer-events:none`, `aria-disabled`, neutered `onMouseDown`/`onKeyDown`). The DeepSeek UI language can be chosen freely by the user again. + ## [4.0.33] - 2026-09-23 ### Added @@ -171,4 +239,4 @@ - конвертация мс ↔ сек на границе UI, подпись `сек` / `sec`; - валидация и тосты переписаны под новые единицы. - **i18n для настроек задержки** — сообщения об ошибках и сохранении переведены на ru/en (ранее были захардкожены на китайском): ключи `overlay.delay.*`. -- Исправлена вёрстка строки задержки в overlay — подписи «до» и «сек» больше не переносятся по буквам (отдельный класс `.cuckoo-delay-label` вместо `.cuckoo-blur-label` с `space-between`). +- Исправлена вёрстка строки задержки в 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 9191156..ca6e23c 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -152,6 +152,8 @@ const STRINGS = { // ===== /new ===== "new.done": "🆕 Новый чат открыт.", + "new.noWindow": "⚠️ Нет активного окна — не удалось открыть новый чат.", + "new.failed": "❌ Не удалось открыть новый чат: {err}", // ===== /diff ===== "diff.noProject": "⚠️ Проект не выбран.", @@ -168,6 +170,17 @@ const STRINGS = { "todos.empty": "☑ Список задач пуст.", "todos.header": "☑ Задачи ({done}/{total}):", + // ===== Выбор проекта кнопками ===== + "project.pickHint": "Выбери проект кнопкой ниже.", + "project.title": "📁 Выбор проекта", + "project.set": "✅ Проект выбран: {dir}", + "project.setFailed": "⚠️ Не удалось выбрать проект: {err}", + "project.current": "📁 Текущий проект: {dir}", + "project.none": "📁 Проект пока не выбран.", + "project.manual": "⌨️ Ввести путь вручную", + "project.manualHint": "⌨️ Отправь абсолютный путь к папке проекта следующим сообщением.", + "project.cancelled": "Отменено.", + // ===== /sessions и /switch ===== "sessions.empty": "📭 Сессий не найдено.", "sessions.title": "🗂 Сессии (стр. {page}/{pages})", @@ -398,6 +411,8 @@ const STRINGS = { // ===== /new ===== "new.done": "🆕 New chat opened.", + "new.noWindow": "⚠️ No active window — cannot open a new chat.", + "new.failed": "❌ Failed to open a new chat: {err}", // ===== /diff ===== "diff.noProject": "⚠️ Project not selected.", @@ -414,6 +429,17 @@ const STRINGS = { "todos.empty": "☑ Task list is empty.", "todos.header": "☑ Tasks ({done}/{total}):", + // ===== Project picker ===== + "project.pickHint": "Pick a project with the button below.", + "project.title": "📁 Project selection", + "project.set": "✅ Project selected: {dir}", + "project.setFailed": "⚠️ Failed to select project: {err}", + "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.cancelled": "Cancelled.", + // ===== /sessions and /switch ===== "sessions.empty": "📭 No sessions found.", "sessions.title": "🗂 Sessions (page {page}/{pages})", diff --git a/botsrc/index.js b/botsrc/index.js index 724ce3c..97789f6 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -303,8 +303,70 @@ async function askQuestion(requestId, questions) { /** Обработать нажатие inline-кнопки с ответом на вопрос. */ async function _handleCallback(chatId, data, cbq) { const token = String(data || ""); + // id сообщения, к которому привязана нажатая кнопка (для editMessageText). + const msgId = cbq && cbq.message && cbq.message.message_id; // Меню настроек /settings — обрабатываем раньше других, т.к. префикс 'st_'. + // Выбор проекта кнопкой / ручной ввод — обрабатываем до settings. + if (token.startsWith("st_proj_")) { + const arg = token.slice("st_proj_".length); + await telegramBot.answerCallbackQuery(cbq.id); + + // Навигация по страницам выбора проекта. + if (arg.startsWith("pg_")) { + const page = parseInt(arg.slice(3), 10) || 1; + _projectPage.set(chatId, page); + const view = _projectView(page); + if (msgId) { + const res = await telegramBot.editMessageText(msgId, view.text, { + parseMode: "HTML", + replyMarkup: view.keyboard, + }); + if (res && res.success) return; + } + await telegramBot.sendMessage(view.text, { + parseMode: "HTML", + replyMarkup: view.keyboard, + }); + return; + } + if (arg === "noop") return; + + if (arg === "manual") { + _projectWaiting.add(chatId); + await telegramBot.sendMessage(_t("project.manualHint")); + return; + } + + // Выбор проекта: формат 'p_' (idx — глобальный индекс в списке). + let idx = NaN; + const m = arg.match(/^p\d+_(\d+)$/); + if (m) { + idx = parseInt(m[1], 10); + } else { + // Обратная совместимость со старым форматом 'st_proj_'. + idx = parseInt(arg, 10); + } + const projects = _knownProjects(); + const dir = projects[idx]; + if (!dir) { + await telegramBot.sendMessage(_t("project.none")); + return; + } + const r = await _setProject(chatId, dir); + if (r.success) + await telegramBot.sendMessage( + _t("project.set", { dir: escapeHtml(r.dir || dir) }), + { parseMode: "HTML" }, + ); + else + await telegramBot.sendMessage( + _t("project.setFailed", { err: escapeHtml(r.error || "") }), + { parseMode: "HTML" }, + ); + return; + } + if (token.startsWith("st_")) { try { await _handleSettingsCallback(token, cbq); @@ -1095,9 +1157,9 @@ async function _handleSettingsCallback(data, cbq) { if (!pager) return; pager.page = page; if (type === "sessions") { - await _cmdSessions(chatId, page); + await _cmdSessions(chatId, page, msgId); } else if (type === "log") { - await _cmdLog(chatId, page); + await _cmdLog(chatId, page, msgId); } else if (type === "todos") { await _cmdTodos(chatId, page); } else if (type === "diff") { @@ -1341,25 +1403,48 @@ async function _cmdStatus() { return telegramBot.sendMessage(lines.join("\n"), { parseMode: "HTML" }); } -/** /new — новый чат (очистка контекста текущей сессии). */ +/** + * /new — новый чат (переход на домашнюю страницу провайдера). + * + * Важно: newChat() делает loadURL на homeUrl и тем самым ВЫГРУЖАЕТ JS-контекст + * окна. Поэтому executeJavaScript с ожиданием результата зависает навсегда + * (промис не резолвится при навигации) — раньше из-за этого бот молчал. + * Здесь мы вызываем newChat «выстрелил-и-забыл» и сразу отвечаем. + */ async function _cmdNew() { - const res = await _evalInWindow( - "(async () => { try { const r = await window.electronAPI.newChat(); return r; } catch (e) { return { success: false, error: e.message }; } })()", - ); - if (!res.success) - return telegramBot.sendMessage("⚠️ " + escapeHtml(res.error)); - const inner = res.result || {}; - if (inner.success === false) - return telegramBot.sendMessage( - "⚠️ " + escapeHtml(inner.error || _t("common.error")), - ); + const win = (() => { + try { + return windowState.getMainWindow(); + } catch (_) { + return null; + } + })(); + if (!win || win.isDestroyed()) + return telegramBot.sendMessage(_t("new.noWindow")); + + // Fire-and-forget: не await-им executeJavaScript, т.к. loadURL внутри + // newChat() выгружает контекст и промис не вернётся. + try { + win.webContents.executeJavaScript( + "try { window.electronAPI.newChat(); } catch (e) {} true", + true, + ).catch(() => {}); + } catch (_) {} + + // Небольшая пауза, чтобы окно успело начать навигацию, и сразу отвечаем. + await new Promise((r) => setTimeout(r, 300)); return telegramBot.sendMessage(_t("new.done")); } /** /diff [page] — показать текущие изменения (git diff) в проекте (с пагинацией). */ async function _cmdDiff(chatId, page) { const projectDir = _projectDir(); - if (!projectDir) return telegramBot.sendMessage(_t("diff.noProject")); + // Проект не выбран: предлагаем выбрать кнопками. + if (!projectDir) + return telegramBot.sendMessage( + escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), + { parseMode: "HTML", replyMarkup: _projectKeyboard() }, + ); const gitDiff = require("../src/main/git-diff"); const status = await gitDiff.getStatus(projectDir); @@ -1483,9 +1568,146 @@ function _projectDir() { ); } -// Пагинация: key = `${chatId}:${type}` → { items, page, pageSize } +// Пагинация: key = `${chatId}:${type}` → { items, page, pageSize, editMessageId } const _pagers = new Map(); +// Последнее отправленное сообщение списка: key = `${chatId}:${type}` → messageId. +// Нужно, чтобы повторная команда (/log, /sessions) редактировала то же сообщение, +// а не отправляла новое. +const _lastListMsg = new Map(); + +// Режим ручного ввода пути к проекту: chatId → true (ждём абсолютный путь). +const _projectWaiting = new Set(); + +// Известные проекты: берём из session-dir-map активного профиля + текущий. +function _knownProjects() { + const dirs = new Set(); + try { + const ctx = _activeContext(); + const store = ctx && ctx.sessionStore; + // 1) Сессии активного профиля: sessionId → projectDir. + if (store && typeof store.readSessionStore === "function") { + const all = store.readSessionStore(); + for (const dir of Object.values(all || {})) { + if (dir && typeof dir === "string") dirs.add(dir); + } + } + // 2) Плюс текущий выбранный проект. + const cur = _projectDir(); + if (cur) dirs.add(cur); + // 3) Плюс недавние из настроек, если есть (telegramRecentProjects). + try { + const s = settingsStore.readSettings(); + const rec = s && s.telegramRecentProjects; + if (Array.isArray(rec)) { + for (const d of rec) if (d && typeof d === "string") dirs.add(d); + } + } catch (_) {} + // Сортируем по алфавиту (без обрезки — пагинация ниже). + return Array.from(dirs).sort((a, b) => a.localeCompare(b)); + } catch (_) { + return []; + } +} + +// Текущая страница выбора проекта: chatId → номер страницы. +const _projectPage = new Map(); +const PROJECT_PAGE_SIZE = 8; + +/** + * Отрисовать страницу выбора проекта: текст + inline-клавиатура. + * Кнопки: по одной на проект (st_proj_p_), навигация ‹/›, + * и «ввести путь вручную» (st_proj_manual). + * @param {number} page номер страницы (1-based) + */ +function _projectView(page) { + const projects = _knownProjects(); + const size = PROJECT_PAGE_SIZE; + const pages = Math.max(1, Math.ceil(projects.length / size)); + let cur = Math.min(Math.max(1, page || 1), pages); + const slice = projects.slice((cur - 1) * size, cur * size); + const offset = (cur - 1) * size; + + const rows = []; + slice.forEach((dir, i) => { + const base = dir.split(/[\\/]/).filter(Boolean).pop() || dir; + rows.push([ + { text: "📁 " + base, callback_data: "st_proj_p" + cur + "_" + (offset + i) }, + ]); + }); + + // Навигация ‹/› (если страниц больше одной). + if (pages > 1) { + const nav = []; + if (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) }); + rows.push(nav); + } + + rows.push([{ text: _t("project.manual"), callback_data: "st_proj_manual" }]); + + const title = _t("project.title"); + const hint = _t("project.pickHint"); + const list = slice.length + ? slice.map((d) => "• " + escapeHtml(d)).join("\n") + : _t("project.none"); + const text = title + "\n" + hint + "\n\n" + list; + + return { + text, + keyboard: { inline_keyboard: rows }, + page: cur, + pages, + }; +} + +/** Клавиатура выбора проекта (первая страница). Оставлено для совместимости. */ +function _projectKeyboard() { + return _projectView(1).keyboard; +} + +/** + * Установить проект в активном окне (через electronAPI того же окна). + * Пишем selectedProjectDir в session-store активного профиля и уведомляем renderer. + */ +async function _setProject(chatId, dir) { + const win = (() => { + try { + return windowState.getMainWindow(); + } catch (_) { + return null; + } + })(); + if (!win || win.isDestroyed()) + return { success: false, error: _t("new.noWindow") }; + + const safeDir = JSON.stringify(String(dir)); + const script = + "(async () => { try {" + + " const store = (window.__cuckooSessionStore || null);" + + " if (window.electronAPI && typeof window.electronAPI.setProjectDir === 'function') {" + + " const r = await window.electronAPI.setProjectDir(" + + safeDir + + ");" + + " return r || { success: true };" + + " }" + + " if (store && store.state) { store.state.selectedProjectDir = " + + safeDir + + "; return { success: true }; }" + + " return { success: false, error: 'setProjectDir недоступен в окне' };" + + " } catch (e) { return { success: false, error: e.message }; } })()"; + + const res = await _evalInWindow(script); + if (!res.success) return { success: false, error: res.error }; + const inner = res.result || {}; + if (inner.success === false) + return { success: false, error: inner.error || _t("common.error") }; + return { success: true, dir: String(dir) }; +} + /** Отрисовать страницу списка с навигацией. */ function _renderPager(type, pager, renderItem, titleFn, hint) { const size = pager.pageSize || 10; @@ -1515,9 +1737,33 @@ function _renderPager(type, pager, renderItem, titleFn, hint) { } /** /sessions — список сессий проекта. */ -async function _cmdSessions(chatId, page) { +async function _cmdSessions(chatId, page, editMessageId) { + const key = chatId + ":sessions"; + const prev = _pagers.get(key); + const editId = editMessageId || (prev && prev.editMessageId) || null; + + const reply = async (text, opts) => { + const o = opts || {}; + if (editId) { + const res = await telegramBot.editMessageText(editId, text, { + parseMode: o.parseMode || "HTML", + replyMarkup: o.replyMarkup || { inline_keyboard: [] }, + }); + if (res && res.success) return res; + } + const sent = await telegramBot.sendMessage(text, o); + if (sent && sent.success && sent.messageId) { + _lastListMsg.set(key, sent.messageId); + } + return sent; + }; + const projectDir = _projectDir(); - if (!projectDir) return telegramBot.sendMessage(_t("diff.noProject")); + // Проект не выбран: подсказываем, как выбрать его кнопками. + if (!projectDir) + return reply(escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), { + replyMarkup: _projectKeyboard(), + }); let sessions = []; try { const ctx = _activeContext(); @@ -1525,12 +1771,14 @@ async function _cmdSessions(chatId, page) { const all = store.readSessionStore(); sessions = Object.keys(all).filter((id) => all[id] === projectDir); } catch (err) { - return telegramBot.sendMessage("⚠ " + escapeHtml(err.message)); + return reply("⚠ " + escapeHtml(err.message)); } - if (sessions.length === 0) - return telegramBot.sendMessage(_t("sessions.empty")); + if (sessions.length === 0) return reply(_t("sessions.empty")); + const pager = { items: sessions, page: page || 1, pageSize: 10 }; - _pagers.set(chatId + ":sessions", pager); + pager.editMessageId = editId; + _pagers.set(key, pager); + const rendered = _renderPager( "sessions", pager, @@ -1538,10 +1786,24 @@ async function _cmdSessions(chatId, page) { (p, pages) => _t("sessions.title", { page: p, pages: pages }), _t("sessions.hint"), ); - return telegramBot.sendMessage(rendered.text, { + + if (editId) { + const res = await telegramBot.editMessageText(editId, rendered.text, { + parseMode: "HTML", + replyMarkup: rendered.keyboard, + }); + if (res && res.success) return res; + } + + const sent = await telegramBot.sendMessage(rendered.text, { parseMode: "HTML", replyMarkup: rendered.keyboard, }); + if (sent && sent.success && sent.messageId) { + pager.editMessageId = sent.messageId; + _pagers.set(key, pager); + } + return sent; } /** /switch — переключиться на сессию. */ @@ -1570,22 +1832,59 @@ async function _cmdSwitch(chatId, id) { ); } -/** /log — история коммитов. */ -async function _cmdLog(chatId, page) { +/** + * /log — история коммитов. + * + * Сообщение одно: при повторном вызове и при пагинации редактируем уже + * отправленное сообщение (editMessageText), а не плодим новые. Id сообщения + * хранится в _pagers под ключом `chatId:log`.editMessageId. + * editMessageId — id уже отправленного сообщения (из callback пагинации); + * если не задан, берётся сохранённый id прошлого вызова /log в этом чате. + */ +async function _cmdLog(chatId, page, editMessageId) { + const key = chatId + ":log"; + const prev = _pagers.get(key); + // id редактируемого сообщения: явно переданный (пагинация) или сохранённый + // от прошлого вызова /log в этом чате. + const editId = editMessageId || (prev && prev.editMessageId) || null; + + const reply = async (text, opts) => { + const o = opts || {}; + // Ошибки/пустой список: если сообщение уже есть — редактируем его, + // иначе отправляем новое. + if (editId) { + const res = await telegramBot.editMessageText(editId, text, { + parseMode: o.parseMode || "HTML", + replyMarkup: o.replyMarkup || { inline_keyboard: [] }, + }); + if (res && res.success) return res; + // Не удалось отредактировать — отправляем новым сообщением. + } + return telegramBot.sendMessage(text, o); + }; + const projectDir = _projectDir(); - if (!projectDir) return telegramBot.sendMessage(_t("diff.noProject")); + // Проект не выбран: подсказываем выбрать кнопками. + if (!projectDir) + 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) - return telegramBot.sendMessage( + return reply( _t("diff.gitUnavailable", { reason: escapeHtml(r.reason || _t("diff.gitDefault")), }), ); const commits = r.commits || []; - if (commits.length === 0) return telegramBot.sendMessage(_t("log.empty")); + if (commits.length === 0) return reply(_t("log.empty")); + const pager = { items: commits, page: page || 1, pageSize: 10 }; - _pagers.set(chatId + ":log", pager); + // Сохраняем id существующего сообщения, чтобы продолжать его редактировать. + pager.editMessageId = editId; + _pagers.set(key, pager); + const rendered = _renderPager( "log", pager, @@ -1598,10 +1897,25 @@ async function _cmdLog(chatId, page) { (p, pages) => _t("log.title", { page: p, pages: pages }), _t("log.hint"), ); - return telegramBot.sendMessage(rendered.text, { + // eslint-disable-next-line no-unused-vars + + if (editId) { + const res = await telegramBot.editMessageText(editId, rendered.text, { + parseMode: "HTML", + replyMarkup: rendered.keyboard, + }); + if (res && res.success) return res; + } + + const sent = await telegramBot.sendMessage(rendered.text, { parseMode: "HTML", replyMarkup: rendered.keyboard, }); + if (sent && sent.success && sent.messageId) { + pager.editMessageId = sent.messageId; + _pagers.set(key, pager); + } + return sent; } /** /show — diff коммита. */ @@ -2017,6 +2331,14 @@ async function _handleIncoming(chatId, text, msg) { await _cmdNew(); return; } + if (cmd === "/projects" || cmd === "/project" || cmd === "/proj") { + const view = _projectView(_projectPage.get(chatId) || 1); + await telegramBot.sendMessage(view.text, { + parseMode: "HTML", + replyMarkup: view.keyboard, + }); + return; + } if (cmd === "/diff") { await _cmdDiff(chatId, 1); return; @@ -2026,7 +2348,7 @@ async function _handleIncoming(chatId, text, msg) { return; } if (cmd === "/sessions") { - await _cmdSessions(chatId, 1); + await _cmdSessions(chatId, 1, _lastListMsg.get(chatId + ":sessions")); return; } if (cmd === "/switch") { @@ -2034,7 +2356,7 @@ async function _handleIncoming(chatId, text, msg) { return; } if (cmd === "/log") { - await _cmdLog(chatId, 1); + await _cmdLog(chatId, 1, _lastListMsg.get(chatId + ":log")); return; } if (cmd === "/show") { @@ -2073,6 +2395,28 @@ async function _handleIncoming(chatId, text, msg) { if (handled) return; } + // ---- Ожидание ручного ввода пути к проекту ---- + if (_projectWaiting.has(chatId)) { + _projectWaiting.delete(chatId); + const dir = String(raw || "").trim().replace(/^["']|["']$/g, ""); + if (!dir) { + await telegramBot.sendMessage(_t("project.cancelled")); + return; + } + const r = await _setProject(chatId, dir); + if (r.success) + await telegramBot.sendMessage( + _t("project.set", { dir: escapeHtml(r.dir || dir) }), + { parseMode: "HTML" }, + ); + else + await telegramBot.sendMessage( + _t("project.setFailed", { err: escapeHtml(r.error || "") }), + { parseMode: "HTML" }, + ); + return; + } + // ---- Обычное сообщение ---- if (!cfg.chatFeed) { _log("info", "chatFeed выключен — игнор входящего:", text); @@ -2180,76 +2524,205 @@ function escapeHtml(s) { .replace(/>/g, ">"); } -/** Уведомление о результате tool. */ +// ==================== Компактные уведомления (стиль «Tech») ==================== +// +// Формат одного действия: +// 🔧 Editing D:/code/ICE/crulh/cuckoo-code-master/... (×3) +// 💻 terminal +// cd "D:/code/ICE/crulh/cuckoo-code-mas... +// +// Несколько однотипных действий подряд схлопываются в (×N). +// Стиль задан пользователем как эталон — не менять без его просьбы. + +// Время (мс), в течение которого подряд идущие уведомления того же типа +// можно склеить в одно сообщение с пометкой (×N). +const TECH_BATCH_MS = 1200; +// Максимальная длина строки пути/команды (обрезаем многоточием). +const TECH_LINE_MAX = 48; + +let _techBatchTimer = null; +let _techBatch = null; // { emoji, label, detail, count } + +/** Обрезать длинную строку до TECH_LINE_MAX с «...» в конце. */ +function _techTrunc(s) { + const str = String(s == null ? "" : s); + if (str.length <= TECH_LINE_MAX) return str; + return str.slice(0, TECH_LINE_MAX - 3) + "..."; +} + +/** + * Иконка + короткая подпись действия по имени инструмента. + * @returns {{emoji:string, label:string}} + */ +function _techIcon(toolName) { + switch (String(toolName || "").toLowerCase()) { + case "edit": + return { emoji: "🔧", label: "Editing" }; + case "write": + return { emoji: "📝", label: "Writing" }; + case "read": + case "readlines": + case "read_lines": + case "readfile": + case "read_file": + return { emoji: "📖", label: "Reading" }; + case "bash": + return { emoji: "💻", label: "terminal" }; + case "pwsh": + return { emoji: "💻", label: "pwsh" }; + case "grep": + return { emoji: "🔎", label: "Grep" }; + case "glob": + return { emoji: "🔎", label: "Glob" }; + case "todoWrite": + case "todowrite": + return { emoji: "☑", label: "Todos" }; + case "webFetch": + case "webfetch": + return { emoji: "🌐", label: "Fetch" }; + default: + return { emoji: "🔧", label: String(toolName || "tool") }; + } +} + +/** + * Короткая деталь действия — путь файла или текст команды. + * Для bash/pwsh берём первую строку команды (обычно это и есть суть). + */ +function _techDetail(toolName, args) { + if (!args || typeof args !== "object") return ""; + const a = args; + const name = String(toolName || "").toLowerCase(); + if (name === "bash" || name === "pwsh") { + const cmd = String(a.command || ""); + return cmd.split("\n")[0]; + } + // read/readLines/read_lines и прочие «читающие» — путь в file_path/filePath. + return String(a.file_path || a.filePath || a.path || a.pattern || ""); +} + +// Максимальная длина результата в уведомлении. +const TECH_RESULT_MAX = 600; + +/** Отправить накопленное уведомление (или ничего, если пусто). */ +function _techFlush() { + const b = _techBatch; + _techBatch = null; + if (_techBatchTimer) { + clearTimeout(_techBatchTimer); + _techBatchTimer = null; + } + if (!b) return null; + + const suffix = b.count > 1 ? " (×" + b.count + ")" : ""; + // Заголовок: «🔧 Editing path (×N)» либо «💻 terminal (×N)» без детали. + const header = b.command + ? b.emoji + " " + b.label + suffix + : b.emoji + " " + b.label + " " + _techTrunc(b.detail) + suffix; + + // Тело: 1) команда/путь, 2) результат — каждый блок своей цитатой. + let msg = header; + 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 (result) { + const shown = + result.length > TECH_RESULT_MAX + ? result.slice(0, TECH_RESULT_MAX) + "\n…(обрезано)" + : result; + msg += "\n
" + escapeHtml(shown) + "
"; + } + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); +} + +// Служебные tool-вызовы, которые не являются «работой» и не должны попадать +// в компактные уведомления: вопрос к пользователю, выход из режима плана и т.п. +// Они и так приходят отдельными сообщениями («❓ Вопрос от ИИ», approval и пр.). +const TECH_SERVICE_TOOLS = new Set([ + "ask_user_question", + "askuserquestion", + "exit_plan_mode", + "exitplanmode", +]); + +/** + * Поставить уведомление в батч: если пришло такое же действие в течение + * TECH_BATCH_MS — увеличиваем счётчик (×N), иначе шлём предыдущее и начинаем новое. + */ +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 result = String(preview || ""); + + if ( + _techBatch && + _techBatch.emoji === icon.emoji && + _techBatch.label === icon.label && + _techBatch.detail === detail && + _techBatch.command === command + ) { + _techBatch.count++; + // Результат последнего вызова в серии — показываем его. + if (result) _techBatch.preview = result; + } else { + const prev = _techBatch; + _techBatch = { + emoji: icon.emoji, + label: icon.label, + detail, + command, + preview: result, + count: 1, + }; + if (prev) _techFlush(); + if (_techBatchTimer) clearTimeout(_techBatchTimer); + _techBatchTimer = setTimeout(() => _techFlush(), TECH_BATCH_MS); + return null; + } + if (_techBatchTimer) clearTimeout(_techBatchTimer); + _techBatchTimer = setTimeout(() => _techFlush(), TECH_BATCH_MS); + return null; +} + +/** Уведомление о результате tool (компактный стиль «Tech»). */ async function notifyToolResult(toolName, ok, detail) { const cfg = _read(); if (!cfg.enabled || !cfg.notifyTools) return { success: false, skipped: true }; + // Служебные вызовы (вопрос к пользователю и т.п.) в уведомления не шлём — + // у них есть собственное сообщение. + const _toolLower = String(toolName || "").toLowerCase(); + if (TECH_SERVICE_TOOLS.has(_toolLower)) { + return { success: false, skipped: true }; + } // Умные уведомления: пропускаем инструменты из списка исключений. - if ( - cfg.notifyIgnore.length > 0 && - cfg.notifyIgnore.includes(String(toolName || "").toLowerCase()) - ) { + if (cfg.notifyIgnore.length > 0 && cfg.notifyIgnore.includes(_toolLower)) { return { success: false, skipped: true }; } - const emoji = ok ? "✅" : "❌"; - const header = emoji + " " + escapeHtml(toolName); - - // detail может быть строкой (ошибка) или объектом { args, preview }. - if (detail && typeof detail === "object") { - const { args, preview } = detail; - const argsText = formatToolArgs(toolName, args); - const parts = []; - - if (toolName === "edit") { - // Красивый diff-стиль: старый код как -, новый как +. - const file = (args && (args.file_path || args.path)) || ""; - const oldS = - args && args.old_string != null ? String(args.old_string) : ""; - const newS = - args && args.new_string != null ? String(args.new_string) : ""; - const lines = []; - const addLines = (prefix, s) => { - for (const ln of s.split("\n")) lines.push(prefix + escapeHtml(ln)); - }; - if (oldS) addLines("➖ ", oldS.slice(0, 1000)); - if (newS) addLines("➕ ", newS.slice(0, 2000)); - const body = lines.join("\n") || _t("tool.editEmpty"); - const title = file ? " " + escapeHtml(file) : ""; - const msg = - emoji + - " edit" + - title + - "\n
" + - body + - "
"; - return telegramBot.sendMessage(msg, { parseMode: "HTML" }); - } - // Остальные инструменты: аргументы + результат в цитате
. - const bodyParts = []; - if (argsText) bodyParts.push(argsText); + const args = detail && typeof detail === "object" ? detail.args : null; + 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)); if (preview) - bodyParts.push( - _t("tool.result") + - String(preview) - .replace(/\n{3,}/g, "\n\n") - .slice(0, 600), - ); - const bodyHtml = escapeHtml(bodyParts.join("\n")); - const msg = - header + (bodyHtml ? "\n
" + bodyHtml + "
" : ""); - return telegramBot.sendMessage(msg, { parseMode: "HTML" }); + msg += "\n" + _techTrunc(String(preview).replace(/\n{3,}/g, "\n\n")); + return telegramBot.sendMessage(escapeHtml(msg), { parseMode: "HTML" }); } - // detail — строка. - let msg = header; - if (detail) - msg += - "\n
" + - escapeHtml(String(detail).slice(0, 600)) + - "
"; - return telegramBot.sendMessage(msg, { parseMode: "HTML" }); + // Успех — в батч (одинаковые подряд схлопываются в ×N). + _techQueue(toolName, args, preview); + return { success: true, batched: true }; } /** Применить настройки: пересоздать конфиг бота и (при необходимости) запустить polling. */ diff --git a/src/main/ipc.js b/src/main/ipc.js index 3940043..2459403 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -401,24 +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); + }); - // 列出会话 - 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 }; - }); + // Установить проект по уже известному пути (без диалога выбора папки). + // Используется 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 }; + }); // 列出项目文件(用于 @ 文件提及自动补全) // 返回 { success, files: [{ rel, abs }] },rel 为相对 projectDir 的路径,abs 为绝对路径。 diff --git a/src/main/project-context.js b/src/main/project-context.js index 7f286f7..23e0f99 100644 --- a/src/main/project-context.js +++ b/src/main/project-context.js @@ -195,6 +195,103 @@ async function initProject(skipPrompt = false, windowContext = null) { }; } +/** + * Установить проект по уже известному пути (без диалога выбора папки). + * Используется Telegram-ботом: выбор проекта кнопкой. + * @param {string} selectedDir абсолютный путь к каталогу проекта + * @param {object|null} windowContext контекст окна (win + sessionStore) + * @returns {{success: boolean, dir?: string, error?: string}} + */ +async function setProjectByDir(selectedDir, windowContext = null) { + const ctx = windowContext || windowState.getMainContext(); + const mainWindow = ctx ? ctx.win : windowState.getMainWindow(); + const sessionStore = ctx ? ctx.sessionStore : null; + + if (!selectedDir || typeof selectedDir !== "string") { + return { success: false, error: "路径 не задан" }; + } + const dir = selectedDir.trim(); + if (!dir) return { success: false, error: "路径 не задан" }; + + // Проверяем, что каталог существует. + try { + const st = fs.statSync(dir); + if (!st.isDirectory()) return { success: false, error: "Не каталог: " + dir }; + } catch (_) { + return { success: false, error: "Каталог не найден: " + dir }; + } + + // Ищем существующую сессию этого проекта: переключаемся на неё, а не + // создаём новый чат (новый чат = переход на homeUrl, контекст теряется). + let existingSessionId = null; + if (sessionStore && typeof sessionStore.readSessionStore === "function") { + try { + const all = sessionStore.readSessionStore(); + for (const [sid, sdir] of Object.entries(all || {})) { + if (sdir === dir) { + existingSessionId = sid; + break; + } + } + } catch (_) {} + } + + // Переключаем окно на найденную сессию (та же навигация, что и /switch). + if (existingSessionId && mainWindow && !mainWindow.isDestroyed()) { + let url = null; + try { + const { getProviderByUrl } = require("../providers"); + const provider = getProviderByUrl(mainWindow.webContents.getURL()); + if (provider && typeof provider.getSessionUrl === "function") { + url = provider.getSessionUrl(existingSessionId); + } else if (provider && provider.sessionUrlBase) { + url = provider.sessionUrlBase + existingSessionId; + } + } catch (_) {} + if (!url) url = "https://chat.deepseek.com/a/chat/s/" + existingSessionId; + try { + await mainWindow.webContents.loadURL(url); + } catch (err) { + console.error("[Cookie Code] setProjectByDir: loadURL failed:", err.message); + } + } + + if (sessionStore) { + // Если нашли сессию — она и станет текущей; иначе запоминаем проект как + // pending (привяжется, когда появится sessionId). + sessionStore.state.selectedProjectDir = dir; + if (existingSessionId) { + sessionStore.state.currentSessionId = existingSessionId; + sessionStore.saveSessionDirMapping(existingSessionId, dir); + } else if (sessionStore.state.currentSessionId) { + sessionStore.saveSessionDirMapping(sessionStore.state.currentSessionId, dir); + } else { + let sessionId = null; + if (mainWindow && !mainWindow.isDestroyed()) { + try { + const url = mainWindow.webContents.getURL(); + sessionId = sessionStore.extractSessionIdFromUrl(url); + } catch (_) {} + } + if (sessionId) { + sessionStore.state.currentSessionId = sessionId; + sessionStore.saveSessionDirMapping(sessionId, dir); + } else { + sessionStore.state.pendingProjectDir = dir; + } + } + } + + // Обновляем display пути и список сессий в renderer. + if (mainWindow && !mainWindow.isDestroyed()) { + try { + mainWindow.webContents.send("project-dir-updated", dir); + } catch (_) {} + } + + return { success: true, dir, sessionId: existingSessionId || null }; +} + /** * Собрать текст промпта инициализации проекта (без диалога и без отправки). * Используется и в initProject(), и при переносе контекста в новый чат. @@ -354,5 +451,6 @@ module.exports = { IGNORED_DIRS, getDirectoryTree, initProject, + setProjectByDir, buildInitPrompt, }; diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 2104061..403aaaf 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -59,7 +59,9 @@ const DEFAULTS = { telegramNotifyTools: false, // присылать уведомления о результате tool // Список имён tool, уведомления о которых НЕ отправлять (по одному в строке). // Актуально только при включённом telegramNotifyTools. - telegramToolNotifyIgnore: ["read", "readLines", "glob", "grep", "todoWrite"], + // "read"/"readLines" здесь НЕ исключаем: чтение файлов — заметное действие, + // и в компактных уведомлениях оно показывается как «📖 Reading ». + telegramToolNotifyIgnore: ["glob", "grep", "todoWrite"], telegramChatFeed: false, // принимать сообщения из TG в чат DeepSeek // ===== Подтверждение tool-вызовов (approval gate) ===== // 'off' — выполнять всё автоматически (прежнее поведение) diff --git a/src/preload/api.js b/src/preload/api.js index 28f4eee..f14fe7a 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -20,14 +20,18 @@ let electronAPI = { return ipcRenderer.invoke("get-file-icons"); }, updateProjectDir: () => { - return ipcRenderer.invoke("init-project", { skipPrompt: true }); - }, - 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 }); }, diff --git a/src/preload/dom/deepseek-language.js b/src/preload/dom/deepseek-language.js deleted file mode 100644 index 306fc8c..0000000 --- a/src/preload/dom/deepseek-language.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Фиксация языка интерфейса DeepSeek на «Система» (System) и запрет его смены. - * - * Работает на родной вкладке General в модалке настроек DeepSeek: - * строка «Language/Язык/语言» содержит кастомный селект .ds-select. - * - * Алгоритм: - * 1) находим Language-селект (первый .ds-select в строке с текстом Language); - * 2) если текущее значение не System — программно выбираем System через - * React onClick соответствующей опции (открывая меню через onMouseDown); - * 3) блокируем селект: pointer-events:none, tabindex=-1, aria-disabled, - * гасим onMouseDown/onKeyDown, визуально приглушаем. - * - * Все операции обёрнуты в safe(): при изменении вёрстки модуль просто - * пропускает шаг, не ломая приложение. - */ - -const { safe } = require("./safe"); - -const LANGUAGE_LABELS = ["Language", "Язык", "语言"]; -const SYSTEM_LABELS = ["System", "Система", "系统"]; - -let started = false; -// Флаг защиты от повторного входа (программный клик по опции). -let applying = false; - -/** - * Найти селект языка: .ds-select, рядом с которым (в той же строке-контейнере) - * есть текст Language/Язык/语言. - * @returns {HTMLElement|null} - */ -function findLanguageSelect() { - return safe( - "deepseek-language.findSelect", - () => { - const selects = document.querySelectorAll(".ds-select"); - for (const sel of selects) { - const row = sel.parentElement; - if (!row) continue; - const rowText = (row.textContent || "").trim(); - if (LANGUAGE_LABELS.some((lbl) => rowText.indexOf(lbl) !== -1)) - return sel; - } - return null; - }, - null, - ); -} - -/** Достать React-props узла (или null). */ -function getReactProps(node) { - return safe( - "deepseek-language.getReactProps", - () => { - if (!node) return null; - const key = Object.keys(node).find( - (k) => k.indexOf("__reactProps") === 0, - ); - return key ? node[key] : null; - }, - null, - ); -} - -/** Текущее отображаемое значение селекта языка. */ -function getCurrentValue(sel) { - return safe( - "deepseek-language.getCurrentValue", - () => { - return sel ? (sel.textContent || "").trim() : ""; - }, - "", - ); -} - -/** - * Открыть меню селекта через его React onMouseDown. - * @returns {boolean} - */ -function openSelectMenu(sel) { - return safe( - "deepseek-language.openMenu", - () => { - const props = getReactProps(sel); - if (!props || typeof props.onMouseDown !== "function") return false; - props.onMouseDown({ - type: "mousedown", - button: 0, - currentTarget: sel, - target: sel, - preventDefault() {}, - stopPropagation() {}, - }); - return true; - }, - false, - ); -} - -/** - * Найти опцию «Система» среди открытых .ds-select-option. - * @returns {HTMLElement|null} - */ -function findSystemOption() { - return safe( - "deepseek-language.findSystemOption", - () => { - const opts = document.querySelectorAll(".ds-select-option"); - for (const opt of opts) { - const tx = (opt.textContent || "").trim(); - if (SYSTEM_LABELS.indexOf(tx) !== -1) return opt; - } - return null; - }, - null, - ); -} - -/** Выбрать опцию через её React onClick. */ -function clickOption(opt) { - return safe( - "deepseek-language.clickOption", - () => { - const props = getReactProps(opt); - if (!props || typeof props.onClick !== "function") return false; - props.onClick({ - type: "click", - button: 0, - currentTarget: opt, - target: opt, - preventDefault() {}, - stopPropagation() {}, - }); - return true; - }, - false, - ); -} - -/** Заблокировать селект языка. */ -function lockSelect(sel) { - return safe("deepseek-language.lock", () => { - if (!sel) return; - sel.setAttribute("tabindex", "-1"); - sel.setAttribute("aria-disabled", "true"); - sel.style.pointerEvents = "none"; - sel.style.cursor = "not-allowed"; - sel.style.opacity = "0.7"; - // Гасим обработчики, чтобы меню не открывалось. - const props = getReactProps(sel); - if (props) { - props.onMouseDown = function () {}; - props.onKeyDown = function () {}; - props.onFocus = function () {}; - } - }); -} - -/** - * Основное действие: выставить System и заблокировать селект. - */ -function enforce() { - if (applying) return; - return safe("deepseek-language.enforce", () => { - const sel = findLanguageSelect(); - if (!sel) return; - - const current = getCurrentValue(sel); - const isSystem = SYSTEM_LABELS.indexOf(current) !== -1; - - if (!isSystem) { - applying = true; - const opened = openSelectMenu(sel); - if (!opened) { - applying = false; - lockSelect(sel); - return; - } - // Меню рендерится асинхронно — ждём и выбираем System. - setTimeout(() => { - const opt = findSystemOption(); - if (opt) clickOption(opt); - applying = false; - lockSelect(sel); - }, 300); - } else { - lockSelect(sel); - } - }); -} - -function startWatch() { - if (started) return; - started = true; - - // Реакция на появление/изменение модалки настроек. - safe("deepseek-language.observeDoc", () => { - const mo = new MutationObserver(() => enforce()); - mo.observe(document.documentElement, { childList: true, subtree: true }); - }); - - safe("deepseek-language.enforceStart", () => enforce()); - setInterval(() => enforce(), 1000); -} - -module.exports = { startWatch, enforce }; diff --git a/src/preload/dom/pet.js b/src/preload/dom/pet.js index 4d46f6b..526e8c0 100644 --- a/src/preload/dom/pet.js +++ b/src/preload/dom/pet.js @@ -81,6 +81,28 @@ let centerPos = null; // { x, y } — null значит «ещё не задан let dragging = false; let dragOff = { x: 0, y: 0 }; +// ---- Поглаживание ---- +// Пет «мурчит», когда его держат мышкой и водят туда-сюда. +// Ведём учёт пройденной дистанции: каждые STROKE_HEART_DISTANCE px движения +// из-под пета вылетает сердечко. Мельчайший клик без движения сердечек не даёт. +// +// ВАЖНО: поглаживание — временное. Пет уводится за мышкой, а после отпускания +// возвращается на исходную позицию (homeCenterPos / homeRatioX / homeRatioY), +// запомненную в onDragStart. Так пет не «уползает» от частого поглаживания. +const STROKE_HEART_DISTANCE = 28; // px движения между сердечками +const STROKE_HEART_TTL = 1200; // мс — сколько сердечко живёт +const STROKE_HEART_MAX = 30; // защита: не больше N сердечек одновременно +const STROKE_RETURN_MS = 260; // мс — плавный возврат на исходную +let stroking = false; // держат ли пета прямо сейчас +let strokeAccum = 0; // накопленная дистанция с последнего сердечка +let strokeLastPos = null; // { x, y } — прошлая точка курсора +let strokeHearts = []; // активные сердечки (для очистки) +let strokeWiggle = 0; // счётчик для покачивания +// Исходная позиция пета на момент начала поглаживания. +let homeCenterPos = null; // { x, y } | null — для режима center +let homeRatioX = null; // доля X — для режима docked +let homeRatioY = null; // доля Y — для режима docked + const MIME = { ".webp": "image/webp", ".jpg": "image/jpeg", @@ -225,27 +247,220 @@ function ensureElements() { } } +// ---- Поглаживание: сердечки ---- + +/** Создать одно сердечко, вылетающее из точки (x, y). */ +function spawnHeart(x, y) { + try { + // Защита от переполнения: если сердечек слишком много — не плодим. + if (strokeHearts.length >= STROKE_HEART_MAX) return; + const h = document.createElement("div"); + h.textContent = "❤"; + const size = 14 + Math.round(Math.random() * 10); + // Небольшой случайный разлёт в стороны, чтобы сердечки не шли в одну точку. + const driftX = (Math.random() - 0.5) * 30; + h.style.cssText = + "position:fixed;z-index:2147483002;pointer-events:none;" + + "left:" + + Math.round(x) + + "px;top:" + + Math.round(y) + + "px;" + + "font-size:" + + size + + "px;line-height:1;" + + "filter:drop-shadow(0 1px 2px rgba(0,0,0,0.35));" + + "will-change:transform,opacity;"; + document.body.appendChild(h); + strokeHearts.push(h); + + // Анимация: всплывает вверх, слегка покачиваясь, и растворяется. + try { + h.animate( + [ + { transform: "translate(-50%,-50%) scale(0.4)", opacity: 0 }, + { transform: "translate(-50%,-90%) scale(1)", opacity: 1, offset: 0.25 }, + { + transform: + "translate(calc(-50% + " + + driftX + + "px),-220%) scale(0.9)", + opacity: 0, + }, + ], + { duration: STROKE_HEART_TTL, easing: "ease-out" }, + ); + } catch (_) { + // Если Web Animations API недоступен — просто убираем через TTL. + } + + setTimeout(() => { + try { + h.remove(); + } catch (_) {} + const i = strokeHearts.indexOf(h); + if (i !== -1) strokeHearts.splice(i, 1); + }, STROKE_HEART_TTL + 50); + } catch (_) {} +} + +/** Лёгкое покачивание пета при поглаживании («мурчит»). */ +function wigglePet() { + if (!petEl) return; + strokeWiggle++; + const dir = strokeWiggle % 2 === 0 ? 1 : -1; + try { + petEl.animate( + [ + { transform: "rotate(0deg) scale(1)" }, + { transform: "rotate(" + dir * 5 + "deg) scale(1.06)" }, + { transform: "rotate(0deg) scale(1)" }, + ], + { duration: 260, easing: "ease-in-out" }, + ); + } catch (_) {} +} + +/** Сбросить состояние поглаживания (при отпускании мыши). */ +function endStroke() { + stroking = false; + strokeAccum = 0; + strokeLastPos = null; +} + // ---- Drag ---- +// Пет перетаскивается мышкой в ЛЮБОМ режиме. Движение мышкой по удержанию +// засчитывается как поглаживание: копим дистанцию и через каждые +// STROKE_HEART_DISTANCE px выпускаем сердечко. function onDragStart(e) { - if (mode !== "center") return; - if (!centerPos) return; + if (aiming) return; // в режиме прицела клик обрабатывает оверлей dragging = true; - dragOff = { x: e.clientX - centerPos.x, y: e.clientY - centerPos.y }; - petEl.style.cursor = "grabbing"; + // Запоминаем исходную позицию пета ДО поглаживания — после отпускания + // мыши пет вернётся сюда. Поглаживание — это временный «увод» пета. + homeCenterPos = centerPos ? { x: centerPos.x, y: centerPos.y } : null; + homeRatioX = dockOffsetXRatio; + homeRatioY = dockOffsetYRatio; + if (mode === "center" && centerPos) { + dragOff = { x: e.clientX - centerPos.x, y: e.clientY - centerPos.y }; + } + if (petEl) petEl.style.cursor = "grabbing"; + // Начинаем поглаживание. + stroking = true; + strokeAccum = 0; + strokeLastPos = { x: e.clientX, y: e.clientY }; try { petEl.setPointerCapture(e.pointerId); } catch (_) {} e.preventDefault(); } + function onDragMove(e) { if (!dragging) return; - centerPos = { x: e.clientX - dragOff.x, y: e.clientY - dragOff.y }; - reposition(); + + // Тянем пета: в режиме 'center' — свободно, в 'docked' — сдвигаем точку + // посадки в долях поля ввода, чтобы пет следовал за мышкой. + if (mode === "center") { + if (!centerPos) centerPos = { x: e.clientX - petSize / 2, y: e.clientY - petSize / 2 }; + else centerPos = { x: e.clientX - dragOff.x, y: e.clientY - dragOff.y }; + reposition(); + } else { + // docked: пересчитываем доли от текущего положения курсора. + dragDockedTo(e.clientX, e.clientY); + } + + // Поглаживание: считаем пройденную дистанцию. + if (stroking && strokeLastPos) { + const dx = e.clientX - strokeLastPos.x; + const dy = e.clientY - strokeLastPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + strokeAccum += dist; + strokeLastPos = { x: e.clientX, y: e.clientY }; + while (strokeAccum >= STROKE_HEART_DISTANCE) { + strokeAccum -= STROKE_HEART_DISTANCE; + spawnHeart(e.clientX, e.clientY - petSize * 0.4); + wigglePet(); + } + } } + function onDragEnd() { if (!dragging) return; dragging = false; + endStroke(); if (petEl) petEl.style.cursor = "grab"; + // Поглаживание — временное: возвращаем пета на исходную позицию, + // запомненную в onDragStart. Новую позицию НЕ сохраняем. + returnPetHome(); +} + +/** + * Вернуть пета на исходную позицию (до поглаживания). + * Плавно, через короткую CSS-анимацию; после — сбрасываем inline-transform, + * чтобы не мешать обычному позиционированию (left/top). + */ +function returnPetHome() { + if (!petEl) return; + try { + const fromLeft = petEl.style.left; + const fromTop = petEl.style.top; + + // Восстанавливаем координаты-«дом». + if (mode === "center" && homeCenterPos) { + centerPos = { x: homeCenterPos.x, y: homeCenterPos.y }; + } else if (mode === "docked") { + if (typeof homeRatioX === "number") dockOffsetXRatio = homeRatioX; + if (typeof homeRatioY === "number") dockOffsetYRatio = homeRatioY; + } + + // Считаем целевую позицию и анимируем перелёт от текущей к ней. + const pos = computePos(); + const toLeft = Math.round(pos.x) + "px"; + const toTop = Math.round(pos.y) + "px"; + + if (fromLeft && fromTop && (fromLeft !== toLeft || fromTop !== toTop)) { + try { + petEl.animate( + [ + { left: fromLeft, top: fromTop }, + { left: toLeft, top: toTop }, + ], + { duration: STROKE_RETURN_MS, easing: "cubic-bezier(.34,1.4,.64,1)" }, + ); + } catch (_) {} + } + + // Фиксируем финальную позицию сразу — анимация визуальная, поверх неё. + reposition(); + } catch (_) { + // В крайнем случае — просто пересчитываем позицию. + try { reposition(); } catch (_) {} + } finally { + homeCenterPos = null; + homeRatioX = null; + homeRatioY = null; + } +} + +/** + * Пересчитать точку посадки пета в долях поля ввода по позиции курсора. + * Используется при перетаскивании в docked-режиме. + */ +function dragDockedTo(clientX, clientY) { + try { + const input = document.querySelector(INPUT_SELECTOR); + if (!input) return; + const r = input.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return; + // Курсор держит пета за центр: точка посадки — центр пета, а тело выше. + const anchorX = clientX; + const anchorY = clientY + petSize / 2; + let rx = (anchorX - r.left) / r.width; + let ry = (anchorY - r.top) / r.height; + // Не даём уехать далеко за пределы поля (мягкие границы). + dockOffsetXRatio = Math.max(-0.2, Math.min(1.2, rx)); + dockOffsetYRatio = Math.max(-1.5, Math.min(1.5, ry)); + reposition(); + } catch (_) {} } // ---- Resize ---- diff --git a/src/preload/index.js b/src/preload/index.js index dabad87..337c51b 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -27,7 +27,7 @@ const background = require("./dom/background"); const reasoningGlass = require("./dom/reasoning-glass"); const inputGlass = require("./dom/input-glass"); const forceDarkTheme = require("./dom/force-dark-theme"); -const deepseekLanguage = require("./dom/deepseek-language"); + const qrOverride = require("./dom/qr-override"); const fileChip = require("./dom/file-chip"); const contextPort = require("./dom/context-port"); @@ -192,8 +192,7 @@ async function init() { // Принудительно держим тёмную тему DeepSeek safe("init.forceDarkThemeStart", () => forceDarkTheme.startWatch()); - // Фиксируем язык интерфейса DeepSeek на «Система» и запрещаем его менять - safe("init.deepseekLanguageStart", () => deepseekLanguage.startWatch()); + // Подмена QR-кода в попапе «Скачать приложение» safe("init.qrOverrideStart", () => qrOverride.startWatch()); diff --git a/tools/AttachTelegramTool.js b/tools/AttachTelegramTool.js new file mode 100644 index 0000000..fc7ed21 --- /dev/null +++ b/tools/AttachTelegramTool.js @@ -0,0 +1,204 @@ +const { Tool, ToolResult } = require("./ToolRegistry"); +const fs = require("fs"); +const path = require("path"); + +// Лимит Telegram Bot API на документы — 50 MB. Оставляем запас и режем на 45 MB. +const MAX_FILE_SIZE = 45 * 1024 * 1024; + +// Предел подписи (caption) в Telegram — 1024 символа. +const MAX_CAPTION_LEN = 1024; + +/** + * attach_telegram — прикрепить локальный файл и отправить его пользователю + * в Telegram через уже настроенного бота (botsrc/telegram.js). + * + * Транспорт (telegramBot.sendDocument) — singleton из botsrc/telegram.js. + * Настройки (token/chatId/enabled) берём из settings-store; их можно + * переопределить параметрами token/chatId. + * + * Требует только Node API (fs/path) + fetch, которые уже используются + * в telegram.js, — новых зависимостей нет. + */ +class AttachTelegramTool extends Tool { + constructor() { + super( + "attach_telegram", + "Прикрепить локальный файл и отправить его пользователю в Telegram как документ. Используй, когда пользователь просит «отправь файл в телеграм», «прикрепи в тг», «скинь мне в телеграм» и т.п. Работает только если Telegram-бот включён и настроены токен и chat_id. Поддерживаются любые типы файлов, размер до 45 MB.", + { + type: "object", + properties: { + filePath: { + type: "string", + description: + "Путь к файлу для отправки (относительно корня проекта или абсолютный).", + }, + caption: { + type: "string", + description: + "Необязательная подпись к файлу в Telegram (до 1024 символов).", + }, + comment: { + type: "string", + description: + "Необязательный комментарий к файлу — то, что ИИ хочет добавить от себя (зачем файл, что внутри, на что обратить внимание). Уходит в Telegram как дополнительный текст: если caption задан, добавляется к нему отдельной строкой; если нет — отправляется отдельным сообщением после файла (до 1024 символов).", + }, + fileName: { + type: "string", + description: + "Необязательное имя файла, под которым он будет отправлен (по умолчанию — basename пути).", + }, + chatId: { + type: "string", + description: + "Необязательный chat_id получателя. По умолчанию берётся из настроек Cookie Code.", + }, + token: { + type: "string", + description: + "Необязательный токен бота. По умолчанию берётся из настроек Cookie Code.", + }, + }, + required: ["filePath"], + additionalProperties: false, + }, + "attachTelegram(filePath, caption?, comment?)", + ); + } + + getPromptSection() { + return { + name: "tool:attach_telegram", + order: 115, + text: "Когда пользователь просит отправить/прикрепить файл ему в Telegram («отправь в тг», «скинь файл в телеграм» и т.п.), используй attachTelegram(filePath, caption?, comment?). Инструмент отправит указанный файл документом через настроенного Telegram-бота. Необязательный comment — то, что ты хочешь добавить от себя (зачем файл, что внутри, на что обратить внимание); можно передать только comment, без caption. Требуется включённый бот с заданными токеном и chat_id (см. настройки Cookie Code). Поддерживаются любые типы файлов размером до 45 MB; для файлов больше лимита вернётся ошибка.", + }; + } + + async execute(params) { + const { filePath, caption, comment, fileName, chatId, token } = params || {}; + + // Ленивая загрузка транспорта и настроек: инструмент может выполняться + // вне electron-контекста (например, в тестах), поэтому require защищаем. + let telegramBot; + let settingsStore; + try { + ({ telegramBot } = require("../botsrc/telegram")); + } catch (err) { + return ToolResult.error( + "Не удалось загрузить Telegram-транспорт: " + (err.message || String(err)), + ); + } + try { + settingsStore = require("../src/main/settings-store"); + } catch (_) { + settingsStore = null; + } + + // Разрешаем путь (переиспользуем логику AttachFileTool). + let absPath; + try { + const { resolveFilePath } = require("./AttachFileTool"); + const projectDir = params && params.projectDir ? params.projectDir : null; + absPath = resolveFilePath(filePath, projectDir); + } catch (err) { + return ToolResult.error( + "Некорректный путь к файлу: " + (err.message || String(err)), + ); + } + + let stat; + try { + stat = fs.statSync(absPath); + } catch (_) { + return ToolResult.error("Файл не найден: " + absPath); + } + if (!stat.isFile()) { + return ToolResult.error("Указанный путь не является файлом: " + absPath); + } + if (stat.size > MAX_FILE_SIZE) { + return ToolResult.error( + "Файл слишком большой: " + + (stat.size / 1024 / 1024).toFixed(1) + + " MB (лимит " + + Math.round(MAX_FILE_SIZE / 1024 / 1024) + + " MB).", + ); + } + + // Настройки: params переопределяют settings-store. + const s = settingsStore ? settingsStore.readSettings() : {}; + const useToken = String(token || (s && s.telegramBotToken) || "").trim(); + const useChatId = String(chatId || (s && s.telegramChatId) || "").trim(); + const enabled = s ? !!s.telegramEnabled : true; + + if (!enabled) { + return ToolResult.error( + "Telegram-бот выключен. Включи его в настройках Cookie Code (telegramEnabled).", + ); + } + if (!useToken) { + return ToolResult.error( + "Не задан токен Telegram-бота (telegramBotToken в настройках).", + ); + } + if (!useChatId) { + return ToolResult.error( + "Не задан chat_id получателя (telegramChatId в настройках).", + ); + } + + // Настраиваем singleton под актуальные параметры и отправляем. + try { + telegramBot.configure(useToken, useChatId); + } catch (_) { + // configure может отсутствовать в моках — игнорируем. + } + + const cap = + caption == null ? "" : String(caption).slice(0, MAX_CAPTION_LEN); + const cmt = + comment == null ? "" : String(comment).slice(0, MAX_CAPTION_LEN); + const sendName = fileName ? String(fileName) : path.basename(absPath); + + // Комментарий: если уже есть caption — приклеиваем к нему отдельной строкой + // (один запрос); иначе отправим отдельным сообщением после файла. + const finalCaption = (cmt ? (cap ? cap + "\n\n" + cmt : cmt) : cap).slice( + 0, + MAX_CAPTION_LEN, + ); + // Комментарий уже внутри finalCaption (см. выше) — отдельное сообщение не нужно. + + let result; + try { + result = await telegramBot.sendDocument(absPath, { + chatId: useChatId, + caption: finalCaption, + fileName: sendName, + }); + } catch (err) { + return ToolResult.error( + "Ошибка отправки в Telegram: " + (err.message || String(err)), + ); + } + + if (!result || result.success !== true) { + return ToolResult.error( + "Не удалось отправить файл в Telegram: " + + ((result && result.error) || "неизвестная ошибка"), + ); + } + + return ToolResult.success({ + fileName: sendName, + size: stat.size, + messageId: result.messageId || null, + message: + "Файл отправлен в Telegram: " + sendName + " (" + stat.size + " байт)", + }); + } +} + +module.exports = { + AttachTelegramTool, + MAX_FILE_SIZE, + MAX_CAPTION_LEN, +}; \ No newline at end of file diff --git a/tools/JsRunner.js b/tools/JsRunner.js index af72d93..3c6e20b 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -182,12 +182,15 @@ const BOOTSTRAP = [ " return await __call('read_photo', { file_path: filePath, caption: caption || '', send: send !== false });", " };", " globalThis.attachFile = async function (filePathOrPayload) {", - " var payload = typeof filePathOrPayload === 'string'", - " ? { filePath: filePathOrPayload }", - " : (filePathOrPayload || {});", - " return await __call('attach_file', payload);", - " };", - " globalThis.exitPlanMode = async function (plan) {", + " var payload = typeof filePathOrPayload === 'string'", + " ? { filePath: filePathOrPayload }", + " : (filePathOrPayload || {});", + " return await __call('attach_file', payload);", + " };", + " globalThis.attachTelegram = async function (filePath, caption, comment) {", + " return await __call('attach_telegram', { filePath: filePath, caption: caption || '', comment: comment || '' });", + " };", + " globalThis.exitPlanMode = async function (plan) {", " return await __call('exit_plan_mode', { plan: plan });", " };", " globalThis.exit_plan_mode = globalThis.exitPlanMode;", diff --git a/tools/cuckoo-tools.d.ts b/tools/cuckoo-tools.d.ts index 87a64c1..eb902dc 100644 --- a/tools/cuckoo-tools.d.ts +++ b/tools/cuckoo-tools.d.ts @@ -334,6 +334,18 @@ declare function injectPageJS(code: string, windowId?: string): Promise; */ declare function attachFile(filePath: string): Promise<{ fileName: string; size: number; message: string }>; +/** + * 将本地文件作为文档发送到用户的 Telegram(通过已配置的 Telegram-бот)。 + * 当用户要求「把文件发到 Telegram / 发到 TG / 发给我」时使用。 + * 需在设置中启用 Telegram-бот 并填写 token 与 chat_id。 + * @param filePath 要发送的文件路径(相对项目根目录或绝对路径) + * @param caption 可选,文件说明文字(最多 1024 字符) + * @param comment 可选,AI 想额外补充的备注(例如为什么发送、文件内容、注意事项)。若已给 caption,会追加到 caption 后另起一段;若未给 caption,则作为 caption 直接发送(最多 1024 字符) + * @returns { fileName: string, size: number, messageId: number | null, message: string } + * @throws 文件不存在、不是文件、超过 45MB、Telegram 未启用/未配置或发送失败时抛出异常 + */ + declare function attachTelegram(filePath: string, caption?: string, comment?: string): Promise<{ fileName: string; size: number; messageId: number | null; message: string }>; + // ================= MCP ================= /** diff --git a/tools/index.js b/tools/index.js index ff3abf0..4fa7deb 100644 --- a/tools/index.js +++ b/tools/index.js @@ -36,6 +36,7 @@ const { AskUserQuestionTool } = require("./AskUserQuestionTool"); const { ReadPhotoTool } = require("./ReadPhotoTool"); const { ExitPlanModeTool } = require("./ExitPlanModeTool"); const { AttachFileTool } = require("./AttachFileTool"); +const { AttachTelegramTool } = require("./AttachTelegramTool"); // 创建全局工具注册表 const registry = new ToolRegistry(); @@ -74,6 +75,7 @@ registry.register(new AskUserQuestionTool()); registry.register(new ReadPhotoTool()); registry.register(new ExitPlanModeTool()); registry.register(new AttachFileTool()); +registry.register(new AttachTelegramTool()); // 导出 module.exports = { @@ -108,7 +110,8 @@ module.exports = { ReadPhotoTool, ExitPlanModeTool, AttachFileTool, - InjectPageJSTool, + AttachTelegramTool, + InjectPageJSTool, // 便捷方法 getAllTools: () => registry, getToolDescriptions: () => registry.getDescriptions(),