From b6c39aa4a254381f8ab15e6f1139c28ed5b1e0a6 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Wed, 30 Sep 2026 16:29:41 +0300 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8F=D1=82=D1=8C=20=D1=84=D0=BE=D0=BD=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BA=D1=80=D0=B0=D1=81=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/preload/dom/background.js | 46 +++++++++++++++++++++++++ src/preload/overlay/template.js | 61 +++++++++++++++++---------------- 2 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/preload/dom/background.js b/src/preload/dom/background.js index 133c498..2f30de4 100644 --- a/src/preload/dom/background.js +++ b/src/preload/dom/background.js @@ -102,6 +102,11 @@ let customBackgroundsDir = ""; // Кэш data-URI: file → data:image/...;base64,... const dataUriCache = new Map(); +// Текущее состояние кастомизации. Если false — фон/blur/эффекты не применяются, +// а inline-стили background-image снимаются с /. +// Синхронизируется в applyCustomizationEnabled() и loadAndApply(). +let customizationEnabledFlag = true; + /** * Полный список фонов: встроенные + пользовательские. * id пользовательских — 'custom:<имя-файла-без-расширения>'. @@ -178,6 +183,18 @@ function apply(id) { const uri = entry ? getDataUri(entry.file) : ""; const value = uri ? 'url("' + uri + '")' : "none"; try { + // При выключенной кастомизации фон не ставим: только снимаем inline-стили, + // чтобы вернуть родной фон страницы DeepSeek. + if (customizationEnabledFlag === false) { + document.documentElement.style.removeProperty("background-image"); + document.body.style.removeProperty("background-image"); + document.documentElement.style.setProperty( + "--cuckoo-bg-image", + "none", + "important", + ); + return; + } document.documentElement.style.setProperty( "background-image", value, @@ -362,6 +379,25 @@ async function loadAndApply() { // нашёлся по id при применении. await loadCustomBackgrounds(); const settings = await window.electronAPI.getCuckooSettings(); + // Синхронизируем флаг кастомизации: если она выключена, фон/blur/эффекты + // не применяем, а снимаем inline-стили (иначе слушатель изменений настроек + // из TG-бота/другого окна вернул бы фон обратно). + const enabled = !settings || settings.customizationEnabled !== false; + applyCustomizationEnabled(enabled); + if (!enabled) { + applyBlur({ + backgroundBlur: 0, + headerBlur: 0, + sidebarBlur: 0, + headerOpacity: 0, + sidebarOpacity: 0, + toolBlockOpacity: 0, + toolBlockBlur: 0, + }); + applyRgbUsername(false); + applyInputGlassEnabled(true); // класс cuckoo-input-glass-off не нужен + return; + } const bgId = (settings && settings.background) || DEFAULT_ID; apply(bgId); applyBlur(settings); @@ -427,8 +463,18 @@ async function resetAll() { */ function applyCustomizationEnabled(enabled) { try { + customizationEnabledFlag = enabled !== false; if (enabled === false) { document.documentElement.classList.add("cuckoo-customization-off"); + // Снимаем inline background-image с /, выставленный apply(), + // иначе он перебивает CSS-правило cuckoo-customization-off. + document.documentElement.style.removeProperty("background-image"); + document.body.style.removeProperty("background-image"); + document.documentElement.style.setProperty( + "--cuckoo-bg-image", + "none", + "important", + ); } else { document.documentElement.classList.remove("cuckoo-customization-off"); } diff --git a/src/preload/overlay/template.js b/src/preload/overlay/template.js index 2e41294..2d720c6 100644 --- a/src/preload/overlay/template.js +++ b/src/preload/overlay/template.js @@ -650,7 +650,8 @@ const OVERLAY_CSS = [ // ========== 页面背景:базовый цвет + стили (картинка ставится через JS) ========== // При выключенной кастомизации (класс cuckoo-customization-off на ) // правила не применяются, и фон остаётся родным фоном страницы DeepSeek. - "html:not(.cuckoo-customization-off), body:not(.cuckoo-customization-off) {", + "html:not(.cuckoo-customization-off),", + "html:not(.cuckoo-customization-off) body {", " background-color: #0f1220 !important;", " background-size: cover !important;", " background-position: center center !important;", @@ -675,7 +676,7 @@ const OVERLAY_CSS = [ "}", // ========== 页面顶部标题栏毛玻璃(DeepSeek) ========== // Прозрачность управляется переменной --cuckoo-header-opacity (0% — полностью прозрачно). - ".the-header {", + "html:not(.cuckoo-customization-off) .the-header {", " backdrop-filter: blur(var(--cuckoo-header-blur)) saturate(140%) !important;", " -webkit-backdrop-filter: blur(var(--cuckoo-header-blur)) saturate(140%) !important;", " background: rgba(15, 18, 32, var(--cuckoo-header-opacity)) !important;", @@ -684,33 +685,33 @@ const OVERLAY_CSS = [ // DeepSeek 在 _871cbca 与其空的子层 d72636e2 上画了从上到下的 // 线性渐变(底部是不透明的 rgb(21,21,23)),会挡住页面背景图。 // 用简写 background: transparent !important 一次清掉颜色+渐变。 - "._871cbca,", - "._871cbca > .d72636e2 {", + "html:not(.cuckoo-customization-off) ._871cbca,", + "html:not(.cuckoo-customization-off) ._871cbca > .d72636e2 {", " background: transparent !important;", "}", // ========== 左侧边栏毛玻璃(DeepSeek) ========== // .b8812f16.a2f3d50e — корень сайдбара. Внутри много слоёв, каждый со своим // фоном (шапка _262baab, список _3586175/_6d215eb/_77cdc67, низ _7b40dad). // Делаем прозрачными все промежуточные слои, чтобы стекло работало насквозь. - ".b8812f16.a2f3d50e,", - ".b8812f16.a2f3d50e > div,", - ".b8812f16.a2f3d50e > div > div,", - ".b8812f16.a2f3d50e ._262baab,", - ".b8812f16.a2f3d50e ._3586175,", - ".b8812f16.a2f3d50e ._6d215eb,", - ".b8812f16.a2f3d50e ._77cdc67,", - ".b8812f16.a2f3d50e ._7b40dad,", - ".b8812f16.a2f3d50e ._8a693f3,", - ".b8812f16.a2f3d50e ._1d72f01,", - ".b8812f16.a2f3d50e .f3d18f6a,", - ".b8812f16.a2f3d50e ._3098d02 {", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e > div,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e > div > div,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._262baab,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._3586175,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._6d215eb,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._77cdc67,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._7b40dad,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._8a693f3,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._1d72f01,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e .f3d18f6a,", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e ._3098d02 {", " background: transparent !important;", " backdrop-filter: none !important;", " -webkit-backdrop-filter: none !important;", "}", // Стекло применяем один раз — на корень сайдбара. // Прозрачность — переменная --cuckoo-sidebar-opacity (0% — полностью прозрачно). - ".b8812f16.a2f3d50e {", + "html:not(.cuckoo-customization-off) .b8812f16.a2f3d50e {", " background: rgba(15, 18, 32, var(--cuckoo-sidebar-opacity)) !important;", " backdrop-filter: blur(var(--cuckoo-sidebar-blur)) saturate(140%) !important;", " -webkit-backdrop-filter: blur(var(--cuckoo-sidebar-blur)) saturate(140%) !important;", @@ -733,13 +734,13 @@ const OVERLAY_CSS = [ " color: #ff6b7a !important;", "}", // ========== Модалка «Настройки» (и другие ds-modal) — матовое стекло ========== - ".ds-modal-content {", + "html:not(.cuckoo-customization-off) .ds-modal-content {", " background: rgba(15, 18, 32, 0.55) !important;", " backdrop-filter: blur(16px) saturate(140%) !important;", " -webkit-backdrop-filter: blur(16px) saturate(140%) !important;", "}", // Приглушаем фон-маску, чтобы под модалкой просвечивала страница - ".ds-modal-mask {", + "html:not(.cuckoo-customization-off) .ds-modal-mask {", " background: rgba(0, 0, 0, 0.35) !important;", "}", // ========== Матовое стекло для поля ввода сообщения DeepSeek ========== @@ -797,7 +798,7 @@ const OVERLAY_CSS = [ " background: transparent !important;", "}", // ========== Кнопка «Новый чат» в сайдбаре — матовое стекло ========== - "._5a8ac7a.a084f19e {", + "html:not(.cuckoo-customization-off) ._5a8ac7a.a084f19e {", " background: rgba(15, 18, 32, 0.5) !important;", " backdrop-filter: blur(10px) saturate(140%) !important;", " -webkit-backdrop-filter: blur(10px) saturate(140%) !important;", @@ -986,8 +987,8 @@ const OVERLAY_CSS = [ // ========== RGB-переливание кнопки «Повторить» (retry под ошибкой ответа) ========== // Класс ds-button--warning стабильный (не хешированный). Ограничение .ds-message // гарантирует, что не затронутся warning-кнопки в других местах (например, в настройках). - ".ds-message .ds-button--warning .ds-button__background,", - ".ds-message .ds-button--warning {", + "html:not(.cuckoo-customization-off) .ds-message .ds-button--warning .ds-button__background,", + "html:not(.cuckoo-customization-off) .ds-message .ds-button--warning {", " background: linear-gradient(90deg, #ff4d4d, #ffa64d, #ffee4d, #4dff88, #4dd2ff, #8a4dff, #ff4dd2, #ff4d4d) !important;", " background-size: 400% 100% !important;", " animation: cuckoo-rgb-retry 5s linear infinite !important;", @@ -1001,7 +1002,7 @@ const OVERLAY_CSS = [ // а виртуализированный список DeepSeek использует transform (ломает fixed-attachment). // Поэтому стекло рисуем сами: псевдоэлемент показывает тот же фон, что на странице, // размытый и спозиционированный под вьюпорт. Координаты считает reasoning-glass.js. - "._5ab5d64 {", + "html:not(.cuckoo-customization-off) ._5ab5d64 {", " position: relative !important;", " overflow: hidden !important;", " border-radius: 10px !important;", @@ -1015,15 +1016,15 @@ const OVERLAY_CSS = [ // Гасим фоны у прямого предка и у потомков плашки: DeepSeek рисует свои // тёмные слои (обёртка _245c867._34a54ec, иконки, span), и они просвечивают // через полупрозрачную плашку, давая «чёрную» область слева. - "._245c867._34a54ec,", - "._74c0879,", - "._245c867._34a54ec > *,", - "._5ab5d64 * {", + "html:not(.cuckoo-customization-off) ._245c867._34a54ec,", + "html:not(.cuckoo-customization-off) ._74c0879,", + "html:not(.cuckoo-customization-off) ._245c867._34a54ec > *,", + "html:not(.cuckoo-customization-off) ._5ab5d64 * {", " background: transparent !important;", " backdrop-filter: none !important;", " -webkit-backdrop-filter: none !important;", "}", - "._5ab5d64::before {", + "html:not(.cuckoo-customization-off) ._5ab5d64::before {", ' content: "" !important;', " position: absolute !important;", " inset: calc(-1 * var(--cuckoo-plate-pad, 40px)) !important;", @@ -1036,7 +1037,7 @@ const OVERLAY_CSS = [ " pointer-events: none !important;", " z-index: 0 !important;", "}", - "._5ab5d64::after {", + "html:not(.cuckoo-customization-off) ._5ab5d64::after {", ' content: "" !important;', " position: absolute !important;", " inset: 0 !important;", @@ -1044,7 +1045,7 @@ const OVERLAY_CSS = [ " pointer-events: none !important;", " z-index: 0 !important;", "}", - "._5ab5d64 > * {", + "html:not(.cuckoo-customization-off) ._5ab5d64 > * {", " position: relative !important;", " z-index: 1 !important;", "}", From 06aa33518feabd93dd8435c3b7295d06d357170b Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Fri, 2 Oct 2026 16:07:36 +0300 Subject: [PATCH 2/8] =?UTF-8?q?fix(export):=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20SVG=20=D0=B8=20=D1=81=D0=BB=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=B1=D0=BD=D1=8B=D0=B5=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=BC=20=D0=B2=20PDF?= =?UTF-8?q?/DOCX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/preload/dom/chat-export.js | 232 ++++++++++++++++++++++----------- 1 file changed, 154 insertions(+), 78 deletions(-) diff --git a/src/preload/dom/chat-export.js b/src/preload/dom/chat-export.js index ed59d18..a44d272 100644 --- a/src/preload/dom/chat-export.js +++ b/src/preload/dom/chat-export.js @@ -7,31 +7,32 @@ * * Кнопка вставляется один раз на группу кнопок (идемпотентно). */ -const { ipcRenderer } = require('electron'); -const { t } = require('../i18n/i18n'); +const { ipcRenderer } = require("electron"); +const { t } = require("../i18n/i18n"); -const BTN_ATTR = 'data-cuckoo-export-btn'; -const MENU_ATTR = 'data-cuckoo-export-menu'; +const BTN_ATTR = "data-cuckoo-export-btn"; +const MENU_ATTR = "data-cuckoo-export-menu"; // Стабильные классы DeepSeek: сама группа кнопок в панели ответа. // Класс ._54866f7 — хешированный, DeepSeek добавляет его не всегда (при React-ререндере // может исчезать). Опираемся только на стабильный ._965abe9 (есть у всех панелей кнопок). -const ACTION_BAR_GROUP_SELECTOR = '.ds-flex._965abe9'; +const ACTION_BAR_GROUP_SELECTOR = ".ds-flex._965abe9"; // Fallback — любой .ds-flex, содержащий кнопки .ds-button--iconLabelTertiary. -const ACTION_BAR_PARENT_SELECTOR = '.ds-flex._0a3d93b'; +const ACTION_BAR_PARENT_SELECTOR = ".ds-flex._0a3d93b"; // SVG-иконка «документ с стрелкой вниз» (в стиле Lucide/Feather, 16×16). const DOWNLOAD_ICON_SVG = '' + '' + '' + - ''; + ""; /** * Классы, повторяющие структуру родных кнопок DeepSeek. * Используем родные классы, чтобы кнопка визуально совпала. */ -const NATIVE_BUTTON_CLASSES = 'ds-button ds-button--iconLabelTertiary ds-button--icon ds-button--capsule ds-button--xs ds-button--icon-relative-l'; +const NATIVE_BUTTON_CLASSES = + "ds-button ds-button--iconLabelTertiary ds-button--icon ds-button--capsule ds-button--xs ds-button--icon-relative-l"; /** * Найти markdown-ответ внутри сообщения, к которому относится панель кнопок. @@ -48,16 +49,18 @@ function findMarkdownForActionBar(actionBar) { let depth = 0; while (cur && cur !== document.body && depth < 6) { // Ищем .ds-markdown внутри текущего уровня, исключая сам actionBar. - const candidates = cur.querySelectorAll ? cur.querySelectorAll('.ds-markdown') : []; + const candidates = cur.querySelectorAll + ? cur.querySelectorAll(".ds-markdown") + : []; for (const md of candidates) { if (!actionBar.contains(md)) return md; } // Fallback: на уровне выше actionBar может быть прямой сиблинг. let prev = cur.previousElementSibling; while (prev) { - if (prev.classList && prev.classList.contains('ds-markdown')) return prev; + if (prev.classList && prev.classList.contains("ds-markdown")) return prev; if (prev.querySelector) { - const md = prev.querySelector('.ds-markdown'); + const md = prev.querySelector(".ds-markdown"); if (md) return md; } prev = prev.previousElementSibling; @@ -72,46 +75,52 @@ function findMarkdownForActionBar(actionBar) { * Построить элемент кнопки. */ function buildExportButton() { - const btn = document.createElement('div'); - btn.setAttribute('role', 'button'); - btn.setAttribute('tabindex', '0'); - btn.setAttribute('title', t('export.btn.title')); - btn.setAttribute(BTN_ATTR, '1'); + const btn = document.createElement("div"); + btn.setAttribute("role", "button"); + btn.setAttribute("tabindex", "0"); + btn.setAttribute("title", t("export.btn.title")); + btn.setAttribute(BTN_ATTR, "1"); btn.className = NATIVE_BUTTON_CLASSES; // Внутренняя разметка, максимально похожая на родную. btn.innerHTML = '
' + '
' + - '
' + DOWNLOAD_ICON_SVG + '
' + - '
'; + '
' + + DOWNLOAD_ICON_SVG + + "
" + + ""; // Меню (скрыто по умолчанию). - const menu = document.createElement('div'); - menu.setAttribute(MENU_ATTR, '1'); + const menu = document.createElement("div"); + menu.setAttribute(MENU_ATTR, "1"); menu.style.cssText = - 'position:absolute; z-index:99999; min-width:160px; ' + - 'background:#1e1e2e; color:#dde1ff; border:1px solid rgba(139,147,255,0.35); ' + - 'border-radius:8px; padding:4px; box-shadow:0 6px 20px rgba(0,0,0,0.45); ' + - 'font-size:13px; display:none; user-select:none;'; + "position:absolute; z-index:99999; min-width:160px; " + + "background:#1e1e2e; color:#dde1ff; border:1px solid rgba(139,147,255,0.35); " + + "border-radius:8px; padding:4px; box-shadow:0 6px 20px rgba(0,0,0,0.45); " + + "font-size:13px; display:none; user-select:none;"; menu.innerHTML = - '
' + t('export.menu.pdf') + '
' + - '
' + t('export.menu.docx') + '
'; + '
' + + t("export.menu.pdf") + + "
" + + '
' + + t("export.menu.docx") + + "
"; // Hover-подсветка пунктов. - menu.addEventListener('mouseover', (e) => { - const target = e.target.closest && e.target.closest('[data-action]'); - if (target) target.style.background = 'rgba(139,147,255,0.18)'; + menu.addEventListener("mouseover", (e) => { + const target = e.target.closest && e.target.closest("[data-action]"); + if (target) target.style.background = "rgba(139,147,255,0.18)"; }); - menu.addEventListener('mouseout', (e) => { - const target = e.target.closest && e.target.closest('[data-action]'); - if (target) target.style.background = ''; + menu.addEventListener("mouseout", (e) => { + const target = e.target.closest && e.target.closest("[data-action]"); + if (target) target.style.background = ""; }); // Обёртка, чтобы позиционировать кнопку; меню живёт в document.body, // чтобы родительские контейнеры DeepSeek с overflow:hidden не обрезали выпадашку. - const wrap = document.createElement('div'); - wrap.style.cssText = 'position:relative; display:inline-flex;'; + const wrap = document.createElement("div"); + wrap.style.cssText = "position:relative; display:inline-flex;"; wrap.appendChild(btn); // Ссылка на меню — чтобы decorateActionBar мог удалить его при пересоздании wrap. @@ -119,73 +128,132 @@ function buildExportButton() { btn._cuckooMenu = menu; // Открытие/закрытие меню по клику. - btn.addEventListener('click', (e) => { + btn.addEventListener("click", (e) => { e.stopPropagation(); - const isOpen = menu.style.display === 'block'; - if (isOpen) { menu.style.display = 'none'; return; } + const isOpen = menu.style.display === "block"; + if (isOpen) { + menu.style.display = "none"; + return; + } // Позиционируем fixed относительно viewport, над кнопкой. const rect = btn.getBoundingClientRect(); - menu.style.display = 'block'; - menu.style.position = 'fixed'; - menu.style.left = 'auto'; - menu.style.right = (window.innerWidth - rect.right) + 'px'; - menu.style.bottom = (window.innerHeight - rect.top + 6) + 'px'; - menu.style.top = 'auto'; - menu.style.marginBottom = '0'; + menu.style.display = "block"; + menu.style.position = "fixed"; + menu.style.left = "auto"; + menu.style.right = window.innerWidth - rect.right + "px"; + menu.style.bottom = window.innerHeight - rect.top + 6 + "px"; + menu.style.top = "auto"; + menu.style.marginBottom = "0"; }); // Обработчик пунктов меню. - menu.addEventListener('click', async (e) => { + menu.addEventListener("click", async (e) => { try { - console.log('[Cookie Code] export menu click, target=', e.target && e.target.tagName, e.target && e.target.className); + console.log( + "[Cookie Code] export menu click, target=", + e.target && e.target.tagName, + e.target && e.target.className, + ); // e.target может быть текстовой нодой (нет .closest) — берём родителя. let tNode = e.target; if (tNode && tNode.nodeType === 3) tNode = tNode.parentElement; // TEXT_NODE → Element - const action = tNode && tNode.closest && tNode.closest('[data-action]'); - if (!action) { console.warn('[Cookie Code] export: клик по меню, но data-action не найден'); return; } + const action = tNode && tNode.closest && tNode.closest("[data-action]"); + if (!action) { + console.warn( + "[Cookie Code] export: клик по меню, но data-action не найден", + ); + return; + } e.stopPropagation(); - const format = action.getAttribute('data-action'); - console.log('[Cookie Code] export action:', format); - menu.style.display = 'none'; + const format = action.getAttribute("data-action"); + console.log("[Cookie Code] export action:", format); + menu.style.display = "none"; - console.log('[Cookie Code] export: ищу markdown элемент...'); - const markdown = wrap._cuckooMarkdown || btn._cuckooMarkdown || findMarkdownForActionBar(btn); + console.log("[Cookie Code] export: ищу markdown элемент..."); + const markdown = + wrap._cuckooMarkdown || + btn._cuckooMarkdown || + findMarkdownForActionBar(btn); if (!markdown) { - console.warn('[Cookie Code] export: не найден .ds-markdown для ответа'); + console.warn("[Cookie Code] export: не найден .ds-markdown для ответа"); return; } - console.log('[Cookie Code] export: найден markdown, клонирую...'); + console.log("[Cookie Code] export: найден markdown, клонирую..."); // Клонируем и вычищаем служебные элементы (toolbar/banner/скрипты). const clone = markdown.cloneNode(true); - clone.querySelectorAll('script, style').forEach((el) => el.remove()); - // Кнопки копирования/скачивания внутри code-block не нужны в документе. - clone.querySelectorAll('button, [class*="toolbar"], [class*="copy"], [class*="download"], [class*="banner"]').forEach((el) => el.remove()); + // 1. Служебные теги. + clone + .querySelectorAll("script, style, link, noscript, iframe") + .forEach((el) => el.remove()); + // 2. Скрытые элементы (DeepSeek прячет так баннеры code-блоков, обёртки тулов). + clone + .querySelectorAll( + '[style*="display: none"], [style*="display:none"], [hidden], [aria-hidden="true"]', + ) + .forEach((el) => el.remove()); + // 3. Иконки: все SVG — это UI-графика (копирование, скачивание, тулы), + // в документе они дают мусор и «svg внутри». + clone.querySelectorAll("svg").forEach((el) => el.remove()); + // 4. Кнопки/тулбары/баннеры, включая родные
DeepSeek + // и обёртки code-блоков (классы хешированные, поэтому ловим по подстрокам). + clone + .querySelectorAll( + 'button, [role="button"], [class*="button"], [class*="toolbar"], [class*="copy"], ' + + '[class*="download"], [class*="banner"], [class*="code-block"], [class*="codeBlock"]', + ) + .forEach((el) => el.remove()); + // 5. Служебные элементы плагина Cookie Code (иконки/панели тулов). + clone + .querySelectorAll( + '[class*="cuckoo-tool"], [class*="cuckoo-response-meta"]', + ) + .forEach((el) => el.remove()); const html = clone.innerHTML; - console.log('[Cookie Code] export: HTML подготовлен, длина =', html.length); + console.log( + "[Cookie Code] export: HTML подготовлен, длина =", + html.length, + ); // Показываем фидбек в самом пункте меню. const originalText = action.textContent; - try { action.textContent = t('export.status.working'); } catch (_) {} + try { + action.textContent = t("export.status.working"); + } catch (_) {} - console.log('[Cookie Code] export: вызываю cuckoo-chat-export IPC напрямую, format =', format, ', html.length =', html.length); - const res = await ipcRenderer.invoke('cuckoo-chat-export', { format, html, defaultName: 'CookieCode' }); - console.log('[Cookie Code] export: IPC ответ =', JSON.stringify(res)); + console.log( + "[Cookie Code] export: вызываю cuckoo-chat-export IPC напрямую, format =", + format, + ", html.length =", + html.length, + ); + const res = await ipcRenderer.invoke("cuckoo-chat-export", { + format, + html, + defaultName: "CookieCode", + }); + console.log("[Cookie Code] export: IPC ответ =", JSON.stringify(res)); if (res && res.success) { - action.textContent = t('export.status.ok'); + action.textContent = t("export.status.ok"); } else if (res && res.canceled) { action.textContent = originalText; } else { - action.textContent = t('export.status.err'); + action.textContent = t("export.status.err"); } - setTimeout(() => { action.textContent = originalText; }, 1500); + setTimeout(() => { + action.textContent = originalText; + }, 1500); } catch (fatalErr) { - console.error('[Cookie Code] export FATAL error:', fatalErr.message, fatalErr.stack); + console.error( + "[Cookie Code] export FATAL error:", + fatalErr.message, + fatalErr.stack, + ); } }); // Не даём клику по кнопке всплыть выше и закрыть себя. - btn.addEventListener('mousedown', (e) => e.stopPropagation()); + btn.addEventListener("mousedown", (e) => e.stopPropagation()); // Меню выносим в document.body ПОСЛЕ навешивания всех обработчиков — // так клик по пункту меню гарантированно попадает в menu.addEventListener('click'). @@ -198,7 +266,7 @@ function buildExportButton() { * Вставить кнопку экспорта в группу кнопок, если её там ещё нет. */ function decorateActionBar(actionBarGroup) { - if (actionBarGroup.querySelector('[' + BTN_ATTR + ']')) return; // уже есть + if (actionBarGroup.querySelector("[" + BTN_ATTR + "]")) return; // уже есть try { // Находим .ds-markdown, к которому относится эта панель, СРАЗУ — // и запоминаем ссылку. Иначе после React-перерендера кнопка может @@ -209,7 +277,7 @@ function decorateActionBar(actionBarGroup) { if (markdown) wrap._cuckooMarkdown = markdown; // Чистим «осиротевшие» меню от предыдущих пересозданий wrap. try { - document.querySelectorAll('[' + MENU_ATTR + ']').forEach((m) => { + document.querySelectorAll("[" + MENU_ATTR + "]").forEach((m) => { const owner = m.__cuckooOwner || null; if (owner && !owner.isConnected) m.remove(); }); @@ -217,7 +285,7 @@ function decorateActionBar(actionBarGroup) { if (wrap._cuckooMenu) wrap._cuckooMenu.__cuckooOwner = wrap; actionBarGroup.appendChild(wrap); } catch (err) { - console.error('[Cookie Code] export decorateActionBar error:', err.message); + console.error("[Cookie Code] export decorateActionBar error:", err.message); } } @@ -231,8 +299,11 @@ function scanAll() { const parents = document.querySelectorAll(ACTION_BAR_PARENT_SELECTOR); groups = []; parents.forEach((p) => { - const firstGroup = p.querySelector('.ds-flex > [role="button"].ds-button--iconLabelTertiary'); - if (firstGroup && firstGroup.parentElement) groups.push(firstGroup.parentElement); + const firstGroup = p.querySelector( + '.ds-flex > [role="button"].ds-button--iconLabelTertiary', + ); + if (firstGroup && firstGroup.parentElement) + groups.push(firstGroup.parentElement); }); } groups.forEach(decorateActionBar); @@ -242,7 +313,9 @@ function scanAll() { * Закрыть все открытые меню при клике вне. */ function closeAllMenus() { - document.querySelectorAll('[' + MENU_ATTR + ']').forEach((m) => { m.style.display = 'none'; }); + document.querySelectorAll("[" + MENU_ATTR + "]").forEach((m) => { + m.style.display = "none"; + }); } let started = false; @@ -272,13 +345,16 @@ function startWatch() { // Закрываем меню при клике в любое место документа (кроме самого меню). // Bubble-фаза (без true), чтобы не перехватывать клик раньше, чем его // обработает сам пункт меню. - document.addEventListener('click', (e) => { - if (e.target && e.target.closest && e.target.closest('[' + MENU_ATTR + ']')) return; + document.addEventListener("click", (e) => { + if (e.target && e.target.closest && e.target.closest("[" + MENU_ATTR + "]")) + return; closeAllMenus(); }); - document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllMenus(); }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeAllMenus(); + }); - console.log('[Cookie Code] chat-export watch started'); + console.log("[Cookie Code] chat-export watch started"); } module.exports = { startWatch, scanAll }; From 82d41631e703ba41b6477fe6fce815c4c7010aa6 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Fri, 2 Oct 2026 16:10:18 +0300 Subject: [PATCH 3/8] i18n --- src/preload/i18n/i18n.js | 8 ++++++++ src/preload/overlay/events.js | 14 +++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 6780af7..eaad759 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -340,6 +340,14 @@ const KEYS = { "mcp.label.configured": { ru: "Настроены", en: "Configured" }, "mcp.empty": { ru: "Нет", en: "None" }, "mcp.btn.save": { ru: "Сохранить настройки", en: "Save config" }, + "mcp.status.connected": { ru: "Подключён", en: "Connected" }, + "mcp.status.disconnected": { ru: "Не подключён", en: "Disconnected" }, + "mcp.status.disabled": { ru: "Отключён", en: "Disabled" }, + "mcp.list.empty": { ru: "Нет MCP-серверов", en: "No MCP servers" }, + "mcp.list.loadError": { ru: "Ошибка загрузки", en: "Failed to load" }, + "mcp.toast.disconnected": { ru: "Отключён: ", en: "Disconnected: " }, + "mcp.toast.connected": { ru: "Подключён: ", en: "Connected: " }, + "mcp.toast.actionFailed": { ru: "Ошибка: ", en: "Error: " }, // ---- Оверлей: бейдж и первый диалог ---- "badge.title": { ru: "Cookie Code работает", en: "Cookie Code is running" }, diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index 9088754..b64ed87 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -184,12 +184,16 @@ async function renderMcpList() { const servers = res && res.success ? res.servers : []; if (!servers || servers.length === 0) { list.innerHTML = - '
暂无 MCP Server
'; + '
' + t("mcp.list.empty") + "
"; return; } list.innerHTML = servers .map((s) => { - const status = s.connected ? "已连接" : s.enabled ? "未连接" : "已禁用"; + const status = s.connected + ? t("mcp.status.connected") + : s.enabled + ? t("mcp.status.disconnected") + : t("mcp.status.disabled"); const statusColor = s.connected ? "#4ade80" : s.enabled @@ -227,16 +231,16 @@ async function renderMcpList() { if (server.connected || server.enabled) { // 已连接或已启用 → 断开/禁用 await window.electronAPI.disableMcpServer(name); - showToast("已断开 " + name, 2000); + showToast(t("mcp.toast.disconnected") + name, 2000); } else { // 未启用 → 连接 await window.electronAPI.enableMcpServer(name); - showToast("已连接 " + name, 2000); + showToast(t("mcp.toast.connected") + name, 2000); } await renderMcpList(); await loadMcpConfigToJson(); } catch (err) { - showToast("操作失败: " + (err.message || err), 3000); + showToast(t("mcp.toast.actionFailed") + (err.message || err), 3000); await renderMcpList(); } }); From e81aaf36edcf80a3738fc418568e190e5e8cdd83 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Fri, 2 Oct 2026 16:37:55 +0300 Subject: [PATCH 4/8] Add plan approval and screenshot workflow Adds Telegram-based plan approval flow and an active-window screenshot command, with synchronization between bot and app window state. Also improves user-question dialogs with close/cancel handling and updates markdown rendering and i18n strings for the new approval and screenshot actions. --- botsrc/i18n.js | 36 +++++ botsrc/index.js | 202 +++++++++++++++++++++++++++ src/main/ipc.js | 56 +++++++- src/preload/dom/ask-user-question.js | 199 ++++++++++++++++++-------- src/preload/dom/exit-plan-mode.js | 195 ++++++++++++++++++-------- 5 files changed, 567 insertions(+), 121 deletions(-) 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..ad3b59c 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') → { pid, messageId } @@ -381,6 +435,11 @@ async function _handleCallback(chatId, data, cbq) { return; } + const planRef = _planCallbackTokens.get(token); + if (planRef) { + await _handlePlanCallback(planRef, cbq); + return; + } const approvalRef = _approvalCallbackTokens.get(token); if (approvalRef) { await _handleApprovalCallback(approvalRef, cbq); @@ -545,6 +604,105 @@ function cancelApproval(requestId) { return { success: true }; } +/** + * Отправить план на утверждение в Telegram. + * Кнопки «Согласовать» / «Отказать». Кто ответит первым (окно или TG) — тот резолвит. + * @param {string} requestId + * @param {string} plan — markdown плана + * @returns {Promise<{success:boolean, approved?:boolean, skipped?:boolean, error?:string}>} + */ +async function requestPlanApproval(requestId, plan) { + const cfg = _read(); + if (!cfg.enabled || !cfg.token || !cfg.chatId) { + return { success: false, skipped: true }; + } + const id = String(requestId || ""); + if (!id) return { success: false, error: "requestId не задан" }; + + const body = escapeHtml(String(plan || "").slice(0, 3500)); + const token = "p" + ++_cbTokenCounter; + const entry = { messageId: null, token, resolve: null }; + const promise = new Promise((resolve) => { + entry.resolve = resolve; + }); + _pendingPlanApprovals.set(id, entry); + _planCallbackTokens.set(token, { requestId: id, approved: true }); + + const msg = + _t("plan.title") + + (body ? "\n
" + 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)) { @@ -3044,6 +3244,8 @@ module.exports = { notifyAllDone, requestApproval, cancelApproval, + requestPlanApproval, + cancelPlanApproval, notifyLongProcess, askQuestion, setOnQuestionAnswered, diff --git a/src/main/ipc.js b/src/main/ipc.js index c2c6c35..e51bfd8 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 (_) {} }, ); @@ -1626,4 +1676,8 @@ function registerIpcHandlers() { }); } -module.exports = { registerIpcHandlers, resolveUserQuestionFromTelegram }; +module.exports = { + registerIpcHandlers, + resolveUserQuestionFromTelegram, + resolvePlanApprovalFromTelegram, +}; diff --git a/src/preload/dom/ask-user-question.js b/src/preload/dom/ask-user-question.js index cdb2e7b..05b4e35 100644 --- a/src/preload/dom/ask-user-question.js +++ b/src/preload/dom/ask-user-question.js @@ -1,24 +1,26 @@ -const { ipcRenderer } = require('electron'); +const { ipcRenderer } = require("electron"); -const STYLE_ID = 'cuckoo-ask-user-question-style'; -const DIALOG_ID = 'cuckoo-ask-user-question'; -const POSITION_KEY = 'cuckoo-ask-user-question-pos'; +const STYLE_ID = "cuckoo-ask-user-question-style"; +const DIALOG_ID = "cuckoo-ask-user-question"; +const POSITION_KEY = "cuckoo-ask-user-question-pos"; function escapeHtml(value) { - const div = document.createElement('div'); - div.textContent = String(value || ''); + const div = document.createElement("div"); + div.textContent = String(value || ""); return div.innerHTML; } function ensureStyles() { if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); + const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${DIALOG_ID} { position:fixed; inset:0; z-index:2147483647; display:flex; align-items:center; justify-content:center; background:rgba(5,8,18,.62); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } #${DIALOG_ID} .cuckoo-question-card { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:min(620px,calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; padding:22px; color:#eef1ff; background:#171b2c; border:1px solid rgba(145,158,255,.42); border-radius:12px; box-shadow:0 20px 70px rgba(0,0,0,.55); } #${DIALOG_ID} .cuckoo-question-drag { margin:-22px -22px 18px; padding:16px 22px 0; cursor:move; user-select:none; } #${DIALOG_ID} h2 { margin:0; font-size:18px; } + #${DIALOG_ID} .cuckoo-question-close { position:absolute; top:12px; right:12px; width:30px; height:30px; padding:0; display:flex; align-items:center; justify-content:center; font-size:18px; line-height:1; color:#aeb5cd; background:transparent; border:1px solid rgba(255,255,255,.14); border-radius:8px; cursor:pointer; } + #${DIALOG_ID} .cuckoo-question-close:hover { color:#fff; background:rgba(255,255,255,.08); } #${DIALOG_ID} .cuckoo-question { margin:0 0 18px; padding:14px; border:1px solid rgba(255,255,255,.12); border-radius:8px; } #${DIALOG_ID} .cuckoo-question-title { margin:0 0 10px; font-weight:600; line-height:1.45; } #${DIALOG_ID} label { display:block; margin:7px 0; padding:9px 11px; border:1px solid rgba(255,255,255,.14); border-radius:7px; cursor:pointer; } @@ -33,48 +35,71 @@ function ensureStyles() { document.head.appendChild(style); } -function askUserQuestion(questions) { +function askUserQuestion(questions, requestId) { ensureStyles(); const old = document.getElementById(DIALOG_ID); if (old) old.remove(); - return new Promise((resolve) => { - const dialog = document.createElement('div'); + return new Promise((resolve, reject) => { + const dialog = document.createElement("div"); dialog.id = DIALOG_ID; - dialog.innerHTML = ''; - const form = dialog.querySelector('form'); + dialog.innerHTML = + ''; + const form = dialog.querySelector("form"); questions.forEach((item, questionIndex) => { - const section = document.createElement('section'); - section.className = 'cuckoo-question'; - section.innerHTML = '

