From be95048ca9d13967d50c5ea35b4709a398011107 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 21:54:06 +0300 Subject: [PATCH 1/7] feat(whats-new): extend markdown renderer with tables, code blocks and more --- src/preload/dom/whats-new.js | 518 ++++++++++++++++++++++++++++------- 1 file changed, 425 insertions(+), 93 deletions(-) diff --git a/src/preload/dom/whats-new.js b/src/preload/dom/whats-new.js index 675f2e9..113e56a 100644 --- a/src/preload/dom/whats-new.js +++ b/src/preload/dom/whats-new.js @@ -8,54 +8,150 @@ * Показ происходит один раз за сессию: main присылает событие только * один раз (см. whats-new.js → consumePendingForWindow). */ -const { ipcRenderer } = require('electron'); +const { ipcRenderer } = require("electron"); -const MODAL_ID = 'cuckoo-whats-new-modal'; -const STYLE_ID = 'cuckoo-whats-new-style'; +const MODAL_ID = "cuckoo-whats-new-modal"; +const STYLE_ID = "cuckoo-whats-new-style"; function ensureStyles() { if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); + const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = [ - '#' + MODAL_ID + ' { position: fixed; inset: 0; z-index: 2147483600;', - ' background: rgba(6,8,18,0.72); display: flex; align-items: center; justify-content: center;', + "#" + MODAL_ID + " { position: fixed; inset: 0; z-index: 2147483600;", + " background: rgba(6,8,18,0.72); display: flex; align-items: center; justify-content: center;", ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }', - '#' + MODAL_ID + ' .wn-card { background: #141726; border: 1px solid rgba(139,147,255,0.35);', - ' border-radius: 14px; max-width: 640px; width: 92%; max-height: 80vh; display: flex;', - ' flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.6); color: #dde1ff; }', - '#' + MODAL_ID + ' .wn-head { padding: 18px 22px; border-bottom: 1px solid rgba(255,255,255,0.07);', - ' display: flex; justify-content: space-between; align-items: center; }', - '#' + MODAL_ID + ' .wn-title { font-size: 17px; font-weight: 700; color: #eef0ff; }', - '#' + MODAL_ID + ' .wn-sub { font-size: 12px; color: #8a90b8; margin-top: 4px; }', - '#' + MODAL_ID + ' .wn-body { flex: 1; overflow: auto; padding: 16px 22px; font-size: 13.5px;', - ' line-height: 1.6; }', - '#' + MODAL_ID + ' .wn-body h3 { font-size: 14px; margin: 16px 0 6px; color: #bec2ff; font-weight: 700; }', - '#' + MODAL_ID + ' .wn-body h3:first-child { margin-top: 0; }', - '#' + MODAL_ID + ' .wn-body strong { color: #dde1ff; }', - '#' + MODAL_ID + ' .wn-body ul { margin: 4px 0 10px; padding-left: 20px; }', - '#' + MODAL_ID + ' .wn-body li { margin: 3px 0; }', - '#' + MODAL_ID + ' .wn-body em { color: #8a90b8; }', - '#' + MODAL_ID + ' .wn-foot { padding: 12px 20px; border-top: 1px solid rgba(255,255,255,0.07);', - ' display: flex; gap: 8px; justify-content: flex-end; }', - '#' + MODAL_ID + ' .wn-btn { padding: 9px 16px; border-radius: 10px; font-weight: 600;', - ' font-size: 13px; cursor: pointer; border: 1px solid rgba(139,147,255,0.4);', - ' background: rgba(139,147,255,0.1); color: #a8afff; transition: all 0.18s ease; }', - '#' + MODAL_ID + ' .wn-btn:hover { background: rgba(139,147,255,0.24); color: #fff; }', - '#' + MODAL_ID + ' .wn-btn-primary { background: linear-gradient(90deg,#8b93ff,#5b63d6);', - ' color: #fff; border-color: transparent; }', - '#' + MODAL_ID + ' .wn-btn-primary:hover { filter: brightness(1.1); }', - '#' + MODAL_ID + ' .wn-btn[disabled] { opacity: 0.45; cursor: not-allowed; }', - '#' + MODAL_ID + ' .wn-btn[disabled]:hover { background: rgba(139,147,255,0.1); color: #a8afff; transform: none; filter: none; }', - '#' + MODAL_ID + ' .wn-btn-primary[disabled]:hover { background: linear-gradient(90deg,#8b93ff,#5b63d6); color: #fff; filter: none; }', - ].join('\n'); + "#" + + MODAL_ID + + " .wn-card { background: #141726; border: 1px solid rgba(139,147,255,0.35);", + " border-radius: 14px; max-width: 640px; width: 92%; max-height: 80vh; display: flex;", + " flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.6); color: #dde1ff; }", + "#" + + MODAL_ID + + " .wn-head { padding: 18px 22px; border-bottom: 1px solid rgba(255,255,255,0.07);", + " display: flex; justify-content: space-between; align-items: center; }", + "#" + + MODAL_ID + + " .wn-title { font-size: 17px; font-weight: 700; color: #eef0ff; }", + "#" + + MODAL_ID + + " .wn-sub { font-size: 12px; color: #8a90b8; margin-top: 4px; }", + "#" + + MODAL_ID + + " .wn-body { flex: 1; overflow: auto; padding: 16px 22px; font-size: 13.5px;", + " line-height: 1.6; }", + "#" + + MODAL_ID + + " .wn-body h3 { font-size: 14px; margin: 16px 0 6px; color: #bec2ff; font-weight: 700; }", + "#" + MODAL_ID + " .wn-body h3:first-child { margin-top: 0; }", + "#" + MODAL_ID + " .wn-body strong { color: #dde1ff; }", + "#" + MODAL_ID + " .wn-body ul { margin: 4px 0 10px; padding-left: 20px; }", + "#" + MODAL_ID + " .wn-body li { margin: 3px 0; }", + "#" + MODAL_ID + " .wn-body em { color: #8a90b8; }", + "#" + + MODAL_ID + + " .wn-foot { padding: 12px 20px; border-top: 1px solid rgba(255,255,255,0.07);", + " display: flex; gap: 8px; justify-content: flex-end; }", + "#" + + MODAL_ID + + " .wn-btn { padding: 9px 16px; border-radius: 10px; font-weight: 600;", + " font-size: 13px; cursor: pointer; border: 1px solid rgba(139,147,255,0.4);", + " background: rgba(139,147,255,0.1); color: #a8afff; transition: all 0.18s ease; }", + "#" + + MODAL_ID + + " .wn-btn:hover { background: rgba(139,147,255,0.24); color: #fff; }", + "#" + + MODAL_ID + + " .wn-btn-primary { background: linear-gradient(90deg,#8b93ff,#5b63d6);", + " color: #fff; border-color: transparent; }", + "#" + MODAL_ID + " .wn-btn-primary:hover { filter: brightness(1.1); }", + "#" + + MODAL_ID + + " .wn-btn[disabled] { opacity: 0.45; cursor: not-allowed; }", + "#" + + MODAL_ID + + " .wn-btn[disabled]:hover { background: rgba(139,147,255,0.1); color: #a8afff; transform: none; filter: none; }", + "#" + + MODAL_ID + + " .wn-btn-primary[disabled]:hover { background: linear-gradient(90deg,#8b93ff,#5b63d6); color: #fff; filter: none; }", + // Жёлтая подсветка (==маркер==) + "#" + + MODAL_ID + + " .wn-body mark { background: rgba(255, 214, 51, 0.32); color: #ffe9a3; padding: 0 3px; border-radius: 3px; box-decoration-break: clone; }", + // Таблицы + "#" + + MODAL_ID + + " .wn-table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 12.5px; }", + "#" + + MODAL_ID + + " .wn-table th, #" + + MODAL_ID + + " .wn-table td { border: 1px solid rgba(139,147,255,0.22); padding: 6px 10px; text-align: left; vertical-align: top; }", + "#" + + MODAL_ID + + " .wn-table th { background: rgba(139,147,255,0.14); color: #d6d9ff; font-weight: 700; }", + "#" + + MODAL_ID + + " .wn-table tr:nth-child(even) td { background: rgba(255,255,255,0.02); }", + // Инлайн-код + "#" + + MODAL_ID + + " .wn-body code { background: rgba(139,147,255,0.14); color: #cfd4ff; padding: 1px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }", + // Блок кода + "#" + + MODAL_ID + + " .wn-pre { background: rgba(0,0,0,0.35); border: 1px solid rgba(139,147,255,0.2); border-radius: 8px; padding: 10px 12px; overflow: auto; margin: 8px 0; }", + "#" + + MODAL_ID + + " .wn-pre code { background: transparent; padding: 0; color: #c8cdff; font-size: 12px; line-height: 1.5; }", + // Цитата + "#" + + MODAL_ID + + " .wn-quote { border-left: 3px solid rgba(139,147,255,0.55); padding: 2px 0 2px 12px; margin: 8px 0; color: #a9aed6; font-style: italic; }", + // Зачёркнутый + "#" + MODAL_ID + " .wn-body del { color: #6c729a; }", + // Ссылки + "#" + + MODAL_ID + + " .wn-body a { color: #a8afff; text-decoration: underline; text-underline-offset: 2px; word-break: break-all; }", + "#" + MODAL_ID + " .wn-body a:hover { color: #fff; }", + // Нумерованный список + "#" + + MODAL_ID + + " .wn-body ol.wn-ol { margin: 4px 0 10px; padding-left: 22px; }", + // Чек-лист + "#" + + MODAL_ID + + " .wn-body li.wn-task { list-style: none; margin-left: -18px; }", + "#" + + MODAL_ID + + " .wn-body li.wn-task-done { color: #7e85a8; text-decoration: line-through; }", + "#" + + MODAL_ID + + " .wn-body .wn-check { color: #9aa1ff; margin-right: 4px; }", + // Разделитель + "#" + + MODAL_ID + + " .wn-hr { border: none; border-top: 1px solid rgba(139,147,255,0.22); margin: 12px 0; }", + // Заголовки h4-h6 (для ## и ниже в CHANGELOG) + "#" + + MODAL_ID + + " .wn-body h4, #" + + MODAL_ID + + " .wn-body h5, #" + + MODAL_ID + + " .wn-body h6 { font-size: 13.5px; margin: 14px 0 6px; color: #bec2ff; font-weight: 700; }", + ].join("\n"); document.head.appendChild(style); } function escapeHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } /** @@ -64,38 +160,258 @@ function escapeHtml(s) { * Этого достаточно для нашего CHANGELOG. */ function renderMarkdown(md) { - const lines = String(md || '').split(/\r?\n/); + const lines = String(md || "").split(/\r?\n/); const out = []; let inList = false; + let inOList = false; + let inCode = false; + let codeBuf = []; + let tableBuf = []; - const inline = (t) => escapeHtml(t) - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/(^|\s)_([^_]+)_(\s|$)/g, '$1$2$3'); + const flushTable = () => { + if (!tableBuf.length) return; + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + const rows = tableBuf.map((r) => + r + .split("|") + .slice(1, -1) + .map((c) => c.trim()), + ); + tableBuf = []; + if (rows.length < 2) { + out.push("
" + inline(rows[0] ? rows[0].join(" | ") : "") + "
"); + return; + } + const isSep = rows[1].every((c) => /^:?-{2,}:?$/.test(c)); + const head = isSep ? rows[0] : null; + const body = isSep ? rows.slice(2) : rows; + let html = ''; + if (head) { + html += + "" + + head.map((c) => "").join("") + + ""; + } + html += + "" + + body + .map( + (r) => + "" + + r.map((c) => "").join("") + + "", + ) + .join("") + + ""; + html += "
" + inline(c) + "
" + inline(c) + "
"; + out.push(html); + }; + + const flushCode = () => { + out.push( + '
' +
+        escapeHtml(codeBuf.join("\n")) +
+        "
", + ); + codeBuf = []; + inCode = false; + }; + + const inline = (t) => { + // Код в бэктиках — инлайн: \`code\` + const codes = []; + let s = escapeHtml(t).replace(/`([^`]+)`/g, (_m, c) => { + codes.push(c); + return "\u0000CODE" + (codes.length - 1) + "\u0000"; + }); + s = s + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/(^|\s)_([^_]+)_(\s|$)/g, "$1$2$3") + // \u0416\u0451\u043b\u0442\u0430\u044f \u043f\u043e\u0434\u0441\u0432\u0435\u0442\u043a\u0430: ==\u043c\u0430\u0440\u043a\u0435\u0440== + .replace(/==([^=]+)==/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + // \u0421\u0441\u044b\u043b\u043a\u0438: [text](url) + .replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ) + // \u0410\u0432\u0442\u043e\u0441\u0441\u044b\u043b\u043a\u0438 http(s)://... + .replace( + /(^|[^"'=])(https?:\/\/[^\s<]+)/g, + '$1$2', + ); + s = s.replace( + /\u0000CODE(\d+)\u0000/g, + (_m, i) => "" + codes[i] + "", + ); + return s; + }; for (const raw of lines) { - const line = raw.replace(/\s+$/, ''); + const line = raw.replace(/\s+$/, ""); + + // Блок кода ``` + if (/^\s*```/.test(line)) { + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + flushTable(); + if (inCode) flushCode(); + else inCode = true; + continue; + } + if (inCode) { + codeBuf.push(raw); + continue; + } + + // Таблица GFM + if (/^\s*\|.*\|\s*$/.test(line)) { + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + tableBuf.push(line.trim()); + continue; + } else { + flushTable(); + } + if (!line) { - if (inList) { out.push(''); inList = false; } + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } continue; } - const mH = line.match(/^###\s+(.+)$/); + + const mH = line.match(/^(#{1,6})\s+(.+)$/); if (mH) { - if (inList) { out.push(''); inList = false; } - out.push('

