From 5de1829e7ca1d627ce3e89105ce36e1c5183fb14 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:47:57 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(telegram):=20=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B2=20Telegram=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20rate-limit=20DeepSeek=20(429/40029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main-world перехват 429 и бизнес-кода 40029 IP_ACCESS_RESTRICTED в fetch/XHR completion - preload слушает postMessage 'rate-limit' с антиспамом 60с - IPC telegram-rate-limit -> botsrc.notifyRateLimit() - КАПС-уведомление с просьбой подождать 5-20 минут (i18n ru/en) - /deepseek-bundle/ в .gitignore --- .gitignore | 3 + botsrc/i18n.js | 20 +++ botsrc/index.js | 38 ++++++ src/main/ipc.js | 14 ++ src/main/token-interceptor-inject.js | 41 ++++++ src/preload/api.js | 2 + src/preload/dom/token-interceptor.js | 197 ++++++++++++++++++++------- 7 files changed, 262 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index a207b48..f180931 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ test/coverage.lcov # Playwright MCP .playwright-mcp/ + +# Скачанный бандл DeepSeek (анализ ошибок, не коммитим) +/deepseek-bundle/ /tiktok /.cuckoo/skills # Проектная память — личные заметки AI, не коммитим. diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 9610957..e11635d 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -476,6 +476,16 @@ const STRINGS = { // ===== Уведомление об ответе AI ===== "ai.truncated": "…(обрезано, всего {n} симв.)", + // ===== Rate limit (превышение лимита запросов DeepSeek) ===== + "rateLimit.title": "⛔️⛔️⛔️ ВНИМАНИЕ! ⛔️⛔️⛔️", + "rateLimit.exceeded": "ТЫ ПРЕВЫСИЛ ЛИМИТ ЗАПРОСОВ К DEEPSEEK!", + "rateLimit.tooOften": "СЛИШКОМ ЧАСТО ОТПРАВЛЯЕШЬ СООБЩЕНИЯ!", + "rateLimit.wait": "⏳ ПОДОЖДИ {min}–{max} МИНУТ, ПОТОМ ПРОБУЙ СНОВА!", + "rateLimit.serverHint": "(Сервер просит подождать минимум ~{retry} мин.)", + "rateLimit.dontSpam": + "Не отправляй сообщения подряд — это только продлит блокировку!", + "rateLimit.statusCode": "Код ответа: {status}", + // ===== Тестовое сообщение ===== "test.send": "👋 Cookie Code: тестовое уведомление. Всё работает.", }, @@ -946,6 +956,16 @@ const STRINGS = { // ===== AI response notification ===== "ai.truncated": "…(truncated, {n} chars total)", + // ===== Rate limit (DeepSeek request limit exceeded) ===== + "rateLimit.title": "⛔️⛔️⛔️ WARNING! ⛔️⛔️⛔️", + "rateLimit.exceeded": "YOU HAVE EXCEEDED THE DEEPSEEK REQUEST LIMIT!", + "rateLimit.tooOften": "YOU ARE SENDING MESSAGES TOO OFTEN!", + "rateLimit.wait": "⏳ WAIT {min}–{max} MINUTES, THEN TRY AGAIN!", + "rateLimit.serverHint": "(Server asks to wait at least ~{retry} min.)", + "rateLimit.dontSpam": + "Do not send messages back-to-back — it will only extend the block!", + "rateLimit.statusCode": "Response code: {status}", + // ===== Test message ===== "test.send": "👋 Cookie Code: test notification. All good.", }, diff --git a/botsrc/index.js b/botsrc/index.js index 94e8b9e..4350b04 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -5461,6 +5461,43 @@ async function notifyAIResponse(text) { return telegramBot.sendMessage("🤖 " + body); } +/** + * Уведомить о превышении rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED=40029). + * Пишет КАПСОМ, что пользователь превысил лимит и нужно подождать 5-20 минут. + * @param {{status?:number, retryAfterSec?:number}} [info] + */ +async function notifyRateLimit(info) { + const cfg = _read(); + if (!cfg.enabled || !cfg.token || !cfg.chatId) + return { success: false, skipped: true }; + + // Случайная задержка ожидания 5–20 минут. + const minMin = 5; + const maxMin = 20; + const waitMin = minMin + Math.floor(Math.random() * (maxMin - minMin + 1)); + + // Если сервер прислал retry-after — используем его как ориентир (в минутах). + const retry = + info && Number(info.retryAfterSec) > 0 + ? Math.ceil(Number(info.retryAfterSec) / 60) + : 0; + + const lines = [ + _t("rateLimit.title"), + "", + _t("rateLimit.exceeded"), + _t("rateLimit.tooOften"), + "", + _t("rateLimit.wait", { min: waitMin, max: maxMin }), + ]; + if (retry > 0) lines.push(_t("rateLimit.serverHint", { retry })); + lines.push("", _t("rateLimit.dontSpam")); + if (info && info.status) + lines.push(_t("rateLimit.statusCode", { status: info.status })); + + return telegramBot.sendMessage(lines.join("\n")); +} + /** Пингануть бота (getMe) для проверки токена. */ async function ping() { const cfg = _read(); @@ -5483,6 +5520,7 @@ module.exports = { notifyToolResult, notifyAgentStarted, notifyAIResponse, + notifyRateLimit, startTyping, stopTyping, notifyAllDone, diff --git a/src/main/ipc.js b/src/main/ipc.js index 82982ad..1741b41 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1450,6 +1450,20 @@ function registerIpcHandlers() { } }); + // Rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED) -> КАПС-уведомление в Telegram. + ipcMain.handle("telegram-rate-limit", async (event, info = {}) => { + try { + const ctx = windowState.getContextByWebContents(event.sender); + if (ctx && ctx.isSubagent) { + return { success: false, skipped: true, reason: "subagent window" }; + } + const bot = require("../../botsrc"); + return await bot.notifyRateLimit(info); + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Индикатор «ИИ печатает…» в Telegram (пока идёт генерация ответа). ipcMain.handle("telegram-typing-start", async () => { try { diff --git a/src/main/token-interceptor-inject.js b/src/main/token-interceptor-inject.js index 46d0211..391e28d 100644 --- a/src/main/token-interceptor-inject.js +++ b/src/main/token-interceptor-inject.js @@ -123,6 +123,32 @@ function buildTokenInterceptorScript() { return typeof url === "string" && url.indexOf(COMPLETION) !== -1; } + // Rate-limit: 429 или бизнес-код 40029 (IP_ACCESS_RESTRICTED). + function publishRateLimit(extra) { + try { + window.postMessage( + { + __cuckoo: "rate-limit", + status: (extra && extra.status) || 0, + retryAfterSec: (extra && extra.retryAfterSec) || 0, + ts: Date.now(), + }, + "*", + ); + } catch (e) {} + } + + // Проверить тело ответа/ошибки на 40029 и вызвать publishRateLimit. + function checkBodyForRateLimit(body) { + if (!body || typeof body !== "string") return; + if ( + body.indexOf("40029") === -1 && + body.indexOf("IP_ACCESS_RESTRICTED") === -1 + ) + return; + publishRateLimit({ status: 200 }); + } + // ---- патч fetch ---- try { var originalFetch = window.fetch; @@ -132,6 +158,18 @@ function buildTokenInterceptorScript() { var p = originalFetch.apply(this, arguments); if (!isCompletion(url)) return p; return p.then(function (response) { + // HTTP 429 — rate limit. + try { + if (response && response.status === 429) { + var ra = 0; + try { + ra = + parseInt(response.headers.get("retry-after") || "0", 10) || + 0; + } catch (e) {} + publishRateLimit({ status: 429, retryAfterSec: ra }); + } + } catch (e) {} try { var clone = response.clone(); clone @@ -139,6 +177,7 @@ function buildTokenInterceptorScript() { .then(function (body) { var u = parseSseText(body); if (u) publish(u); + checkBodyForRateLimit(body); }) .catch(function () {}); } catch (e) {} @@ -164,8 +203,10 @@ function buildTokenInterceptorScript() { if (isCompletion(this.__cuckooUrl || "")) { this.addEventListener("load", function () { try { + if (self.status === 429) publishRateLimit({ status: 429 }); var u = parseSseText(self.responseText); if (u) publish(u); + checkBodyForRateLimit(self.responseText); } catch (e) {} }); } diff --git a/src/preload/api.js b/src/preload/api.js index d225835..ed85fea 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -52,6 +52,8 @@ let electronAPI = { telegramTest: () => ipcRenderer.invoke("telegram-test"), telegramNotifyAI: (text) => ipcRenderer.invoke("telegram-notify-ai", { text }), + telegramRateLimit: (info) => + ipcRenderer.invoke("telegram-rate-limit", info || {}), telegramTypingStart: () => ipcRenderer.invoke("telegram-typing-start"), telegramTypingStop: () => ipcRenderer.invoke("telegram-typing-stop"), telegramWhisperStatus: (opts) => diff --git a/src/preload/dom/token-interceptor.js b/src/preload/dom/token-interceptor.js index e8ef5e1..6573496 100644 --- a/src/preload/dom/token-interceptor.js +++ b/src/preload/dom/token-interceptor.js @@ -21,11 +21,11 @@ * не бросает исключение наружу, а просто не применяется. */ -const { safe } = require('./safe'); +const { safe } = require("./safe"); -const COMPLETION_URL_PART = '/api/v0/chat/completion'; -const EVENT_NAME = 'cuckoo:token-update'; -const LOG_PREFIX = '[Cookie Code][tokens]'; +const COMPLETION_URL_PART = "/api/v0/chat/completion"; +const EVENT_NAME = "cuckoo:token-update"; +const LOG_PREFIX = "[Cookie Code][tokens]"; // Последнее известное накопленное значение (по всему приложению). let lastTotal = 0; @@ -39,7 +39,7 @@ let installed = false; * @returns {{total:number, prompt:number, completion:number, source:string}|null} */ function extractUsage(obj) { - if (!obj || typeof obj !== 'object') return null; + if (!obj || typeof obj !== "object") return null; // === Реальный формат DeepSeek (SSE completion) === // Кадр-снимок (WIP): {"v":{"response":{"accumulated_token_usage":50, ...}}} @@ -49,35 +49,52 @@ function extractUsage(obj) { if (Array.isArray(obj.v)) { for (let i = obj.v.length - 1; i >= 0; i--) { const op = obj.v[i]; - if (op && typeof op === 'object' && op.p === 'accumulated_token_usage') { - const n = pickNumber(op, ['v', 'value']); - if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' }; + if (op && typeof op === "object" && op.p === "accumulated_token_usage") { + const n = pickNumber(op, ["v", "value"]); + if (n != null) + return { total: n, prompt: 0, completion: 0, source: "accumulated" }; } } } // 1b) Кадр-снимок: v.response.accumulated_token_usage. - const snapResp = obj.v && typeof obj.v === 'object' && !Array.isArray(obj.v) ? obj.v.response : null; - if (snapResp && typeof snapResp === 'object') { - const n = pickNumber(snapResp, ['accumulated_token_usage']); - if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' }; + const snapResp = + obj.v && typeof obj.v === "object" && !Array.isArray(obj.v) + ? obj.v.response + : null; + if (snapResp && typeof snapResp === "object") { + const n = pickNumber(snapResp, ["accumulated_token_usage"]); + if (n != null) + return { total: n, prompt: 0, completion: 0, source: "accumulated" }; } // 2) accumulated_token_usage в корне (число либо объект) — на случай смены формата. const acc = obj.accumulated_token_usage; - if (typeof acc === 'number' && Number.isFinite(acc)) { - return { total: acc, prompt: 0, completion: 0, source: 'accumulated' }; + if (typeof acc === "number" && Number.isFinite(acc)) { + return { total: acc, prompt: 0, completion: 0, source: "accumulated" }; } - if (acc && typeof acc === 'object') { - const t = pickNumber(acc, ['total_tokens', 'total', 'value']); - if (t != null) return { total: t, prompt: pickNumber(acc, ['prompt_tokens']) || 0, completion: pickNumber(acc, ['completion_tokens']) || 0, source: 'accumulated' }; + if (acc && typeof acc === "object") { + const t = pickNumber(acc, ["total_tokens", "total", "value"]); + if (t != null) + return { + total: t, + prompt: pickNumber(acc, ["prompt_tokens"]) || 0, + completion: pickNumber(acc, ["completion_tokens"]) || 0, + source: "accumulated", + }; } // 3) usage.total_tokens — резервный источник (OpenAI-подобный формат). const usage = obj.usage; - if (usage && typeof usage === 'object') { - const t = pickNumber(usage, ['total_tokens', 'total']); - if (t != null) return { total: t, prompt: pickNumber(usage, ['prompt_tokens']) || 0, completion: pickNumber(usage, ['completion_tokens']) || 0, source: 'usage' }; + if (usage && typeof usage === "object") { + const t = pickNumber(usage, ["total_tokens", "total"]); + if (t != null) + return { + total: t, + prompt: pickNumber(usage, ["prompt_tokens"]) || 0, + completion: pickNumber(usage, ["completion_tokens"]) || 0, + source: "usage", + }; } return null; @@ -92,7 +109,7 @@ function extractUsage(obj) { function pickNumber(obj, keys) { for (const k of keys) { const v = obj[k]; - if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === "number" && Number.isFinite(v)) return v; } return null; } @@ -113,12 +130,30 @@ function publish(usage) { // Нулевую дельту (стартовый кадр-снимок) не рассылаем — полезны только реальные приросты. if (delta === 0) return; try { - window.dispatchEvent(new CustomEvent(EVENT_NAME, { - detail: { total: usage.total, delta, prompt: usage.prompt, completion: usage.completion, source: usage.source }, - })); - } catch (_) { /* CustomEvent недоступен — молча */ } + window.dispatchEvent( + new CustomEvent(EVENT_NAME, { + detail: { + total: usage.total, + delta, + prompt: usage.prompt, + completion: usage.completion, + source: usage.source, + }, + }), + ); + } catch (_) { + /* CustomEvent недоступен — молча */ + } try { - console.log(LOG_PREFIX + ' server tokens: total=' + usage.total + ' delta=' + delta + ' src=' + usage.source); + console.log( + LOG_PREFIX + + " server tokens: total=" + + usage.total + + " delta=" + + delta + + " src=" + + usage.source, + ); } catch (_) {} } @@ -129,14 +164,14 @@ function publish(usage) { * @returns {{total:number, prompt:number, completion:number, source:string}|null} */ function parseSseText(text) { - if (!text || typeof text !== 'string') return null; + if (!text || typeof text !== "string") return null; let found = null; const lines = text.split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data:')) continue; + if (!trimmed || !trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); - if (!payload || payload === '[DONE]') continue; + if (!payload || payload === "[DONE]") continue; const parsed = safeJson(payload); if (!parsed) continue; const usage = extractUsage(parsed); @@ -151,7 +186,11 @@ function parseSseText(text) { * @returns {object|null} */ function safeJson(s) { - try { return JSON.parse(s); } catch (_) { return null; } + try { + return JSON.parse(s); + } catch (_) { + return null; + } } /** @@ -160,10 +199,34 @@ function safeJson(s) { * @returns {boolean} */ function isCompletionUrl(url) { - if (!url || typeof url !== 'string') return false; + if (!url || typeof url !== "string") return false; return url.indexOf(COMPLETION_URL_PART) !== -1; } +// Антиспам: не чаще одного уведомления о rate-limit в 60 секунд. +let lastRateLimitNotifyTs = 0; +const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 60 * 1000; + +/** + * Уведомить Telegram о превышении rate-limit (429 / IP_ACCESS_RESTRICTED). + * @param {{status?:number, retryAfterSec?:number}} d + */ +function notifyRateLimit(d) { + try { + const now = Date.now(); + if (now - lastRateLimitNotifyTs < RATE_LIMIT_NOTIFY_COOLDOWN_MS) return; + lastRateLimitNotifyTs = now; + const api = typeof window !== "undefined" ? window.electronAPI : null; + if (!api || typeof api.telegramRateLimit !== "function") return; + api + .telegramRateLimit({ + status: (d && d.status) || 0, + retryAfterSec: (d && d.retryAfterSec) || 0, + }) + .catch(() => {}); + } catch (_) {} +} + /** * Установить приём серверных токенов. * @@ -183,14 +246,28 @@ function install() { installed = true; // Основной канал: postMessage из main world. - safe('token-interceptor.listenMessage', () => { - window.addEventListener('message', (e) => { + safe("token-interceptor.listenMessage", () => { + window.addEventListener("message", (e) => { try { const d = e && e.data; - if (!d || d.__cuckoo !== 'token-update') return; - if (typeof d.total !== 'number' || !Number.isFinite(d.total)) return; - const delta = typeof d.delta === 'number' ? d.delta : 0; - const usage = { total: d.total, delta, prompt: 0, completion: 0, source: d.source || 'accumulated' }; + if (!d) return; + + // Rate-limit (429 / IP_ACCESS_RESTRICTED=40029) -> уведомление в Telegram. + if (d.__cuckoo === "rate-limit") { + notifyRateLimit(d); + return; + } + + if (d.__cuckoo !== "token-update") return; + if (typeof d.total !== "number" || !Number.isFinite(d.total)) return; + const delta = typeof d.delta === "number" ? d.delta : 0; + const usage = { + total: d.total, + delta, + prompt: 0, + completion: 0, + source: d.source || "accumulated", + }; // publish ожидает total и сам считает дельту от lastTotal; чтобы не // считать дважды, синхронизируем lastTotal из main world. lastTotal = d.total - delta; @@ -200,8 +277,8 @@ function install() { }); // Резерв: патчи в самом preload (сработают только без contextIsolation). - safe('token-interceptor.patchFetch', () => patchFetch()); - safe('token-interceptor.patchXhr', () => patchXhr()); + safe("token-interceptor.patchFetch", () => patchFetch()); + safe("token-interceptor.patchXhr", () => patchXhr()); } /** @@ -209,22 +286,26 @@ function install() { * клонируем ответ и читаем тело как текст (SSE). */ function patchFetch() { - if (typeof window === 'undefined' || typeof window.fetch !== 'function') return; + if (typeof window === "undefined" || typeof window.fetch !== "function") + return; const originalFetch = window.fetch; window.fetch = function patchedFetch(input, init) { - const url = typeof input === 'string' ? input : (input && input.url) || ''; + const url = typeof input === "string" ? input : (input && input.url) || ""; const p = originalFetch.apply(this, arguments); if (!isCompletionUrl(url)) return p; return p.then((response) => { - safe('token-interceptor.fetchRead', () => { + safe("token-interceptor.fetchRead", () => { // Клонируем, чтобы не «съесть» тело у вызывающего кода. const clone = response.clone(); - clone.text().then((body) => { - const usage = parseSseText(body); - if (usage) publish(usage); - }).catch(() => {}); + clone + .text() + .then((body) => { + const usage = parseSseText(body); + if (usage) publish(usage); + }) + .catch(() => {}); }); return response; }); @@ -235,22 +316,28 @@ function patchFetch() { * Патч XMLHttpRequest.open/send: запоминаем URL, по завершении читаем responseText. */ function patchXhr() { - if (typeof window === 'undefined' || typeof window.XMLHttpRequest !== 'function') return; + if ( + typeof window === "undefined" || + typeof window.XMLHttpRequest !== "function" + ) + return; const XHR = window.XMLHttpRequest; const originalOpen = XHR.prototype.open; const originalSend = XHR.prototype.send; XHR.prototype.open = function patchedOpen(method, url) { - try { this.__cuckooUrl = url; } catch (_) {} + try { + this.__cuckooUrl = url; + } catch (_) {} return originalOpen.apply(this, arguments); }; XHR.prototype.send = function patchedSend() { try { - const url = this.__cuckooUrl || ''; + const url = this.__cuckooUrl || ""; if (isCompletionUrl(url)) { - this.addEventListener('load', () => { - safe('token-interceptor.xhrRead', () => { + this.addEventListener("load", () => { + safe("token-interceptor.xhrRead", () => { const body = this.responseText; const usage = parseSseText(body); if (usage) publish(usage); @@ -269,8 +356,12 @@ module.exports = { _parseSseText: parseSseText, _isCompletionUrl: isCompletionUrl, _publish: publish, - _resetTotal: () => { lastTotal = 0; }, - _setLastTotal: (v) => { lastTotal = v; }, + _resetTotal: () => { + lastTotal = 0; + }, + _setLastTotal: (v) => { + lastTotal = v; + }, _getLastTotal: () => lastTotal, EVENT_NAME, }; From 6fe43d311fe50ed4eacd64d2e8b5db60c6f7ab8c Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:56:21 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(subagent):=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=81=D1=83=D0=B1=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=BC=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20initial-prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-input: ожидание поля ввода 15с -> 90с, applyInitialPrompt с антидублем - pull-модель: через 20с окно само запрашивает initial-prompt у main - subagent: повторные отправки (3с/8с/15с), таймаут 180с, кэш промпта по windowId - ipc: хендлер subagent-get-init-prompt --- src/main/ipc.js | 16 +++++++ src/main/subagent.js | 34 ++++++++++++--- src/preload/dom/chat-input.js | 79 ++++++++++++++++++++++++++++------- 3 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/main/ipc.js b/src/main/ipc.js index 1741b41..014d1a1 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -407,6 +407,22 @@ function registerIpcHandlers() { } }); + // Pull-модель: дочернее окно само запрашивает initial-prompt, если push + // потерялся из-за гонки IPC (окно долго грузилось и не успело подписаться). + ipcMain.handle("subagent-get-init-prompt", async (event) => { + try { + const ctx = windowState.getContextByWebContents(event.sender); + if (!ctx || !ctx.win || ctx.win.isDestroyed()) { + return { success: false, error: "no window context" }; + } + const text = subagent.getInitPrompt(ctx.win.id); + if (!text) return { success: false, error: "no init prompt" }; + return { success: true, text }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Мост Telegram → окно: ответ на вопрос из TG резолвит тот же promise. try { const bot = require("../../botsrc"); diff --git a/src/main/subagent.js b/src/main/subagent.js index 0c35387..350070b 100644 --- a/src/main/subagent.js +++ b/src/main/subagent.js @@ -79,6 +79,15 @@ function buildAgentPrompt(agentSystemPrompt, task) { * Дождаться готовности дочернего окна и отправить в него initial-prompt * (system prompt Cookie Code + промпт агента + task). */ +// combined initial-prompt по windowId — чтобы окно могло запросить его повторно +// (pull-модель), если push-сообщение потерялось из-за гонки IPC. +const initPrompts = new Map(); + +/** Получить сохранённый initial-prompt по windowId (или null). */ +function getInitPrompt(windowId) { + return initPrompts.get(windowId) || null; +} + function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) { return new Promise((resolve) => { if (!win || win.isDestroyed()) return resolve(false); @@ -92,6 +101,9 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) { "deepseek", ); const combined = base + "\n\n" + extraPrompt; + try { + initPrompts.set(win.id, combined); + } catch (_) {} win.webContents.send("initial-prompt", combined); sent = true; console.log( @@ -108,15 +120,24 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) { resolve(false); } }; - win.webContents.once("did-finish-load", () => setTimeout(send, 1200)); - // На случай, если страница уже загрузилась до подписки - setTimeout(send, 3000); - // Абсолютный таймаут + // DeepSeek на холодном старте монтирует React долго — ждём дольше. + win.webContents.once("did-finish-load", () => setTimeout(send, 3000)); + // Повторная попытка, если первая не сработала (окно догрузилось позже). + setTimeout(send, 8000); + setTimeout(send, 15000); + // Абсолютный таймаут: ждём до 3 минут, пока окно будет готово. setTimeout( () => { - if (!sent) resolve(false); + if (!sent) { + console.warn( + "[Subagent] initial-prompt не отправлен за " + + Math.round(Math.min(timeoutMs, 180000) / 1000) + + "с — окно, возможно, не готово", + ); + resolve(false); + } }, - Math.min(timeoutMs, 60000), + Math.min(timeoutMs, 180000), ); }); } @@ -381,4 +402,5 @@ module.exports = { runAgent, onSubagentResponse, hasRunningSubagent, + getInitPrompt, }; diff --git a/src/preload/dom/chat-input.js b/src/preload/dom/chat-input.js index 4718e5a..7832da4 100644 --- a/src/preload/dom/chat-input.js +++ b/src/preload/dom/chat-input.js @@ -290,13 +290,17 @@ function sendInitialPromptToInput() { */ function waitForInitialPromptAndSend() { let attempts = 0; - const maxAttempts = 30; + // Ждём появления поля ввода до 90 секунд (180 * 500мс). DeepSeek на холодном + // старте может долго монтировать React-приложение — 15с не хватало, и окно + // субагента висело пустым без промпта. + const maxAttempts = 180; const checkInterval = setInterval(() => { attempts++; if (attempts > maxAttempts) { clearInterval(checkInterval); - state.pendingInitialPrompt = false; + // НЕ сбрасываем pendingInitialPrompt: пусть следующий вызов + // (например, по новому initial-prompt или ручному ретраю) попробует снова. return; } @@ -374,6 +378,32 @@ function fallbackSend(provider, input) { console.log("[Cookie Code] 已通过 Enter 键触发发送 (未找到发送按钮)"); } } +/** + * Применить initial-prompt: сохранить и отправить в поле ввода (или ждать его). + * @param {string} content + */ +let _initialPromptApplied = false; +function applyInitialPrompt(content) { + // Антидубль: push и pull могут прийти оба — применяем только один раз. + if (_initialPromptApplied) return; + const text = content || ""; + if (!text) return; + _initialPromptApplied = true; + state.initialPromptContent = text; + state.pendingInitialPrompt = true; + if (state.pendingInitialPrompt && state.initialPromptContent) { + try { + const input = findInputArea(); + if (input) { + sendInitialPromptToInput(); + } else { + // Ждём появления поля ввода. + waitForInitialPromptAndSend(); + } + } catch (e) {} + } +} + /** * 注册主进程消息监听(initial-prompt) * 与原 preload.js 顶层注册时机一致:preload 入口加载时同步调用。 @@ -381,22 +411,39 @@ function fallbackSend(provider, input) { function registerIpcListeners() { // 监听主进程发送的初始提示 ipcRenderer.on("initial-prompt", (_event, content) => { - state.initialPromptContent = content || ""; - state.pendingInitialPrompt = true; - // 如果当前已有新的空会话输入框,立即发送 - if (state.pendingInitialPrompt && state.initialPromptContent) { - try { - const input = findInputArea(); - if (input) { - sendInitialPromptToInput(); - } else { - // 等待输入框出现 - waitForInitialPromptAndSend(); - } - } catch (e) {} - } + applyInitialPrompt(content); }); + // Pull-модель: если push initial-prompt потерялся (окно субагента долго + // грузилось и не успело подписаться на канал), через 20с сами запросим его + // у main-процесса. Актуально только для окна-субагента. + try { + const isSubagent = + (typeof process !== "undefined" && + Array.isArray(process.argv) && + process.argv.some( + (a) => typeof a === "string" && a.startsWith("--cuckoo-subagent="), + )) || + false; + if (isSubagent) { + setTimeout(() => { + if (state.initialPromptContent) return; // уже получили + try { + Promise.resolve(ipcRenderer.invoke("subagent-get-init-prompt")) + .then((res) => { + if (res && res.success && res.text) { + console.log( + "[Cookie Code] initial-prompt получен по pull-запросу", + ); + applyInitialPrompt(res.text); + } + }) + .catch(() => {}); + } catch (_) {} + }, 20000); + } + } catch (_) {} + // Ответ из окна кастомного уведомления — сразу вставляем в чат и отправляем. ipcRenderer.on("cuckoo-notif-reply", (_event, text) => { const msg = String(text || "").trim(); From 5390c5bcf7f338f6d1d85ca88fa46ff6d3f8e662 Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:09:02 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(subagent):=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=82=D1=8C=20DSML/XML-=D0=BC=D1=83=D1=81?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B4=D0=BE=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - проверка DSML перенесена выше анализа cuckoo-блоков (ловит мусор и внутри
)
- детект по textContent + innerHTML (React может отрендерить  как элемент)
- расширены паттерны: DSML, XML-теги, invoke/parameter/tool_calls с name= без '<'
- диагностический лог с первыми символами текста и HTML
- при мусоре ругательство в чат (на английском), ответ не завершается (лимит 3, антидубль)
---
 src/preload/dom/subagent-watch.js | 110 +++++++++++++++++++++++++++++-
 1 file changed, 109 insertions(+), 1 deletion(-)