' + (questionIndex + 1) + '. ' + escapeHtml(item.question) + '

'; + const section = document.createElement("section"); + section.className = "cuckoo-question"; + section.innerHTML = + '

' + + (questionIndex + 1) + + ". " + + escapeHtml(item.question) + + "

"; item.options.forEach((option, optionIndex) => { - const label = document.createElement('label'); - label.innerHTML = '' + escapeHtml(option.label) + - (option.recommended ? 'Рекомендуемый' : '') + - (option.description ? '' + escapeHtml(option.description) + '' : ''); + const label = document.createElement("label"); + label.innerHTML = + '' + + escapeHtml(option.label) + + (option.recommended + ? 'Рекомендуемый' + : "") + + (option.description + ? '' + + escapeHtml(option.description) + + "" + : ""); section.appendChild(label); }); - const custom = document.createElement('input'); - custom.type = 'text'; - custom.name = 'custom-' + questionIndex; - custom.placeholder = 'Свой ответ'; + const custom = document.createElement("input"); + custom.type = "text"; + custom.name = "custom-" + questionIndex; + custom.placeholder = "Свой ответ"; section.appendChild(custom); form.appendChild(section); }); - const footer = document.createElement('div'); - footer.className = 'cuckoo-question-footer'; - footer.innerHTML = ''; + const footer = document.createElement("div"); + footer.className = "cuckoo-question-footer"; + footer.innerHTML = + ''; form.appendChild(footer); document.body.appendChild(dialog); - const card = dialog.querySelector('.cuckoo-question-card'); - const dragHandle = dialog.querySelector('.cuckoo-question-drag'); + const card = dialog.querySelector(".cuckoo-question-card"); + const dragHandle = dialog.querySelector(".cuckoo-question-drag"); try { - const saved = JSON.parse(localStorage.getItem(POSITION_KEY) || 'null'); - if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') { - card.style.left = saved.left + 'px'; - card.style.top = saved.top + 'px'; - card.style.transform = 'none'; + const saved = JSON.parse(localStorage.getItem(POSITION_KEY) || "null"); + if ( + saved && + typeof saved.left === "number" && + typeof saved.top === "number" + ) { + card.style.left = saved.left + "px"; + card.style.top = saved.top + "px"; + card.style.transform = "none"; } } catch (_) {} @@ -88,68 +113,126 @@ function askUserQuestion(questions) { const rect = card.getBoundingClientRect(); const maxLeft = Math.max(0, window.innerWidth - rect.width); const maxTop = Math.max(0, window.innerHeight - rect.height); - const left = Math.max(0, Math.min(maxLeft, startLeft + event.clientX - startX)); - const top = Math.max(0, Math.min(maxTop, startTop + event.clientY - startY)); - card.style.left = left + 'px'; - card.style.top = top + 'px'; - card.style.transform = 'none'; + const left = Math.max( + 0, + Math.min(maxLeft, startLeft + event.clientX - startX), + ); + const top = Math.max( + 0, + Math.min(maxTop, startTop + event.clientY - startY), + ); + card.style.left = left + "px"; + card.style.top = top + "px"; + card.style.transform = "none"; }; const onUp = () => { if (!dragging) return; dragging = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); try { const rect = card.getBoundingClientRect(); - localStorage.setItem(POSITION_KEY, JSON.stringify({ left: rect.left, top: rect.top })); + localStorage.setItem( + POSITION_KEY, + JSON.stringify({ left: rect.left, top: rect.top }), + ); } catch (_) {} }; - dragHandle.addEventListener('mousedown', (event) => { + dragHandle.addEventListener("mousedown", (event) => { dragging = true; const rect = card.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; startX = event.clientX; startY = event.clientY; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); event.preventDefault(); }); const finish = () => { const answers = questions.map((item, questionIndex) => { - const custom = form.querySelector('[name="custom-' + questionIndex + '"]').value.trim(); - const selected = form.querySelector('[name="question-' + questionIndex + '"]:checked'); - const optionIndex = selected ? Number(selected.value.replace('option-', '')) : -1; - return { question: item.question, answer: custom || (optionIndex >= 0 ? item.options[optionIndex].label : '') }; + const custom = form + .querySelector('[name="custom-' + questionIndex + '"]') + .value.trim(); + const selected = form.querySelector( + '[name="question-' + questionIndex + '"]:checked', + ); + const optionIndex = selected + ? Number(selected.value.replace("option-", "")) + : -1; + return { + question: item.question, + answer: + custom || (optionIndex >= 0 ? item.options[optionIndex].label : ""), + }; }); if (answers.some((answer) => !answer.answer)) return; onUp(); dialog.remove(); resolve(answers); }; - form.addEventListener('submit', (event) => { event.preventDefault(); finish(); }); - form.addEventListener('keydown', (event) => { - if (event.key === 'Enter' && event.target.tagName !== 'TEXTAREA') { + form.addEventListener("submit", (event) => { + event.preventDefault(); + finish(); + }); + form.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target.tagName !== "TEXTAREA") { event.preventDefault(); finish(); } }); - form.querySelector('input').focus(); + + // Закрытие/отмена: кнопка ×, Esc, клик по затемнению вокруг карточки. + const cancel = () => { + onUp(); + document.removeEventListener("keydown", onKeyDown, true); + dialog.remove(); + reject(new Error("canceled")); + }; + const onKeyDown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + cancel(); + } + }; + const closeBtn = dialog.querySelector(".cuckoo-question-close"); + if (closeBtn) closeBtn.addEventListener("click", cancel); + dialog.addEventListener("mousedown", (event) => { + if (event.target === dialog) cancel(); + }); + document.addEventListener("keydown", onKeyDown, true); + + form.querySelector("input").focus(); }); } function registerAskUserQuestionListener() { - ipcRenderer.on('ask-user-question', (_event, payload) => { - askUserQuestion(payload.questions || []).then((answers) => { - ipcRenderer.send('ask-user-question-response', { requestId: payload.requestId, answers }); - }); + ipcRenderer.on("ask-user-question", (_event, payload) => { + askUserQuestion(payload.questions || [], payload.requestId).then( + (answers) => { + ipcRenderer.send("ask-user-question-response", { + requestId: payload.requestId, + answers, + }); + }, + (err) => { + // Пользователь закрыл диалог (×/Esc/клик по фону) — сообщаем об отмене. + if (err && err.message === "canceled") { + ipcRenderer.send("ask-user-question-response", { + requestId: payload.requestId, + canceled: true, + }); + } + }, + ); }); // Вопрос был отвечен в Telegram — просто закрываем диалог в окне. - ipcRenderer.on('ask-user-question-resolved', () => { + ipcRenderer.on("ask-user-question-resolved", () => { const dialog = document.getElementById(DIALOG_ID); if (dialog) dialog.remove(); }); } -module.exports = { askUserQuestion, registerAskUserQuestionListener }; \ No newline at end of file +module.exports = { askUserQuestion, registerAskUserQuestionListener }; diff --git a/src/preload/dom/exit-plan-mode.js b/src/preload/dom/exit-plan-mode.js index 416d33a..7e72c86 100644 --- a/src/preload/dom/exit-plan-mode.js +++ b/src/preload/dom/exit-plan-mode.js @@ -7,17 +7,17 @@ * плана для ТЕКУЩЕЙ СЕССИИ и подставляем в поле ввода «Работай в соответствии * с планом» (без автоотправки — пользователь отправляет сам). */ -const { ipcRenderer } = require('electron'); -const state = require('./state'); -const chatInput = require('./chat-input'); -const { t } = require('../i18n/i18n'); +const { ipcRenderer } = require("electron"); +const state = require("./state"); +const chatInput = require("./chat-input"); +const { t } = require("../i18n/i18n"); -const STYLE_ID = 'cuckoo-exit-plan-style'; -const DIALOG_ID = 'cuckoo-exit-plan-dialog'; +const STYLE_ID = "cuckoo-exit-plan-style"; +const DIALOG_ID = "cuckoo-exit-plan-dialog"; function escapeHtml(value) { - const div = document.createElement('div'); - div.textContent = String(value || ''); + const div = document.createElement("div"); + div.textContent = String(value || ""); return div.innerHTML; } @@ -27,7 +27,9 @@ function escapeHtml(value) { * @returns {string} */ function renderMarkdown(md) { - const lines = String(md || '').replace(/\r\n/g, '\n').split('\n'); + const lines = String(md || "") + .replace(/\r\n/g, "\n") + .split("\n"); const out = []; let inCode = false; let inUl = false; @@ -35,61 +37,96 @@ function renderMarkdown(md) { const inline = (text) => { let s = escapeHtml(text); - s = s.replace(/`([^`]+)`/g, '$1'); - s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); - s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); - s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + s = s.replace(/`([^`]+)`/g, "$1"); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2"); + s = s.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); return s; }; const closeLists = () => { - if (inUl) { out.push(''); inUl = false; } - if (inOl) { out.push(''); inOl = false; } + if (inUl) { + out.push(""); + inUl = false; + } + if (inOl) { + out.push(""); + inOl = false; + } }; for (const line of lines) { if (/^```/.test(line)) { closeLists(); - if (!inCode) { out.push('
'); inCode = true; }
-      else { out.push('
'); inCode = false; } + if (!inCode) { + out.push('
');
+        inCode = true;
+      } else {
+        out.push("
"); + inCode = false; + } + continue; + } + if (inCode) { + out.push(escapeHtml(line)); continue; } - if (inCode) { out.push(escapeHtml(line)); continue; } const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { closeLists(); const lv = h[1].length; - out.push('' + inline(h[2]) + ''); + out.push( + "' + inline(h[2]) + "", + ); + continue; + } + if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { + closeLists(); + out.push('
'); continue; } - if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { closeLists(); out.push('
'); continue; } const ul = line.match(/^\s*[-*+]\s+(.*)$/); const ol = line.match(/^\s*\d+[.)]\s+(.*)$/); if (ul) { - if (inOl) { out.push(''); inOl = false; } - if (!inUl) { out.push('
    '); inUl = true; } - out.push('
  • ' + inline(ul[1]) + '
  • '); + if (inOl) { + out.push(""); + inOl = false; + } + if (!inUl) { + out.push('
      '); + inUl = true; + } + out.push("
    • " + inline(ul[1]) + "
    • "); continue; } if (ol) { - if (inUl) { out.push('
    '); inUl = false; } - if (!inOl) { out.push('
      '); inOl = true; } - out.push('
    1. ' + inline(ol[1]) + '
    2. '); + if (inUl) { + out.push("
"); + inUl = false; + } + if (!inOl) { + out.push('
    '); + inOl = true; + } + out.push("
  1. " + inline(ol[1]) + "
  2. "); continue; } closeLists(); if (!line.trim()) continue; - out.push('

    ' + inline(line) + '

    '); + out.push('

    ' + inline(line) + "

    "); } closeLists(); - if (inCode) out.push(''); - return out.join('\n'); + if (inCode) out.push(""); + return out.join("\n"); } function ensureStyles() { if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); + const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${DIALOG_ID} { position:fixed; inset:0; z-index:2147483647; display:flex; align-items:center; justify-content:center; background:rgba(5,8,18,.62); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } @@ -131,34 +168,40 @@ function showExitPlanDialog(plan) { if (old) old.remove(); return new Promise((resolve) => { - const dialog = document.createElement('div'); + const dialog = document.createElement("div"); dialog.id = DIALOG_ID; dialog.innerHTML = '" + '
    ' + '
    ' + - '' + - '' + - '
    ' + - '
'; + '" + + '" + + "" + + ""; document.body.appendChild(dialog); - dialog.querySelector('.cuckoo-plan-body').innerHTML = renderMarkdown(plan); + dialog.querySelector(".cuckoo-plan-body").innerHTML = renderMarkdown(plan); // ===== Перетаскивание окна за заголовок ===== (function enableDrag() { - const card = dialog.querySelector('.cuckoo-plan-card'); - const head = dialog.querySelector('.cuckoo-plan-head'); + const card = dialog.querySelector(".cuckoo-plan-card"); + const head = dialog.querySelector(".cuckoo-plan-head"); if (!card || !head) return; - head.style.cursor = 'move'; + head.style.cursor = "move"; let offsetX = 0; let offsetY = 0; - head.addEventListener('mousedown', (e) => { - if (e.target.closest('.cuckoo-plan-close')) return; + head.addEventListener("mousedown", (e) => { + if (e.target.closest(".cuckoo-plan-close")) return; const startX = e.clientX; const startY = e.clientY; const baseX = offsetX; @@ -166,14 +209,15 @@ function showExitPlanDialog(plan) { const onMove = (ev) => { offsetX = baseX + (ev.clientX - startX); offsetY = baseY + (ev.clientY - startY); - card.style.transform = 'translate(' + offsetX + 'px,' + offsetY + 'px)'; + card.style.transform = + "translate(" + offsetX + "px," + offsetY + "px)"; }; const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); e.preventDefault(); }); })(); @@ -182,20 +226,31 @@ function showExitPlanDialog(plan) { const finish = (approved) => { if (settled) return; settled = true; - document.removeEventListener('keydown', onKey, true); + document.removeEventListener("keydown", onKey, true); dialog.remove(); resolve(approved); }; const onKey = (event) => { - if (event.key === 'Escape') { event.preventDefault(); finish(false); } - else if (event.key === 'Enter') { event.preventDefault(); finish(true); } + if (event.key === "Escape") { + event.preventDefault(); + finish(false); + } else if (event.key === "Enter") { + event.preventDefault(); + finish(true); + } }; - document.addEventListener('keydown', onKey, true); + document.addEventListener("keydown", onKey, true); - dialog.querySelector('.cuckoo-plan-approve').addEventListener('click', () => finish(true)); - dialog.querySelector('.cuckoo-plan-deny').addEventListener('click', () => finish(false)); - dialog.querySelector('.cuckoo-plan-close').addEventListener('click', () => finish(false)); - dialog.querySelector('.cuckoo-plan-approve').focus(); + dialog + .querySelector(".cuckoo-plan-approve") + .addEventListener("click", () => finish(true)); + dialog + .querySelector(".cuckoo-plan-deny") + .addEventListener("click", () => finish(false)); + dialog + .querySelector(".cuckoo-plan-close") + .addEventListener("click", () => finish(false)); + dialog.querySelector(".cuckoo-plan-approve").focus(); }); } @@ -204,25 +259,41 @@ function showExitPlanDialog(plan) { */ function applyApproval() { state.planMode = false; - try { window.electronAPI.setPlanMode(false).catch(() => {}); } catch (_) {} - try { require('./plan-mode-toggle').syncState(); } catch (_) {} + try { + window.electronAPI.setPlanMode(false).catch(() => {}); + } catch (_) {} + try { + require("./plan-mode-toggle").syncState(); + } catch (_) {} // Вставляем запрос на работу в поле ввода, но НЕ отправляем автоматически — // пользователь отправит сам. try { const input = chatInput.findInputArea(); - if (input) chatInput.setInputContent(input, t('plan.approve.prompt')); + if (input) chatInput.setInputContent(input, t("plan.approve.prompt")); } catch (_) {} } /** Слушатель события exit-plan-mode от главного процесса. */ function registerExitPlanModeListener() { - ipcRenderer.on('exit-plan-mode', (_event, payload = {}) => { + ipcRenderer.on("exit-plan-mode", (_event, payload = {}) => { const { requestId, plan } = payload; showExitPlanDialog(plan).then((approved) => { - try { window.electronAPI.exitPlanModeResponse(requestId, approved); } catch (_) {} + try { + window.electronAPI.exitPlanModeResponse(requestId, approved); + } catch (_) {} if (approved) applyApproval(); }); }); + // План утверждён/отклонён в Telegram — закрываем диалог в окне. + ipcRenderer.on("exit-plan-mode-resolved", (_event, payload = {}) => { + const dialog = document.getElementById(DIALOG_ID); + if (dialog) dialog.remove(); + if (payload.approved) applyApproval(); + }); } -module.exports = { registerExitPlanModeListener, showExitPlanDialog, renderMarkdown }; +module.exports = { + registerExitPlanModeListener, + showExitPlanDialog, + renderMarkdown, +}; From c94f8dcba8d0bbfb4a44da70cd6534feede5a43d Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Fri, 2 Oct 2026 16:44:35 +0300 Subject: [PATCH 5/8] Update index.js --- botsrc/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/botsrc/index.js b/botsrc/index.js index ad3b59c..53b5d13 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -3142,6 +3142,7 @@ async function _registerCommands() { switch: "переключить сессию", diagnostics: "диагностика", todos: "список задач", + screen: "скриншот окна", cancel: "отменить ввод", }, en: { @@ -3159,6 +3160,7 @@ async function _registerCommands() { switch: "switch session", diagnostics: "diagnostics", todos: "todo list", + screen: "window screenshot", cancel: "cancel", }, }[lang]; From 20e0b11a749d06f40ad11b8e68684687d915e362 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Sun, 4 Oct 2026 00:37:21 +0300 Subject: [PATCH 6/8] Bind transferred chats to project directory Persist projectDir through context transfer and restore a fallback lastProjectDir so new chats stay attached to the same project after reloads or navigation. This also binds pendingProjectDir when resuming a transferred session, keeping the session-to-directory mapping alive until the new chat gets a session ID. --- src/main/context-port.js | 3 ++ src/main/ipc.js | 48 +++++++++++++++++++++++++--- src/main/project-context.js | 45 ++++++++++++++++----------- src/main/session-store.js | 55 +++++++++++++++++++++------------ src/preload/api.js | 6 +++- src/preload/dom/context-port.js | 42 ++++++++++++++++++++++++- 6 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/main/context-port.js b/src/main/context-port.js index d3d2568..668dcfe 100644 --- a/src/main/context-port.js +++ b/src/main/context-port.js @@ -26,6 +26,9 @@ function set(webContentsId, data) { // Промпт инициализации проекта (дерево каталога + системный промпт), // переносится вместе с контекстом, чтобы новый чат «знал» проект. initPrompt: data.initPrompt || "", + // Каталог проекта — чтобы привязать новый чат к тому же проекту + // (sessionStore.state.pendingProjectDir) после переноса. + projectDir: data.projectDir || "", createdAt: Date.now(), }); } diff --git a/src/main/ipc.js b/src/main/ipc.js index e51bfd8..46b2fad 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -614,12 +614,30 @@ function registerIpcHandlers() { try { const ctx = windowState.getContextByWebContents(event.sender); const store = ctx ? ctx.sessionStore : null; - const projectDir = store ? store.state.selectedProjectDir : null; - if (!projectDir) return { success: true, prompt: "" }; + // selectedProjectDir сбрасывается при навигации (например, при переносе + // контекста). Fallback на lastProjectDir, чтобы системный промпт проекта + // и projectDir всё равно попали в новый чат. + let projectDir = store + ? store.state.selectedProjectDir || store.state.lastProjectDir + : null; + // Третий fallback: последний известный каталог из персистентного + // маппинга session-dir-map (переживает перезапуск main). + if ( + !projectDir && + store && + typeof store.readSessionStore === "function" + ) { + try { + const map = store.readSessionStore(); + const dirs = Object.values(map || {}).filter(Boolean); + if (dirs.length > 0) projectDir = dirs[dirs.length - 1]; + } catch (_) {} + } + if (!projectDir) return { success: true, prompt: "", projectDir: "" }; const { buildInitPrompt } = require("./project-context"); const providerId = (ctx && ctx.providerId) || ""; const prompt = await buildInitPrompt(projectDir, providerId); - return { success: true, prompt: prompt || "" }; + return { success: true, prompt: prompt || "", projectDir: projectDir }; } catch (err) { console.error( "[Cookie Code] context-port:get-init-prompt error:", @@ -674,13 +692,14 @@ function registerIpcHandlers() { // Хранилище живёт в main и переживает reload страницы при смене чата. ipcMain.handle( "context-port:start", - async (event, { history, stage, initPrompt }) => { + async (event, { history, stage, initPrompt, projectDir }) => { try { const wcId = event.sender.id; contextPort.set(wcId, { history: history || "", stage: stage || "history", initPrompt: initPrompt || "", + projectDir: projectDir || "", }); return { success: true }; } catch (err) { @@ -698,6 +717,7 @@ function registerIpcHandlers() { history: cur.history || "", summary: summary || "", initPrompt: cur.initPrompt || "", + projectDir: cur.projectDir || "", }); return { success: true }; } catch (err) { @@ -724,6 +744,26 @@ function registerIpcHandlers() { } }); + // Привязать новый чат к проекту после переноса контекста. + // Ставит pendingProjectDir — при появлении sessionId (после отправки + // первого сообщения) session-store сам сохранит маппинг session->dir. + ipcMain.handle("context-port:bind-project", async (event, { dir } = {}) => { + try { + const ctx = windowState.getContextByWebContents(event.sender); + const store = ctx ? ctx.sessionStore : null; + if (!store || !dir) return { success: false, error: "no-store-or-dir" }; + store.state.pendingProjectDir = dir; + store.state.lastProjectDir = dir; + console.log( + "[Cookie Code] context-port:bind-project: pendingProjectDir =", + dir, + ); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // 执行命令 ipcMain.handle("execute-command", async (event, { command, id }) => { if (!command || typeof command !== "string") { diff --git a/src/main/project-context.js b/src/main/project-context.js index 23e0f99..1285647 100644 --- a/src/main/project-context.js +++ b/src/main/project-context.js @@ -124,6 +124,7 @@ async function initProject(skipPrompt = false, windowContext = null) { // 保存选中的项目目录(若该窗口有独立的 sessionStore) if (sessionStore) { sessionStore.state.selectedProjectDir = selectedDir; + sessionStore.state.lastProjectDir = selectedDir; // ========== 持久化存储会话-目录映射 ========== // 如果当前有会话ID,保存映射 @@ -216,7 +217,8 @@ async function setProjectByDir(selectedDir, windowContext = null) { // Проверяем, что каталог существует. try { const st = fs.statSync(dir); - if (!st.isDirectory()) return { success: false, error: "Не каталог: " + dir }; + if (!st.isDirectory()) + return { success: false, error: "Не каталог: " + dir }; } catch (_) { return { success: false, error: "Каталог не найден: " + dir }; } @@ -237,34 +239,41 @@ async function setProjectByDir(selectedDir, windowContext = null) { } // Переключаем окно на найденную сессию (та же навигация, что и /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 (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; + sessionStore.state.lastProjectDir = dir; if (existingSessionId) { sessionStore.state.currentSessionId = existingSessionId; sessionStore.saveSessionDirMapping(existingSessionId, dir); } else if (sessionStore.state.currentSessionId) { - sessionStore.saveSessionDirMapping(sessionStore.state.currentSessionId, dir); + sessionStore.saveSessionDirMapping( + sessionStore.state.currentSessionId, + dir, + ); } else { let sessionId = null; if (mainWindow && !mainWindow.isDestroyed()) { diff --git a/src/main/session-store.js b/src/main/session-store.js index a32f438..bbe91bf 100644 --- a/src/main/session-store.js +++ b/src/main/session-store.js @@ -2,9 +2,9 @@ * 会话-目录映射持久化存储 + URL 会话检测(每 profile 独立实例) * 由原 session-store.js 改造:从单例改为工厂函数,每个 profile 拥有独立存储文件和状态。 */ -const fs = require('fs'); -const path = require('path'); -const { getProviderByUrl } = require('../providers'); +const fs = require("fs"); +const path = require("path"); +const { getProviderByUrl } = require("../providers"); /** * 创建 profile 专属的 session store 实例 @@ -13,25 +13,28 @@ const { getProviderByUrl } = require('../providers'); * @param {object} windowState window 管理模块引用 */ function createSessionStore(profileId, storeDir, windowState) { - const STORE_FILE = path.join(storeDir, 'session-dir-map-' + profileId + '.json'); + const STORE_FILE = path.join( + storeDir, + "session-dir-map-" + profileId + ".json", + ); function readSessionStore() { try { if (fs.existsSync(STORE_FILE)) { - return JSON.parse(fs.readFileSync(STORE_FILE, 'utf-8')); + return JSON.parse(fs.readFileSync(STORE_FILE, "utf-8")); } } catch (err) { - console.error('[Cookie Code] 读取会话存储失败:', err.message); + console.error("[Cookie Code] 读取会话存储失败:", err.message); } return {}; } function writeSessionStore(store) { try { - fs.writeFileSync(STORE_FILE, JSON.stringify(store, null, 2), 'utf-8'); - console.log('[Cookie Code] 会话存储已保存:', STORE_FILE); + fs.writeFileSync(STORE_FILE, JSON.stringify(store, null, 2), "utf-8"); + console.log("[Cookie Code] 会话存储已保存:", STORE_FILE); } catch (err) { - console.error('[Cookie Code] 写入会话存储失败:', err.message); + console.error("[Cookie Code] 写入会话存储失败:", err.message); } } @@ -53,11 +56,13 @@ function createSessionStore(profileId, storeDir, windowState) { // 平台 provider 优先(DeepSeek /chat/s/ 等) try { const provider = getProviderByUrl(url); - if (provider && typeof provider.extractSessionId === 'function') { + if (provider && typeof provider.extractSessionId === "function") { const sid = provider.extractSessionId(url); if (sid) return sid; } - } catch (_) { /* provider 异常时回退旧逻辑 */ } + } catch (_) { + /* provider 异常时回退旧逻辑 */ + } // DeepSeek: https://chat.deepseek.com/a/chat/s/xxx const match = url.match(/\/chat\/s\/([a-f0-9-]+)/i); if (match) return match[1]; @@ -69,6 +74,10 @@ function createSessionStore(profileId, storeDir, windowState) { currentSessionId: null, selectedProjectDir: null, pendingProjectDir: null, + // Последний известный каталог проекта для профиля. + // Нужен как fallback, когда selectedProjectDir сброшен при навигации + // (например, при переносе контекста в новый чат). + lastProjectDir: null, }; function handleUrlChange(url, targetWindow) { @@ -77,38 +86,46 @@ function createSessionStore(profileId, storeDir, windowState) { if (sessionId) { state.currentSessionId = sessionId; - console.log('[Cookie Code][' + profileId + '] 当前会话ID: ' + sessionId); + console.log("[Cookie Code][" + profileId + "] 当前会话ID: " + sessionId); if (state.pendingProjectDir) { saveSessionDirMapping(sessionId, state.pendingProjectDir); state.selectedProjectDir = state.pendingProjectDir; + state.lastProjectDir = state.pendingProjectDir; state.pendingProjectDir = null; if (win && !win.isDestroyed()) { - win.webContents.send('project-dir-updated', state.selectedProjectDir); - win.webContents.send('session-restored', { sessionId, projectDir: state.selectedProjectDir }); + win.webContents.send("project-dir-updated", state.selectedProjectDir); + win.webContents.send("session-restored", { + sessionId, + projectDir: state.selectedProjectDir, + }); } - console.log('[Cookie Code][' + profileId + '] 暂存目录已绑定'); + console.log("[Cookie Code][" + profileId + "] 暂存目录已绑定"); return; } const restoredDir = getProjectDirBySessionId(sessionId); if (restoredDir) { state.selectedProjectDir = restoredDir; + state.lastProjectDir = restoredDir; if (win && !win.isDestroyed()) { - win.webContents.send('session-restored', { sessionId, projectDir: restoredDir }); - win.webContents.send('project-dir-updated', restoredDir); + win.webContents.send("session-restored", { + sessionId, + projectDir: restoredDir, + }); + win.webContents.send("project-dir-updated", restoredDir); } } else { state.selectedProjectDir = null; if (win && !win.isDestroyed()) { - win.webContents.send('project-dir-updated', null); + win.webContents.send("project-dir-updated", null); } } } else { state.currentSessionId = null; state.selectedProjectDir = null; if (win && !win.isDestroyed()) { - win.webContents.send('project-dir-updated', null); + win.webContents.send("project-dir-updated", null); } } } diff --git a/src/preload/api.js b/src/preload/api.js index a9abeb9..c058cdd 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -101,11 +101,12 @@ let electronAPI = { contextPortGetInitPrompt: () => { return ipcRenderer.invoke("context-port:get-init-prompt"); }, - contextPortStart: (history, stage, initPrompt) => { + contextPortStart: (history, stage, initPrompt, projectDir) => { return ipcRenderer.invoke("context-port:start", { history, stage, initPrompt, + projectDir, }); }, contextPortSummary: (summary) => { @@ -114,6 +115,9 @@ let electronAPI = { contextPortTake: () => { return ipcRenderer.invoke("context-port:take"); }, + contextPortBindProject: (dir) => { + return ipcRenderer.invoke("context-port:bind-project", { dir }); + }, contextPortClear: () => { return ipcRenderer.invoke("context-port:clear"); }, diff --git a/src/preload/dom/context-port.js b/src/preload/dom/context-port.js index cf39715..9934221 100644 --- a/src/preload/dom/context-port.js +++ b/src/preload/dom/context-port.js @@ -205,10 +205,12 @@ async function startTransfer() { // Берём его из main (пересобирается из projectDir), т.к. состояние // renderer теряется при reload и не всегда содержит промпт. let initPrompt = ""; + let transferProjectDir = ""; try { if (typeof window.electronAPI.contextPortGetInitPrompt === "function") { const r = await window.electronAPI.contextPortGetInitPrompt(); if (r && r.success && r.prompt) initPrompt = r.prompt; + if (r && r.success && r.projectDir) transferProjectDir = r.projectDir; } } catch (err) { console.warn( @@ -225,9 +227,16 @@ async function startTransfer() { history.length, "initPrompt =", initPrompt.length, + "projectDir =", + transferProjectDir, ); try { - await window.electronAPI.contextPortStart(history, "history", initPrompt); + await window.electronAPI.contextPortStart( + history, + "history", + initPrompt, + transferProjectDir, + ); // После reload preload сам выполнит шаг 2 (суммаризация). await window.electronAPI.newChat(); return { success: true }; @@ -346,6 +355,8 @@ async function resume() { hasHistory: !!(res && res.data && res.data.history), hasSummary: !!(res && res.data && res.data.summary), hasInitPrompt: !!(res && res.data && res.data.initPrompt), + hasProjectDir: !!(res && res.data && res.data.projectDir), + projectDir: res && res.data && res.data.projectDir, }), ); if (!res || !res.success || !res.data) return; @@ -364,6 +375,35 @@ async function resume() { if (data.stage === "history" && data.history) { await runSummarizeStage(data.history); } else if (data.stage === "summary" && data.summary) { + // Финальная стадия — привязываем целевой чат к проекту, из которого + // переносим контекст. pendingProjectDir сработает, когда появится + // sessionId нового чата (после отправки сообщения). + if (data.projectDir) { + try { + if (typeof window.electronAPI.contextPortBindProject === "function") { + await window.electronAPI.contextPortBindProject(data.projectDir); + console.log( + "[Cookie Code][context-port] resume: проект привязан =", + data.projectDir, + ); + } + } catch (err) { + console.warn( + "[Cookie Code][context-port] resume: bind-project error:", + err.message, + ); + } + } + if (data.projectDir) { + console.log( + "[Cookie Code][context-port] resume: bind выполнен, projectDir =", + data.projectDir, + ); + } else { + console.warn( + "[Cookie Code][context-port] resume: projectDir ОТСУТСТВУЕТ в take!", + ); + } await runInjectStage(data.summary, data.initPrompt); } } catch (err) { From 3ed8734c2693c1e87a01252ed4744bfecc6ae8a1 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Sun, 4 Oct 2026 00:50:14 +0300 Subject: [PATCH 7/8] Delete intermediate summarizer chat after context transfer Add language-independent chat delete API (chat-delete IPC) that opens the chat menu, picks the --error option and confirms via ds-button--error. Transfer now remembers the summarizer chat id and removes it from the final chat. --- src/main/context-port.js | 2 + src/main/ipc.js | 92 +++++++++++++++++++++++++++------ src/preload/api.js | 10 +++- src/preload/dom/context-port.js | 44 ++++++++++++---- 4 files changed, 119 insertions(+), 29 deletions(-) diff --git a/src/main/context-port.js b/src/main/context-port.js index 668dcfe..c45b29f 100644 --- a/src/main/context-port.js +++ b/src/main/context-port.js @@ -29,6 +29,8 @@ function set(webContentsId, data) { // Каталог проекта — чтобы привязать новый чат к тому же проекту // (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 46b2fad..a50e755 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -587,6 +587,60 @@ function registerIpcHandlers() { } }); + // Удалить чат из списка (DeepSeek и др. — через DOM сайдбара). + // Инжектим скрипт в main world: находим ссылку a[href*="/a/chat/s/"], + // открываем её меню (div[role=button]), жмём "Удалить", подтверждаем в модалке. + ipcMain.handle("chat-delete", async (event, { sessionId } = {}) => { + if (!sessionId) return { success: false, error: "缺少会话ID" }; + const ctx = windowState.getContextByWebContents(event.sender); + const win = ctx ? ctx.win : null; + if (!win || win.isDestroyed()) + return { success: false, error: "窗口已关闭" }; + const safeId = JSON.stringify(String(sessionId)); + const script = `(async function(){ + const sid = ${safeId}; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const link = document.querySelector('a[href*="/a/chat/s/' + sid + '"]'); + if (!link) return { success: false, error: "chat-link-not-found" }; + // Открыть контекстное меню чата (кнопка "..." внутри ссылки). + const menuBtn = link.querySelector('div[role="button"]'); + if (!menuBtn) return { success: false, error: "menu-button-not-found" }; + menuBtn.click(); + await sleep(500); + // Найти пункт "Удалить" в меню. Текст зависит от языка сайта, + // поэтому ищем по структурному признаку: единственный пункт --error. + const delOpt = document.querySelector( + ".ds-dropdown-menu-option--error", + ); + if (!delOpt) return { success: false, error: "delete-option-not-found" }; + delOpt.click(); + await sleep(600); + // Подтвердить в модалке. Кнопки DeepSeek — это div.ds-button (не + //