' + inline(mH[1]) + '

'); + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + const hl = mH[1].length; + const lvl = hl <= 3 ? 3 : Math.min(hl, 6); + out.push("" + inline(mH[2]) + ""); + continue; + } + + // Горизонтальная линия + if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + out.push('
'); + continue; + } + + // Цитата + const mQ = line.match(/^\s*>\s?(.*)$/); + if (mQ) { + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + out.push( + '
' + inline(mQ[1]) + "
", + ); continue; } + + // Нумерованный список + const mO = line.match(/^\s*\d+[.)]\s+(.+)$/); + if (mO) { + if (inList) { + out.push(""); + inList = false; + } + if (!inOList) { + out.push('
    '); + inOList = true; + } + out.push("
  1. " + inline(mO[1]) + "
  2. "); + continue; + } + const mI = line.match(/^\s*[-*]\s+(.+)$/); if (mI) { - if (!inList) { out.push('
"); + inOList = false; + } + if (!inList) { + out.push("'); inList = false; } - out.push('
' + inline(line) + '
'); + if (inList) { + out.push(""); + inList = false; + } + if (inOList) { + out.push(""); + inOList = false; + } + out.push("
" + inline(line) + "
"); } - if (inList) out.push(''); - return out.join('\n'); + flushTable(); + if (inCode) flushCode(); + if (inList) out.push(""); + if (inOList) out.push(""); + return out.join("\n"); } function close() { @@ -118,33 +434,39 @@ function show(payload) { close(); // на всякий случай — не должно быть открыто const from = payload && payload.from ? payload.from : null; - const to = payload && payload.to ? payload.to : ''; - const md = payload && payload.markdown ? payload.markdown : ''; + const to = payload && payload.to ? payload.to : ""; + const md = payload && payload.markdown ? payload.markdown : ""; const subtitle = from - ? 'Обновление ' + from + ' → ' + to - : 'Новая версия ' + to; + ? "Обновление " + from + " → " + to + : "Новая версия " + to; - const modal = document.createElement('div'); + const modal = document.createElement("div"); modal.id = MODAL_ID; modal.innerHTML = '
' + - '
' + - '
' + - '
🎉 Что нового в Cookie Code v' + escapeHtml(to) + '
' + - '
' + escapeHtml(subtitle) + '
' + - '
' + - '' + - '
' + - '
' + renderMarkdown(md) + '
' + - '
' + - '' + - '' + - '
' + - '
'; + '
' + + "
" + + '
🎉 Что нового в Cookie Code v' + + escapeHtml(to) + + "
" + + '
' + + escapeHtml(subtitle) + + "
" + + "
" + + '" + + "
" + + '
' + + renderMarkdown(md) + + "
" + + '
' + + '' + + '" + + "
" + + ""; // ---- Блокировка закрытия на CLOSE_LOCK_MS ---- let locked = true; @@ -158,12 +480,15 @@ function show(payload) { const left = Math.max(0, lockUntil - Date.now()); if (left > 0) { const sec = Math.ceil(left / 1000); - if (closeLabelEl) closeLabelEl.textContent = 'Прочитайте — ' + sec + '…'; + if (closeLabelEl) closeLabelEl.textContent = "Прочитайте — " + sec + "…"; } else { locked = false; - if (closeIconEl) closeIconEl.textContent = '✕'; - if (closeLabelEl) closeLabelEl.textContent = 'Ок, спасибо'; - closeBtns.forEach((b) => { b.disabled = false; b.removeAttribute('disabled'); }); + if (closeIconEl) closeIconEl.textContent = "✕"; + if (closeLabelEl) closeLabelEl.textContent = "Ок, спасибо"; + closeBtns.forEach((b) => { + b.disabled = false; + b.removeAttribute("disabled"); + }); return; } setTimeout(tick, 100); @@ -172,42 +497,47 @@ function show(payload) { // Заблокировать Esc на время лока const escHandler = (e) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { if (locked) { e.preventDefault(); e.stopPropagation(); return; } close(); - document.removeEventListener('keydown', escHandler, true); + document.removeEventListener("keydown", escHandler, true); } }; - document.addEventListener('keydown', escHandler, true); + document.addEventListener("keydown", escHandler, true); // Клик по кнопкам / вне карточки - modal.addEventListener('click', async (e) => { + modal.addEventListener("click", async (e) => { const t = e.target; if (!t || !t.closest) return; // Клик вне карточки — попытка закрыть - const card = t.closest('.wn-card'); + const card = t.closest(".wn-card"); if (!card) { - if (!locked) { close(); document.removeEventListener('keydown', escHandler, true); } + if (!locked) { + close(); + document.removeEventListener("keydown", escHandler, true); + } return; } - const btn = t.closest('[data-act]'); + const btn = t.closest("[data-act]"); if (!btn) return; if (btn.disabled) return; - const act = btn.getAttribute('data-act'); - if (act === 'close') { + const act = btn.getAttribute("data-act"); + if (act === "close") { if (locked) return; close(); - document.removeEventListener('keydown', escHandler, true); - } else if (act === 'changelog') { + document.removeEventListener("keydown", escHandler, true); + } else if (act === "changelog") { // «Показать CHANGELOG» доступна всегда — она не закрывает модалку - try { await ipcRenderer.invoke('whats-new-open-changelog'); } catch (_) {} + try { + await ipcRenderer.invoke("whats-new-open-changelog"); + } catch (_) {} } }); @@ -218,9 +548,11 @@ function show(payload) { * Подписаться на событие из main. Вызывается из preload/index.js. */ function registerWhatsNewListener() { - ipcRenderer.on('whats-new-show', (_event, payload) => { - try { show(payload); } catch (err) { - console.error('[WhatsNew] render error:', err && err.message); + ipcRenderer.on("whats-new-show", (_event, payload) => { + try { + show(payload); + } catch (err) { + console.error("[WhatsNew] render error:", err && err.message); } }); } From 6b48fa5dfefb7fb668696073bfd030a1faf59c4c Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 22:00:34 +0300 Subject: [PATCH 2/7] docs(agents): document extended What's New markdown renderer in changelog-writer Add a section describing the What's New modal's extended markdown capabilities: GFM tables, ==highlight==, task lists, fenced code blocks, blockquotes and links. Include a practical CHANGELOG example, usage rules and a warning that changelog-parser.js only recognizes ### sections and - bullets. Extend the parser verification steps, add rules 10 (no raw HTML) and 11 (extended markdown is optional), and update the frontmatter description. --- cookie/agents/changelog-writer.md | 92 ++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/cookie/agents/changelog-writer.md b/cookie/agents/changelog-writer.md index 06fd33c..d5ca883 100644 --- a/cookie/agents/changelog-writer.md +++ b/cookie/agents/changelog-writer.md @@ -1,6 +1,6 @@ --- name: changelog-writer -description: Ведёт CHANGELOG.md по правилам проекта. Добавляет записи о новых версиях. Использовать когда нужно задокументировать изменения перед релизом. +description: Ведёт CHANGELOG.md по правилам проекта. Добавляет записи о новых версиях. Знает расширенный markdown-рендер What's New (таблицы, ==mark==, чек-листы, код, цитаты). Использовать когда нужно задокументировать изменения перед релизом. tools: read, readLines, edit, write, glob, grep, bash maxTurns: 25 --- @@ -9,6 +9,11 @@ maxTurns: 25 Твоя задача — превратить набор изменений в правильно оформленную запись, которую корректно распарсит `src/main/changelog-parser.js` и покажет в What's New. +**Важно:** модалка What's New теперь использует расширенный markdown-рендер +(таблицы, `==жёлтая подсветка==`, чек-листы, блоки кода, цитаты, ссылки). +Полный список возможностей и правила использования — в разделе +«Возможности рендера What's New» ниже. + ## Первым делом **Прочитай `CHANGELOG_GUIDE.md`** — там описан точный формат, который понимает парсер. @@ -133,6 +138,77 @@ log(diff); - Детали — вложенными пунктами (отступ 2 пробела). - Не выдумывай фичи — только то, что реально видно в diff/логе. +## Возможности рендера What's New (актуально с v5.0.0) + +Модалка `whats-new` рендерит markdown сама (не через внешние библиотеки) и +понимает **расширенный набор синтаксиса**. Используй эти возможности, чтобы +запись выглядела живой и информативной. + +**Поддерживается:** + +| Синтаксис | Результат | Когда использовать | +| ----------------------------------- | ------------------------------ | --------------------------------------- | +| `==текст==` | 🟡 жёлтая подсветка (``) | Ключевые изменения, «обратите внимание» | +| `**текст**` | **жирный** | Названия фич, заголовки разделов | +| `_текст_` | _курсив_ | Акценты, примечания | +| `~~текст~~` | ~~зачёркнуто~~ | Устаревшие/удалённые вещи | +| `\`code\`` | инлайн-код | Команды, имена функций, пути | +| `\`\`\`js ... \`\`\`` | блок кода | Примеры конфигов, сниппеты | +| `\| a \| b \|` + `\| --- \| --- \|` | таблица GFM | Сравнение, матрицы фич, «до/после» | +| `> текст` | цитата | Предупреждения, важные заметки | +| `---` | горизонтальный разделитель | Визуальный отступ между темами | +| `1. 2. 3.` | нумерованный список | Пошаговые инструкции | +| `- [x]` / `- [ ]` | ☑ / ☐ чек-лист | Статус фич, чеклист обновления | +| `[text](https://url)` | ссылка | PR, issue, документация | +| `https://...` (голый) | автоссылка | Быстрые ссылки | +| `###` / `##` / `#` | h3 / h4 / h5 | Многоуровневые заголовки | + +**Практические примеры для CHANGELOG:** + +```markdown +## [5.0.2] - 2026-10-06 + +### Added + +- **Новая модалка What's New** — теперь понимает ==расширенный markdown==: + - таблицы GFM для сравнений; + - чек-листы `- [x] / - [ ]`; + - блоки кода с подсветкой `\`js`; + - жёлтую подсветку `==...==` для ключевых изменений. + +### Fixed + +- Починили рендер ссылок — теперь `[text](url)` и голые URL работают. + +### Changed + +- **Поведение кнопки закрытия** — было 1 сек, стало `3 сек` (`CLOSE_LOCK_MS`). + > Это сделано, чтобы пользователь успел прочитать нововведения. + +### Removed + +- ~~Старый markdown-рендер~~ — заменён на расширенный. +``` + +**Правила использования новых возможностей:** + +1. **Не злоупотребляй** — рендер остаётся минимальным, не превращай + CHANGELOG в HTML-вёрстку. `==mark==` — для 1–3 действительно важных + пунктов, не для всего подряд. +2. **Таблицы** — уместны для «до/после», матриц фич, списков сравнения. + Простой список из 3 пунктов таблицей делать не надо. +3. **Чек-листы** — только для реальных чек-листов (план обновления, шаги + миграции). Для перечисления фич используй обычные `-` пункты. +4. **Блоки кода** — с указанием языка (`\`js`, `\`bash`, `\`json`), + это чисто для читаемости в исходнике (подсветка не активна). +5. **Экранирование** — HTML в CHANGELOG экранируется автоматически + (`escapeHtml`), не пытайся вставлять `` — используй markdown. +6. **Парсер не знает про новый рендер** — `src/main/changelog-parser.js` + извлекает **только** `###` секции и `- `/`* ` пункты. Таблицы, + цитаты, блоки кода — это **свободный текст** внутри пункта или между + секциями. Если хочешь, чтобы строка попала в What's New — она должна + быть либо `### Section`, либо `- пункт`. + ## Перепиши CHANGELOG.md — оставь ТОЛЬКО последнюю версию **КРИТИЧНО:** файл `CHANGELOG.md` должен содержать **ровно одну** запись — @@ -198,6 +274,15 @@ log(check); Новая версия должна быть **первой** в выводе. +Дополнительно проверь, что запись корректно отрендерится в What's New: + +- все `==...==`, таблицы и чек-листы — **внутри** `### Section` и/или `- пункт`, + иначе парсер их не подхватит; +- заголовки `## [X.Y.Z] - YYYY-MM-DD` — строго этот формат, иначе версия не попадёт в диапазон; +- нет `Unreleased`, нет старых версий; +- если использовал таблицу — разделитель `| --- | --- |` обязателен, иначе рендер + выведет строку как простой текст с `|` посередине. + ## Жёсткие правила 1. **НЕ трогай `package.json`** — версию туда выставит CI из git-тега. @@ -209,6 +294,11 @@ log(check); 7. **Версия = последний git-тег + 1 в patch** (по умолчанию). Не выдумывай minor/major сам. 8. **НЕ вставляй "Unreleased"** — эта секция не покажется в What's New. 9. **НЕ добавляй историю старых версий** — ни `## [5.0.0]`, ни предыдущих. Только текущая. +10. **НЕ вставляй сырой HTML** — рендер экранирует теги. Используй только markdown + (`==mark==`, `**bold**`, таблицы, чек-листы) — см. раздел «Возможности рендера What's New». +11. **Расширенный markdown (таблицы, `==mark==`, чек-листы, `> цитаты`) — опционально.** + Их можно использовать для выразительности, но не в ущерб читаемости. Простой + список фич таблицей не оформляй. ## Формат отчёта родителю From 02b48182c8eaa0ea071289901e62f9a49c25b785 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 22:26:44 +0300 Subject: [PATCH 3/7] feat(telegram): allow refusing to answer AI questions Add a skip inline button to AI question messages in Telegram. Handling the qs_ callback prefix before token lookup; _handleQuestionSkip clears pending questions, updates the message to a cancelled state, and notifies the window via setOnQuestionCancelled so the pending promise rejects as user-cancelled. Add i18n strings question.skip and question.skipped (ru/en). --- botsrc/i18n.js | 4 +++ botsrc/index.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main/ipc.js | 28 +++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/botsrc/i18n.js b/botsrc/i18n.js index ca973a0..c2391d5 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -343,6 +343,8 @@ const STRINGS = { "question.closed": "Вопрос уже закрыт", "question.notFound": "Вариант не найден", "question.accepted": "Принято: {answer}", + "question.skip": "🚫 Отказаться отвечать", + "question.skipped": "Вопрос отменён", // ===== Уведомление об ответе AI ===== "ai.truncated": "…(обрезано, всего {n} симв.)", @@ -687,6 +689,8 @@ const STRINGS = { "question.closed": "Question already closed", "question.notFound": "Option not found", "question.accepted": "Accepted: {answer}", + "question.skip": "🚫 Refuse to answer", + "question.skipped": "Question cancelled", // ===== AI response notification ===== "ai.truncated": "…(truncated, {n} chars total)", diff --git a/botsrc/index.js b/botsrc/index.js index f3d71a7..705f0eb 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -298,12 +298,18 @@ const _planCallbackTokens = new Map(); const _longProcessTokens = new Map(); let _cbTokenCounter = 0; let _onQuestionAnswered = null; +let _onQuestionCancelled = null; /** Установить колбэк, вызываемый при ответе на вопрос из TG. */ function setOnQuestionAnswered(fn) { _onQuestionAnswered = typeof fn === "function" ? fn : null; } +/** Установить колбэк, вызываемый при отказе отвечать на вопрос из TG. */ +function setOnQuestionCancelled(fn) { + _onQuestionCancelled = typeof fn === "function" ? fn : null; +} + /** Экранирование для HTML в тексте вопроса. */ function _qEsc(s) { return String(s == null ? "" : s) @@ -357,6 +363,11 @@ async function askQuestion(requestId, questions) { keyboard.push([{ text: qi + 1 + ") " + label, callback_data: token }]); }); }); + // Кнопка "Отказаться отвечать" — отменяет весь вопрос целиком. + entry.skipToken = "qs_" + requestId; + keyboard.push([ + { text: _t("question.skip"), callback_data: entry.skipToken }, + ]); const res = await telegramBot.sendMessage(text, { parseMode: "HTML", @@ -461,6 +472,13 @@ async function _handleCallback(chatId, data, cbq) { await _handleLongProcessCallback(token, longProcRef, cbq); return; } + + // Кнопка "Отказаться отвечать" — отменяет вопрос целиком. + if (token.startsWith("qs_")) { + await _handleQuestionSkip(token.slice(3), cbq); + return; + } + const ref = _callbackTokens.get(token); if (!ref) { await telegramBot.answerCallbackQuery(cbq.id); @@ -795,6 +813,48 @@ async function _handleLongProcessCallback(token, ref, cbq) { } /** Перерисовать сообщение с вопросами: отметить выбранное, убрать лишние кнопки. */ +/** Обработать нажатие "Отказаться отвечать": отменить вопрос целиком. */ +async function _handleQuestionSkip(requestId, cbq) { + const entry = _pendingQuestions.get(requestId); + await telegramBot.answerCallbackQuery(cbq.id, { + text: _t("question.skipped"), + }); + if (!entry) return; + + // Очищаем запись и все связанные токены. + _pendingQuestions.delete(requestId); + for (const row of entry.tokens || []) { + for (const t of row || []) _callbackTokens.delete(t); + } + if (entry.skipToken) _callbackTokens.delete(entry.skipToken); + + // Обновляем сообщение: заголовок + пометка "отменено", кнопок больше нет. + if (entry.messageId) { + const lines = [_t("question.title")]; + entry.questions.forEach((q, qi) => { + lines.push(""); + lines.push(qi + 1 + ". " + _qEsc(q.question)); + }); + lines.push(""); + lines.push("🚫 " + _t("question.skipped")); + try { + await telegramBot.editMessageText(entry.messageId, lines.join("\n"), { + parseMode: "HTML", + replyMarkup: { inline_keyboard: [] }, + }); + } catch (_) {} + } + + // Сообщаем окну, что вопрос отменён (reject промиса). + if (typeof _onQuestionCancelled === "function") { + try { + _onQuestionCancelled(requestId); + } catch (err) { + _log("error", "onQuestionCancelled error:", err.message); + } + } +} + async function _refreshQuestionMessage(requestId, entry) { if (!entry.messageId) return; const lines = [_t("question.title")]; @@ -819,6 +879,12 @@ async function _refreshQuestionMessage(requestId, entry) { keyboard.push([{ text: qi + 1 + ") " + label, callback_data: token }]); }); }); + // Кнопка "Отказаться отвечать" остаётся, пока вопрос не закрыт. + if (entry.skipToken) { + keyboard.push([ + { text: _t("question.skip"), callback_data: entry.skipToken }, + ]); + } await telegramBot.editMessageText(entry.messageId, lines.join("\n"), { parseMode: "HTML", @@ -3795,6 +3861,7 @@ module.exports = { notifyLongProcess, askQuestion, setOnQuestionAnswered, + setOnQuestionCancelled, _handleIncoming, _handleCallback, _sendToChat, diff --git a/src/main/ipc.js b/src/main/ipc.js index f3ba4ac..13ddc55 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -68,6 +68,28 @@ function requestUserQuestion(sender, questions) { * Резолв вопроса, отвеченного в Telegram (вызывается из botsrc). * @returns {boolean} true, если нашли ожидающий вопрос. */ +/** + * Отмена вопроса из Telegram: пользователь нажал "Отказаться отвечать". + * Отклоняет ожидающий promise и просит окно закрыть диалог. + * @returns {boolean} true, если нашли ожидающий вопрос. + */ +function cancelUserQuestionFromTelegram(requestId) { + for (const [key, pending] of pendingUserQuestions) { + if (key.endsWith(":" + requestId)) { + pendingUserQuestions.delete(key); + try { + const senderId = Number(key.split(":")[0]); + const wc = require("electron").webContents.fromId(senderId); + if (wc && !wc.isDestroyed()) + wc.send("ask-user-question-resolved", { requestId }); + } catch (_) {} + pending.reject(new Error("Пользователь отменил вопрос")); + return true; + } + } + return false; +} + function resolveUserQuestionFromTelegram(requestId, answers) { for (const [key, pending] of pendingUserQuestions) { if (key.endsWith(":" + requestId)) { @@ -353,6 +375,12 @@ function registerIpcHandlers() { resolveUserQuestionFromTelegram(requestId, answers); }); } + // Мост Telegram → окно: отказ отвечать отклоняет тот же promise. + if (typeof bot.setOnQuestionCancelled === "function") { + bot.setOnQuestionCancelled((requestId) => { + cancelUserQuestionFromTelegram(requestId); + }); + } } catch (_) {} // Решение пользователя по плану (согласие/отказ из окна). From 73b4efcebff3c286b227b61c86e878450d983f56 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 22:56:40 +0300 Subject: [PATCH 4/7] feat(telegram): add /toggles command for DeepSeek search and deep-think Add inline keyboard to toggle Smart Search and Deep Think on the DeepSeek page via injectPageJS. New helpers _readTogglesState, _toggleDeepSeek, _togglesKeyboard, _cmdToggles; callback handlers for st_tg_search/st_tg_think/st_tg_refresh. Commands act on the parent window, not a subagent. i18n strings in ru/en. --- botsrc/i18n.js | 26 +++++++ botsrc/index.js | 181 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/botsrc/i18n.js b/botsrc/i18n.js index c2391d5..a5c9c5d 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -45,6 +45,7 @@ const STRINGS = { "help.cmd.files": "/files <hash> — файлы коммита", "help.cmd.todos": "/todos — список задач активного окна", "help.cmd.agents": "/agents — список субагентов проекта", + "help.cmd.toggles": "/toggles — тумблеры поиска и раздумий DeepSeek", "help.cmd.screen": "/screen — скриншот активного окна", "help.cmd.cancel": "/cancel — отменить ввод / подтверждение", "help.cmd.help": "/help — эта справка", @@ -298,6 +299,18 @@ const STRINGS = { "agents.cmd.hint": '💡 Делегируйте задачу: run_agent("name", "задача")', "agents.cmd.more": "…и ещё {n}", + "toggles.title": "🎛 Тумблеры DeepSeek", + "toggles.hint": "Нажми, чтобы включить/выключить.", + "toggles.search": "🔍 Умный поиск", + "toggles.think": "🧠 Глубокие раздумья", + "toggles.on": "вкл", + "toggles.off": "выкл", + "toggles.unknown": "?", + "toggles.notFound": + "❌ Кнопки не найдены на странице. Открой чат DeepSeek и повтори.", + "toggles.noWindow": "📂 Нет активного окна.", + "toggles.switched": "Переключено: {name} → {state}", + "toggles.failed": "Не удалось переключить: {err}", // ===== Approval ===== "approval.title": "🔐 Подтверждение команды", @@ -390,6 +403,7 @@ const STRINGS = { "help.cmd.files": "/files <hash> — commit files", "help.cmd.todos": "/todos — tasks of the active window", "help.cmd.agents": "/agents — project subagents list", + "help.cmd.toggles": "/toggles — DeepSeek search & deep-think toggles", "help.cmd.screen": "/screen — screenshot of the active window", "help.cmd.cancel": "/cancel — cancel input / approval", "help.cmd.help": "/help — this help", @@ -644,6 +658,18 @@ const STRINGS = { "agents.cmd.hint": '💡 Delegate a task: run_agent("name", "task")', "agents.cmd.more": "…and {n} more", + "toggles.title": "🎛 DeepSeek toggles", + "toggles.hint": "Tap to enable/disable.", + "toggles.search": "🔍 Smart Search", + "toggles.think": "🧠 Deep Think", + "toggles.on": "on", + "toggles.off": "off", + "toggles.unknown": "?", + "toggles.notFound": + "❌ Buttons not found on page. Open a DeepSeek chat and try again.", + "toggles.noWindow": "📂 No active window.", + "toggles.switched": "Toggled: {name} → {state}", + "toggles.failed": "Failed to toggle: {err}", // ===== Approval ===== "approval.title": "🔐 Command approval", diff --git a/botsrc/index.js b/botsrc/index.js index 705f0eb..db22509 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -1408,6 +1408,46 @@ async function _handleSettingsCallback(data, cbq) { return; } + // ---- Тумблеры DeepSeek ---- + if ( + data === "st_tg_search" || + data === "st_tg_think" || + data === "st_tg_refresh" + ) { + if (data === "st_tg_refresh") { + await telegramBot.answerCallbackQuery(cbq.id, { + text: _t("common.refresh"), + }); + await _cmdToggles(chatId, msgId); + return; + } + const which = data === "st_tg_search" ? "search" : "think"; + const name = + which === "search" ? _t("toggles.search") : _t("toggles.think"); + const res = await _toggleDeepSeek(which); + if (!res.ok) { + await telegramBot.answerCallbackQuery(cbq.id, { + text: _t("toggles.failed", { err: res.error || "?" }), + showAlert: true, + }); + return; + } + const st = res.after ? _t("toggles.on") : _t("toggles.off"); + await telegramBot.answerCallbackQuery(cbq.id, { + text: _t("toggles.switched", { name: name, state: st }), + }); + // Обновим клавиатуру с новым состоянием. + const state = await _readTogglesState(); + if (msgId && state) { + const text = _t("toggles.title") + "\n\n" + _t("toggles.hint"); + await telegramBot.editMessageText(msgId, text, { + parseMode: "HTML", + replyMarkup: _togglesKeyboard(state), + }); + } + return; + } + // ---- Выбор языка бота (/start) ---- if (data.startsWith("st_lang_")) { const lang = i18n.normalizeLang(data.slice("st_lang_".length)); @@ -1846,6 +1886,139 @@ async function _cmdTodos(chatId, page) { }); } +/** + * Прочитать состояние тумблеров DeepThink/Search через injectPageJS. + * Возвращает { search: bool|null, think: bool|null }. + */ +async function _readTogglesState() { + const win = windowState.getParentWindow + ? windowState.getParentWindow() + : windowState.getMainWindow(); + if (!win || win.isDestroyed()) return null; + + const script = `(function(){ + const find = (needle) => { + const all = document.querySelectorAll('.ds-toggle-button'); + for (const el of all) { + const t = (el.textContent || '').trim().toLowerCase(); + if (t.indexOf(needle) >= 0) return el; + } + return null; + }; + const think = find('deepthink'); + const search = find('search'); + const state = (el) => el ? el.getAttribute('aria-pressed') === 'true' : null; + return { think: state(think), search: state(search) }; + })()`; + + try { + const res = await win.webContents.executeJavaScript(script, true); + return res || { think: null, search: null }; + } catch (err) { + _log("error", "_readTogglesState error:", err.message); + return null; + } +} + +/** + * Переключить один тумблер. name: 'search' | 'think'. + * Возвращает { ok: bool, before: bool|null, after: bool|null, error?: string }. + */ +async function _toggleDeepSeek(name) { + const win = windowState.getParentWindow + ? windowState.getParentWindow() + : windowState.getMainWindow(); + if (!win || win.isDestroyed()) return { ok: false, error: "noWindow" }; + + const needle = name === "think" ? "deepthink" : "search"; + const script = `(function(){ + const find = (n) => { + const all = document.querySelectorAll('.ds-toggle-button'); + for (const el of all) { + const t = (el.textContent || '').trim().toLowerCase(); + if (t.indexOf(n) >= 0) return el; + } + return null; + }; + const el = find('${needle}'); + if (!el) return { ok: false, error: 'notFound' }; + const before = el.getAttribute('aria-pressed') === 'true'; + el.click(); + return { ok: true, before: before }; + })()`; + + try { + const res = await win.webContents.executeJavaScript(script, true); + if (!res || !res.ok) + return { ok: false, error: (res && res.error) || "unknown" }; + // Даём React время отрисовать новое состояние + await new Promise((r) => setTimeout(r, 250)); + const st = await _readTogglesState(); + const after = st ? (name === "think" ? st.think : st.search) : null; + return { ok: true, before: res.before, after: after }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + +/** Inline-клавиатура с тумблерами. */ +function _togglesKeyboard(state) { + const fmt = (on) => + on === true + ? "✅ " + _t("toggles.on") + : on === false + ? "⬜ " + _t("toggles.off") + : "❔ " + _t("toggles.unknown"); + return { + inline_keyboard: [ + [ + { + text: _t("toggles.search") + ": " + fmt(state ? state.search : null), + callback_data: "st_tg_search", + }, + ], + [ + { + text: _t("toggles.think") + ": " + fmt(state ? state.think : null), + callback_data: "st_tg_think", + }, + ], + [{ text: "🔄 " + _t("common.refresh"), callback_data: "st_tg_refresh" }], + ], + }; +} + +/** /toggles — показать текущее состояние тумблеров. */ +async function _cmdToggles(chatId, msgId) { + const win = windowState.getParentWindow + ? windowState.getParentWindow() + : windowState.getMainWindow(); + if (!win || win.isDestroyed()) { + return telegramBot.sendMessage(_t("toggles.noWindow"), { + parseMode: "HTML", + }); + } + const state = await _readTogglesState(); + if (!state || (state.search == null && state.think == null)) { + return telegramBot.sendMessage(_t("toggles.notFound"), { + parseMode: "HTML", + }); + } + const text = _t("toggles.title") + "\n\n" + _t("toggles.hint"); + const keyboard = _togglesKeyboard(state); + if (msgId) { + const r = await telegramBot.editMessageText(msgId, text, { + parseMode: "HTML", + replyMarkup: keyboard, + }); + if (r && r.success) return r; + } + return telegramBot.sendMessage(text, { + parseMode: "HTML", + replyMarkup: keyboard, + }); +} + /** /agents — список субагентов проекта (из cookie/agents/*.md). */ async function _cmdAgents() { let agents = []; @@ -2365,6 +2538,7 @@ async function _cmdHelp() { _t("help.cmd.files"), _t("help.cmd.todos"), _t("help.cmd.agents"), + _t("help.cmd.toggles"), _t("help.cmd.screen"), _t("help.cmd.cancel"), _t("help.cmd.help"), @@ -2862,6 +3036,11 @@ async function _handleIncoming(chatId, text, msg) { await _cmdAgents(); return; } + // Команда /toggles — управление тумблерами DeepSeek (Search / DeepThink). + if (cmd === "/toggles" || cmd === "/toggle") { + await _cmdToggles(chatId); + return; + } // ---- Ожидание ввода значения настройки ---- if (_settingsWaiting.has(chatId)) { @@ -3750,6 +3929,7 @@ async function _registerCommands() { diagnostics: "диагностика", todos: "список задач", agents: "субагенты проекта", + toggles: "тумблеры DeepSeek", screen: "скриншот окна", cancel: "отменить ввод", }, @@ -3769,6 +3949,7 @@ async function _registerCommands() { diagnostics: "diagnostics", todos: "todo list", agents: "project subagents", + toggles: "DeepSeek toggles", screen: "window screenshot", cancel: "cancel", }, From 3c4fc4c912ea52acecd8430ce88ed4ff5a8fca84 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 22:56:48 +0300 Subject: [PATCH 5/7] refactor(create_agent): build tool list dynamically from registry Replace hardcoded prompt section with buildAgentInstruction: the available tools are now read from the registry and grouped by category, so the instruction stays in sync automatically. Also expand the guidance for the AI (required/optional fields, whitelist examples, hard rules) with real line breaks. --- tools/CreateAgentTool.js | 122 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/tools/CreateAgentTool.js b/tools/CreateAgentTool.js index de2ea6b..7f870e4 100644 --- a/tools/CreateAgentTool.js +++ b/tools/CreateAgentTool.js @@ -46,6 +46,121 @@ function buildAgentFile(input) { return fm.join("\n"); } +/** + * Собрать текст инструкции для create_agent. + * Список инструментов берётся из реестра — не хардкодится. + */ +function buildAgentInstruction() { + // Категории инструментов. Ключ — заголовок, значение — массив имён из реестра. + // Всё, что не попало ни в одну категорию, будет выведено в «Прочее». + const CATEGORIES = [ + ["Чтение", ["read", "read_lines", "file_read"]], + ["Запись", ["write", "file_write", "edit", "file_edit"]], + ["Удаление", ["file_delete"]], + ["Поиск", ["glob", "file_glob", "grep", "file_grep"]], + ["Команды", ["bash", "pwsh"]], + ["Задачи", ["todo_write", "todo_edit", "todo_delete"]], + ["Web / браузер", ["web_fetch", "open_browser_window", "inject_js", "inject_page_js"]], + ["Данные", ["mysql", "city_time"]], + ["MCP", ["mcp_call", "mcp_list_servers", "mcp_get_tools"]], + ["Skill", ["skill_list", "skill_load", "skill_execute"]], + ["UI и файлы", ["ask_user_question", "read_photo", "attach_file", "attach_telegram"]], + ["Память", ["memory_save", "memory_read", "memory_clear"]], + ["Plan", ["exit_plan_mode"]], + ["Субагенты", ["run_agent", "list_agents", "read_agent", "create_agent"]], + ]; + + // Все доступные инструменты (из реестра). + let allNames = []; + try { + const { registry } = require("./index"); + allNames = registry.listNames(); + } catch (_) {} + const allSet = new Set(allNames); + + // Собираем секции: только реально существующие инструменты. + const used = new Set(); + const lines = []; + for (const [label, names] of CATEGORIES) { + const present = names.filter((n) => allSet.has(n)); + if (present.length === 0) continue; + present.forEach((n) => used.add(n)); + lines.push("**" + label + ":** " + present.map((n) => "`" + n + "`").join(", ")); + } + // Остальные — в «Прочее». + const rest = allNames.filter((n) => !used.has(n)); + if (rest.length > 0) { + lines.push("**Прочее:** " + rest.map((n) => "`" + n + "`").join(", ")); + } + const toolsBlock = lines.join("\n"); + + // Инструкция. Список инструментов вставляем динамически. + return [ + "## create_agent — создание субагента", + "", + "Используй create_agent(...) когда задача повторяющаяся, требует отдельного", + "контекста или специализации. После создания агент **сразу** доступен для", + "run_agent по имени.", + "", + "### Обязательные поля", + "", + "- **name** — латиница, цифры, дефис или подчёркивание, 1..64 символов.", + " Короткое и говорящее: code-reviewer, pro-lua, sql-doctor, test-runner.", + " Без пробелов и кириллицы. Имя = имя файла .md.", + "- **description** — одна строка (<=200), «КОГДА делегировать». Это", + " единственное, что видит родительский AI в системном промпте. Пиши", + " как триггер: «Проверка качества кода. Использовать после написания».", + "- **systemPrompt** — тело промпта агента (без frontmatter — он генерится", + " автоматически). Это то, КЕМ агент является и КАК работает. Структура:", + " 1. Роль: «Ты — эксперт по ревью / DBA / технический писатель...»", + " 2. Что делает: список конкретных действий.", + " 3. Как работает: шаги, edge-cases, принципы.", + " 4. Формат ответа: что вернуть родителю (структура, объём).", + " 5. Жёсткие правила: чего НЕ делать.", + "", + "### Опциональные поля", + "", + "- **tools** — массив ТОЧНЫХ имён инструментов из списка ниже. Если не", + " указывать — агенту разрешены ВСЕ (обычно не то, что нужно: давать", + " доступ к bash/mysql/file_delete стоит только если агент с ними работает).", + " Опечатка в имени = ошибка создания. Регистр важен.", + "- **maxTurns** — лимит шагов (1..1000). Один шаг = одно сообщение AI с", + " cuckoo-блоками. Ориентиры: 5 — простая задача, 20 — стандарт,", + " 40+ — много git/серверных операций. Не указывай — без лимита.", + "- **overwrite** — перезаписать существующего (по умолчанию false).", + "", + "### Доступные инструменты (используй ТОЧНЫЕ имена)", + "", + toolsBlock, + "", + "⚠ Не давай агенту run_agent — во время выполнения он заблокирован", + "(anti-recursion), но лучше не включать его в whitelist.", + "", + "### Примеры whitelist", + "", + "Только чтение (безопасный): read, read_lines, glob, grep", + "Пишущий (рефакторинг): read, edit, write, glob, grep, bash", + "Git-мастер: read, read_lines, bash, grep, glob", + "Тестировщик: read, read_lines, bash, glob, grep", + "", + "### Жёсткие правила", + "", + "1. Не давай bash/pwsh/mysql/file_delete без реальной необходимости —", + " принцип наименьших привилегий.", + "2. description обязателен — без него агент будет пропущен сканером.", + "3. systemPrompt — это инструкция для ДРУГОГО AI, не для пользователя.", + " Пиши во втором лице: «Ты — ...», «Твоя задача — ...».", + "4. Не создавай агента «на всякий случай» — только когда задача", + " повторяющаяся или требует отдельного контекста.", + "5. Не забывай overwrite: true при обновлении существующего агента.", + "", + "### После создания", + "", + "Агент сразу доступен — вызывай run_agent(name, task) в том же ответе.", + "Проверить список: list_agents(). Прочитать промпт: read_agent(name).", + ].join("\n"); +} + class CreateAgentTool extends Tool { constructor() { super( @@ -85,13 +200,12 @@ class CreateAgentTool extends Tool { } getPromptSection() { + // Жирная инструкция + ДИНАМИЧЕСКИЙ список инструментов из реестра. + // При добавлении нового tool'а инструкция обновится сама. return { name: "tool:create_agent", order: 114, - text: - "Используй create_agent(...) чтобы создать нового субагента под задачу. " + - "После создания агент сразу доступен для run_agent. Создавай только когда " + - "задача повторяющаяся или требует отдельного контекста.", + text: buildAgentInstruction(), }; } From 01d5797c6b00b1fe047b930add39acc238f0bb12 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 22:56:56 +0300 Subject: [PATCH 6/7] feat(agents): add page-scripter subagent Subagent focused on browser-page JS injection (inject_page_js/inject_js) and MCP calls (playwright, VideoAudioServer). No file, shell or git access. --- cookie/agents/page-scripter.md | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 cookie/agents/page-scripter.md diff --git a/cookie/agents/page-scripter.md b/cookie/agents/page-scripter.md new file mode 100644 index 0000000..083c71d --- /dev/null +++ b/cookie/agents/page-scripter.md @@ -0,0 +1,52 @@ +--- +name: page-scripter +description: Инжект JS в страницу браузера (inject_page_js / inject_js) и вызов MCP-инструментов (playwright, VideoAudioServer). Без доступа к файлам, shell и git. +tools: inject_page_js, inject_js, open_browser_window, mcp_list_servers, mcp_get_tools, mcp_call +maxTurns: 40 +--- + +Ты — агент, работающий **исключительно** со страницей в браузере и через MCP. Больше ничего делать не умеешь — и не пытайся. + +## Что тебе доступно + +**Инжект JS в страницу (основной инструмент):** +- `injectPageJS(code, windowId?)` — выполнить JS в main world страницы (как F12). Без `windowId` — текущее окно с чатом; с `windowId` — окно, открытое через `openBrowserWindow`. Доступны DOM, `window`, `localStorage`, но **не** `window.electronAPI`. Поддерживает `await`; возвращай JSON-совместимые значения. +- `injectJS(windowId, code)` — выполнить JS в конкретном Electron-окне (по `windowId` от `openBrowserWindow`). +- `openBrowserWindow(url, options?)` — открыть страницу в отдельном окне, вернёт `windowId`. + +**MCP:** +- `mcpListServers()` — список серверов и статус. +- `mcpGetTools(server)` — инструменты сервера с параметрами. +- `mcpCall(server, tool, args)` — вызвать MCP-инструмент. + +MCP-серверы: **playwright** (25 тулов: browser_navigate, browser_evaluate, browser_click, browser_fill_form, browser_snapshot, browser_take_screenshot, browser_network_requests, browser_console_messages и др.), **VideoAudioServer** (27 тулов: extract_audio_from_video, trim_video, convert_video_format, add_subtitles и др.). Сверяйся с `mcpGetTools` перед вызовом. + +## Чего НЕЛЬЗЯ + +- Читать/писать файлы проекта, shell/bash/pwsh, git, поиск по коду. +- Любые тулы, кроме перечисленных. + +Если задача вне набора — верни, что не можешь выполнить, и какой инструмент нужен. + +## Как работаешь + +1. Пойми цель. +2. Проверь окружение: для MCP — `mcpGetTools(server)`; для окна — убедись, что есть `windowId`. +3. Сделай инжект/вызов: код безопасный, идемпотентный, возвращает JSON; `window.electronAPI` недоступен. +4. Проверь результат. + +## Правила + +- Только необходимое, без «на всякий случай». +- Не выдумывай имена MCP-тулов и параметры. +- `injectPageJS` упал — 2–3 попытки с исправлением. +- Возвращай сжатый результат. + +## Формат вывода + +``` +Готово. +Что сделал: <кратко> +Результат: <данные / изменения в DOM> +Замечания: <если есть> +``` From c2cf6efa8deea0c3b94ec5c67fa9394b2ea18bcb Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 6 Oct 2026 23:16:06 +0300 Subject: [PATCH 7/7] feat(session): add rename-session IPC and auto-rename subagent chats Add rename-session IPC handler that renames DeepSeek chats via sidebar DOM (hover -> menu -> rename -> input -> Enter; selectors verified with MCP Playwright). Expose renameSession(sessionId, title) in preload API. Auto-rename subagent chat to [SUB-AGENT] when sessionId appears, using pendingLineage. --- src/main/ipc.js | 93 ++++++++++++++++++++++++++++++++++++++ src/main/session-store.js | 95 +++++++++++++++++++++++++++++++++++++++ src/preload/api.js | 5 +++ 3 files changed, 193 insertions(+) diff --git a/src/main/ipc.js b/src/main/ipc.js index 13ddc55..3e67a75 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -686,6 +686,99 @@ function registerIpcHandlers() { } }); + // Переименовать чат (DeepSeek и др. — через DOM сайдбара). + // Скрипт: находим ссылку a[href*="/a/chat/s/"], открываем её меню, + // жмём первый пункт "Переименовать" (.ds-dropdown-menu-option:nth-child(1)), + // заполняем input.ds-input__input новым именем и подтверждаем (Enter). + // Селекторы подтверждены разведкой через MCP Playwright: + // - кнопка меню: div[role="button"].ds-button внутри ссылки + // - меню: div.ds-dropdown-menu[role="menu"] + // - пункт Rename: .ds-dropdown-menu-option:nth-child(1) + // - input: input.ds-input__input + // - подтверждение: Enter (кнопки Save в DOM нет) + ipcMain.handle("rename-session", async (event, { sessionId, title } = {}) => { + if (!sessionId) return { success: false, error: "缺少会话ID" }; + if (!title || !String(title).trim()) { + return { success: false, error: "缺少新名称" }; + } + 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 safeTitle = JSON.stringify(String(title).trim()); + const script = `(async function(){ + const sid = ${safeId}; + const newTitle = ${safeTitle}; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + // 1) Найти ссылку сессии в сайдбаре. + const link = document.querySelector('a[href*="/a/chat/s/' + sid + '"]'); + if (!link) return { success: false, error: "chat-link-not-found" }; + + // 2) Навести события hover — кнопка меню появляется только при hover. + try { + link.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); + } catch (_) {} + await sleep(200); + + // 3) Открыть контекстное меню (кнопка "..." внутри ссылки). + const menuBtn = link.querySelector('div[role="button"]'); + if (!menuBtn) return { success: false, error: "menu-button-not-found" }; + menuBtn.click(); + await sleep(400); + + // 4) Пункт "Переименовать" — первый в меню (не зависит от языка). + const menu = document.querySelector('div.ds-dropdown-menu[role="menu"]'); + if (!menu) return { success: false, error: "menu-not-found" }; + const renameOpt = menu.querySelector('.ds-dropdown-menu-option'); + if (!renameOpt) return { success: false, error: "rename-option-not-found" }; + renameOpt.click(); + await sleep(400); + + // 5) Input для нового имени. + const input = document.querySelector('input.ds-input__input'); + if (!input) return { success: false, error: "input-not-found" }; + input.focus(); + // Записываем через native setter, чтобы React заметил изменение. + const proto = Object.getPrototypeOf(input); + const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + if (setter) setter.call(input, newTitle); + else input.value = newTitle; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + await sleep(150); + + // 6) Подтверждение — Enter (кнопки Save нет). + const enterOpts = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }; + input.dispatchEvent(new KeyboardEvent("keydown", enterOpts)); + input.dispatchEvent(new KeyboardEvent("keypress", enterOpts)); + input.dispatchEvent(new KeyboardEvent("keyup", enterOpts)); + await sleep(500); + + // 7) Проверка: прочитать новое имя ссылки. + const after = document.querySelector('a[href*="/a/chat/s/' + sid + '"]'); + const finalName = after ? (after.textContent || "").trim() : null; + return { success: true, finalName: finalName }; + })()`; + + try { + const result = await win.webContents.executeJavaScript(script, true); + if (!result || result.success !== true) { + return { + success: false, + error: (result && result.error) || "rename-failed", + }; + } + return { success: true, finalName: result.finalName || null }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Открыть новый чат (переход на домашнюю страницу провайдера). ipcMain.handle("new-chat", async (event) => { const ctx = windowState.getContextByWebContents(event.sender); diff --git a/src/main/session-store.js b/src/main/session-store.js index b4d1a77..23b5424 100644 --- a/src/main/session-store.js +++ b/src/main/session-store.js @@ -88,6 +88,101 @@ function createSessionStore(profileId, storeDir, windowState) { state.currentSessionId = sessionId; console.log("[Cookie Code][" + profileId + "] 当前会话ID: " + sessionId); + // Авто-переименование чата субагента: как только появился sessionId + // и в state лежит pendingLineage (kind=subagent) — переименовываем + // чат в "[SUB-AGENT] " через IPC rename-session. + // Откладываем на 3 сек, чтобы страница успела отрисовать сайдбар + // и DeepSeek зарегистрировал новую сессию в списке. + try { + const lin = state.pendingLineage; + if (lin && lin.kind === "subagent" && lin.agentName) { + const newTitle = "[SUB-AGENT] " + lin.agentName; + const subWin = win; + console.log( + "[Cookie Code][" + + profileId + + "] Авто-переименование субагента: " + + newTitle + + " (" + + sessionId + + ")", + ); + setTimeout(() => { + try { + if (!subWin || subWin.isDestroyed()) return; + // Ищем обработчик rename-session через ipcMain.handle — + // вызываем напрямую через webContents.executeJavaScript + // (тот же DOM-скрипт, что в ipc.js rename-session). + const safeId = JSON.stringify(String(sessionId)); + const safeTitle = JSON.stringify(String(newTitle)); + const script = `(async function(){ + const sid = ${safeId}; + const newTitle = ${safeTitle}; + 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" }; + try { + link.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); + } catch (_) {} + await sleep(200); + const menuBtn = link.querySelector('div[role="button"]'); + if (!menuBtn) return { success: false, error: "menu-button-not-found" }; + menuBtn.click(); + await sleep(400); + const menu = document.querySelector('div.ds-dropdown-menu[role="menu"]'); + if (!menu) return { success: false, error: "menu-not-found" }; + const renameOpt = menu.querySelector('.ds-dropdown-menu-option'); + if (!renameOpt) return { success: false, error: "rename-option-not-found" }; + renameOpt.click(); + await sleep(400); + const input = document.querySelector('input.ds-input__input'); + if (!input) return { success: false, error: "input-not-found" }; + input.focus(); + const proto = Object.getPrototypeOf(input); + const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + if (setter) setter.call(input, newTitle); + else input.value = newTitle; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + await sleep(150); + const enterOpts = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }; + input.dispatchEvent(new KeyboardEvent("keydown", enterOpts)); + input.dispatchEvent(new KeyboardEvent("keypress", enterOpts)); + input.dispatchEvent(new KeyboardEvent("keyup", enterOpts)); + await sleep(500); + const after = document.querySelector('a[href*="/a/chat/s/' + sid + '"]'); + return { success: true, finalName: after ? (after.textContent || "").trim() : null }; + })()`; + subWin.webContents + .executeJavaScript(script, true) + .then((r) => { + if (r && r.success) { + console.log( + "[Cookie Code] Субагент переименован в: " + + (r.finalName || newTitle), + ); + } else { + console.warn( + "[Cookie Code] Не удалось переименовать субагента: " + + ((r && r.error) || "unknown"), + ); + } + }) + .catch((err) => { + console.warn( + "[Cookie Code] rename subagent error:", + err.message, + ); + }); + } catch (_) {} + }, 3000); + // Чистим pendingLineage — переименование одноразовое. + state.pendingLineage = null; + } + } catch (_) {} + if (state.pendingProjectDir) { saveSessionDirMapping(sessionId, state.pendingProjectDir); state.selectedProjectDir = state.pendingProjectDir; diff --git a/src/preload/api.js b/src/preload/api.js index 34a9fb6..3495140 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -84,6 +84,11 @@ let electronAPI = { deleteChat: (sessionId) => { return ipcRenderer.invoke("chat-delete", { sessionId }); }, + // Переименовать чат. Возвращает { success, finalName } — имя, прочитанное + // из DOM после сохранения. title — новое название, sessionId — UUID. + renameSession: (sessionId, title) => { + return ipcRenderer.invoke("rename-session", { sessionId, title }); + }, // ========== Статистика использования ========== statsGetSummary: (days) => { return ipcRenderer.invoke("stats-get-summary", { days });