diff --git a/src/preload/dom/subagent-watch.js b/src/preload/dom/subagent-watch.js
index 5a1a6d8..8b1f1d9 100644
--- a/src/preload/dom/subagent-watch.js
+++ b/src/preload/dom/subagent-watch.js
@@ -14,6 +14,7 @@
 const { ipcRenderer } = require("electron");
 const { getProviderByUrl } = require("../../../src/providers");
 const { getJsCodeBlocksFromMarkdown } = require("./js-detector");
+const chatInput = require("./chat-input");
 
 const STABLE_MS = 2500; // тишина, после которой считаем ответ финальным
 const POLL_MS = 700;
@@ -82,6 +83,98 @@ function showProjectDir(cfg) {
   }
 }
 
+// ===== Детект DSML/XML-мусора вместо cuckoo-блоков =====
+// AI иногда выдаёт сырые XML-вызовы (<||DSML||invoke>, ,
+// ) вместо cuckoo-кодблока. Такой ответ нельзя считать
+// финальным — надо поругать модель и дождаться корректного формата.
+const DSML_PATTERNS = [
+  /<\s*||DSML||/i,
+  /<\s*(?:[\w-]+:)?invoke\s+name=/i,
+  /<\s*\/\s*(?:[\w-]+:)?invoke\s*>/i,
+  /<\s*(?:[\w-]+:)?parameter\s+name=/i,
+  /<\s*(?:[\w-]+:)?tool_calls?\b/i,
+];
+
+/**
+ * Есть ли DSML/XML-мусор (сломанный tool-call вместо cuckoo-блока).
+ * Проверяем и видимый текст (textContent), и HTML (innerHTML): React/markdown
+ * может отрендерить  как элемент — тогда в textContent останется
+ * только "name=...", а тег будет лишь в innerHTML.
+ * @param {string} text  — md.textContent
+ * @param {string} [html] — md.innerHTML
+ * @returns {boolean}
+ */
+function detectDsmlGarbage(text, html) {
+  const a = typeof text === "string" ? text : "";
+  const b = typeof html === "string" ? html : "";
+  const hay = a + "\n" + b;
+  if (!hay.trim()) return false;
+  // 1) Явные маркеры DSML в любом виде.
+  if (/DSML/i.test(hay)) return true;
+  // 2) XML-подобные вызовы инструментов в тексте или HTML.
+  if (DSML_PATTERNS.some((re) => re.test(hay))) return true;
+  // 3) Текст без "<", но с характерной парой invoke/parameter + name=
+  //    (теги съедены markdown-рендером): "invoke name=" / "parameter name=".
+  if (/(?:^|[\s>"'])(?:invoke|parameter|tool_calls?)\s+name\s*=/i.test(hay))
+    return true;
+  return false;
+}
+
+/**
+ * Поругать AI за DSML/XML-формат и НЕ завершать ответ.
+ * Ограничено MAX_HINTS попытками, чтобы не зациклиться навсегда.
+ * @param {string} snapshot — текущий текст ответа (для антидубля)
+ */
+const MAX_DSML_HINTS = 3;
+let dsmlHintCount = 0;
+let lastDsmlHintSnapshot = "";
+function handleDsmlGarbage(snapshot) {
+  // Один и тот же сломанный ответ не ругаем повторно.
+  if (snapshot === lastDsmlHintSnapshot) return;
+  if (dsmlHintCount >= MAX_DSML_HINTS) {
+    console.warn(
+      "[Cookie Code][Subagent] ⚠️ DSML-мусор: исчерпан лимит подсказок (" +
+        MAX_DSML_HINTS +
+        "), ждём без отправки новых",
+    );
+    lastDsmlHintSnapshot = snapshot;
+    return;
+  }
+  dsmlHintCount++;
+  lastDsmlHintSnapshot = snapshot;
+  const BT = String.fromCharCode(96);
+  const fence = BT + BT + BT;
+  console.log(
+    "[Cookie Code][Subagent] ⚠️ Обнаружен DSML/XML-мусор (подсказка " +
+      dsmlHintCount +
+      "/" +
+      MAX_DSML_HINTS +
+      "), просим использовать cuckoo-блок",
+  );
+  try {
+    chatInput.sendMessageToChat(
+      "❌ INVALID TOOL CALL FORMAT DETECTED: you used XML/DSML " +
+        "(<||DSML||invoke>, , ), but you MUST use a " +
+        fence +
+        "cuckoo" +
+        fence +
+        " code block. " +
+        "DO NOT write XML tags. Call the tool via a " +
+        fence +
+        "cuckoo" +
+        fence +
+        " code block with JS code (await tool_name({...})). " +
+        "This is critical: the XML format is NOT executed.",
+      "DSML format warning",
+    );
+  } catch (err) {
+    console.error(
+      "[Cookie Code][Subagent] не удалось отправить DSML-подсказку:",
+      err && err.message,
+    );
+  }
+}
+
 /**
  * Если окно — субагент, запустить цикл наблюдения за финальным ответом.
  */
@@ -142,7 +235,22 @@ function initSubagentWatch() {
       // Текст не менялся — проверяем, устоялся ли.
       if (Date.now() - lastChangeAt < STABLE_MS) return;
 
-      // Устоявшийся текст. Проверяем, есть ли в нём cuckoo-блоки.
+      // Устоявшийся текст. СНАЧАЛА проверяем DSML/XML-мусор (по тексту и HTML),
+      // ДО анализа блоков: мусор может прийти и внутри 
, тогда обычная
+      // ветка "tool-блоки" его не поймает и ответ завершится.
+      const rawHtml = (got.md && got.md.innerHTML) || "";
+      if (detectDsmlGarbage(snapshot, rawHtml)) {
+        console.log(
+          "[Cookie Code][Subagent] DSML-детект: мусор найден. text[0..120]=" +
+            JSON.stringify(snapshot.slice(0, 120)) +
+            " html[0..160]=" +
+            JSON.stringify(rawHtml.slice(0, 160)),
+        );
+        handleDsmlGarbage(snapshot);
+        return;
+      }
+
+      // Проверяем, есть ли в нём cuckoo-блоки.
       const blocks = (() => {
         try {
           return getJsCodeBlocksFromMarkdown(got.md) || [];

From 6f994ffc594c18a30f3693eb514acc6f2b41de1c Mon Sep 17 00:00:00 2001
From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com>
Date: Mon, 26 Oct 2026 16:16:12 +0300
Subject: [PATCH 4/4] Update CHANGELOG.md

---
 CHANGELOG.md | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2cad02..76d5c16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
 # Changelog
 
+## [5.0.9] - 2026-10-26
+
+### Added
+
+- ⛔ **Уведомление в Telegram при rate-limit DeepSeek (429 / 40029)** — при превышении лимита запросов в Telegram приходит ==КАПС-предупреждение== с просьбой подождать:
+  - в main-world перехватчике `token-interceptor-inject.js` в патчах `fetch`/`XHR` ловятся HTTP `429` и бизнес-код `40029` (`IP_ACCESS_RESTRICTED`) в теле ответа;
+  - событие уходит через `postMessage({ __cuckoo: "rate-limit" })`, preload слушает с антиспамом 60с;
+  - IPC `telegram-rate-limit` → `botsrc.notifyRateLimit()` шлёт сообщение с ожиданием 5–20 минут (учёт `retry-after` при наличии);
+  - строки локализации `rateLimit.*` (ru/en) в `botsrc/i18n.js`; в окнах-субагентах уведомление подавляется.
+- 🔄 **Pull-модель получения initial-prompt в окне субагента** — если push потерялся из-за гонки IPC, окно само запрашивает промпт у main:
+  - `subagent.js` кэширует combined-промпт по `windowId` и отдаёт через `getInitPrompt()`;
+  - новый IPC-хендлер `subagent-get-init-prompt` в `src/main/ipc.js`;
+  - в `chat-input.js` окно-субагент через 20с запрашивает промпт, если не получило пушем.
+
+### Changed
+
+- 🪟 **Отправка initial-prompt в субагент стала устойчивее** — повторные отправки в `3с / 8с / 15с`, абсолютный таймаут до 3 минут (было: 1.2с и 60с) в `subagent.js`.
+- ⏳ **Ожидание поля ввода в окне субагента** — увеличено с ~15с до 90с (180 попыток × 500мс), `pendingInitialPrompt` больше не сбрасывается по таймауту (`chat-input.js`).
+- 🛡 **Защита от дубля initial-prompt** — `applyInitialPrompt()` с флагом `_initialPromptApplied` не даёт отправить промпт дважды (push + pull).
+
+### Fixed
+
+- 🐛 **Окно субагента больше не остаётся пустым без промпта** — повторные отправки + pull-запрос закрывают гонку долгой загрузки окна.
+- 🧹 **DSML/XML-мусор в ответе субагента не считается финальным ответом** — проверка вынесена ==выше анализа cuckoo-блоков== (`subagent-watch.js`):
+  - детект по `textContent` **и** `innerHTML` (ловит мусор внутри `
` и когда теги съедены markdown-рендером);
+  - паттерны: маркеры `DSML`, `invoke/parameter/tool_calls name=` с тегом и без;
+  - при обнаружении в чат уходит предупреждение (на английском) использовать `cuckoo`-блок, ответ не завершается; лимит 3 подсказки + антидубль по снапшоту.
+
 ## [5.0.8] - 2026-10-22
 
 ### Added