From 3561bd8b0a787674d04cee76c3cf6459b8ac52ca Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Thu, 17 Sep 2026 00:56:28 +0300 Subject: [PATCH 1/4] feat(telegram): add AI typing indicator during response streaming --- .gitignore | 1 + botsrc/index.js | 1148 ++++++++++++++++++++++++----------- botsrc/telegram.js | 194 +++--- src/main/ipc.js | 19 + src/preload/api.js | 2 + src/preload/dom/observer.js | 747 ++++++++++++++++------- 6 files changed, 1464 insertions(+), 647 deletions(-) diff --git a/.gitignore b/.gitignore index 0487949..008dcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ test/coverage.lcov # Playwright MCP .playwright-mcp/ +/tiktok diff --git a/botsrc/index.js b/botsrc/index.js index b283439..7cbe5eb 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -8,25 +8,25 @@ * - notifyToolResult(toolName, ok, detail) → уведомление в TG (diff-стиль для edit). * - входящее сообщение из TG → sendToChat в активном окне DeepSeek. */ -const { telegramBot, TelegramBot } = require('./telegram'); -const settingsStore = require('../src/main/settings-store'); -const windowState = require('../src/main/window'); +const { telegramBot, TelegramBot } = require("./telegram"); +const settingsStore = require("../src/main/settings-store"); +const windowState = require("../src/main/window"); let started = false; // ==================== Файловый лог ==================== -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); let _logFile = null; const LOG_MAX_BYTES = 512 * 1024; // 512 KB, затем ротация в .1 function _getLogFile() { if (_logFile) return _logFile; try { - const { app } = require('electron'); - _logFile = path.join(app.getPath('userData'), 'telegram-bot.log'); + const { app } = require("electron"); + _logFile = path.join(app.getPath("userData"), "telegram-bot.log"); } catch (_) { - _logFile = path.join(__dirname, 'telegram-bot.log'); + _logFile = path.join(__dirname, "telegram-bot.log"); } return _logFile; } @@ -36,10 +36,10 @@ function _appendLogFile(line) { const file = _getLogFile(); try { if (fs.existsSync(file) && fs.statSync(file).size > LOG_MAX_BYTES) { - fs.renameSync(file, file + '.1'); + fs.renameSync(file, file + ".1"); } } catch (_) {} - fs.appendFileSync(file, line + '\n', 'utf-8'); + fs.appendFileSync(file, line + "\n", "utf-8"); } catch (_) {} } @@ -47,26 +47,36 @@ function _read() { const s = settingsStore.readSettings(); return { enabled: !!s.telegramEnabled, - token: s.telegramBotToken || '', - chatId: s.telegramChatId || '', + token: s.telegramBotToken || "", + chatId: s.telegramChatId || "", notifyTools: !!s.telegramNotifyTools, chatFeed: !!s.telegramChatFeed, - approvalMode: s.toolApprovalMode || 'off', + approvalMode: s.toolApprovalMode || "off", }; } function _log(level, ...args) { - const tag = '[Cookie Code][telegram]'; - const token = (() => { try { return settingsStore.readSettings().telegramBotToken; } catch (_) { return ''; } })(); - const safe = args.map((a) => (typeof a === 'string' ? TelegramBot.maskToken(a, token) : a)); - if (level === 'error') console.error(tag, ...safe); - else if (level === 'warn') console.warn(tag, ...safe); + const tag = "[Cookie Code][telegram]"; + const token = (() => { + try { + return settingsStore.readSettings().telegramBotToken; + } catch (_) { + return ""; + } + })(); + const safe = args.map((a) => + typeof a === "string" ? TelegramBot.maskToken(a, token) : a, + ); + if (level === "error") console.error(tag, ...safe); + else if (level === "warn") console.warn(tag, ...safe); else console.log(tag, ...safe); // Дублируем в файл для отладки (уровни warn/error + info). try { const ts = new Date().toISOString(); - const text = safe.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); - _appendLogFile(ts + ' [' + level + '] ' + text); + const text = safe + .map((a) => (typeof a === "string" ? a : JSON.stringify(a))) + .join(" "); + _appendLogFile(ts + " [" + level + "] " + text); } catch (_) {} } @@ -78,7 +88,8 @@ function _log(level, ...args) { async function _sendToChat(text) { try { const win = windowState.getMainWindow(); - if (!win || win.isDestroyed()) return { success: false, error: 'нет активного окна' }; + if (!win || win.isDestroyed()) + return { success: false, error: "нет активного окна" }; const safe = JSON.stringify(String(text)); const script = `(function(){ @@ -95,12 +106,24 @@ async function _sendToChat(text) { // Небольшая пауза, чтобы React обработал ввод. await new Promise((r) => setTimeout(r, 150)); // Отправляем нативный Enter (тот же приём, что chat-send-enter). - win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Return', key: 'Enter' }); - win.webContents.sendInputEvent({ type: 'char', keyCode: 'Return', key: '\r' }); - win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Return', key: 'Enter' }); + win.webContents.sendInputEvent({ + type: "keyDown", + keyCode: "Return", + key: "Enter", + }); + win.webContents.sendInputEvent({ + type: "char", + keyCode: "Return", + key: "\r", + }); + win.webContents.sendInputEvent({ + type: "keyUp", + keyCode: "Return", + key: "Enter", + }); return { success: true }; } catch (err) { - _log('error', 'sendToChat error:', err.message); + _log("error", "sendToChat error:", err.message); return { success: false, error: err.message }; } } @@ -108,7 +131,8 @@ async function _sendToChat(text) { /** Выполнить JS в активном окне и вернуть результат. */ async function _evalInWindow(script) { const win = windowState.getMainWindow(); - if (!win || win.isDestroyed()) return { success: false, error: 'нет активного окна' }; + if (!win || win.isDestroyed()) + return { success: false, error: "нет активного окна" }; try { const result = await win.webContents.executeJavaScript(script, true); return { success: true, result }; @@ -140,11 +164,11 @@ function _activeSenderId() { /** Текст со списком задач (для /todos и уведомления о завершении). */ function formatTodos(todos) { const items = Array.isArray(todos) ? todos : []; - if (items.length === 0) return '☑ Список задач пуст.'; - const icon = { pending: '☐', in_progress: '◔', completed: '☑' }; - const done = items.filter((t) => t.status === 'completed').length; - const lines = items.map((t) => (icon[t.status] || '☐') + ' ' + t.content); - return '☑ Задачи (' + done + '/' + items.length + '):\n' + lines.join('\n'); + if (items.length === 0) return "☑ Список задач пуст."; + const icon = { pending: "☐", in_progress: "◔", completed: "☑" }; + const done = items.filter((t) => t.status === "completed").length; + const lines = items.map((t) => (icon[t.status] || "☐") + " " + t.content); + return "☑ Задачи (" + done + "/" + items.length + "):\n" + lines.join("\n"); } /** @@ -166,15 +190,15 @@ let _onQuestionAnswered = null; /** Установить колбэк, вызываемый при ответе на вопрос из TG. */ function setOnQuestionAnswered(fn) { - _onQuestionAnswered = typeof fn === 'function' ? fn : null; + _onQuestionAnswered = typeof fn === "function" ? fn : null; } /** Экранирование для HTML в тексте вопроса. */ function _qEsc(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>'); + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">"); } /** @@ -186,20 +210,26 @@ function _qEsc(s) { */ async function askQuestion(requestId, questions) { const cfg = _read(); - if (!cfg.enabled || !cfg.token || !cfg.chatId) return { success: false, skipped: true }; + if (!cfg.enabled || !cfg.token || !cfg.chatId) + return { success: false, skipped: true }; const items = Array.isArray(questions) ? questions : []; if (items.length === 0) return { success: false, skipped: true }; - const lines = ['❓ Вопрос от ИИ']; + const lines = ["❓ Вопрос от ИИ"]; items.forEach((q, qi) => { - lines.push(''); - lines.push((qi + 1) + '. ' + _qEsc(q.question)); + lines.push(""); + lines.push(qi + 1 + ". " + _qEsc(q.question)); }); - lines.push(''); - lines.push('Выберите вариант кнопкой ниже.'); - const text = lines.join('\n'); - - const entry = { questions: items, answers: new Array(items.length).fill(null), messageId: null, tokens: [] }; + lines.push(""); + lines.push("Выберите вариант кнопкой ниже."); + const text = lines.join("\n"); + + const entry = { + questions: items, + answers: new Array(items.length).fill(null), + messageId: null, + tokens: [], + }; _pendingQuestions.set(requestId, entry); // Клавиатура: по строке на каждый вариант каждого вопроса. @@ -209,16 +239,16 @@ async function askQuestion(requestId, questions) { const opts = Array.isArray(q.options) ? q.options : []; entry.tokens[qi] = []; opts.forEach((o, oi) => { - const label = String(o.label || '').slice(0, 60); - const token = 't' + (++_cbTokenCounter); + const label = String(o.label || "").slice(0, 60); + const token = "t" + ++_cbTokenCounter; _callbackTokens.set(token, { requestId, qi, oi }); entry.tokens[qi][oi] = token; - keyboard.push([{ text: (qi + 1) + ') ' + label, callback_data: token }]); + keyboard.push([{ text: qi + 1 + ") " + label, callback_data: token }]); }); }); const res = await telegramBot.sendMessage(text, { - parseMode: 'HTML', + parseMode: "HTML", replyMarkup: { inline_keyboard: keyboard }, }); if (!res.success) { @@ -231,14 +261,14 @@ async function askQuestion(requestId, questions) { /** Обработать нажатие inline-кнопки с ответом на вопрос. */ async function _handleCallback(chatId, data, cbq) { - const token = String(data || ''); + const token = String(data || ""); // Меню настроек /settings — обрабатываем раньше других, т.к. префикс 'st_'. - if (token.startsWith('st_')) { + if (token.startsWith("st_")) { try { await _handleSettingsCallback(token, cbq); } catch (err) { - _log('error', 'settings callback error:', err.message); + _log("error", "settings callback error:", err.message); } return; } @@ -257,19 +287,25 @@ async function _handleCallback(chatId, data, cbq) { const entry = _pendingQuestions.get(requestId); if (!entry || entry.answers[qi] != null) { _callbackTokens.delete(token); - await telegramBot.answerCallbackQuery(cbq.id, { text: 'Вопрос уже закрыт' }); + await telegramBot.answerCallbackQuery(cbq.id, { + text: "Вопрос уже закрыт", + }); return; } const q = entry.questions[qi]; const opt = (q.options || [])[oi]; if (!opt) { - await telegramBot.answerCallbackQuery(cbq.id, { text: 'Вариант не найден' }); + await telegramBot.answerCallbackQuery(cbq.id, { + text: "Вариант не найден", + }); return; } - entry.answers[qi] = { question: q.question, answer: String(opt.label || '') }; - await telegramBot.answerCallbackQuery(cbq.id, { text: 'Принято: ' + String(opt.label || '').slice(0, 40) }); + entry.answers[qi] = { question: q.question, answer: String(opt.label || "") }; + await telegramBot.answerCallbackQuery(cbq.id, { + text: "Принято: " + String(opt.label || "").slice(0, 40), + }); // Обновляем сообщение: показываем выбранные ответы, убираем использованные кнопки. _refreshQuestionMessage(requestId, entry); @@ -278,11 +314,13 @@ async function _handleCallback(chatId, data, cbq) { if (entry.answers.every((a) => a && a.answer)) { _pendingQuestions.delete(requestId); for (const row of entry.tokens) { - for (const t of (row || [])) _callbackTokens.delete(t); + for (const t of row || []) _callbackTokens.delete(t); } - if (typeof _onQuestionAnswered === 'function') { - try { _onQuestionAnswered(requestId, entry.answers.slice()); } catch (err) { - _log('error', 'onQuestionAnswered error:', err.message); + if (typeof _onQuestionAnswered === "function") { + try { + _onQuestionAnswered(requestId, entry.answers.slice()); + } catch (err) { + _log("error", "onQuestionAnswered error:", err.message); } } } @@ -291,36 +329,49 @@ async function _handleCallback(chatId, data, cbq) { /** Отправить запрос подтверждения tool-вызова в Telegram. */ async function requestApproval(requestId, info) { const cfg = _read(); - if (!cfg.enabled || !cfg.token || !cfg.chatId || cfg.approvalMode === 'off') { + if (!cfg.enabled || !cfg.token || !cfg.chatId || cfg.approvalMode === "off") { return { success: false, skipped: true }; } - const id = String(requestId || ''); - if (!id) return { success: false, error: 'requestId не задан' }; - - const isJs = info && info.kind === 'js'; - const name = isJs ? 'JS-скрипт' : String((info && info.toolName) || 'инструмент'); - let details = ''; + const id = String(requestId || ""); + if (!id) return { success: false, error: "requestId не задан" }; + + const isJs = info && info.kind === "js"; + const name = isJs + ? "JS-скрипт" + : String((info && info.toolName) || "инструмент"); + let details = ""; try { details = isJs - ? String((info && info.code) || '') + ? String((info && info.code) || "") : JSON.stringify((info && info.params) || {}, null, 2); - } catch (_) { details = ''; } + } catch (_) { + details = ""; + } const body = escapeHtml(details.slice(0, 3000)); - const token = 'a' + (++_cbTokenCounter); + const token = "a" + ++_cbTokenCounter; const entry = { messageId: null, token, resolve: null }; - const promise = new Promise((resolve) => { entry.resolve = resolve; }); + const promise = new Promise((resolve) => { + entry.resolve = resolve; + }); _pendingApprovals.set(id, entry); _approvalCallbackTokens.set(token, { requestId: id, approved: true }); - const msg = '🔐 Подтверждение команды\n' + escapeHtml(name) + '' + - (body ? '\n
' + body + '
' : '') + - '\nРазрешить выполнение?'; + const msg = + "🔐 Подтверждение команды\n" + + escapeHtml(name) + + "" + + (body ? "\n
" + body + "
" : "") + + "\nРазрешить выполнение?"; const res = await telegramBot.sendMessage(msg, { - parseMode: 'HTML', - replyMarkup: { inline_keyboard: [[ - { text: '✅ Разрешить', callback_data: token }, - { text: '❌ Отклонить', callback_data: token + 'd' }, - ]] }, + parseMode: "HTML", + replyMarkup: { + inline_keyboard: [ + [ + { text: "✅ Разрешить", callback_data: token }, + { text: "❌ Отклонить", callback_data: token + "d" }, + ], + ], + }, }); if (!res.success) { _pendingApprovals.delete(id); @@ -328,55 +379,62 @@ async function requestApproval(requestId, info) { return res; } entry.messageId = res.messageId || null; - _approvalCallbackTokens.set(token + 'd', { requestId: id, approved: false }); + _approvalCallbackTokens.set(token + "d", { requestId: id, approved: false }); return promise; } async function _handleApprovalCallback(ref, cbq) { const entry = _pendingApprovals.get(ref.requestId); if (!entry) { - await telegramBot.answerCallbackQuery(cbq.id, { text: 'Запрос уже закрыт' }); + await telegramBot.answerCallbackQuery(cbq.id, { + text: "Запрос уже закрыт", + }); return; } _pendingApprovals.delete(ref.requestId); _approvalCallbackTokens.delete(entry.token); - _approvalCallbackTokens.delete(entry.token + 'd'); + _approvalCallbackTokens.delete(entry.token + "d"); await telegramBot.answerCallbackQuery(cbq.id, { - text: ref.approved ? 'Команда разрешена' : 'Команда отклонена', + text: ref.approved ? "Команда разрешена" : "Команда отклонена", }); if (entry.messageId) { - const status = ref.approved ? '✅ Разрешено' : '❌ Отклонено'; - await telegramBot.editMessageText(entry.messageId, '🔐 Подтверждение команды\n' + status, { - parseMode: 'HTML', - replyMarkup: { inline_keyboard: [] }, - }); + const status = ref.approved ? "✅ Разрешено" : "❌ Отклонено"; + await telegramBot.editMessageText( + entry.messageId, + "🔐 Подтверждение команды\n" + status, + { + parseMode: "HTML", + replyMarkup: { inline_keyboard: [] }, + }, + ); } - if (typeof entry.resolve === 'function') { + if (typeof entry.resolve === "function") { entry.resolve({ success: true, approved: ref.approved }); } } /** Отменить запрос, если подтверждение уже получено в окне приложения. */ function cancelApproval(requestId) { - const id = String(requestId || ''); + const id = String(requestId || ""); const entry = _pendingApprovals.get(id); if (!entry) return { success: true, skipped: true }; _pendingApprovals.delete(id); _approvalCallbackTokens.delete(entry.token); - _approvalCallbackTokens.delete(entry.token + 'd'); - if (typeof entry.resolve === 'function') entry.resolve({ success: false, skipped: true }); + _approvalCallbackTokens.delete(entry.token + "d"); + if (typeof entry.resolve === "function") + entry.resolve({ success: false, skipped: true }); return { success: true }; } /** Перерисовать сообщение с вопросами: отметить выбранное, убрать лишние кнопки. */ async function _refreshQuestionMessage(requestId, entry) { if (!entry.messageId) return; - const lines = ['❓ Вопрос от ИИ']; + const lines = ["❓ Вопрос от ИИ"]; entry.questions.forEach((q, qi) => { - lines.push(''); + lines.push(""); const chosen = entry.answers[qi]; - lines.push((qi + 1) + '. ' + _qEsc(q.question)); - if (chosen) lines.push('✅ ' + _qEsc(chosen.answer)); + lines.push(qi + 1 + ". " + _qEsc(q.question)); + if (chosen) lines.push("✅ " + _qEsc(chosen.answer)); }); // Оставляем кнопки только для неотвеченных вопросов. @@ -385,16 +443,20 @@ async function _refreshQuestionMessage(requestId, entry) { if (entry.answers[qi]) return; const opts = Array.isArray(q.options) ? q.options : []; opts.forEach((o, oi) => { - const label = String(o.label || '').slice(0, 60); - const token = (entry.tokens[qi] && entry.tokens[qi][oi]) || ('t' + (++_cbTokenCounter)); - if (!_callbackTokens.has(token)) _callbackTokens.set(token, { requestId, qi, oi }); - keyboard.push([{ text: (qi + 1) + ') ' + label, callback_data: token }]); + const label = String(o.label || "").slice(0, 60); + const token = + (entry.tokens[qi] && entry.tokens[qi][oi]) || "t" + ++_cbTokenCounter; + if (!_callbackTokens.has(token)) + _callbackTokens.set(token, { requestId, qi, oi }); + keyboard.push([{ text: qi + 1 + ") " + label, callback_data: token }]); }); }); - await telegramBot.editMessageText(entry.messageId, lines.join('\n'), { - parseMode: 'HTML', - replyMarkup: keyboard.length ? { inline_keyboard: keyboard } : { inline_keyboard: [] }, + await telegramBot.editMessageText(entry.messageId, lines.join("\n"), { + parseMode: "HTML", + replyMarkup: keyboard.length + ? { inline_keyboard: keyboard } + : { inline_keyboard: [] }, }); } @@ -406,67 +468,185 @@ async function _refreshQuestionMessage(requestId, entry) { const SETTINGS_PAGES = { root: { - title: '⚙️ Настройки Cookie Code', - text: 'Что настраиваем?', + title: "⚙️ Настройки Cookie Code", + text: "Что настраиваем?", items: [ - { goto: 'ui', label: '🎨 Интерфейс' }, - { goto: 'glass', label: '🪟 Стекло и панель' }, - { goto: 'agent', label: '🤖 Агент и приватность' }, - { goto: 'tg', label: '📡 Telegram-бот' }, + { goto: "ui", label: "🎨 Интерфейс" }, + { goto: "glass", label: "🪟 Стекло и панель" }, + { goto: "agent", label: "🤖 Агент и приватность" }, + { goto: "tg", label: "📡 Telegram-бот" }, ], }, ui: { - title: '🎨 Интерфейс', - text: 'Общие визуальные эффекты.', + title: "🎨 Интерфейс", + text: "Общие визуальные эффекты.", items: [ - { key: 'customizationEnabled', label: 'Кастомизация вкл', type: 'bool' }, - { key: 'rgbUsername', label: 'RGB-переливание ника', type: 'bool' }, - { key: 'language', label: 'Язык UI', type: 'enum', values: [['ru', '🇷🇺 RU'], ['en', '🇬🇧 EN']] }, + { key: "customizationEnabled", label: "Кастомизация вкл", type: "bool" }, + { key: "rgbUsername", label: "RGB-переливание ника", type: "bool" }, + { + key: "language", + label: "Язык UI", + type: "enum", + values: [ + ["ru", "🇷🇺 RU"], + ["en", "🇬🇧 EN"], + ], + }, ], back: true, }, glass: { - title: '🪟 Стекло и панель', - text: 'Прозрачность, размытие и цвета панели Cookie Code.', + title: "🪟 Стекло и панель", + text: "Прозрачность, размытие и цвета панели Cookie Code.", items: [ - { key: 'backgroundBlur', label: 'Размытие фона', type: 'number', step: 2, min: 0, max: 30, unit: 'px' }, - { key: 'headerBlur', label: 'Размытие шапки', type: 'number', step: 2, min: 0, max: 30, unit: 'px' }, - { key: 'sidebarBlur', label: 'Размытие сайдбара', type: 'number', step: 2, min: 0, max: 30, unit: 'px' }, - { key: 'headerOpacity', label: 'Прозрачность шапки', type: 'number', step: 5, min: 0, max: 100, unit: '%' }, - { key: 'sidebarOpacity', label: 'Прозрачность сайдбара', type: 'number', step: 5, min: 0, max: 100, unit: '%' }, - { key: 'toolBlockOpacity', label: 'Прозрачность tool-блоков', type: 'number', step: 5, min: 0, max: 100, unit: '%' }, - { key: 'toolBlockBlur', label: 'Размытие tool-блоков', type: 'number', step: 2, min: 0, max: 30, unit: 'px' }, - { key: 'overlayOpacity', label: 'Прозрачность панели', type: 'number', step: 5, min: 0, max: 100, unit: '%' }, - { key: 'overlayBlur', label: 'Размытие панели', type: 'number', step: 2, min: 0, max: 30, unit: 'px' }, - { key: 'overlayWidth', label: 'Ширина панели', type: 'number', step: 20, min: 200, max: 600, unit: 'px' }, - { key: 'overlayBtnRadius', label: 'Скругление кнопок', type: 'number', step: 2, min: 0, max: 24, unit: 'px' }, - { key: 'overlayBgColor', label: 'Цвет подложки', type: 'color' }, - { key: 'overlayPrimaryColor', label: 'Акцентный цвет', type: 'color' }, + { + key: "backgroundBlur", + label: "Размытие фона", + type: "number", + step: 2, + min: 0, + max: 30, + unit: "px", + }, + { + key: "headerBlur", + label: "Размытие шапки", + type: "number", + step: 2, + min: 0, + max: 30, + unit: "px", + }, + { + key: "sidebarBlur", + label: "Размытие сайдбара", + type: "number", + step: 2, + min: 0, + max: 30, + unit: "px", + }, + { + key: "headerOpacity", + label: "Прозрачность шапки", + type: "number", + step: 5, + min: 0, + max: 100, + unit: "%", + }, + { + key: "sidebarOpacity", + label: "Прозрачность сайдбара", + type: "number", + step: 5, + min: 0, + max: 100, + unit: "%", + }, + { + key: "toolBlockOpacity", + label: "Прозрачность tool-блоков", + type: "number", + step: 5, + min: 0, + max: 100, + unit: "%", + }, + { + key: "toolBlockBlur", + label: "Размытие tool-блоков", + type: "number", + step: 2, + min: 0, + max: 30, + unit: "px", + }, + { + key: "overlayOpacity", + label: "Прозрачность панели", + type: "number", + step: 5, + min: 0, + max: 100, + unit: "%", + }, + { + key: "overlayBlur", + label: "Размытие панели", + type: "number", + step: 2, + min: 0, + max: 30, + unit: "px", + }, + { + key: "overlayWidth", + label: "Ширина панели", + type: "number", + step: 20, + min: 200, + max: 600, + unit: "px", + }, + { + key: "overlayBtnRadius", + label: "Скругление кнопок", + type: "number", + step: 2, + min: 0, + max: 24, + unit: "px", + }, + { key: "overlayBgColor", label: "Цвет подложки", type: "color" }, + { key: "overlayPrimaryColor", label: "Акцентный цвет", type: "color" }, ], back: true, }, agent: { - title: '🤖 Агент и приватность', - text: 'Подтверждения, служебные сообщения, форматтеры.', + title: "🤖 Агент и приватность", + text: "Подтверждения, служебные сообщения, форматтеры.", items: [ - { key: 'toolApprovalMode', label: 'Подтверждение tools', type: 'enum', values: [['off', '⚪ Off'], ['risky', '🟡 Risky'], ['all', '🔴 All']] }, - { key: 'hideSystemMessages', label: 'Скрывать служебные сообщения', type: 'bool' }, - { key: 'formattersEnabled', label: 'Авто-форматтеры', type: 'bool' }, - { key: 'fileChipEnabled', label: 'Пути как чипы', type: 'bool' }, - { key: 'showProducedFiles', label: 'Затронутые файлы', type: 'bool' }, - { key: 'dangerousPatterns', label: 'Опасные паттерны', type: 'multiline' }, + { + key: "toolApprovalMode", + label: "Подтверждение tools", + type: "enum", + values: [ + ["off", "⚪ Off"], + ["risky", "🟡 Risky"], + ["all", "🔴 All"], + ], + }, + { + key: "hideSystemMessages", + label: "Скрывать служебные сообщения", + type: "bool", + }, + { key: "formattersEnabled", label: "Авто-форматтеры", type: "bool" }, + { key: "fileChipEnabled", label: "Пути как чипы", type: "bool" }, + { key: "showProducedFiles", label: "Затронутые файлы", type: "bool" }, + { + key: "dangerousPatterns", + label: "Опасные паттерны", + type: "multiline", + }, ], back: true, }, tg: { - title: '📡 Telegram-бот', - text: 'Управление ботом и уведомлениями.', + title: "📡 Telegram-бот", + text: "Управление ботом и уведомлениями.", items: [ - { key: 'telegramEnabled', label: 'Бот включён', type: 'bool' }, - { key: 'telegramNotifyTools', label: 'Уведомления о tool', type: 'bool' }, - { key: 'telegramChatFeed', label: 'Принимать из TG', type: 'bool' }, - { key: 'telegramBotToken', label: 'Токен бота', type: 'text', secret: true }, - { key: 'telegramChatId', label: 'Chat ID', type: 'text' }, + { key: "telegramEnabled", label: "Бот включён", type: "bool" }, + { key: "telegramNotifyTools", label: "Уведомления о tool", type: "bool" }, + { key: "telegramChatFeed", label: "Принимать из TG", type: "bool" }, + { + key: "telegramBotToken", + label: "Токен бота", + type: "text", + secret: true, + }, + { key: "telegramChatId", label: "Chat ID", type: "text" }, ], back: true, }, @@ -479,104 +659,127 @@ function _findSetting(key) { for (const name of Object.keys(SETTINGS_PAGES)) { const page = SETTINGS_PAGES[name]; if (!page.items) continue; - for (const it of page.items) if (it.key === key) return { page: name, item: it }; + for (const it of page.items) + if (it.key === key) return { page: name, item: it }; } return null; } function _getVal(key) { - try { return settingsStore.readSettings()[key]; } catch (_) { return undefined; } + try { + return settingsStore.readSettings()[key]; + } catch (_) { + return undefined; + } } function _setVal(key, value) { - try { return !!settingsStore.setSetting(key, value); } catch (err) { - _log('error', 'setSetting error:', err.message); + try { + return !!settingsStore.setSetting(key, value); + } catch (err) { + _log("error", "setSetting error:", err.message); return false; } } -function _esc(s) { return escapeHtml(s); } +function _esc(s) { + return escapeHtml(s); +} function _formatValue(item, value) { - if (item.type === 'bool') return value ? '✅ ВКЛ' : '⚪ ВЫКЛ'; - if (item.type === 'enum') { + if (item.type === "bool") return value ? "✅ ВКЛ" : "⚪ ВЫКЛ"; + if (item.type === "enum") { const found = (item.values || []).find(([v]) => v === value); return found ? found[1] : String(value); } - if (item.type === 'number') return String(value) + (item.unit || ''); - if (item.type === 'color') return String(value || ''); - if (item.type === 'text' || item.type === 'multiline') { - if (item.secret) return value ? '••••' + String(value).slice(-4) : '(пусто)'; - const s = String(value == null ? '' : value); - return s.length > 20 ? s.slice(0, 20) + '…' : (s || '(пусто)'); + if (item.type === "number") return String(value) + (item.unit || ""); + if (item.type === "color") return String(value || ""); + if (item.type === "text" || item.type === "multiline") { + if (item.secret) + return value ? "••••" + String(value).slice(-4) : "(пусто)"; + const s = String(value == null ? "" : value); + return s.length > 20 ? s.slice(0, 20) + "…" : s || "(пусто)"; } return String(value); } function _renderPage(pageName) { const page = SETTINGS_PAGES[pageName]; - if (!page) return _renderPage('root'); - const lines = [page.title, '', page.text]; + if (!page) return _renderPage("root"); + const lines = [page.title, "", page.text]; const keyboard = []; if (page.items) { for (const item of page.items) { const v = _getVal(item.key); - if (item.type === 'bool') { - const icon = v ? '✅' : '⚪'; - keyboard.push([{ text: icon + ' ' + item.label, callback_data: 'st_t_' + item.key }]); - } else if (item.type === 'enum') { + if (item.type === "bool") { + const icon = v ? "✅" : "⚪"; + keyboard.push([ + { text: icon + " " + item.label, callback_data: "st_t_" + item.key }, + ]); + } else if (item.type === "enum") { const cur = _formatValue(item, v); - keyboard.push([{ text: item.label + ': ' + cur, callback_data: 'st_noop' }]); + keyboard.push([ + { text: item.label + ": " + cur, callback_data: "st_noop" }, + ]); const opts = (item.values || []).map(([val, lbl]) => ({ text: lbl, - callback_data: 'st_s_' + item.key + '__' + val, + callback_data: "st_s_" + item.key + "__" + val, })); if (opts.length) keyboard.push(opts); - } else if (item.type === 'number') { + } else if (item.type === "number") { const cur = _formatValue(item, v); keyboard.push([ - { text: '➖', callback_data: 'st_n_' + item.key + '_-' }, - { text: item.label + ': ' + cur, callback_data: 'st_noop' }, - { text: '➕', callback_data: 'st_n_' + item.key + '_+' }, + { text: "➖", callback_data: "st_n_" + item.key + "_-" }, + { text: item.label + ": " + cur, callback_data: "st_noop" }, + { text: "➕", callback_data: "st_n_" + item.key + "_+" }, ]); - } else if (item.type === 'color') { + } else if (item.type === "color") { keyboard.push([ - { text: '✏️ ' + item.label, callback_data: 'st_e_' + item.key }, - { text: String(v || ''), callback_data: 'st_noop' }, + { text: "✏️ " + item.label, callback_data: "st_e_" + item.key }, + { text: String(v || ""), callback_data: "st_noop" }, ]); - } else if (item.type === 'text' || item.type === 'multiline') { + } else if (item.type === "text" || item.type === "multiline") { keyboard.push([ - { text: '✏️ ' + item.label + ': ' + _formatValue(item, v), callback_data: 'st_e_' + item.key }, + { + text: "✏️ " + item.label + ": " + _formatValue(item, v), + callback_data: "st_e_" + item.key, + }, ]); } } } if (page.back) { - keyboard.push([{ text: '⬅️ Назад', callback_data: 'st_page_root' }]); + keyboard.push([{ text: "⬅️ Назад", callback_data: "st_page_root" }]); } else { keyboard.push([ - { text: '🔧 Агент', callback_data: 'st_page_agent' }, - { text: '🎨 UI', callback_data: 'st_page_ui' }, + { text: "🔧 Агент", callback_data: "st_page_agent" }, + { text: "🎨 UI", callback_data: "st_page_ui" }, ]); keyboard.push([ - { text: '🪟 Стекло', callback_data: 'st_page_glass' }, - { text: '📡 Telegram', callback_data: 'st_page_tg' }, + { text: "🪟 Стекло", callback_data: "st_page_glass" }, + { text: "📡 Telegram", callback_data: "st_page_tg" }, ]); - keyboard.push([{ text: '🔄 Обновить', callback_data: 'st_page_root' }]); + keyboard.push([{ text: "🔄 Обновить", callback_data: "st_page_root" }]); } - return { text: lines.join('\n'), keyboard: { inline_keyboard: keyboard } }; + return { text: lines.join("\n"), keyboard: { inline_keyboard: keyboard } }; } async function _showSettingsMenu(chatId, pageName, messageId) { const state = _settingsState.get(chatId) || {}; - const page = SETTINGS_PAGES[pageName] ? pageName : 'root'; + const page = SETTINGS_PAGES[pageName] ? pageName : "root"; state.page = page; _settingsState.set(chatId, state); const { text, keyboard } = _renderPage(page); if (messageId) { - return telegramBot.editMessageText(messageId, text, { parseMode: 'HTML', replyMarkup: keyboard }); + return telegramBot.editMessageText(messageId, text, { + parseMode: "HTML", + replyMarkup: keyboard, + }); } - const res = await telegramBot.sendMessage(text, { parseMode: 'HTML', replyMarkup: keyboard }); + const res = await telegramBot.sendMessage(text, { + parseMode: "HTML", + replyMarkup: keyboard, + }); if (res.success && res.messageId) { state.msgId = res.messageId; _settingsState.set(chatId, state); @@ -587,73 +790,107 @@ async function _showSettingsMenu(chatId, pageName, messageId) { async function _handleSettingsCallback(data, cbq) { const chatId = String(cbq.message && cbq.message.chat && cbq.message.chat.id); const msgId = cbq.message && cbq.message.message_id; - const state = _settingsState.get(chatId) || { page: 'root' }; + const state = _settingsState.get(chatId) || { page: "root" }; - if (data.startsWith('st_page_')) { + if (data.startsWith("st_page_")) { await telegramBot.answerCallbackQuery(cbq.id); - await _showSettingsMenu(chatId, data.slice('st_page_'.length), msgId); + await _showSettingsMenu(chatId, data.slice("st_page_".length), msgId); return; } - if (data.startsWith('st_t_')) { - const key = data.slice('st_t_'.length); + if (data.startsWith("st_t_")) { + const key = data.slice("st_t_".length); const next = !_getVal(key); const ok = _setVal(key, next); - await telegramBot.answerCallbackQuery(cbq.id, { text: ok ? (next ? 'Включено' : 'Выключено') : 'Ошибка' }); - if (ok) { try { await applySettings(); } catch (_) {} } + await telegramBot.answerCallbackQuery(cbq.id, { + text: ok ? (next ? "Включено" : "Выключено") : "Ошибка", + }); + if (ok) { + try { + await applySettings(); + } catch (_) {} + } await _showSettingsMenu(chatId, state.page, msgId); return; } - if (data.startsWith('st_s_')) { - const rest = data.slice('st_s_'.length); - const sep = rest.indexOf('__'); + if (data.startsWith("st_s_")) { + const rest = data.slice("st_s_".length); + const sep = rest.indexOf("__"); const key = rest.slice(0, sep); const val = rest.slice(sep + 2); const ok = _setVal(key, val); - await telegramBot.answerCallbackQuery(cbq.id, { text: ok ? 'Сохранено' : 'Ошибка' }); - if (ok && (key === 'language')) { try { await applySettings(); } catch (_) {} } + await telegramBot.answerCallbackQuery(cbq.id, { + text: ok ? "Сохранено" : "Ошибка", + }); + if (ok && key === "language") { + try { + await applySettings(); + } catch (_) {} + } await _showSettingsMenu(chatId, state.page, msgId); return; } - if (data.startsWith('st_n_')) { - const rest = data.slice('st_n_'.length); - const sep = rest.lastIndexOf('_'); + if (data.startsWith("st_n_")) { + const rest = data.slice("st_n_".length); + const sep = rest.lastIndexOf("_"); const key = rest.slice(0, sep); const sign = rest.slice(sep + 1); const found = _findSetting(key); - if (!found) { await telegramBot.answerCallbackQuery(cbq.id); return; } + if (!found) { + await telegramBot.answerCallbackQuery(cbq.id); + return; + } const item = found.item; const step = item.step || 1; let val = Number(_getVal(key)); if (!Number.isFinite(val)) val = 0; - val += sign === '+' ? step : -step; - if (typeof item.min === 'number') val = Math.max(item.min, val); - if (typeof item.max === 'number') val = Math.min(item.max, val); + val += sign === "+" ? step : -step; + if (typeof item.min === "number") val = Math.max(item.min, val); + if (typeof item.max === "number") val = Math.min(item.max, val); const ok = _setVal(key, val); - await telegramBot.answerCallbackQuery(cbq.id, { text: ok ? (String(val) + (item.unit || '')) : 'Ошибка' }); + await telegramBot.answerCallbackQuery(cbq.id, { + text: ok ? String(val) + (item.unit || "") : "Ошибка", + }); await _showSettingsMenu(chatId, state.page, msgId); return; } - if (data.startsWith('st_e_')) { - const key = data.slice('st_e_'.length); + if (data.startsWith("st_e_")) { + const key = data.slice("st_e_".length); const found = _findSetting(key); - if (!found) { await telegramBot.answerCallbackQuery(cbq.id); return; } + if (!found) { + await telegramBot.answerCallbackQuery(cbq.id); + return; + } _settingsWaiting.set(chatId, { key }); - await telegramBot.answerCallbackQuery(cbq.id, { text: 'Отправьте новое значение' }); + await telegramBot.answerCallbackQuery(cbq.id, { + text: "Отправьте новое значение", + }); const cur = _getVal(key); - const hint = found.item.type === 'multiline' - ? 'Отправьте новый список — по одному паттерну в строке. /cancel — отмена.' - : 'Отправьте новое значение одним сообщением. /cancel — отмена.'; - const curText = found.item.type === 'multiline' - ? (Array.isArray(cur) ? cur.join('\n') : String(cur || '')) - : String(cur == null ? '' : cur); + const hint = + found.item.type === "multiline" + ? "Отправьте новый список — по одному паттерну в строке. /cancel — отмена." + : "Отправьте новое значение одним сообщением. /cancel — отмена."; + const curText = + found.item.type === "multiline" + ? Array.isArray(cur) + ? cur.join("\n") + : String(cur || "") + : String(cur == null ? "" : cur); await telegramBot.sendMessage( - '✏️ ' + _esc(found.item.label) + '\n' + - hint + '\n\nТекущее:\n
' + _esc(curText.slice(0, 1500)) + '
', - { parseMode: 'HTML' } + "✏️ " + + _esc(found.item.label) + + "\n" + + hint + + "\n\nТекущее:\n
" +
+        _esc(curText.slice(0, 1500)) +
+        "
", + { parseMode: "HTML" }, ); return; } - if (data === 'st_noop') { await telegramBot.answerCallbackQuery(cbq.id); return; } + if (data === "st_noop") { + await telegramBot.answerCallbackQuery(cbq.id); + return; + } await telegramBot.answerCallbackQuery(cbq.id); } @@ -662,82 +899,126 @@ async function _cmdStop() { let count = 0; try { const win = windowState.getMainWindow(); - if (win && !win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) { - try { win.webContents.stop(); } catch (_) {} + if ( + win && + !win.isDestroyed() && + win.webContents && + !win.webContents.isDestroyed() + ) { + try { + win.webContents.stop(); + } catch (_) {} } - const { processManager } = require('../src/main/process-manager'); + const { processManager } = require("../src/main/process-manager"); const r = await processManager.killAll(); count = r.count || 0; } catch (err) { - _log('error', '/stop error:', err.message); - return telegramBot.sendMessage('❌ Не удалось остановить: ' + escapeHtml(err.message)); + _log("error", "/stop error:", err.message); + return telegramBot.sendMessage( + "❌ Не удалось остановить: " + escapeHtml(err.message), + ); } - return telegramBot.sendMessage('🛑 Остановлено. Убито процессов: ' + count + '.', { parseMode: 'HTML' }); + return telegramBot.sendMessage( + "🛑 Остановлено. Убито процессов: " + count + ".", + { parseMode: "HTML" }, + ); } /** /status — окно, проект, процессы, версия, polling. */ async function _cmdStatus() { const cfg = _read(); - const lines = ['📊 Статус Cookie Code', '']; + const lines = ["📊 Статус Cookie Code", ""]; - let version = '?'; - try { version = require('electron').app.getVersion(); } catch (_) {} - lines.push('📦 Версия: ' + escapeHtml(version) + ''); + let version = "?"; + try { + version = require("electron").app.getVersion(); + } catch (_) {} + lines.push("📦 Версия: " + escapeHtml(version) + ""); const win = windowState.getMainWindow(); const winOk = !!(win && !win.isDestroyed()); - lines.push('🪟 Окно: ' + (winOk ? 'активно' : '❌ нет')); + lines.push("🪟 Окно: " + (winOk ? "активно" : "❌ нет")); const ctx = _activeContext(); - const projectDir = ctx && ctx.sessionStore && ctx.sessionStore.state.selectedProjectDir; - lines.push('📁 Проект: ' + (projectDir ? '' + escapeHtml(projectDir) + '' : 'не выбран')); - if (ctx && ctx.providerId) lines.push('🌐 Провайдер: ' + escapeHtml(ctx.providerId)); + const projectDir = + ctx && ctx.sessionStore && ctx.sessionStore.state.selectedProjectDir; + lines.push( + "📁 Проект: " + + (projectDir + ? "" + escapeHtml(projectDir) + "" + : "не выбран"), + ); + if (ctx && ctx.providerId) + lines.push("🌐 Провайдер: " + escapeHtml(ctx.providerId)); let procCount = 0; try { - const { processManager } = require('../src/main/process-manager'); - procCount = (processManager.activeProcesses && processManager.activeProcesses.size) || 0; + const { processManager } = require("../src/main/process-manager"); + procCount = + (processManager.activeProcesses && processManager.activeProcesses.size) || + 0; } catch (_) {} - lines.push('⚙️ Активных процессов: ' + procCount + ''); + lines.push("⚙️ Активных процессов: " + procCount + ""); - let todos = 0, done = 0; + let todos = 0, + done = 0; try { const sid = _activeSenderId(); if (sid != null) { - const list = require('../src/main/todo-store').getList(sid) || []; + const list = require("../src/main/todo-store").getList(sid) || []; todos = list.length; - done = list.filter((t) => t.status === 'completed').length; + done = list.filter((t) => t.status === "completed").length; } } catch (_) {} - lines.push('☑️ Задачи: ' + done + '/' + todos); + lines.push("☑️ Задачи: " + done + "/" + todos); const st = telegramBot.getStatus(); - lines.push('📡 Бот: ' + (cfg.enabled ? (st.polling ? '✅ работает' : '⚠️ включён, но не опрашивает') : '⚪ выключен')); - if (st.lastError) lines.push('⚠️ Последняя ошибка: ' + escapeHtml(String(st.lastError).slice(0, 200))); + lines.push( + "📡 Бот: " + + (cfg.enabled + ? st.polling + ? "✅ работает" + : "⚠️ включён, но не опрашивает" + : "⚪ выключен"), + ); + if (st.lastError) + lines.push( + "⚠️ Последняя ошибка: " + escapeHtml(String(st.lastError).slice(0, 200)), + ); - return telegramBot.sendMessage(lines.join('\n'), { parseMode: 'HTML' }); + return telegramBot.sendMessage(lines.join("\n"), { parseMode: "HTML" }); } /** /new — новый чат (очистка контекста текущей сессии). */ async function _cmdNew() { - const res = await _evalInWindow('(async () => { try { const r = await window.electronAPI.newChat(); return r; } catch (e) { return { success: false, error: e.message }; } })()'); - if (!res.success) return telegramBot.sendMessage('⚠️ ' + escapeHtml(res.error)); + const res = await _evalInWindow( + "(async () => { try { const r = await window.electronAPI.newChat(); return r; } catch (e) { return { success: false, error: e.message }; } })()", + ); + if (!res.success) + return telegramBot.sendMessage("⚠️ " + escapeHtml(res.error)); const inner = res.result || {}; - if (inner.success === false) return telegramBot.sendMessage('⚠️ ' + escapeHtml(inner.error || 'не удалось')); - return telegramBot.sendMessage('🆕 Новый чат открыт.'); + if (inner.success === false) + return telegramBot.sendMessage( + "⚠️ " + escapeHtml(inner.error || "не удалось"), + ); + return telegramBot.sendMessage("🆕 Новый чат открыт."); } /** /diff — показать текущие изменения (git diff) в проекте. */ async function _cmdDiff() { const ctx = _activeContext(); - const projectDir = ctx && ctx.sessionStore && ctx.sessionStore.state.selectedProjectDir; - if (!projectDir) return telegramBot.sendMessage('⚠️ Проект не выбран.'); + const projectDir = + ctx && ctx.sessionStore && ctx.sessionStore.state.selectedProjectDir; + if (!projectDir) return telegramBot.sendMessage("⚠️ Проект не выбран."); - const gitDiff = require('../src/main/git-diff'); + const gitDiff = require("../src/main/git-diff"); const status = await gitDiff.getStatus(projectDir); - if (!status.success) return telegramBot.sendMessage('⚠️ ' + escapeHtml(status.reason || 'git недоступен')); + if (!status.success) + return telegramBot.sendMessage( + "⚠️ " + escapeHtml(status.reason || "git недоступен"), + ); const files = status.files || []; - if (files.length === 0) return telegramBot.sendMessage('✅ Изменений нет.'); + if (files.length === 0) return telegramBot.sendMessage("✅ Изменений нет."); const chunks = []; let total = 0; @@ -747,54 +1028,66 @@ async function _cmdDiff() { const d = await gitDiff.getFileDiff(projectDir, f.path, f.status); if (!d.success || !d.diff) continue; const text = d.diff.slice(0, MAX_TOTAL - total); - chunks.push('===== ' + f.path + ' =====\n' + text); + chunks.push("===== " + f.path + " =====\n" + text); total += text.length; } - const header = '📝 Изменения (' + files.length + ')\n'; - const fileList = files.map((f) => '• ' + escapeHtml(f.path)).join('\n'); - const body = chunks.join('\n\n') || '(diff недоступен)'; - const msg = header + '
' + escapeHtml(fileList) + '
\n
' + escapeHtml(body) + '
'; - return telegramBot.sendMessage(msg, { parseMode: 'HTML' }); + const header = "📝 Изменения (" + files.length + ")\n"; + const fileList = files.map((f) => "• " + escapeHtml(f.path)).join("\n"); + const body = chunks.join("\n\n") || "(diff недоступен)"; + const msg = + header + + "
" + + escapeHtml(fileList) + + "
\n
" +
+    escapeHtml(body) +
+    "
"; + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } /** /diagnostics — отчёт диагностики интеграции. */ async function _cmdDiagnostics() { - const res = await _evalInWindow('(async () => { try { return await window.electronAPI.runDiagnosticsText(); } catch (e) { return "ERROR: " + e.message; } })()'); - if (!res.success) return telegramBot.sendMessage('⚠️ ' + escapeHtml(res.error)); - const text = String(res.result || '(пусто)').slice(0, 3500); - return telegramBot.sendMessage('🩺 Диагностика\n
' + escapeHtml(text) + '
', { parseMode: 'HTML' }); + const res = await _evalInWindow( + '(async () => { try { return await window.electronAPI.runDiagnosticsText(); } catch (e) { return "ERROR: " + e.message; } })()', + ); + if (!res.success) + return telegramBot.sendMessage("⚠️ " + escapeHtml(res.error)); + const text = String(res.result || "(пусто)").slice(0, 3500); + return telegramBot.sendMessage( + "🩺 Диагностика\n
" + escapeHtml(text) + "
", + { parseMode: "HTML" }, + ); } async function _cmdHelp() { const text = [ - '🦆 Cookie Code — помощь', - '', - 'Команды', - '/settings — открыть меню настроек', - '/status — статус: окно, проект, процессы', - '/stop — прервать задачу и убить процессы', - '/new — новый чат', - '/diff — показать изменения (git diff)', - '/diagnostics — диагностика интеграции', - '/todos — список задач активного окна', - '/cancel — отменить ввод / подтверждение', - '/help — эта справка', - '', - 'Возможности', - '• Уведомления о вызовах инструментов', - '• Приём входящих сообщений в чат DeepSeek', - '• Подтверждение команд — кнопки «Разрешить / Отклонить»', - '• Вопросы от AI с вариантами ответа', - '', - 'Настройки синхронизированы с десктопным приложением.', - ].join('\n'); - return telegramBot.sendMessage(text, { parseMode: 'HTML' }); + "🦆 Cookie Code — помощь", + "", + "Команды", + "/settings — открыть меню настроек", + "/status — статус: окно, проект, процессы", + "/stop — прервать задачу и убить процессы", + "/new — новый чат", + "/diff — показать изменения (git diff)", + "/diagnostics — диагностика интеграции", + "/todos — список задач активного окна", + "/cancel — отменить ввод / подтверждение", + "/help — эта справка", + "", + "Возможности", + "• Уведомления о вызовах инструментов", + "• Приём входящих сообщений в чат DeepSeek", + "• Подтверждение команд — кнопки «Разрешить / Отклонить»", + "• Вопросы от AI с вариантами ответа", + "", + "Настройки синхронизированы с десктопным приложением.", + ].join("\n"); + return telegramBot.sendMessage(text, { parseMode: "HTML" }); } async function _cmdSettings(chatId) { - _settingsState.set(chatId, { page: 'root' }); - return _showSettingsMenu(chatId, 'root'); + _settingsState.set(chatId, { page: "root" }); + return _showSettingsMenu(chatId, "root"); } async function _handleSettingsInput(chatId, text) { @@ -802,67 +1095,89 @@ async function _handleSettingsInput(chatId, text) { if (!waiting) return false; const { key } = waiting; const found = _findSetting(key); - if (!found) { _settingsWaiting.delete(chatId); return false; } + if (!found) { + _settingsWaiting.delete(chatId); + return false; + } _settingsWaiting.delete(chatId); const item = found.item; let value = text; - if (item.type === 'multiline') { - value = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); - } else if (item.type === 'color') { + if (item.type === "multiline") { + value = text + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + } else if (item.type === "color") { value = text.trim(); if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)) { - await telegramBot.sendMessage('⚠ Некорректный цвет. Ожидается hex вида #8b93ff.', { parseMode: 'HTML' }); + await telegramBot.sendMessage( + "⚠ Некорректный цвет. Ожидается hex вида #8b93ff.", + { parseMode: "HTML" }, + ); return true; } - } else if (item.type === 'text') { + } else if (item.type === "text") { value = text.trim(); } const ok = _setVal(key, value); - if (ok && (key === 'telegramBotToken' || key === 'telegramChatId' || key === 'telegramEnabled')) { - try { await applySettings(); } catch (_) {} + if ( + ok && + (key === "telegramBotToken" || + key === "telegramChatId" || + key === "telegramEnabled") + ) { + try { + await applySettings(); + } catch (_) {} } - await telegramBot.sendMessage(ok ? ('✅ Сохранено: ' + _esc(item.label) + '') : '❌ Не удалось сохранить', { parseMode: 'HTML' }); + await telegramBot.sendMessage( + ok + ? "✅ Сохранено: " + _esc(item.label) + "" + : "❌ Не удалось сохранить", + { parseMode: "HTML" }, + ); const state = _settingsState.get(chatId); - if (state && state.msgId) await _showSettingsMenu(chatId, state.page || 'root', state.msgId); + if (state && state.msgId) + await _showSettingsMenu(chatId, state.page || "root", state.msgId); return true; } /** Входящее из TG → в чат DeepSeek (или команда). */ async function _handleIncoming(chatId, text) { const cfg = _read(); - const raw = String(text || ''); + const raw = String(text || ""); const cmd = raw.trim().toLowerCase(); // ---- Служебные команды ---- - if (cmd === '/help' || cmd === '/start') { + if (cmd === "/help" || cmd === "/start") { await _cmdHelp(); return; } - if (cmd === '/settings' || cmd === '/setting' || cmd === '/config') { + if (cmd === "/settings" || cmd === "/setting" || cmd === "/config") { await _cmdSettings(chatId); return; } - if (cmd === '/stop' || cmd === '/kill') { + if (cmd === "/stop" || cmd === "/kill") { await _cmdStop(); return; } - if (cmd === '/status' || cmd === '/stat') { + if (cmd === "/status" || cmd === "/stat") { await _cmdStatus(); return; } - if (cmd === '/new') { + if (cmd === "/new") { await _cmdNew(); return; } - if (cmd === '/diff') { + if (cmd === "/diff") { await _cmdDiff(); return; } - if (cmd === '/diagnostics' || cmd === '/diag') { + if (cmd === "/diagnostics" || cmd === "/diag") { await _cmdDiagnostics(); return; } - if (cmd === '/cancel') { + if (cmd === "/cancel") { const hadSettings = _settingsWaiting.delete(chatId); // Отменяем все ожидающие approval (AI больше не ждёт подтверждения). let cancelledApprovals = 0; @@ -870,22 +1185,25 @@ async function _handleIncoming(chatId, text) { const r = cancelApproval(id); if (r && r.success && !r.skipped) cancelledApprovals++; } - if (hadSettings) await telegramBot.sendMessage('✖️ Ввод отменён.'); - else if (cancelledApprovals > 0) await telegramBot.sendMessage('✖️ Отменено подтверждений: ' + cancelledApprovals); - else await telegramBot.sendMessage('Нечего отменять.'); + if (hadSettings) await telegramBot.sendMessage("✖️ Ввод отменён."); + else if (cancelledApprovals > 0) + await telegramBot.sendMessage( + "✖️ Отменено подтверждений: " + cancelledApprovals, + ); + else await telegramBot.sendMessage("Нечего отменять."); return; } // Команда /todos — показать список задач активного окна. - if (cmd === '/todos' || cmd === '/todo') { + if (cmd === "/todos" || cmd === "/todo") { let todos = []; try { const senderId = _activeSenderId(); if (senderId != null) { - const todoStore = require('../src/main/todo-store'); + const todoStore = require("../src/main/todo-store"); todos = todoStore.getList(senderId); } } catch (err) { - _log('error', '/todos error:', err.message); + _log("error", "/todos error:", err.message); } await telegramBot.sendMessage(formatTodos(todos)); return; @@ -899,25 +1217,35 @@ async function _handleIncoming(chatId, text) { // ---- Обычное сообщение ---- if (!cfg.chatFeed) { - _log('info', 'chatFeed выключен — игнор входящего:', text); + _log("info", "chatFeed выключен — игнор входящего:", text); return; } - _log('info', 'incoming → chat:', text); + _log("info", "incoming → chat:", text); const res = await _sendToChat(text); if (!res.success) { - await telegramBot.sendMessage('⚠ Не удалось отправить в чат: ' + (res.error || 'unknown')); + await telegramBot.sendMessage( + "⚠ Не удалось отправить в чат: " + (res.error || "unknown"), + ); } } /** Уведомить, что все задачи выполнены (со списком ниже). */ async function notifyAllDone(todos) { const cfg = _read(); - if (!cfg.enabled || !cfg.notifyTools) return { success: false, skipped: true }; + if (!cfg.enabled || !cfg.notifyTools) + return { success: false, skipped: true }; const items = Array.isArray(todos) ? todos : []; if (items.length === 0) return { success: false, skipped: true }; - const lines = items.map((t) => '☑ ' + escapeHtml(t.content)).join('\n'); - const msg = '🎉 Все задачи выполнены (' + items.length + '/' + items.length + ')\n
' + lines + '
'; - return telegramBot.sendMessage(msg, { parseMode: 'HTML' }); + const lines = items.map((t) => "☑ " + escapeHtml(t.content)).join("\n"); + const msg = + "🎉 Все задачи выполнены (" + + items.length + + "/" + + items.length + + ")\n
" + + lines + + "
"; + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } /** @@ -925,46 +1253,65 @@ async function notifyAllDone(todos) { * Для edit показываем НОВЫЙ код (new_string) до 2000 символов. */ function formatToolArgs(toolName, args) { - if (!args || typeof args !== 'object') return ''; + if (!args || typeof args !== "object") return ""; const a = args; switch (toolName) { - case 'edit': { - const file = a.file_path || a.path || ''; - const neu = a.new_string != null ? String(a.new_string) : ''; - const old = a.old_string != null ? String(a.old_string) : ''; - let s = ''; - if (file) s += '📄 ' + file + '\n'; - if (old) s += '➖ было:\n' + old.slice(0, 500) + (old.length > 500 ? '\n…(обрезано)' : '') + '\n'; - if (neu) s += '➕ стало:\n' + neu.slice(0, 2000) + (neu.length > 2000 ? '\n…(обрезано, всего ' + neu.length + ' симв.)' : ''); + case "edit": { + const file = a.file_path || a.path || ""; + const neu = a.new_string != null ? String(a.new_string) : ""; + const old = a.old_string != null ? String(a.old_string) : ""; + let s = ""; + if (file) s += "📄 " + file + "\n"; + if (old) + s += + "➖ было:\n" + + old.slice(0, 500) + + (old.length > 500 ? "\n…(обрезано)" : "") + + "\n"; + if (neu) + s += + "➕ стало:\n" + + neu.slice(0, 2000) + + (neu.length > 2000 + ? "\n…(обрезано, всего " + neu.length + " симв.)" + : ""); return s; } - case 'read': - case 'readLines': - case 'write': - return a.file_path || a.path ? '📄 ' + (a.file_path || a.path) : ''; - case 'grep': - return '🔎 ' + (a.pattern || '') + (a.path ? ' в ' + a.path : '') + (a.include ? ' (' + a.include + ')' : ''); - case 'glob': - return '🔎 ' + (a.pattern || '') + (a.path ? ' в ' + a.path : ''); - case 'bash': - case 'pwsh': - return '💻 ' + (a.command || ''); - case 'todoWrite': - return '☑ ' + (Array.isArray(a.todos) ? a.todos.length + ' пункт(ов)' : ''); + case "read": + case "readLines": + case "write": + return a.file_path || a.path ? "📄 " + (a.file_path || a.path) : ""; + case "grep": + return ( + "🔎 " + + (a.pattern || "") + + (a.path ? " в " + a.path : "") + + (a.include ? " (" + a.include + ")" : "") + ); + case "glob": + return "🔎 " + (a.pattern || "") + (a.path ? " в " + a.path : ""); + case "bash": + case "pwsh": + return "💻 " + (a.command || ""); + case "todoWrite": + return ( + "☑ " + (Array.isArray(a.todos) ? a.todos.length + " пункт(ов)" : "") + ); default: { // Общий случай — компактный JSON, кроме длинных полей. try { const shallow = {}; for (const k of Object.keys(a)) { const v = a[k]; - if (typeof v === 'string' && v.length > 200) shallow[k] = v.slice(0, 200) + '…'; + if (typeof v === "string" && v.length > 200) + shallow[k] = v.slice(0, 200) + "…"; else shallow[k] = v; } const s = JSON.stringify(shallow); - return s && s !== '{}' ? '📥 ' + s : ''; + return s && s !== "{}" ? "📥 " + s : ""; } catch (_) { - return ''; + return ""; } } } @@ -972,55 +1319,75 @@ function formatToolArgs(toolName, args) { /** Экранирование для HTML parse_mode. */ function escapeHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>'); + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">"); } /** Уведомление о результате tool. */ async function notifyToolResult(toolName, ok, detail) { const cfg = _read(); - if (!cfg.enabled || !cfg.notifyTools) return { success: false, skipped: true }; - const emoji = ok ? '✅' : '❌'; - const header = emoji + ' ' + escapeHtml(toolName); + if (!cfg.enabled || !cfg.notifyTools) + return { success: false, skipped: true }; + const emoji = ok ? "✅" : "❌"; + const header = emoji + " " + escapeHtml(toolName); // detail может быть строкой (ошибка) или объектом { args, preview }. - if (detail && typeof detail === 'object') { + if (detail && typeof detail === "object") { const { args, preview } = detail; const argsText = formatToolArgs(toolName, args); const parts = []; - if (toolName === 'edit') { + if (toolName === "edit") { // Красивый diff-стиль: старый код как -, новый как +. - const file = (args && (args.file_path || args.path)) || ''; - const oldS = args && args.old_string != null ? String(args.old_string) : ''; - const newS = args && args.new_string != null ? String(args.new_string) : ''; + const file = (args && (args.file_path || args.path)) || ""; + const oldS = + args && args.old_string != null ? String(args.old_string) : ""; + const newS = + args && args.new_string != null ? String(args.new_string) : ""; const lines = []; const addLines = (prefix, s) => { - for (const ln of s.split('\n')) lines.push(prefix + escapeHtml(ln)); + for (const ln of s.split("\n")) lines.push(prefix + escapeHtml(ln)); }; - if (oldS) addLines('➖ ', oldS.slice(0, 1000)); - if (newS) addLines('➕ ', newS.slice(0, 2000)); - const body = lines.join('\n') || '(пустое изменение)'; - const title = file ? ' ' + escapeHtml(file) : ''; - const msg = emoji + ' edit' + title + '\n
' + body + '
'; - return telegramBot.sendMessage(msg, { parseMode: 'HTML' }); + if (oldS) addLines("➖ ", oldS.slice(0, 1000)); + if (newS) addLines("➕ ", newS.slice(0, 2000)); + const body = lines.join("\n") || "(пустое изменение)"; + const title = file ? " " + escapeHtml(file) : ""; + const msg = + emoji + + " edit" + + title + + "\n
" + + body + + "
"; + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } // Остальные инструменты: аргументы + результат в цитате
. const bodyParts = []; if (argsText) bodyParts.push(argsText); - if (preview) bodyParts.push('📤 ' + String(preview).replace(/\n{3,}/g, '\n\n').slice(0, 600)); - const bodyHtml = escapeHtml(bodyParts.join('\n')); - const msg = header + (bodyHtml ? '\n
' + bodyHtml + '
' : ''); - return telegramBot.sendMessage(msg, { parseMode: 'HTML' }); + if (preview) + bodyParts.push( + "📤 " + + String(preview) + .replace(/\n{3,}/g, "\n\n") + .slice(0, 600), + ); + const bodyHtml = escapeHtml(bodyParts.join("\n")); + const msg = + header + (bodyHtml ? "\n
" + bodyHtml + "
" : ""); + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } // detail — строка. let msg = header; - if (detail) msg += '\n
' + escapeHtml(String(detail).slice(0, 600)) + '
'; - return telegramBot.sendMessage(msg, { parseMode: 'HTML' }); + if (detail) + msg += + "\n
" + + escapeHtml(String(detail).slice(0, 600)) + + "
"; + return telegramBot.sendMessage(msg, { parseMode: "HTML" }); } /** Применить настройки: пересоздать конфиг бота и (при необходимости) запустить polling. */ @@ -1042,6 +1409,38 @@ function getStatus() { return Object.assign({ started }, telegramBot.getStatus()); } +// ==================== «ИИ печатает…» ==================== +// +// Telegram показывает статус «печатает…» ~5 секунд после sendChatAction('typing'). +// Пока идёт стриминг ответа, раз в 4 секунды продлеваем индикатор. +// startTyping() — включить, stopTyping() — выключить (по завершении генерации). + +let _typingTimer = null; + +function startTyping() { + const cfg = _read(); + if (!cfg.enabled || !cfg.token || !cfg.chatId) + return { success: false, skipped: true }; + telegramBot.configure(cfg.token, cfg.chatId); + if (_typingTimer) return { success: true, already: true }; + const tick = () => { + telegramBot.sendChatAction("typing").catch(() => {}); + }; + tick(); + _typingTimer = setInterval(tick, 4000); + return { success: true }; +} + +function stopTyping() { + // Telegram гасит «печатает…» сам через ~5с после последнего sendChatAction. + // Достаточно прекратить продление индикатора. + if (_typingTimer) { + clearInterval(_typingTimer); + _typingTimer = null; + } + return { success: true }; +} + /** * Отправить ответ AI в Telegram (для просмотра с телефона). * Обрезает слишком длинные ответы. @@ -1049,11 +1448,14 @@ function getStatus() { async function notifyAIResponse(text) { const cfg = _read(); if (!cfg.enabled || !cfg.chatFeed) return { success: false, skipped: true }; - const s = String(text || '').trim(); + const s = String(text || "").trim(); if (!s) return { success: false, skipped: true }; const MAX = 3500; - const body = s.length > MAX ? s.slice(0, MAX) + '\n…(обрезано, всего ' + s.length + ' симв.)' : s; - return telegramBot.sendMessage('🤖 ' + body); + const body = + s.length > MAX + ? s.slice(0, MAX) + "\n…(обрезано, всего " + s.length + " симв.)" + : s; + return telegramBot.sendMessage("🤖 " + body); } /** Пингануть бота (getMe) для проверки токена. */ @@ -1067,7 +1469,9 @@ async function ping() { async function testSend() { const cfg = _read(); telegramBot.configure(cfg.token, cfg.chatId); - return telegramBot.sendMessage('👋 Cookie Code: тестовое уведомление. Всё работает.'); + return telegramBot.sendMessage( + "👋 Cookie Code: тестовое уведомление. Всё работает.", + ); } module.exports = { @@ -1077,6 +1481,8 @@ module.exports = { testSend, notifyToolResult, notifyAIResponse, + startTyping, + stopTyping, notifyAllDone, requestApproval, cancelApproval, diff --git a/botsrc/telegram.js b/botsrc/telegram.js index d21cb27..881d368 100644 --- a/botsrc/telegram.js +++ b/botsrc/telegram.js @@ -14,44 +14,46 @@ * * Использует global fetch (Node 18+/Electron), без npm-зависимостей. */ -const API_BASE = 'https://api.telegram.org'; +const API_BASE = "https://api.telegram.org"; class TelegramBot { constructor() { - this.token = ''; - this.chatId = ''; + this.token = ""; + this.chatId = ""; this.polling = false; this.offset = 0; this.pollTimer = null; - this.onMessage = null; // (chatId, text, msg) => void - this.onCallback = null; // (chatId, data, cbq) => void - this.onLog = null; // (level, ...args) => void + this.onMessage = null; // (chatId, text, msg) => void + this.onCallback = null; // (chatId, data, cbq) => void + this.onLog = null; // (level, ...args) => void this._lastError = null; this._pollDelayMs = 2000; // пауза при ошибке/пустом ответе - this._queue = []; // очередь исходящих сообщений + this._queue = []; // очередь исходящих сообщений this._draining = false; - this._minGapMs = 1100; // ~1 msg/sec на чат (лимит Telegram) + this._minGapMs = 1100; // ~1 msg/sec на чат (лимит Telegram) } /** Замаскировать токен бота (bot) в произвольной строке. */ static maskToken(str, token) { - const s = String(str == null ? '' : str); + const s = String(str == null ? "" : str); if (!token) return s; - return s.split(token).join('bot'); + return s.split(token).join("bot"); } _log(level, ...args) { - if (typeof this.onLog !== 'function') return; + if (typeof this.onLog !== "function") return; const masked = args.map((a) => - typeof a === 'string' ? TelegramBot.maskToken(a, this.token) : a + typeof a === "string" ? TelegramBot.maskToken(a, this.token) : a, ); - try { this.onLog(level, ...masked); } catch (_) {} + try { + this.onLog(level, ...masked); + } catch (_) {} } /** Настроить токен/chat_id. Возвращает true, если параметры валидны. */ configure(token, chatId) { - this.token = String(token || '').trim(); - this.chatId = String(chatId || '').trim(); + this.token = String(token || "").trim(); + this.chatId = String(chatId || "").trim(); return this.isConfigured(); } @@ -60,7 +62,7 @@ class TelegramBot { } _apiUrl(method) { - return API_BASE + '/bot' + this.token + '/' + method; + return API_BASE + "/bot" + this.token + "/" + method; } /** @@ -71,27 +73,35 @@ class TelegramBot { * @returns {Promise<{success:boolean, status?:number, data?:object, error?:string}>} */ async _callApi(method, body, opts = {}) { - if (!this.token) return { success: false, error: 'token не задан' }; + if (!this.token) return { success: false, error: "token не задан" }; const attempts = opts.attempts || 3; for (let attempt = 0; attempt < attempts; attempt++) { try { const res = await fetch(this._apiUrl(method), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); // Rate limit: уважаем retry_after и повторяем. if (res.status === 429 || (data && data.error_code === 429)) { - const retryAfter = (data && data.parameters && data.parameters.retry_after) || 3; - this._log('warn', method + ': 429 rate limit, retry через ' + retryAfter + 'с'); + const retryAfter = + (data && data.parameters && data.parameters.retry_after) || 3; + this._log( + "warn", + method + ": 429 rate limit, retry через " + retryAfter + "с", + ); await this._sleep(retryAfter * 1000); continue; } if (!res.ok || !data.ok) { - return { success: false, status: res.status, error: (data && data.description) || ('HTTP ' + res.status) }; + return { + success: false, + status: res.status, + error: (data && data.description) || "HTTP " + res.status, + }; } return { success: true, status: res.status, data }; } catch (err) { @@ -102,7 +112,7 @@ class TelegramBot { return { success: false, error: err.message }; } } - return { success: false, error: 'rate limit: превышено число попыток' }; + return { success: false, error: "rate limit: превышено число попыток" }; } /** @@ -112,16 +122,18 @@ class TelegramBot { * @returns {Promise<{success: boolean, error?: string, messageId?: number}>} */ sendMessage(text, opts = {}) { - if (!this.token) return Promise.resolve({ success: false, error: 'token не задан' }); + if (!this.token) + return Promise.resolve({ success: false, error: "token не задан" }); const chatId = opts.chatId || this.chatId; - if (!chatId) return Promise.resolve({ success: false, error: 'chat_id не задан' }); + if (!chatId) + return Promise.resolve({ success: false, error: "chat_id не задан" }); return new Promise((resolve) => { this._queue.push({ - method: 'sendMessage', + method: "sendMessage", body: { chat_id: chatId, - text: String(text || ''), + text: String(text || ""), parse_mode: opts.parseMode || undefined, disable_web_page_preview: true, reply_markup: opts.replyMarkup || undefined, @@ -132,6 +144,23 @@ class TelegramBot { }); } + /** + * Отправить chat action (например 'typing'). Telegram показывает его ~5 секунд. + * @param {string} action — 'typing' | 'upload_photo' | 'record_video' | ... + * @param {{chatId?: string}} [opts] + * @returns {Promise<{success: boolean, error?: string}>} + */ + async sendChatAction(action, opts = {}) { + if (!this.token) return { success: false, error: "token не задан" }; + const chatId = opts.chatId || this.chatId; + if (!chatId) return { success: false, error: "chat_id не задан" }; + const r = await this._callApi("sendChatAction", { + chat_id: chatId, + action: String(action || "typing"), + }); + return r.success ? { success: true } : { success: false, error: r.error }; + } + /** Последовательно разгребает очередь с минимальным интервалом между запросами. */ async _drainQueue() { if (this._draining) return; @@ -141,7 +170,10 @@ class TelegramBot { const res = await this._callApi(job.method, job.body); if (res.success) { this._lastError = null; - job.resolve({ success: true, messageId: res.data.result && res.data.result.message_id }); + job.resolve({ + success: true, + messageId: res.data.result && res.data.result.message_id, + }); } else { this._lastError = res.error; job.resolve({ success: false, error: res.error }); @@ -158,39 +190,46 @@ class TelegramBot { * @returns {Promise<{success:boolean, messageId?:number, error?:string}>} */ async sendDocument(filePath, opts = {}) { - return this._sendFile('sendDocument', 'document', filePath, opts); + return this._sendFile("sendDocument", "document", filePath, opts); } /** Отправить фото в Telegram. */ async sendPhoto(filePath, opts = {}) { - return this._sendFile('sendPhoto', 'photo', filePath, opts); + return this._sendFile("sendPhoto", "photo", filePath, opts); } /** Общая реализация отправки файла через multipart/form-data. */ async _sendFile(method, field, filePath, opts = {}) { - if (!this.token) return { success: false, error: 'token не задан' }; + if (!this.token) return { success: false, error: "token не задан" }; const chatId = opts.chatId || this.chatId; - if (!chatId) return { success: false, error: 'chat_id не задан' }; + if (!chatId) return { success: false, error: "chat_id не задан" }; try { - const fs = require('fs'); - const path = require('path'); - if (!fs.existsSync(filePath)) return { success: false, error: 'файл не найден: ' + filePath }; + const fs = require("fs"); + const path = require("path"); + if (!fs.existsSync(filePath)) + return { success: false, error: "файл не найден: " + filePath }; const buf = fs.readFileSync(filePath); const form = new FormData(); - form.append('chat_id', String(chatId)); - if (opts.caption) form.append('caption', String(opts.caption)); + form.append("chat_id", String(chatId)); + if (opts.caption) form.append("caption", String(opts.caption)); const name = opts.fileName || path.basename(filePath); form.append(field, new Blob([buf]), name); - const res = await fetch(this._apiUrl(method), { method: 'POST', body: form }); + const res = await fetch(this._apiUrl(method), { + method: "POST", + body: form, + }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { - const err = (data && data.description) || ('HTTP ' + res.status); + const err = (data && data.description) || "HTTP " + res.status; this._lastError = err; return { success: false, error: err }; } this._lastError = null; - return { success: true, messageId: data.result && data.result.message_id }; + return { + success: true, + messageId: data.result && data.result.message_id, + }; } catch (err) { this._lastError = err.message; return { success: false, error: err.message }; @@ -203,7 +242,7 @@ class TelegramBot { * @param {{text?: string, showAlert?: boolean}} [opts] */ async answerCallbackQuery(callbackQueryId, opts = {}) { - const r = await this._callApi('answerCallbackQuery', { + const r = await this._callApi("answerCallbackQuery", { callback_query_id: callbackQueryId, text: opts.text || undefined, show_alert: !!opts.showAlert, @@ -218,13 +257,13 @@ class TelegramBot { * @param {{chatId?: string, parseMode?: string, replyMarkup?: object}} [opts] */ async editMessageText(messageId, text, opts = {}) { - if (!this.token) return { success: false, error: 'token не задан' }; + if (!this.token) return { success: false, error: "token не задан" }; const chatId = opts.chatId || this.chatId; - if (!chatId) return { success: false, error: 'chat_id не задан' }; - const r = await this._callApi('editMessageText', { + if (!chatId) return { success: false, error: "chat_id не задан" }; + const r = await this._callApi("editMessageText", { chat_id: chatId, message_id: messageId, - text: String(text || ''), + text: String(text || ""), parse_mode: opts.parseMode || undefined, disable_web_page_preview: true, reply_markup: opts.replyMarkup || undefined, @@ -236,12 +275,15 @@ class TelegramBot { * Короткий пинг бота: getMe. Полезно для проверки токена из UI. */ async getMe() { - if (!this.token) return { success: false, error: 'token не задан' }; + if (!this.token) return { success: false, error: "token не задан" }; try { - const res = await fetch(this._apiUrl('getMe')); + const res = await fetch(this._apiUrl("getMe")); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { - return { success: false, error: (data && data.description) || ('HTTP ' + res.status) }; + return { + success: false, + error: (data && data.description) || "HTTP " + res.status, + }; } return { success: true, username: data.result && data.result.username }; } catch (err) { @@ -257,15 +299,17 @@ class TelegramBot { startPolling(onMessage, onCallback) { if (this.polling) { // Обновляем обработчики на случай повторного старта. - this.onMessage = typeof onMessage === 'function' ? onMessage : this.onMessage; - this.onCallback = typeof onCallback === 'function' ? onCallback : this.onCallback; + this.onMessage = + typeof onMessage === "function" ? onMessage : this.onMessage; + this.onCallback = + typeof onCallback === "function" ? onCallback : this.onCallback; return { success: true, already: true }; } - if (!this.token) return { success: false, error: 'token не задан' }; - this.onMessage = typeof onMessage === 'function' ? onMessage : null; - this.onCallback = typeof onCallback === 'function' ? onCallback : null; + if (!this.token) return { success: false, error: "token не задан" }; + this.onMessage = typeof onMessage === "function" ? onMessage : null; + this.onCallback = typeof onCallback === "function" ? onCallback : null; this.polling = true; - this._log('info', 'Telegram polling запущен'); + this._log("info", "Telegram polling запущен"); this._pollLoop(); return { success: true }; } @@ -276,45 +320,53 @@ class TelegramBot { clearTimeout(this.pollTimer); this.pollTimer = null; } - this._log('info', 'Telegram polling остановлен'); + this._log("info", "Telegram polling остановлен"); return { success: true }; } async _pollLoop() { while (this.polling) { try { - const res = await fetch(this._apiUrl('getUpdates'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch(this._apiUrl("getUpdates"), { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ offset: this.offset, - timeout: 25, // long-poll: держим соединение до 25с - allowed_updates: ['message', 'callback_query'], + timeout: 25, // long-poll: держим соединение до 25с + allowed_updates: ["message", "callback_query"], }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) { - this._log('warn', 'getUpdates error:', (data && data.description) || res.status); + this._log( + "warn", + "getUpdates error:", + (data && data.description) || res.status, + ); await this._sleep(this._pollDelayMs); continue; } const updates = Array.isArray(data.result) ? data.result : []; for (const upd of updates) { - if (typeof upd.update_id === 'number') { + if (typeof upd.update_id === "number") { this.offset = upd.update_id + 1; } // Нажатие inline-кнопки. const cbq = upd.callback_query; if (cbq) { - const cbChat = String(cbq.message && cbq.message.chat && cbq.message.chat.id); + const cbChat = String( + cbq.message && cbq.message.chat && cbq.message.chat.id, + ); if (this.chatId && cbChat !== this.chatId) { - this._log('warn', 'Игнор callback из чужого чата:', cbChat); + this._log("warn", "Игнор callback из чужого чата:", cbChat); continue; } if (this.onCallback) { - try { this.onCallback(cbChat, String(cbq.data || ''), cbq); } catch (e) { - this._log('error', 'onCallback handler error:', e.message); + try { + this.onCallback(cbChat, String(cbq.data || ""), cbq); + } catch (e) { + this._log("error", "onCallback handler error:", e.message); } } continue; @@ -325,12 +377,14 @@ class TelegramBot { const fromChat = String(msg.chat && msg.chat.id); // Принимаем только сообщения от настроенного chat_id (если он задан). if (this.chatId && fromChat !== this.chatId) { - this._log('warn', 'Игнор сообщения из чужого чата:', fromChat); + this._log("warn", "Игнор сообщения из чужого чата:", fromChat); continue; } if (this.onMessage) { - try { this.onMessage(fromChat, msg.text, msg); } catch (e) { - this._log('error', 'onMessage handler error:', e.message); + try { + this.onMessage(fromChat, msg.text, msg); + } catch (e) { + this._log("error", "onMessage handler error:", e.message); } } } @@ -340,7 +394,7 @@ class TelegramBot { } } catch (err) { this._lastError = err.message; - this._log('warn', 'getUpdates exception:', err.message); + this._log("warn", "getUpdates exception:", err.message); await this._sleep(this._pollDelayMs * 2); } } diff --git a/src/main/ipc.js b/src/main/ipc.js index 6782d7c..1499432 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -894,6 +894,25 @@ function registerIpcHandlers() { } }); + // Индикатор «ИИ печатает…» в Telegram (пока идёт генерация ответа). + ipcMain.handle("telegram-typing-start", async () => { + try { + const bot = require("../../botsrc"); + return bot.startTyping(); + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle("telegram-typing-stop", async () => { + try { + const bot = require("../../botsrc"); + return bot.stopTyping(); + } catch (err) { + return { success: false, error: err.message }; + } + }); + ipcMain.handle( "telegram-approval-request", async (_event, { requestId, info } = {}) => { diff --git a/src/preload/api.js b/src/preload/api.js index efa49f3..7f823c1 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -44,6 +44,8 @@ let electronAPI = { telegramTest: () => ipcRenderer.invoke("telegram-test"), telegramNotifyAI: (text) => ipcRenderer.invoke("telegram-notify-ai", { text }), + telegramTypingStart: () => ipcRenderer.invoke("telegram-typing-start"), + telegramTypingStop: () => ipcRenderer.invoke("telegram-typing-stop"), telegramApprovalRequest: (requestId, info) => ipcRenderer.invoke("telegram-approval-request", { requestId, info }), telegramApprovalCancel: (requestId) => diff --git a/src/preload/dom/observer.js b/src/preload/dom/observer.js index 025b0c4..18c283f 100644 --- a/src/preload/dom/observer.js +++ b/src/preload/dom/observer.js @@ -3,38 +3,54 @@ * 由原 preload.js 拆分而来,逻辑保持不变。 */ const { - showOverlay, setTaskStatus, showToast, showConfirmDialog, addHistory, flashBadge, truncate, displayCommand, generateId, -} = require('../overlay/ui'); -const { scanForCommands } = require('./detector'); -const { tryParseToolCall } = require('./tool-parser'); -const { getJsCodeBlocksFromMarkdown, looksLikeIncompleteCodeError, FENCE } = require('./js-detector'); -const toolRender = require('./tool-render'); -const toolResultInline = require('./tool-result-inline'); -const responseMeta = require('./response-meta'); - -const { sendToolResultToChat, sendCombinedJsResultsToChat, sendMessageToChat } = require('./chat-input'); -const { isAIResponseComplete } = require('./ai-response'); -const approval = require('./approval'); -const { getProviderByUrl } = require('../../../src/providers'); -const { hasTool, toolNamesList } = require('../tool-names'); -const { t } = require('../i18n/i18n'); -const state = require('./state'); -const { safe } = require('./safe'); + showOverlay, + setTaskStatus, + showToast, + showConfirmDialog, + addHistory, + flashBadge, + truncate, + displayCommand, + generateId, +} = require("../overlay/ui"); +const { scanForCommands } = require("./detector"); +const { tryParseToolCall } = require("./tool-parser"); +const { + getJsCodeBlocksFromMarkdown, + looksLikeIncompleteCodeError, + FENCE, +} = require("./js-detector"); +const toolRender = require("./tool-render"); +const toolResultInline = require("./tool-result-inline"); +const responseMeta = require("./response-meta"); + +const { + sendToolResultToChat, + sendCombinedJsResultsToChat, + sendMessageToChat, +} = require("./chat-input"); +const { isAIResponseComplete } = require("./ai-response"); +const approval = require("./approval"); +const { getProviderByUrl } = require("../../../src/providers"); +const { hasTool, toolNamesList } = require("../tool-names"); +const { t } = require("../i18n/i18n"); +const state = require("./state"); +const { safe } = require("./safe"); /** * 手动解析按钮点击处理 * 用户点击后,仅解析最后一条 AI 回复中的工具调用并执行 */ async function triggerManualParseAttention() { - const btn = document.getElementById('cuckoo-btn-manual-parse'); + const btn = document.getElementById("cuckoo-btn-manual-parse"); if (btn) { - btn.classList.remove('cuckoo-btn-attention'); + btn.classList.remove("cuckoo-btn-attention"); // 强制回流以重新触发动画 void btn.offsetWidth; - btn.classList.add('cuckoo-btn-attention'); + btn.classList.add("cuckoo-btn-attention"); // 动画结束后移除类,避免状态残留 setTimeout(() => { - btn.classList.remove('cuckoo-btn-attention'); + btn.classList.remove("cuckoo-btn-attention"); }, 3500); } } @@ -44,26 +60,26 @@ let isExecuting = false; async function handleManualParse() { if (isExecuting) { - showToast(t('overlay.toast.executing'), 3000); + showToast(t("overlay.toast.executing"), 3000); return; } - const btn = document.getElementById('cuckoo-btn-manual-parse'); + const btn = document.getElementById("cuckoo-btn-manual-parse"); if (btn) { btn.disabled = true; - btn.textContent = t('overlay.btn.manualParse.loading'); + btn.textContent = t("overlay.btn.manualParse.loading"); } try { // 复用自动解析逻辑:仅解析最后一条 AI 回复 processLatestAIResponse(0, true); - showToast(t('overlay.toast.manualParseTriggered'), 3000); + showToast(t("overlay.toast.manualParseTriggered"), 3000); } catch (err) { - console.error('[Cookie Code] 手动解析出错:', err); - showToast(t('overlay.toast.manualParseError', { msg: err.message }), 3000); + console.error("[Cookie Code] 手动解析出错:", err); + showToast(t("overlay.toast.manualParseError", { msg: err.message }), 3000); } finally { if (btn) { btn.disabled = false; - btn.textContent = t('overlay.btn.manualParse'); + btn.textContent = t("overlay.btn.manualParse"); } } } @@ -106,24 +122,36 @@ const STABILITY_POLL_INTERVAL = 500; * 则从第一个 { 开始检查括号配对;配对不完整返回 false(需要重试) */ function isJsonBalanced(str) { - const trimmed = (str || '').trim(); + const trimmed = (str || "").trim(); // 不含 { 或没有工具调用特征 → 不是工具调用,直接通过 - if (!trimmed.includes('{')) return true; - if (!/toolName|"tool"|file_|json复制|```/.test(trimmed) && !trimmed.trimStart().startsWith('{')) { + if (!trimmed.includes("{")) return true; + if ( + !/toolName|"tool"|file_|json复制|```/.test(trimmed) && + !trimmed.trimStart().startsWith("{") + ) { return true; } // 从第一个 { 开始检查括号配对 - const jsonPart = trimmed.substring(trimmed.indexOf('{')); + const jsonPart = trimmed.substring(trimmed.indexOf("{")); let braceCount = 0; let inString = false; let escapeNext = false; for (const char of jsonPart) { - if (escapeNext) { escapeNext = false; continue; } - if (char === '\\') { escapeNext = true; continue; } - if (char === '"') { inString = !inString; continue; } + if (escapeNext) { + escapeNext = false; + continue; + } + if (char === "\\") { + escapeNext = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } if (!inString) { - if (char === '{') braceCount++; - else if (char === '}') { + if (char === "{") braceCount++; + else if (char === "}") { braceCount--; if (braceCount < 0) return true; // 多出的 },视为异常但不再等 } @@ -138,12 +166,31 @@ function getCurrentProvider() { return getProviderByUrl(window.location.href); } +/** + * Идёт ли прямо сейчас генерация ответа ИИ. + * Прямой DOM-сигнал: активная (не disabled) круглая primary-кнопка «стоп» + * в composer. В отличие от isAIResponseComplete() не зависит от трактовки + * завершённости и не срабатывает в пустом диалоге. + */ +function isGenerating() { + try { + const stopBtn = document.querySelector( + ".ds-button.ds-button--primary.ds-button--filled.ds-button--circle" + + ".ds-button--m.ds-button--icon-relative-m:not(.ds-button--disabled)", + ); + return !!stopBtn; + } catch (_) { + return false; + } +} + /** * 获取当前平台消息容器元素列表(过滤用户消息) */ function getMessageCandidates() { const provider = getCurrentProvider(); - if (!provider || typeof provider.getMessageCandidates !== 'function') return []; + if (!provider || typeof provider.getMessageCandidates !== "function") + return []; return provider.getMessageCandidates(); } @@ -152,7 +199,8 @@ function getMessageCandidates() { */ function getMessageMarkdown(messageEl) { const provider = getCurrentProvider(); - if (!provider || typeof provider.getMessageMarkdown !== 'function') return messageEl; + if (!provider || typeof provider.getMessageMarkdown !== "function") + return messageEl; return provider.getMessageMarkdown(messageEl); } @@ -177,41 +225,77 @@ async function executeJsBlocksWithRetry(initialBlocks, markdown, force) { } const hasIncompleteFailure = results.some( - item => item && item.result && !item.result.success && looksLikeIncompleteCodeError(item.result.error) + (item) => + item && + item.result && + !item.result.success && + looksLikeIncompleteCodeError(item.result.error), ); if (!hasIncompleteFailure) break; if (attempt < MAX_JS_RETRY) { - console.log('[' + new Date().toISOString() + '] [Cookie Code] ⏳ 代码不完整,等待 1 秒后重新获取并重试(' + (attempt + 1) + '/' + MAX_JS_RETRY + ')...'); + console.log( + "[" + + new Date().toISOString() + + "] [Cookie Code] ⏳ 代码不完整,等待 1 秒后重新获取并重试(" + + (attempt + 1) + + "/" + + MAX_JS_RETRY + + ")...", + ); await sleep(1000); - console.log('[' + new Date().toISOString() + '] [Cookie Code] ⏳ 等待结束,开始第 ' + (attempt + 1) + ' 次重试'); + console.log( + "[" + + new Date().toISOString() + + "] [Cookie Code] ⏳ 等待结束,开始第 " + + (attempt + 1) + + " 次重试", + ); blocks = getJsCodeBlocksFromMarkdown(markdown); } } const stillIncomplete = results.some( - item => item && item.result && !item.result.success && looksLikeIncompleteCodeError(item.result.error) + (item) => + item && + item.result && + !item.result.success && + looksLikeIncompleteCodeError(item.result.error), ); if (stillIncomplete) { - console.log('[' + new Date().toISOString() + '] [Cookie Code] ⚠️ 代码不完整,已重试 ' + MAX_JS_RETRY + ' 次仍失败,将报错回传 AI'); + console.log( + "[" + + new Date().toISOString() + + "] [Cookie Code] ⚠️ 代码不完整,已重试 " + + MAX_JS_RETRY + + " 次仍失败,将报错回传 AI", + ); } if (results.length > 0) { // Инлайн: прикрепляем каждый результат прямо в его карточку вызова, // чтобы пользователь видел результат под кодом, а не отдельным сообщением. for (const item of results) { - try { toolResultInline.markToolBlockResult(item.code, item.result); } catch (_) {} + try { + toolResultInline.markToolBlockResult(item.code, item.result); + } catch (_) {} // Счётчик изменённых строк в шапке инлайн-блока +829 -53 try { - let added = 0, removed = 0; + let added = 0, + removed = 0; const r = item.result; if (r) { if (Array.isArray(r.stats)) { - for (const s of r.stats) { added += Number(s.added)||0; removed += Number(s.removed)||0; } - } else if (r.stats && typeof r.stats.added === 'number') { - added = Number(r.stats.added)||0; removed = Number(r.stats.removed)||0; + for (const s of r.stats) { + added += Number(s.added) || 0; + removed += Number(s.removed) || 0; + } + } else if (r.stats && typeof r.stats.added === "number") { + added = Number(r.stats.added) || 0; + removed = Number(r.stats.removed) || 0; } else if (r.data && r.data.stats) { - added = Number(r.data.stats.added)||0; removed = Number(r.data.stats.removed)||0; + added = Number(r.data.stats.added) || 0; + removed = Number(r.data.stats.removed) || 0; } } // fallback: если stats нет — попробуем оценить из кода (write/edit) чтобы не оставлять пусто @@ -221,13 +305,17 @@ async function executeJsBlocksWithRetry(initialBlocks, markdown, force) { // оставляем пусто — не показываем 0/0 } catch (_) {} } - if (added || removed) toolRender.markToolBlockDiff(item.code, { added, removed }); + if (added || removed) + toolRender.markToolBlockDiff(item.code, { added, removed }); } catch (_) {} } sendCombinedJsResultsToChat(results); // Факт-основанный учёт «затронутых файлов» — только успешные write/edit/delete try { - const msgEl = (markdown && typeof markdown.closest === 'function' ? markdown.closest('.ds-message') : null) || responseMeta.findLatestAIMessage(); + const msgEl = + (markdown && typeof markdown.closest === "function" + ? markdown.closest(".ds-message") + : null) || responseMeta.findLatestAIMessage(); if (msgEl) responseMeta.recordExecutionResults(msgEl, results); } catch (_) {} } @@ -243,7 +331,7 @@ function ensureStabilityTimer() { const now = Date.now(); for (const [msg, rec] of jsStability) { // 节点已被页面卸载:清理 - if (typeof msg.isConnected === 'boolean' && !msg.isConnected) { + if (typeof msg.isConnected === "boolean" && !msg.isConnected) { jsStability.delete(msg); continue; } @@ -262,7 +350,6 @@ function ensureStabilityTimer() { }, STABILITY_POLL_INTERVAL); } - /** * 回复结束后,获取最新一条 AI 回复的内容并解析工具调用 * 改为 async:执行前可能需要等待用户确认(approval gate)。 @@ -276,16 +363,24 @@ function ensureStabilityTimer() { function processLatestAIResponse(retryCount = 0, force = false) { try { const p = processLatestAIResponseInner(retryCount, force); - if (p && typeof p.catch === 'function') { + if (p && typeof p.catch === "function") { p.catch((err) => { const msg = err && err.message ? err.message : String(err); - try { console.warn('[Cookie Code][safe:observer.processLatestAIResponse] ' + msg); } catch (_) {} + try { + console.warn( + "[Cookie Code][safe:observer.processLatestAIResponse] " + msg, + ); + } catch (_) {} }); } return p; } catch (err) { const msg = err && err.message ? err.message : String(err); - try { console.warn('[Cookie Code][safe:observer.processLatestAIResponse] ' + msg); } catch (_) {} + try { + console.warn( + "[Cookie Code][safe:observer.processLatestAIResponse] " + msg, + ); + } catch (_) {} return undefined; } } @@ -302,7 +397,7 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { for (let i = messages.length - 1; i >= 0; i--) { const candidate = messages[i]; const md = getMessageMarkdown(candidate); - const hasContent = md && (md.textContent || '').trim().length > 0; + const hasContent = md && (md.textContent || "").trim().length > 0; if (hasContent) { lastMessage = candidate; markdown = md; @@ -310,7 +405,7 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { } } if (!lastMessage || !markdown) { - console.log('[Cookie Code] 未找到有内容的 AI 回复'); + console.log("[Cookie Code] 未找到有内容的 AI 回复"); return; } @@ -320,9 +415,13 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { // 跳过用户消息(其中包含系统提示词里的示例代码块,不应被执行) const providerForUser = getCurrentProvider(); - if (providerForUser && typeof providerForUser.isUserMessage === 'function' && providerForUser.isUserMessage(lastMessage)) { + if ( + providerForUser && + typeof providerForUser.isUserMessage === "function" && + providerForUser.isUserMessage(lastMessage) + ) { processedMessages.add(lastMessage); - console.log('[Cookie Code] ⏭ 跳过用户消息(包含系统提示词示例)'); + console.log("[Cookie Code] ⏭ 跳过用户消息(包含系统提示词示例)"); return; } @@ -340,11 +439,18 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { // 优先检测 JS 工具代码块(cuckoo 代码块 / 调用工具函数的 js 代码块) const jsBlocks = getJsCodeBlocksFromMarkdown(markdown); - console.log('[DEBUG][processLatest] lastMessage=' + (lastMessage.className || lastMessage.tagName) + - ' markdown=' + (markdown.className || markdown.tagName) + - ' jsBlocks=' + jsBlocks.length + - ' force=' + force + - ' retryCount=' + retryCount); + console.log( + "[DEBUG][processLatest] lastMessage=" + + (lastMessage.className || lastMessage.tagName) + + " markdown=" + + (markdown.className || markdown.tagName) + + " jsBlocks=" + + jsBlocks.length + + " force=" + + force + + " retryCount=" + + retryCount, + ); if (jsBlocks.length > 0) { // 稳定性双通道校验:流式渲染期间代码块只渲染了一半(曾导致 "const content" // 这样的残缺代码被执行 → SyntaxError)。mutation 驱动 + interval 兜底, @@ -352,33 +458,43 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { if (force) { // 手动解析:跳过稳定性校验,直接执行(标记已处理,避免同节点重复自动执行) processedMessages.add(lastMessage); - console.log('[Cookie Code] 手动解析模式,跳过稳定性校验'); + console.log("[Cookie Code] 手动解析模式,跳过稳定性校验"); executeJsBlocksWithRetry(jsBlocks, markdown, true); return; } - const snapshot = markdown.textContent || ''; - const blocksSig = jsBlocks.map((b) => b.length).join(','); + const snapshot = markdown.textContent || ""; + const blocksSig = jsBlocks.map((b) => b.length).join(","); const now = Date.now(); const rec = jsStability.get(lastMessage); if (!rec || rec.snapshot !== snapshot || rec.blocksSig !== blocksSig) { // 内容仍在变化:记录快照,等待下一次 mutation / interval 复查 jsStability.set(lastMessage, { snapshot, blocksSig, lastChange: now }); ensureStabilityTimer(); - console.log('[Cookie Code] ⏳ 检测到 JS 工具代码块,流式渲染中,等待稳定...'); + console.log( + "[Cookie Code] ⏳ 检测到 JS 工具代码块,流式渲染中,等待稳定...", + ); return; // 不标记 processed,稳定后执行 } // 内容一致:需稳定满窗口确认 if (now - rec.lastChange < JS_STABILITY_WINDOW) { - console.log('[Cookie Code] ⏳ JS 代码块稳定中(等待 ' + JS_STABILITY_WINDOW + 'ms 确认)...'); + console.log( + "[Cookie Code] ⏳ JS 代码块稳定中(等待 " + + JS_STABILITY_WINDOW + + "ms 确认)...", + ); return; } // 稳定满窗口 → 执行 jsStability.delete(lastMessage); processedMessages.add(lastMessage); - console.log('[Cookie Code] ✅ 代码块稳定,检测到 JS 工具代码块(' + jsBlocks.length + ' 个),开始执行'); + console.log( + "[Cookie Code] ✅ 代码块稳定,检测到 JS 工具代码块(" + + jsBlocks.length + + " 个),开始执行", + ); // 正确使用 cuckoo 代码块,重置 XML 提示计数 xmlHintCount = 0; executeJsBlocksWithRetry(jsBlocks, markdown, false); @@ -390,74 +506,119 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { // 导致同一条消息后续渲染出的完整代码块被永久跳过(智谱等 SPA 回复中途 // isResponseComplete 即可能返回 true)。与 JS 块共用稳定性通道。 if (!force) { - const snapshot = markdown.textContent || ''; + const snapshot = markdown.textContent || ""; const now = Date.now(); const rec = jsStability.get(lastMessage); if (!rec || rec.snapshot !== snapshot) { - jsStability.set(lastMessage, { snapshot, blocksSig: 'text', lastChange: now }); + jsStability.set(lastMessage, { + snapshot, + blocksSig: "text", + lastChange: now, + }); ensureStabilityTimer(); - console.log('[Cookie Code] ⏳ 文本内容渲染中,等待稳定(防流式中途漏检)...'); + console.log( + "[Cookie Code] ⏳ 文本内容渲染中,等待稳定(防流式中途漏检)...", + ); return; } if (now - rec.lastChange < JS_STABILITY_WINDOW) { - console.log('[Cookie Code] ⏳ 文本内容稳定中(等待 ' + JS_STABILITY_WINDOW + 'ms 确认)...'); + console.log( + "[Cookie Code] ⏳ 文本内容稳定中(等待 " + + JS_STABILITY_WINDOW + + "ms 确认)...", + ); return; } jsStability.delete(lastMessage); } // 提取文本:优先从 pre code 提取(代码块内容天然不含 json/复制/下载等按钮文字) - let text = ''; - const codeEl = markdown.querySelector('pre code'); + let text = ""; + const codeEl = markdown.querySelector("pre code"); if (codeEl) { - text = (codeEl.textContent || codeEl.innerText || '').trim(); - console.log('[Cookie Code] 提取方式: pre code 元素'); + text = (codeEl.textContent || codeEl.innerText || "").trim(); + console.log("[Cookie Code] 提取方式: pre code 元素"); } else { // 无代码块:克隆节点并剔除可能的工具栏元素 const clone = markdown.cloneNode(true); - clone.querySelectorAll('button, [class*="toolbar"], [class*="copy"], [class*="download"], [class*="code-block-header"], [class*="lang"], [class*="header"]').forEach(el => el.remove()); - text = (clone.textContent || clone.innerText || '').trim(); - console.log('[Cookie Code] 提取方式: 克隆节点(剔除工具栏)'); + clone + .querySelectorAll( + 'button, [class*="toolbar"], [class*="copy"], [class*="download"], [class*="code-block-header"], [class*="lang"], [class*="header"]', + ) + .forEach((el) => el.remove()); + text = (clone.textContent || clone.innerText || "").trim(); + console.log("[Cookie Code] 提取方式: 克隆节点(剔除工具栏)"); } if (!text) { - console.log('[DEBUG][processLatest] 提取文本为空'); + console.log("[DEBUG][processLatest] 提取文本为空"); return; } - console.log('[DEBUG][processLatest] text长度=' + text.length + ' 前60字符=' + JSON.stringify(text.slice(0, 60))); + console.log( + "[DEBUG][processLatest] text长度=" + + text.length + + " 前60字符=" + + JSON.stringify(text.slice(0, 60)), + ); console.log(text); // Декорируем cuckoo-блоки в чате (раскрывающиеся tool-блоки) — // только при включённой кастомизации; парсинг ниже работает всегда. if (state.customizationEnabled !== false) { - try { toolRender.decorate(markdown); } catch (e) { /* не критично */ } + try { + toolRender.decorate(markdown); + } catch (e) { + /* не критично */ + } } // 是否为疑似工具内容(用于控制详细日志与提示文案) - const looksToolish = text.includes(FENCE) || - /toolName|"tool"|file_|await\s+(?:read|write|edit|glob|grep|bash|pwsh|todoWrite|deleteFile|webFetch|cityTime|openBrowserWindow|injectJS|readFile|writeFile|editFile)\s*\(/.test(text); + const looksToolish = + text.includes(FENCE) || + /toolName|"tool"|file_|await\s+(?:read|write|edit|glob|grep|bash|pwsh|todoWrite|deleteFile|webFetch|cityTime|openBrowserWindow|injectJS|readFile|writeFile|editFile)\s*\(/.test( + text, + ); // 长度必打;原文/转义仅在疑似工具内容时打印(普通聊天回复不再刷屏) - console.log('[Cookie Code] 回复文本长度: ' + text.length + (looksToolish ? '(疑似工具内容)' : '(普通文本)')); + console.log( + "[Cookie Code] 回复文本长度: " + + text.length + + (looksToolish ? "(疑似工具内容)" : "(普通文本)"), + ); if (looksToolish) { - console.log('[Cookie Code] 回复完整内容(原文):'); + console.log("[Cookie Code] 回复完整内容(原文):"); console.log(text); - console.log('[Cookie Code] 回复完整内容(转义显示):'); + console.log("[Cookie Code] 回复完整内容(转义显示):"); console.log(JSON.stringify(text)); } // 内容不完整(疑似流式输出未真正结束):延迟重试,避免处理截断的 JSON if (!force && !isJsonBalanced(text)) { if (retryCount < MAX_RETRY_COUNT) { - console.log('[Cookie Code] ⏳ JSON 不完整(疑似流式未结束),' + (retryCount + 1) + '/' + MAX_RETRY_COUNT + ' 次延迟重试, 当前长度=' + text.length + '...'); + console.log( + "[Cookie Code] ⏳ JSON 不完整(疑似流式未结束)," + + (retryCount + 1) + + "/" + + MAX_RETRY_COUNT + + " 次延迟重试, 当前长度=" + + text.length + + "...", + ); setTimeout(() => processLatestAIResponse(retryCount + 1), RETRY_INTERVAL); return; // 不标记 processed,允许重试 } - console.log('[Cookie Code] ⚠️ JSON 持续不完整(20次重试仍截断),放弃本次处理,当前长度=' + text.length); + console.log( + "[Cookie Code] ⚠️ JSON 持续不完整(20次重试仍截断),放弃本次处理,当前长度=" + + text.length, + ); // 回传 AI,让它重新完整输出 sendToolResultToChat( - { toolName: '未知', callId: 'incomplete' }, - { success: false, error: '收到不完整的工具调用 JSON(内容被截断),请重新完整输出工具调用。' } + { toolName: "未知", callId: "incomplete" }, + { + success: false, + error: + "收到不完整的工具调用 JSON(内容被截断),请重新完整输出工具调用。", + }, ); } @@ -470,31 +631,39 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { // 验证 toolName 是否在工具库中 const available = hasTool(toolCall.toolName); if (!available) { - console.log('[Cookie Code] ⚠️ 工具不存在: ' + toolCall.toolName + ', 可用工具: ' + toolNamesList()); - // 回传 AI,告知工具不存在 - sendToolResultToChat( - toolCall, - { success: false, error: '工具 ' + toolCall.toolName + ' 不存在,可用工具: ' + toolNamesList() } + console.log( + "[Cookie Code] ⚠️ 工具不存在: " + + toolCall.toolName + + ", 可用工具: " + + toolNamesList(), ); + // 回传 AI,告知工具不存在 + sendToolResultToChat(toolCall, { + success: false, + error: + "工具 " + toolCall.toolName + " 不存在,可用工具: " + toolNamesList(), + }); return; } - console.log('[Cookie Code] ✅ 工具存在: ' + toolCall.toolName + ', 开始执行'); + console.log( + "[Cookie Code] ✅ 工具存在: " + toolCall.toolName + ", 开始执行", + ); notifyToolCallDetected(toolCall); // ===== Approval gate:按 toolApprovalMode 在执行前请求用户确认 ===== let verdict = { approved: true }; try { verdict = await approval.requestApprovalIfNeeded({ - kind: 'tool', + kind: "tool", toolName: toolCall.toolName, params: toolCall.params, }); } catch (err) { - console.error('[Cookie Code] approval gate error:', err.message); + console.error("[Cookie Code] approval gate error:", err.message); verdict = { approved: false }; } if (!verdict.approved) { - console.log('[Cookie Code] ⛔ 工具被用户拒绝: ' + toolCall.toolName); + console.log("[Cookie Code] ⛔ 工具被用户拒绝: " + toolCall.toolName); // 回传 AI(经隐身通道),告知调用被拒绝,不要盲目重试 sendToolResultToChat(toolCall, { success: false, @@ -508,9 +677,22 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { } else { // JSON 工具调用未解析到,再检测 XML 格式的工具调用 // 诊断:打印 XML 检测相关状态(text 和 innerHTML) - console.log('[Cookie Code] [XML诊断] text长度=' + text.length + ', 开头100字符=' + JSON.stringify(text.slice(0, 100))); - console.log('[Cookie Code] [XML诊断] markdown.innerHTML长度=' + (markdown.innerHTML || '').length + ', 开头200字符=' + JSON.stringify((markdown.innerHTML || '').slice(0, 200))); - console.log('[Cookie Code] [XML诊断] 是否有 pre code 元素=' + !!markdown.querySelector('pre code')); + console.log( + "[Cookie Code] [XML诊断] text长度=" + + text.length + + ", 开头100字符=" + + JSON.stringify(text.slice(0, 100)), + ); + console.log( + "[Cookie Code] [XML诊断] markdown.innerHTML长度=" + + (markdown.innerHTML || "").length + + ", 开头200字符=" + + JSON.stringify((markdown.innerHTML || "").slice(0, 200)), + ); + console.log( + "[Cookie Code] [XML诊断] 是否有 pre code 元素=" + + !!markdown.querySelector("pre code"), + ); // 精准判断: // 1. <||DSML|| 开头直接触发(自定义标签前缀,如 <||DSML||tool_calls>、<||DSML||invoke>) // 2. = XML_HINT_MAX) { // 已连续提示多次,AI 仍用 XML 格式,熔断停止发送,避免无限循环 - console.log('[Cookie Code] ⚠️ 已连续提示 ' + xmlHintCount + ' 次 XML 格式,停止发送提示语'); + console.log( + "[Cookie Code] ⚠️ 已连续提示 " + + xmlHintCount + + " 次 XML 格式,停止发送提示语", + ); return; } xmlHintCount++; - console.log('[Cookie Code] ⚠️ 检测到 XML 格式工具调用(第 ' + xmlHintCount + ' 次提示),提示 AI 改用 cuckoo 代码块'); + console.log( + "[Cookie Code] ⚠️ 检测到 XML 格式工具调用(第 " + + xmlHintCount + + " 次提示),提示 AI 改用 cuckoo 代码块", + ); const BT = String.fromCharCode(96); sendMessageToChat( - '请使用' + BT + BT + BT + 'cuckoo' + BT + BT + BT + ' 代码块进行工具调用,不要使用 XML invoke 格式。', - 'XML工具调用提示' + "请使用" + + BT + + BT + + BT + + "cuckoo" + + BT + + BT + + BT + + " 代码块进行工具调用,不要使用 XML invoke 格式。", + "XML工具调用提示", ); return; } if (looksToolish) { // 疑似工具内容但 JS 块检测与 JSON 解析都没命中 → 打印诊断,帮助定位 - console.log('[Cookie Code] ⚠️ 回复疑似工具调用但未被识别(JS 代码块未匹配 / JSON 解析失败)'); - const pres = markdown.querySelectorAll('pre'); + console.log( + "[Cookie Code] ⚠️ 回复疑似工具调用但未被识别(JS 代码块未匹配 / JSON 解析失败)", + ); + const pres = markdown.querySelectorAll("pre"); if (pres.length > 0) { for (const p of pres) { const providerForLang = getCurrentProvider(); - const lang = (providerForLang && typeof providerForLang.getCodeBlockLanguage === 'function') - ? providerForLang.getCodeBlockLanguage(p) - : ''; - console.log('[Cookie Code] [诊断] 代码块 language=' + (lang || '(无)') + ', 内容前80字符=' + ((p.textContent || '').trim().slice(0, 80))); + const lang = + providerForLang && + typeof providerForLang.getCodeBlockLanguage === "function" + ? providerForLang.getCodeBlockLanguage(p) + : ""; + console.log( + "[Cookie Code] [诊断] 代码块 language=" + + (lang || "(无)") + + ", 内容前80字符=" + + (p.textContent || "").trim().slice(0, 80), + ); } } else { - console.log('[Cookie Code] [诊断] 消息中没有任何 pre 代码块'); + console.log("[Cookie Code] [诊断] 消息中没有任何 pre 代码块"); } } else { - console.log('[Cookie Code] ℹ️ 正常文本回复,未检测到工具调用(无需处理)'); + console.log( + "[Cookie Code] ℹ️ 正常文本回复,未检测到工具调用(无需处理)", + ); // 防重复:同一文本不重复通知(完成检测轮询每 2s 触发一次,避免刷屏) if (lastNotifiedText !== text) { lastNotifiedText = text; @@ -566,9 +775,9 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { let isProcessingResponse = false; let lastObserverRun = 0; let completionPollTimer = null; -let lastNotifiedText = ''; +let lastNotifiedText = ""; // Последний текст, отправленный в Telegram (антидубль). -let lastAiTgText = ''; +let lastAiTgText = ""; /** * Убрать из текста markdown code-блоки, оставив человеческий текст. @@ -579,51 +788,84 @@ let lastAiTgText = ''; * code-блоки и тулбары, вернуть очищенный textContent. */ function extractHumanTextFromNode(node) { - if (!node) return ''; + if (!node) return ""; const clone = node.cloneNode(true); - clone.querySelectorAll('.md-code-block, .cuckoo-tool-block, .cuckoo-tool-header, .cuckoo-tool-label, .cuckoo-tool-file, .cuckoo-tool-sep, .cuckoo-tool-icon, .cuckoo-tool-chevron, pre, button, [class*="toolbar"], [class*="copy"], [class*="download"], [class*="code-block"], [class*="lang"]').forEach(el => el.remove()); + clone + .querySelectorAll( + '.md-code-block, .cuckoo-tool-block, .cuckoo-tool-header, .cuckoo-tool-label, .cuckoo-tool-file, .cuckoo-tool-sep, .cuckoo-tool-icon, .cuckoo-tool-chevron, pre, button, [class*="toolbar"], [class*="copy"], [class*="download"], [class*="code-block"], [class*="lang"]', + ) + .forEach((el) => el.remove()); // Обходим DOM и расставляем переносы на границах блочных элементов — // textContent их склеивает (абзацы/пункты списка/заголовки). - const BLOCK = { P: 1, DIV: 1, LI: 1, UL: 1, OL: 1, BR: 1, TR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, BLOCKQUOTE: 1, TABLE: 1, SECTION: 1, ARTICLE: 1 }; + const BLOCK = { + P: 1, + DIV: 1, + LI: 1, + UL: 1, + OL: 1, + BR: 1, + TR: 1, + H1: 1, + H2: 1, + H3: 1, + H4: 1, + H5: 1, + H6: 1, + BLOCKQUOTE: 1, + TABLE: 1, + SECTION: 1, + ARTICLE: 1, + }; const out = []; const walk = (el) => { - const tag = el.nodeName ? el.nodeName.toUpperCase() : ''; - if (tag === 'BR') { out.push('\n'); return; } + const tag = el.nodeName ? el.nodeName.toUpperCase() : ""; + if (tag === "BR") { + out.push("\n"); + return; + } for (let i = 0; i < el.childNodes.length; i++) { const child = el.childNodes[i]; if (child.nodeType === 3) { out.push(child.nodeValue); } else if (child.nodeType === 1) { const isBlock = !!BLOCK[child.nodeName.toUpperCase()]; - if (isBlock) out.push('\n'); + if (isBlock) out.push("\n"); walk(child); - if (isBlock) out.push('\n'); + if (isBlock) out.push("\n"); } } }; walk(clone); - let t = out.join('').replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim(); + let t = out + .join("") + .replace(/[ \t]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); return t; } function extractHumanText(raw) { - if (!raw) return ''; + if (!raw) return ""; const BT = String.fromCharCode(96); const fence = BT + BT + BT; let t = String(raw); - const re = new RegExp(fence + '[\\s\\S]*?' + fence, 'g'); - t = t.replace(re, ' '); - t = t.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); + const re = new RegExp(fence + "[\\s\\S]*?" + fence, "g"); + t = t.replace(re, " "); + t = t + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); return t; } function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } function startObserver() { if (state.customizationEnabled !== false) { - safe('observer.startObserver.toolRender', () => toolRender.startWatch()); + safe("observer.startObserver.toolRender", () => toolRender.startWatch()); } - safe('observer.startObserver.responseMeta', () => responseMeta.startWatch()); + safe("observer.startObserver.responseMeta", () => responseMeta.startWatch()); const observer = new MutationObserver((mutations) => { // 节流:避免页面高频 DOM 变化导致日志与检测刷屏。 @@ -637,7 +879,7 @@ function startObserver() { // Это дорогая операция, а нам нужно лишь понять «что-то изменилось». let hasNewContent = false; for (const mutation of mutations) { - if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { // scanForCommands вызываем только для «осмысленных» узлов (не text). const meaningful = []; for (let i = 0; i < mutation.addedNodes.length; i++) { @@ -647,11 +889,18 @@ function startObserver() { if (meaningful.length > 0) { const commands = scanForCommands(meaningful); for (const cmd of commands) { - displayCommand({ command: cmd, timestamp: Date.now(), id: generateId() }); + displayCommand({ + command: cmd, + timestamp: Date.now(), + id: generateId(), + }); } hasNewContent = true; } - } else if (mutation.type === 'characterData' || mutation.type === 'attributes') { + } else if ( + mutation.type === "characterData" || + mutation.type === "attributes" + ) { hasNewContent = true; } } @@ -662,7 +911,10 @@ function startObserver() { // мгновенно, как только новый AI-элемент появился в DOM. try { const metaCandidates = getMessageCandidates(); - const latestForMeta = metaCandidates.length > 0 ? metaCandidates[metaCandidates.length - 1] : null; + const latestForMeta = + metaCandidates.length > 0 + ? metaCandidates[metaCandidates.length - 1] + : null; if (latestForMeta) responseMeta.startTimer(latestForMeta); } catch (_) {} @@ -670,7 +922,8 @@ function startObserver() { if (hasNewContent && !isProcessingResponse) { // 若最后一条 AI 消息已处理过,则跳过,避免反复打印和等待 const candidates = getMessageCandidates(); - const lastMsg = candidates.length > 0 ? candidates[candidates.length - 1] : null; + const lastMsg = + candidates.length > 0 ? candidates[candidates.length - 1] : null; if (lastMsg && processedMessages.has(lastMsg)) { return; } @@ -678,8 +931,20 @@ function startObserver() { isProcessingResponse = true; (async () => { try { + // Индикатор «печатает…» в TG — только пока реально идёт генерация. + if (isGenerating()) { + try { + window.electronAPI.telegramTypingStart(); + } catch (_) {} + } else { + try { + window.electronAPI.telegramTypingStop(); + } catch (_) {} + } if (await isAIResponseComplete()) { - try { responseMeta.finishTimer(responseMeta.findLatestAIMessage()); } catch (_) {} + try { + responseMeta.finishTimer(responseMeta.findLatestAIMessage()); + } catch (_) {} processLatestAIResponse(); } } finally { @@ -703,40 +968,56 @@ function startObserver() { if (isProcessingResponse) return; (async () => { try { + // Страховка: генерация закончилась, но mutation-сигнал потерялся — + // гасим «печатает…» в TG. + if (!isGenerating()) { + try { + window.electronAPI.telegramTypingStop(); + } catch (_) {} + } if (await isAIResponseComplete()) { - try { responseMeta.finishTimer(responseMeta.findLatestAIMessage()); } catch (_) {} + try { + responseMeta.finishTimer(responseMeta.findLatestAIMessage()); + } catch (_) {} processLatestAIResponse(); } - } catch (_) { /* 轮询失败静默,等待下一轮 */ } + } catch (_) { + /* 轮询失败静默,等待下一轮 */ + } })(); }, 2000); } } - /** * 通知用户检测到工具调用(闪烁状态徽章 + 展开覆盖层) */ function notifyToolCallDetected(toolCall) { // 方向 C:不强制弹面板,只更新预览和徽章 // 更新预览区域显示检测到的工具调用 - const preview = document.getElementById('cuckoo-cmd-preview'); + const preview = document.getElementById("cuckoo-cmd-preview"); if (preview) { - preview.textContent = t('overlay.preview.toolPrefix', { name: toolCall.toolName }) + String.fromCharCode(10) + t('overlay.preview.params', { json: JSON.stringify(toolCall.params, null, 2) }); + preview.textContent = + t("overlay.preview.toolPrefix", { name: toolCall.toolName }) + + String.fromCharCode(10) + + t("overlay.preview.params", { + json: JSON.stringify(toolCall.params, null, 2), + }); } // 闪烁状态徽章 - flashBadge(t('overlay.badge.toolDetected')); + flashBadge(t("overlay.badge.toolDetected")); } /** * 通知用户检测到 JS 工具脚本(更新预览 + 闪烁徽章) */ function notifyJsScriptDetected(code) { // 方向 C:不强制弹面板 - const preview = document.getElementById('cuckoo-cmd-preview'); + const preview = document.getElementById("cuckoo-cmd-preview"); if (preview) { - preview.textContent = t('overlay.preview.jsPrefix') + String.fromCharCode(10) + code; + preview.textContent = + t("overlay.preview.jsPrefix") + String.fromCharCode(10) + code; } - flashBadge(t('overlay.badge.jsDetected')); + flashBadge(t("overlay.badge.jsDetected")); } /** * 执行检测到的 JS 工具脚本(带双通道去重) @@ -745,90 +1026,127 @@ async function handleJsToolScript(code) { // ===== Approval gate:JS 块同样按 toolApprovalMode 请求用户确认 ===== let verdict = { approved: true }; try { - verdict = await approval.requestApprovalIfNeeded({ kind: 'js', code }); + verdict = await approval.requestApprovalIfNeeded({ kind: "js", code }); } catch (err) { - console.error('[Cookie Code] approval gate error:', err.message); + console.error("[Cookie Code] approval gate error:", err.message); verdict = { approved: false }; } if (!verdict.approved) { - console.log('[Cookie Code] ⛔ JS 脚本被用户拒绝'); + console.log("[Cookie Code] ⛔ JS 脚本被用户拒绝"); // 作为失败结果合并回传 AI(denied=true → 不提示“修正后重试”) - return { code, result: { success: false, denied: true, error: approval.DENIED_JS_ERROR } }; + return { + code, + result: { success: false, denied: true, error: approval.DENIED_JS_ERROR }, + }; } // 方向 C:不强制弹面板 isExecuting = true; notifyJsScriptDetected(code); setTaskStatus(true); - showToast(t('overlay.toast.execStarted')); + showToast(t("overlay.toast.execStarted")); - const callId = 'js_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6); - console.log('[Cookie Code] [诊断] 即将执行的代码(JSON转义): ' + JSON.stringify(code)); + const callId = + "js_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6); + console.log( + "[Cookie Code] [诊断] 即将执行的代码(JSON转义): " + JSON.stringify(code), + ); try { const result = await window.electronAPI.executeJs(code, callId); - const resultSection = document.getElementById('cuckoo-result-section'); - const resultStatus = document.getElementById('cuckoo-result-status'); - const resultOutput = document.getElementById('cuckoo-result-output'); - if (resultSection) resultSection.classList.remove('cuckoo-hidden'); + const resultSection = document.getElementById("cuckoo-result-section"); + const resultStatus = document.getElementById("cuckoo-result-status"); + const resultOutput = document.getElementById("cuckoo-result-output"); + if (resultSection) resultSection.classList.remove("cuckoo-hidden"); // Определяем «фактическую ошибку»: result.success=false ИЛИ в выводе ненулевой exit code. - const outText = String(result.output || ''); + const outText = String(result.output || ""); const exitMatch = outText.match(/\[exit code:\s*(-?\d+)\]/); - const hasBadExit = !!(exitMatch && exitMatch[1] !== '0'); + const hasBadExit = !!(exitMatch && exitMatch[1] !== "0"); if (result.success) { if (resultStatus) { resultStatus.textContent = hasBadExit - ? t('overlay.status.jsExit', { code: exitMatch[1] }) - : t('overlay.status.jsOk'); - resultStatus.className = hasBadExit ? 'cuckoo-result-status error' : 'cuckoo-result-status success'; + ? t("overlay.status.jsExit", { code: exitMatch[1] }) + : t("overlay.status.jsOk"); + resultStatus.className = hasBadExit + ? "cuckoo-result-status error" + : "cuckoo-result-status success"; } if (resultOutput) { - resultOutput.textContent = result.output || t('overlay.output.scriptDone'); + resultOutput.textContent = + result.output || t("overlay.output.scriptDone"); } if (hasBadExit) { try { - toolRender.markToolBlockError(code, 'Команда завершилась с кодом ' + exitMatch[1] + ': ' + outText.slice(0, 300)); + toolRender.markToolBlockError( + code, + "Команда завершилась с кодом " + + exitMatch[1] + + ": " + + outText.slice(0, 300), + ); } catch (_) {} } } else { if (resultStatus) { - resultStatus.textContent = t('overlay.status.jsFailed'); - resultStatus.className = 'cuckoo-result-status error'; + resultStatus.textContent = t("overlay.status.jsFailed"); + resultStatus.className = "cuckoo-result-status error"; } if (resultOutput) { - resultOutput.textContent = result.error || t('overlay.output.unknownError'); + resultOutput.textContent = + result.error || t("overlay.output.unknownError"); } // Помечаем tool-блок в чате как ошибочный - try { toolRender.markToolBlockError(code, result.error || t('overlay.output.execFailed')); } catch (_) {} + try { + toolRender.markToolBlockError( + code, + result.error || t("overlay.output.execFailed"), + ); + } catch (_) {} } addHistory({ id: callId, - command: '[JS] ' + truncate((code.split(String.fromCharCode(10))[0] || code), 60), + command: + "[JS] " + truncate(code.split(String.fromCharCode(10))[0] || code, 60), success: result.success && !hasBadExit, - output: result.success ? (result.output || '') : (result.error || t('overlay.output.unknownError')), + output: result.success + ? result.output || "" + : result.error || t("overlay.output.unknownError"), timestamp: Date.now(), }); // 返回执行结果,由调用方统一合并回传 return { code, result }; } catch (err) { - console.error('[Cookie Code] JS 工具脚本执行异常:', err); - const resultSection = document.getElementById('cuckoo-result-section'); - const resultStatus = document.getElementById('cuckoo-result-status'); - const resultOutput = document.getElementById('cuckoo-result-output'); - if (resultSection) resultSection.classList.remove('cuckoo-hidden'); + console.error("[Cookie Code] JS 工具脚本执行异常:", err); + const resultSection = document.getElementById("cuckoo-result-section"); + const resultStatus = document.getElementById("cuckoo-result-status"); + const resultOutput = document.getElementById("cuckoo-result-output"); + if (resultSection) resultSection.classList.remove("cuckoo-hidden"); if (resultStatus) { - resultStatus.textContent = t('overlay.status.sysError'); - resultStatus.className = 'cuckoo-result-status error'; + resultStatus.textContent = t("overlay.status.sysError"); + resultStatus.className = "cuckoo-result-status error"; } if (resultOutput) { resultOutput.textContent = err.message || String(err); } - try { toolRender.markToolBlockError(code, 'Системная ошибка: ' + (err.message || String(err))); } catch (_) {} - return { code, result: { success: false, error: t('overlay.output.systemException', { msg: err.message || String(err) }) } }; + try { + toolRender.markToolBlockError( + code, + "Системная ошибка: " + (err.message || String(err)), + ); + } catch (_) {} + return { + code, + result: { + success: false, + error: t("overlay.output.systemException", { + msg: err.message || String(err), + }), + }, + }; } finally { isExecuting = false; setTaskStatus(false); @@ -844,66 +1162,83 @@ async function handleToolCall(toolCall) { // 方向 C:不强制弹面板 isExecuting = true; setTaskStatus(true); - showToast(t('overlay.toast.execStarted')); + showToast(t("overlay.toast.execStarted")); try { - const result = await window.electronAPI.executeTool(toolName, params, callId); + const result = await window.electronAPI.executeTool( + toolName, + params, + callId, + ); // 显示执行结果 - const resultSection = document.getElementById('cuckoo-result-section'); - const resultStatus = document.getElementById('cuckoo-result-status'); - const resultOutput = document.getElementById('cuckoo-result-output'); + const resultSection = document.getElementById("cuckoo-result-section"); + const resultStatus = document.getElementById("cuckoo-result-status"); + const resultOutput = document.getElementById("cuckoo-result-output"); - if (resultSection) resultSection.classList.remove('cuckoo-hidden'); + if (resultSection) resultSection.classList.remove("cuckoo-hidden"); if (result.success) { if (resultStatus) { - resultStatus.textContent = t('overlay.status.toolOk', { name: toolName }); - resultStatus.className = 'cuckoo-result-status success'; + resultStatus.textContent = t("overlay.status.toolOk", { + name: toolName, + }); + resultStatus.className = "cuckoo-result-status success"; } if (resultOutput) { resultOutput.textContent = JSON.stringify(result.data, null, 2); } } else { if (resultStatus) { - resultStatus.textContent = t('overlay.status.toolFailed', { name: toolName }); - resultStatus.className = 'cuckoo-result-status error'; + resultStatus.textContent = t("overlay.status.toolFailed", { + name: toolName, + }); + resultStatus.className = "cuckoo-result-status error"; } if (resultOutput) { - resultOutput.textContent = result.error || t('overlay.output.unknownError'); + resultOutput.textContent = + result.error || t("overlay.output.unknownError"); } } // 添加到历史 addHistory({ id: callId, - command: t('overlay.preview.toolPrefix', { name: toolName }), + command: t("overlay.preview.toolPrefix", { name: toolName }), success: result.success, - output: result.success ? JSON.stringify(result.data, null, 2) : (result.error || t('overlay.output.unknownError')), + output: result.success + ? JSON.stringify(result.data, null, 2) + : result.error || t("overlay.output.unknownError"), timestamp: Date.now(), }); // Факт-основанный учёт файлов — только успешные file-операции try { const msgEl = responseMeta.findLatestAIMessage(); - if (msgEl) responseMeta.recordSingleToolCall(msgEl, toolName, params, result); + if (msgEl) + responseMeta.recordSingleToolCall(msgEl, toolName, params, result); } catch (_) {} // 将执行结果发送回聊天,让 AI 看到结果并继续工作 sendToolResultToChat(toolCall, result); } catch (err) { - console.error('[Cookie Code] 工具执行异常:', err); - const resultSection = document.getElementById('cuckoo-result-section'); - const resultStatus = document.getElementById('cuckoo-result-status'); - const resultOutput = document.getElementById('cuckoo-result-output'); - if (resultSection) resultSection.classList.remove('cuckoo-hidden'); + console.error("[Cookie Code] 工具执行异常:", err); + const resultSection = document.getElementById("cuckoo-result-section"); + const resultStatus = document.getElementById("cuckoo-result-status"); + const resultOutput = document.getElementById("cuckoo-result-output"); + if (resultSection) resultSection.classList.remove("cuckoo-hidden"); if (resultStatus) { - resultStatus.textContent = t('overlay.status.sysError'); - resultStatus.className = 'cuckoo-result-status error'; + resultStatus.textContent = t("overlay.status.sysError"); + resultStatus.className = "cuckoo-result-status error"; } if (resultOutput) resultOutput.textContent = err.message || String(err); // 系统异常也要回传 AI,让它知道发生了什么 - sendToolResultToChat(toolCall, { success: false, error: t('overlay.output.systemException', { msg: err.message || String(err) }) }); + sendToolResultToChat(toolCall, { + success: false, + error: t("overlay.output.systemException", { + msg: err.message || String(err), + }), + }); } finally { isExecuting = false; setTaskStatus(false); From ddaf0a268490447e58a9d3e5ce7912eea671c23b Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Thu, 17 Sep 2026 10:04:46 +0300 Subject: [PATCH 2/4] Add attach_file tool (upload attachments) Introduce attach_file capability: adds a new AttachFileTool (tools/AttachFileTool.js) to upload local files into the chat input by injecting a File via DataTransfer into a hidden . Registers the tool in tools/index.js and exposes attachFile in the JS sandbox (JsRunner) and preload tool list. Adds IPC handler (src/main/ipc.js) to run injection code from main process. Updates types (tools/cuckoo-tools.d.ts) and .gitignore. Enforces a 30MB size limit, MIME guessing, and a 15s upload-detection timeout; returns detailed errors on failures. --- .gitignore | 1 + src/main/ipc.js | 34 +++ src/preload/tool-names.js | 53 ++-- tools/AttachFileTool.js | 270 ++++++++++++++++++++ tools/JsRunner.js | 525 ++++++++++++++++++++++---------------- tools/cuckoo-tools.d.ts | 13 + tools/index.js | 65 ++--- 7 files changed, 693 insertions(+), 268 deletions(-) create mode 100644 tools/AttachFileTool.js diff --git a/.gitignore b/.gitignore index 008dcc9..aa8ae16 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ test/coverage.lcov # Playwright MCP .playwright-mcp/ /tiktok +/.cuckoo/skills diff --git a/src/main/ipc.js b/src/main/ipc.js index 1499432..6211bd5 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -221,6 +221,38 @@ async function insertImageToChat(sender, filePath, caption, send) { } } +/** + * Загрузить файл как вложение в поле ввода чата активного окна. + * В отличие от картинок (clipboard + Ctrl+V), произвольные файлы вставляются + * через скрытый на странице чата: формируем File из base64 + * и прокидываем его в input через DataTransfer + событие change. + * @param {Electron.WebContents} sender + * @param {{code:string, fileName:string, size:number}} payload + * @returns {Promise<{success: boolean, fileName?: string, error?: string}>} + */ +async function attachFileToChat(sender, payload) { + try { + if (!sender || sender.isDestroyed()) { + return { success: false, error: "Окно недоступно" }; + } + const code = payload && payload.code; + if (!code || typeof code !== "string") { + return { success: false, error: "Не передано содержимое файла" }; + } + const result = await sender.executeJavaScript(code, true); + if (!result || result.success !== true) { + return { + success: false, + error: (result && result.error) || "Не удалось загрузить вложение", + }; + } + return { success: true, fileName: result.fileName }; + } catch (err) { + console.error("[Cookie Code] attachFileToChat error:", err.message); + return { success: false, error: err.message }; + } +} + /** * Попытаться открыть файл в VS Code (команда code --goto ). * @param {string} filePath — абсолютный путь @@ -601,6 +633,7 @@ function registerIpcHandlers() { requestUserQuestion(event.sender, questions), pasteImage: (filePath, caption, send) => insertImageToChat(event.sender, filePath, caption, send), + attachFile: (payload) => attachFileToChat(event.sender, payload), exitPlanMode: (plan) => requestExitPlanMode(event.sender, plan), }); if (isTaskCanceled(event.sender.id, taskToken)) { @@ -790,6 +823,7 @@ function registerIpcHandlers() { insertImageToChat(event.sender, filePath, caption, send), (plan) => requestExitPlanMode(event.sender, plan), sessionIdOf(event), + (payload) => attachFileToChat(event.sender, payload), ); if (isTaskCanceled(event.sender.id, taskToken)) { return { diff --git a/src/preload/tool-names.js b/src/preload/tool-names.js index d0c9553..a75de87 100644 --- a/src/preload/tool-names.js +++ b/src/preload/tool-names.js @@ -5,31 +5,32 @@ * 注册表(tools/index.js)完成。列表与顺序保持与原内联注册表一致。 */ const TOOL_NAMES = [ - 'file_write', - 'write', - 'file_read', - 'read', - 'read_lines', - 'file_edit', - 'edit', - 'file_glob', - 'glob', - 'file_grep', - 'grep', - 'todo_write', - 'bash', - 'pwsh', - 'mysql', - 'web_fetch', - 'city_time', - 'open_browser_window', - 'inject_js', - 'mcp_list_servers', - 'mcp_get_tools', - 'skill_list', - 'skill_load', - 'skill_execute', - 'exit_plan_mode', + "file_write", + "write", + "file_read", + "read", + "read_lines", + "file_edit", + "edit", + "file_glob", + "glob", + "file_grep", + "grep", + "todo_write", + "bash", + "pwsh", + "mysql", + "web_fetch", + "city_time", + "open_browser_window", + "inject_js", + "attach_file", + "mcp_list_servers", + "mcp_get_tools", + "skill_list", + "skill_load", + "skill_execute", + "exit_plan_mode", ]; /** 判断工具名是否存在(原 toolManager.tools.has(name)) */ @@ -39,7 +40,7 @@ function hasTool(name) { /** 工具名列表字符串(原 Array.from(toolManager.tools.keys()).join(', ')) */ function toolNamesList() { - return TOOL_NAMES.join(', '); + return TOOL_NAMES.join(", "); } module.exports = { TOOL_NAMES, hasTool, toolNamesList }; diff --git a/tools/AttachFileTool.js b/tools/AttachFileTool.js new file mode 100644 index 0000000..ffd8232 --- /dev/null +++ b/tools/AttachFileTool.js @@ -0,0 +1,270 @@ +const { Tool, ToolResult } = require("./ToolRegistry"); +const fs = require("fs"); +const path = require("path"); + +// 上传文件大小上限(30MB),避免超大文件拖垮内存与注入脚本 +const MAX_FILE_SIZE = 30 * 1024 * 1024; + +// 上传成功后等待附件 chip 出现的超时 +const UPLOAD_TIMEOUT_MS = 15000; + +// 常见扩展名 → MIME 映射(兜底 application/octet-stream) +const MIME_MAP = { + ".txt": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".csv": "text/csv", + ".tsv": "text/tab-separated-values", + ".json": "application/json", + ".json5": "application/json", + ".xml": "application/xml", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".html": "text/html", + ".htm": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".cjs": "text/javascript", + ".ts": "text/typescript", + ".tsx": "text/typescript", + ".jsx": "text/javascript", + ".py": "text/x-python", + ".java": "text/x-java", + ".c": "text/x-c", + ".h": "text/x-c", + ".cpp": "text/x-c++", + ".cc": "text/x-c++", + ".hpp": "text/x-c++", + ".cs": "text/x-csharp", + ".go": "text/x-go", + ".rs": "text/x-rust", + ".php": "text/x-php", + ".rb": "text/x-ruby", + ".sh": "text/x-sh", + ".bat": "text/x-bat", + ".cmd": "text/x-bat", + ".sql": "text/x-sql", + ".log": "text/plain", + ".ini": "text/plain", + ".conf": "text/plain", + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".doc": "application/msword", + ".docx": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".zip": "application/zip", +}; + +/** + * 解析文件路径:绝对路径原样返回;相对路径基于 projectDir(无则基于 cwd)。 + * @param {string} filePath + * @param {string|null} projectDir + * @returns {string} 绝对路径 + */ +function resolveFilePath(filePath, projectDir) { + if (!filePath || typeof filePath !== "string") { + throw new Error("filePath must be a non-empty string"); + } + const normalized = filePath.replace(/\//g, path.sep); + if (path.isAbsolute(normalized)) return normalized; + if (projectDir) return path.join(projectDir, normalized); + return path.resolve(normalized); +} + +/** + * 按扩展名推断 MIME 类型,未知扩展名返回 application/octet-stream。 + * @param {string} fileName + * @returns {string} + */ +function guessMimeType(fileName) { + const ext = path.extname(fileName || "").toLowerCase(); + return MIME_MAP[ext] || "application/octet-stream"; +} + +/** + * 生成注入到页面的上传脚本(在主 world 执行,使用标准 DOM API)。 + * @param {string} base64 文件内容的 base64 + * @param {string} fileName 文件名 + * @param {string} mimeType MIME 类型 + * @param {number} timeoutMs 等待附件出现的超时 + * @returns {string} IIFE 代码字符串 + */ +function buildInjectCode(base64, fileName, mimeType, timeoutMs) { + return ( + "(async () => {\n" + + " try {\n" + + " const b64 = " + + JSON.stringify(base64) + + ";\n" + + " const fileName = " + + JSON.stringify(fileName) + + ";\n" + + " const mimeType = " + + JSON.stringify(mimeType) + + ";\n" + + " const timeoutMs = " + + JSON.stringify(timeoutMs) + + ";\n" + + " const bin = atob(b64);\n" + + " const bytes = new Uint8Array(bin.length);\n" + + " for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n" + + " const file = new File([bytes], fileName, { type: mimeType });\n" + + " const input = document.querySelector('input[type=file]');\n" + + " if (!input) return { success: false, error: '未找到文件上传输入框' };\n" + + " const dt = new DataTransfer();\n" + + " dt.items.add(file);\n" + + " input.files = dt.files;\n" + + " input.dispatchEvent(new Event('change', { bubbles: true }));\n" + + " function fileVisible() {\n" + + " let node = input;\n" + + " for (let d = 0; d < 12 && node; d++) {\n" + + " if (node.innerText && node.innerText.includes(fileName)) return true;\n" + + " node = node.parentElement;\n" + + " }\n" + + " return false;\n" + + " }\n" + + " const deadline = Date.now() + timeoutMs;\n" + + " while (Date.now() < deadline) {\n" + + " await new Promise((r) => setTimeout(r, 300));\n" + + " if (fileVisible()) {\n" + + " return { success: true, fileName: fileName };\n" + + " }\n" + + " }\n" + + " return { success: false, error: '上传超时,未检测到附件出现' };\n" + + " } catch (err) {\n" + + " return { success: false, error: err && err.message ? err.message : String(err) };\n" + + " }\n" + + "})()" + ); +} + +/** + * attach_file 工具:将本地文件作为附件上传到当前对话输入框。 + * + * 与上游(Electron 自研聊天窗口,直接用 BrowserWindow.fromId)不同,本项目 + * 的聊天窗口是外部网页(DeepSeek 等),窗口对象在 ipc.js 中通过 + * windowState.getContextByWebContents(event.sender) 获取。因此本工具不直接 + * 依赖 electron 的 BrowserWindow,而是通过 ipc.js 注入的 attachFile callback + * 完成实际 DOM 注入(与 read_photo 的 pasteImage 模式一致)。 + */ +class AttachFileTool extends Tool { + constructor() { + super( + "attach_file", + "当你需要基于无法用 read 读取的文件内容(PDF、Word、Excel、PPT、图片等二进制文件)作答,或需要把文件交给用户看时,主动用本工具把该文件作为附件上传到输入框——无需等用户明确说“上传”。典型场景:用户给出这类文件的路径要求分析、描述了某个文件让你查看、或你生成了想交给用户的文件。上传后文件会随下一条消息一起发出,之后你即可基于其内容作答。若只需判断文件是否存在或读取其文本内容,用 read/glob 即可,不要上传。", + { + type: "object", + properties: { + filePath: { + type: "string", + description: "要上传的文件路径(相对项目根目录或绝对路径)", + }, + }, + required: ["filePath"], + additionalProperties: false, + }, + "attachFile(filePath)", + ); + } + + getPromptSection() { + return { + name: "tool:attach_file", + order: 114, + text: "当你需要基于无法用 read 读取的文件(PDF、Word、Excel、PPT、图片等二进制文件)作答时,主动使用 attachFile(filePath) 将其上传到输入框——无需等用户明确说“上传”。适用于:用户给出这类文件的路径、描述了某个文件让你查看、或你生成了想交给用户的文件。文本类文件优先用 read 直接读取;若只需判断文件是否存在,用 glob 即可。上传后文件随下一条消息发出,你即可基于其内容作答。仅支持已存在的单个文件,大小上限 30MB。", + }; + } + + async execute(params) { + const { filePath, projectDir, attachFile } = params || {}; + + if (typeof attachFile !== "function") { + return ToolResult.error( + "attach_file недоступен в текущем контексте (отсутствует attachFile callback).", + ); + } + + let absPath; + try { + absPath = resolveFilePath(filePath, projectDir || null); + } catch (err) { + return ToolResult.error(err.message); + } + + let stat; + try { + stat = fs.statSync(absPath); + } catch (err) { + return ToolResult.error("文件不存在或无法访问: " + absPath); + } + if (!stat.isFile()) { + return ToolResult.error("目标不是文件: " + absPath); + } + if (stat.size > MAX_FILE_SIZE) { + return ToolResult.error( + "文件超过 " + + Math.round(MAX_FILE_SIZE / 1024 / 1024) + + "MB 上限: " + + absPath, + ); + } + + let buffer; + try { + buffer = fs.readFileSync(absPath); + } catch (err) { + return ToolResult.error("读取文件失败: " + err.message); + } + + const fileName = path.basename(absPath); + const mimeType = guessMimeType(fileName); + const base64 = buffer.toString("base64"); + const code = buildInjectCode(base64, fileName, mimeType, UPLOAD_TIMEOUT_MS); + + let result; + try { + result = await attachFile({ + code, + fileName, + size: stat.size, + base64, + mimeType, + }); + } catch (err) { + return ToolResult.error( + "注入上传脚本失败: " + (err.message || String(err)), + ); + } + if (!result || result.success !== true) { + return ToolResult.error((result && result.error) || "上传失败"); + } + + return ToolResult.success({ + fileName: result.fileName || fileName, + size: stat.size, + message: "附件已上传到输入框: " + (result.fileName || fileName), + }); + } +} + +module.exports = { + AttachFileTool, + resolveFilePath, + guessMimeType, + buildInjectCode, + MAX_FILE_SIZE, + UPLOAD_TIMEOUT_MS, +}; diff --git a/tools/JsRunner.js b/tools/JsRunner.js index 9ac6889..8d20ddb 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -9,11 +9,11 @@ * - 每个工具调用都带执行截止时间检查,防止死循环;整体运行有 60 秒超时 */ -const vm = require('vm'); -const { exec } = require('child_process'); -const path = require('path'); -const { getDangerousCmds } = require('./BashTool'); -const { decodeOutput, normalizeCommand } = require('./decodeOutput'); +const vm = require("vm"); +const { exec } = require("child_process"); +const path = require("path"); +const { getDangerousCmds } = require("./BashTool"); +const { decodeOutput, normalizeCommand } = require("./decodeOutput"); // 同步执行超时(vm timeout,覆盖无 await 的死循环) const SYNC_TIMEOUT = 30 * 1000; @@ -27,115 +27,115 @@ const OUTPUT_LIMIT = 20000; * 注意:该脚本运行在沙箱 realm 内,其抛出的 Error 也是沙箱 realm 对象,无逃逸风险 */ const BOOTSTRAP = [ -"'use strict';", -"(function () {", -" globalThis.__logs = [];", -"", -" function __stringify(value) {", -" if (typeof value === 'string') return value;", -" try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }", -" }", -"", -" globalThis.log = function () {", -" var parts = [];", -" for (var i = 0; i < arguments.length; i++) parts.push(__stringify(arguments[i]));", -" globalThis.__logs.push(parts.join(' '));", -" };", -"", -" globalThis.projectDir = __projectDir;", -"", -" async function __call(name, args) {", -" var resText = await __hostBridge(name, JSON.stringify(args == null ? {} : args));", -" var res;", -" try { res = JSON.parse(resText); } catch (e) { throw new Error('工具结果解析失败: ' + e.message); }", -" if (!res || res.success !== true) {", -" throw new Error((res && res.error) || ('工具 ' + name + ' 执行失败'));", -" }", -" return res.data;", -" }", -"", -" globalThis.readFile = async function (filePath, encoding) {", -" return await __call('file_read', { file_path: filePath, encoding: encoding || 'utf-8' });", -" };", -" globalThis.readFileWithLines = async function (filePath, encoding) {", -" return await __call('file_read', { file_path: filePath, encoding: encoding || 'utf-8', line_numbers: true });", -" };", -" globalThis.read = async function (filePath, options) {", -" options = options || {};", -" return await __call('read', {", -" file_path: filePath,", -" offset: options.offset,", -" limit: options.limit", -" });", -" };", -" globalThis.readLines = async function (filePath, options) {", -" options = options || {};", -" return await __call('read_lines', {", -" file_path: filePath,", -" offset: options.offset,", -" limit: options.limit", -" });", -" };", -" globalThis.write = async function (filePath, content) {", -" return await __call('write', { file_path: filePath, content: content });", -" };", -" globalThis.writeFile = async function (filePath, content, encoding) {", -" return await __call('file_write', { file_path: filePath, content: content, encoding: encoding || 'utf-8' });", -" };", -" globalThis.edit = async function (filePath, oldString, newString, replaceAll, dryRun) {", -" return await __call('edit', { file_path: filePath, old_string: oldString, new_string: newString, replaceAll: replaceAll === true, dryRun: dryRun === true });", -" };", -" globalThis.editFile = async function (filePath, oldString, newString, replaceAll) {", -" return await __call('file_edit', { file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll === true });", -" };", -" globalThis.glob = async function (pattern, searchPath) {", -" return await __call('glob', { pattern: pattern, path: searchPath });", -" };", -" globalThis.grep = async function (pattern, options) {", -" options = options || {};", -" return await __call('grep', {", -" pattern: pattern,", -" path: options.path,", -" include: options.include", -" });", -" };", -" globalThis.todoWrite = async function (todos) {", -" return await __call('todo_write', { todos: todos });", -" };", -" globalThis.bash = async function (command, options) {", -" options = options || {};", -" return await __call('__bash', {", -" command: command,", -" description: options.description,", -" workdir: options.workdir || options.cwd,", -" timeoutMs: options.timeoutMs || options.timeout", -" });", -" };", -" globalThis.pwsh = async function (command, options) {", -" options = options || {};", -" return await __call('pwsh', {", -" command: command,", -" description: options.description,", -" workdir: options.workdir || options.cwd,", -" timeoutMs: options.timeoutMs || options.timeout", -" });", -" };", -" globalThis.deleteFile = async function (filePath) {", -" return await __call('file_delete', { file_path: filePath });", -" };", -" globalThis.webFetch = async function (url) {", -" return await __call('web_fetch', { url: url });", -" };", -" globalThis.cityTime = async function () {", -" return await __call('city_time', {});", -" };", -" globalThis.mysql = async function (options) {", -" options = options || {};", -" return await __call('mysql', options);", -" };", -" globalThis.mcpCall = async function (server, tool, args) {", -" return await __call('mcp_call', { server: server, tool: tool, args: args || {} });", -" };", + "'use strict';", + "(function () {", + " globalThis.__logs = [];", + "", + " function __stringify(value) {", + " if (typeof value === 'string') return value;", + " try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }", + " }", + "", + " globalThis.log = function () {", + " var parts = [];", + " for (var i = 0; i < arguments.length; i++) parts.push(__stringify(arguments[i]));", + " globalThis.__logs.push(parts.join(' '));", + " };", + "", + " globalThis.projectDir = __projectDir;", + "", + " async function __call(name, args) {", + " var resText = await __hostBridge(name, JSON.stringify(args == null ? {} : args));", + " var res;", + " try { res = JSON.parse(resText); } catch (e) { throw new Error('工具结果解析失败: ' + e.message); }", + " if (!res || res.success !== true) {", + " throw new Error((res && res.error) || ('工具 ' + name + ' 执行失败'));", + " }", + " return res.data;", + " }", + "", + " globalThis.readFile = async function (filePath, encoding) {", + " return await __call('file_read', { file_path: filePath, encoding: encoding || 'utf-8' });", + " };", + " globalThis.readFileWithLines = async function (filePath, encoding) {", + " return await __call('file_read', { file_path: filePath, encoding: encoding || 'utf-8', line_numbers: true });", + " };", + " globalThis.read = async function (filePath, options) {", + " options = options || {};", + " return await __call('read', {", + " file_path: filePath,", + " offset: options.offset,", + " limit: options.limit", + " });", + " };", + " globalThis.readLines = async function (filePath, options) {", + " options = options || {};", + " return await __call('read_lines', {", + " file_path: filePath,", + " offset: options.offset,", + " limit: options.limit", + " });", + " };", + " globalThis.write = async function (filePath, content) {", + " return await __call('write', { file_path: filePath, content: content });", + " };", + " globalThis.writeFile = async function (filePath, content, encoding) {", + " return await __call('file_write', { file_path: filePath, content: content, encoding: encoding || 'utf-8' });", + " };", + " globalThis.edit = async function (filePath, oldString, newString, replaceAll, dryRun) {", + " return await __call('edit', { file_path: filePath, old_string: oldString, new_string: newString, replaceAll: replaceAll === true, dryRun: dryRun === true });", + " };", + " globalThis.editFile = async function (filePath, oldString, newString, replaceAll) {", + " return await __call('file_edit', { file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll === true });", + " };", + " globalThis.glob = async function (pattern, searchPath) {", + " return await __call('glob', { pattern: pattern, path: searchPath });", + " };", + " globalThis.grep = async function (pattern, options) {", + " options = options || {};", + " return await __call('grep', {", + " pattern: pattern,", + " path: options.path,", + " include: options.include", + " });", + " };", + " globalThis.todoWrite = async function (todos) {", + " return await __call('todo_write', { todos: todos });", + " };", + " globalThis.bash = async function (command, options) {", + " options = options || {};", + " return await __call('__bash', {", + " command: command,", + " description: options.description,", + " workdir: options.workdir || options.cwd,", + " timeoutMs: options.timeoutMs || options.timeout", + " });", + " };", + " globalThis.pwsh = async function (command, options) {", + " options = options || {};", + " return await __call('pwsh', {", + " command: command,", + " description: options.description,", + " workdir: options.workdir || options.cwd,", + " timeoutMs: options.timeoutMs || options.timeout", + " });", + " };", + " globalThis.deleteFile = async function (filePath) {", + " return await __call('file_delete', { file_path: filePath });", + " };", + " globalThis.webFetch = async function (url) {", + " return await __call('web_fetch', { url: url });", + " };", + " globalThis.cityTime = async function () {", + " return await __call('city_time', {});", + " };", + " globalThis.mysql = async function (options) {", + " options = options || {};", + " return await __call('mysql', options);", + " };", + " globalThis.mcpCall = async function (server, tool, args) {", + " return await __call('mcp_call', { server: server, tool: tool, args: args || {} });", + " };", " globalThis.mcpListServers = async function () {", " return await __call('mcp_list_servers', {});", " };", @@ -158,35 +158,41 @@ const BOOTSTRAP = [ " globalThis.read_photo = async function (filePath, caption, send) {", " return await __call('read_photo', { file_path: filePath, caption: caption || '', send: send !== false });", " };", + " globalThis.attachFile = async function (filePathOrPayload) {", + " var payload = typeof filePathOrPayload === 'string'", + " ? { filePath: filePathOrPayload }", + " : (filePathOrPayload || {});", + " return await __call('attach_file', payload);", + " };", " globalThis.exitPlanMode = async function (plan) {", " return await __call('exit_plan_mode', { plan: plan });", " };", " globalThis.exit_plan_mode = globalThis.exitPlanMode;", -" globalThis.openBrowserWindow = async function (url, options) {", -" options = options || {};", -" return await __call('open_browser_window', {", -" url: url,", -" id: options.id,", -" width: options.width,", -" height: options.height", -" });", -" };", -" globalThis.injectJS = async function (windowId, code) {", -" return await __call('inject_js', { windowId: windowId, code: code });", -" };", -"", -" if (!globalThis.projectDir) {", -" globalThis.log('[提示] 尚未初始化项目目录,相对路径将基于系统目录解析。可点击覆盖层“初始化项目”。');", -" }", -"})();", -"", -].join('\n'); + " globalThis.openBrowserWindow = async function (url, options) {", + " options = options || {};", + " return await __call('open_browser_window', {", + " url: url,", + " id: options.id,", + " width: options.width,", + " height: options.height", + " });", + " };", + " globalThis.injectJS = async function (windowId, code) {", + " return await __call('inject_js', { windowId: windowId, code: code });", + " };", + "", + " if (!globalThis.projectDir) {", + " globalThis.log('[提示] 尚未初始化项目目录,相对路径将基于系统目录解析。可点击覆盖层“初始化项目”。');", + " }", + "})();", + "", +].join("\n"); /** * 解析命令工作目录(相对路径基于项目目录) */ function resolveDir(dir, projectDir) { - if (!dir) return projectDir || process.env.USERPROFILE || path.resolve('.'); + if (!dir) return projectDir || process.env.USERPROFILE || path.resolve("."); const normalized = String(dir).replace(/\//g, path.sep); if (path.isAbsolute(normalized)) return normalized; if (projectDir) return path.join(projectDir, normalized); @@ -199,56 +205,77 @@ function resolveDir(dir, projectDir) { * 让 AI 代码可以像普通 shell 一样判断结果。 */ function runBash(args, projectDir) { - const command = normalizeCommand(String(args.command || '').trim()); - if (!command) return Promise.resolve({ success: false, error: 'invalid command: expected a non-empty string' }); + const command = normalizeCommand(String(args.command || "").trim()); + if (!command) + return Promise.resolve({ + success: false, + error: "invalid command: expected a non-empty string", + }); const dangerous = getDangerousCmds(); if (dangerous.some((pattern) => pattern.test(command))) { - return Promise.resolve({ success: false, error: '命令被安全策略拒绝(危险命令): ' + command }); + return Promise.resolve({ + success: false, + error: "命令被安全策略拒绝(危险命令): " + command, + }); } - const timeout = typeof args.timeoutMs === 'number' && args.timeoutMs > 0 ? args.timeoutMs : 30000; + const timeout = + typeof args.timeoutMs === "number" && args.timeoutMs > 0 + ? args.timeoutMs + : 30000; const cwd = resolveDir(args.workdir || args.cwd, projectDir); return new Promise((resolve) => { let processManager = null; try { - processManager = require('../src/main/process-manager').processManager; + processManager = require("../src/main/process-manager").processManager; } catch (_) {} - const child = exec(command, { cwd, timeout, maxBuffer: 1024 * 1024, windowsHide: true, encoding: 'buffer' }, (error, stdout, stderr) => { - const wasKilledByUser = processManager && child && processManager.wasKilled(child.pid); - if (processManager && child) processManager.untrack(child); - const out = decodeOutput(stdout); - const err = decodeOutput(stderr); + const child = exec( + command, + { + cwd, + timeout, + maxBuffer: 1024 * 1024, + windowsHide: true, + encoding: "buffer", + }, + (error, stdout, stderr) => { + const wasKilledByUser = + processManager && child && processManager.wasKilled(child.pid); + if (processManager && child) processManager.untrack(child); + const out = decodeOutput(stdout); + const err = decodeOutput(stderr); - // dsh 风格渲染:stdout + [stderr] 分节 + 状态标记 - let body = out; - if (err && err.length > 0) { - if (body.length > 0 && !body.endsWith('\n')) body += '\n'; - body += '[stderr]\n' + err; - } - if (body.length === 0) body = '(no output)'; + // dsh 风格渲染:stdout + [stderr] 分节 + 状态标记 + let body = out; + if (err && err.length > 0) { + if (body.length > 0 && !body.endsWith("\n")) body += "\n"; + body += "[stderr]\n" + err; + } + if (body.length === 0) body = "(no output)"; - const markers = []; - if (error) { - if (wasKilledByUser) { - markers.push('[terminated by user]'); - } else if (error.killed) { - markers.push('[timed out after ' + timeout + 'ms]'); - } else if (typeof error.code === 'number') { - markers.push('[exit code: ' + error.code + ']'); - } else { - markers.push('[exit code: 1]'); + const markers = []; + if (error) { + if (wasKilledByUser) { + markers.push("[terminated by user]"); + } else if (error.killed) { + markers.push("[timed out after " + timeout + "ms]"); + } else if (typeof error.code === "number") { + markers.push("[exit code: " + error.code + "]"); + } else { + markers.push("[exit code: 1]"); + } } - } - if (markers.length > 0) { - if (!body.endsWith('\n')) body += '\n'; - body += markers.join('\n'); - } + if (markers.length > 0) { + if (!body.endsWith("\n")) body += "\n"; + body += markers.join("\n"); + } - // 非零退出也正常返回(success:true),模型看到标记自行判断 - resolve({ success: true, data: body }); - }); + // 非零退出也正常返回(success:true),模型看到标记自行判断 + resolve({ success: true, data: body }); + }, + ); if (processManager && child) { processManager.track(child); @@ -266,7 +293,7 @@ function safeStringify(value) { try { return String(value); } catch (e2) { - return '[无法序列化的返回值]'; + return "[无法序列化的返回值]"; } } } @@ -285,9 +312,18 @@ class JsRunner { * @param {string|null} projectDir - 当前项目目录(相对路径基准) * @returns {Promise<{success: boolean, output?: string, error?: string}>} */ - async run(code, projectDir, senderId, askUserQuestion, pasteImage, exitPlanMode, sessionId) { - if (!code || typeof code !== 'string' || !code.trim()) { - return { success: false, error: '无效的 JS 代码' }; + async run( + code, + projectDir, + senderId, + askUserQuestion, + pasteImage, + exitPlanMode, + sessionId, + attachFile, + ) { + if (!code || typeof code !== "string" || !code.trim()) { + return { success: false, error: "无效的 JS 代码" }; } const startTime = Date.now(); @@ -299,64 +335,84 @@ class JsRunner { // 避免沙箱内出现宿主 realm 的 Error / Function 逃逸通道。 const hostBridge = async (op, argsJson) => { if (Date.now() - startTime > deadlineMs) { - return JSON.stringify({ success: false, error: 'JS 脚本执行超时(' + Math.round(deadlineMs / 1000) + ' 秒)' }); + return JSON.stringify({ + success: false, + error: "JS 脚本执行超时(" + Math.round(deadlineMs / 1000) + " 秒)", + }); } let args = {}; try { - args = JSON.parse(argsJson || '{}'); + args = JSON.parse(argsJson || "{}"); } catch (e) { args = {}; } // Режим плана: блокируем изменяющие операции (кроме записи plan.md). try { - const planMode = require('../src/main/plan-mode'); + const planMode = require("../src/main/plan-mode"); if (planMode.isPlanMode(senderId, sessionId)) { const verdict = planMode.checkBlocked(op, args); if (verdict.blocked) { return JSON.stringify({ success: false, error: verdict.error }); } } - } catch (_) { /* plan-mode недоступен — не блокируем */ } + } catch (_) { + /* plan-mode недоступен — не блокируем */ + } let result; - if (op === '__bash') { + if (op === "__bash") { result = await runBash(args, projectDir); } else { const tool = this.registry.get(op); if (!tool) { - result = { success: false, error: '未知工具: ' + op }; + result = { success: false, error: "未知工具: " + op }; } else { try { - result = await tool.execute(Object.assign({}, args, { projectDir, senderId, askUserQuestion, pasteImage, exitPlanMode })); + result = await tool.execute( + Object.assign({}, args, { + projectDir, + senderId, + askUserQuestion, + pasteImage, + exitPlanMode, + attachFile, + }), + ); } catch (err) { - result = { success: false, error: '工具 ' + op + ' 执行异常: ' + (err.message || String(err)) }; + result = { + success: false, + error: + "工具 " + op + " 执行异常: " + (err.message || String(err)), + }; } } } // Собираем diff-статистику для инлайн-счётчика +829 -53 try { - if (result && result.stats && typeof result.stats.added === 'number') { + if (result && result.stats && typeof result.stats.added === "number") { collectedStats.push({ op, - path: args.file_path || args.path || '', + path: args.file_path || args.path || "", added: result.stats.added, removed: result.stats.removed, - operation: result.stats.operation || '' + operation: result.stats.operation || "", }); } } catch (_) {} // Уведомление в Telegram о результате tool (не блокирует выполнение). try { - const { notifyToolResult } = require('../botsrc'); - const label = op === '__bash' ? 'Bash' : op; + const { notifyToolResult } = require("../botsrc"); + const label = op === "__bash" ? "Bash" : op; const preview = result.success - ? (typeof result.data === 'string' ? result.data : '') - : (result.error || ''); + ? typeof result.data === "string" + ? result.data + : "" + : result.error || ""; // Фактический успех: для bash/pwsh ненулевой exit code — это ошибка, // хотя инструмент возвращает success:true. const exitMatch = String(preview).match(/\[exit code:\s*(-?\d+)\]/); - const hasBadExit = !!(exitMatch && exitMatch[1] !== '0'); + const hasBadExit = !!(exitMatch && exitMatch[1] !== "0"); const ok = !!result.success && !hasBadExit; notifyToolResult(label, ok, { args: args, preview: preview }); } catch (_) {} @@ -365,21 +421,35 @@ class JsRunner { // ========== 沙箱构建与加固 ========== const sandbox = {}; - Object.defineProperty(sandbox, '__hostBridge', { - value: hostBridge, enumerable: true, writable: false, configurable: false, + Object.defineProperty(sandbox, "__hostBridge", { + value: hostBridge, + enumerable: true, + writable: false, + configurable: false, }); - Object.defineProperty(sandbox, '__projectDir', { - value: projectDir || null, enumerable: true, writable: false, configurable: false, + Object.defineProperty(sandbox, "__projectDir", { + value: projectDir || null, + enumerable: true, + writable: false, + configurable: false, }); // 截断沙箱对象与桥接函数的原型链,阻止经 constructor/__proto__ 逃逸到宿主 realm - try { Object.setPrototypeOf(sandbox, null); } catch (e) { /* 尽力而为 */ } - try { Object.setPrototypeOf(hostBridge, null); } catch (e) { /* 尽力而为 */ } + try { + Object.setPrototypeOf(sandbox, null); + } catch (e) { + /* 尽力而为 */ + } + try { + Object.setPrototypeOf(hostBridge, null); + } catch (e) { + /* 尽力而为 */ + } let context; try { context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false }, - name: 'cuckoo-js-sandbox', + name: "cuckoo-js-sandbox", }); } catch (err) { // 兜底:极少数环境下 null 原型沙箱不可用 @@ -388,57 +458,86 @@ class JsRunner { fallback.__projectDir = projectDir || null; context = vm.createContext(fallback, { codeGeneration: { strings: false, wasm: false }, - name: 'cuckoo-js-sandbox', + name: "cuckoo-js-sandbox", }); } try { - vm.runInContext(BOOTSTRAP, context, { filename: 'cuckoo-js-api.js' }); + vm.runInContext(BOOTSTRAP, context, { filename: "cuckoo-js-api.js" }); } catch (err) { - return { success: false, error: '沙箱初始化失败: ' + (err.message || String(err)) }; + return { + success: false, + error: "沙箱初始化失败: " + (err.message || String(err)), + }; } // 包装为 async IIFE:支持顶层 await、return 返回值 - const script = new vm.Script('(async () => {\n' + code + '\n})()', { filename: 'cuckoo-js-tool-script.js' }); + const script = new vm.Script("(async () => {\n" + code + "\n})()", { + filename: "cuckoo-js-tool-script.js", + }); let settleTimer = null; try { const deadline = new Promise((_resolve, reject) => { settleTimer = setTimeout( - () => reject(new Error('JS 脚本执行超时(' + Math.round(deadlineMs / 1000) + ' 秒)')), - deadlineMs + () => + reject( + new Error( + "JS 脚本执行超时(" + Math.round(deadlineMs / 1000) + " 秒)", + ), + ), + deadlineMs, ); }); - const ret = await Promise.race([script.runInContext(context, { timeout: SYNC_TIMEOUT }), deadline]); + const ret = await Promise.race([ + script.runInContext(context, { timeout: SYNC_TIMEOUT }), + deadline, + ]); // 收集 log() 输出 let logs = []; try { - const logsJson = vm.runInContext('JSON.stringify(globalThis.__logs || [])', context); + const logsJson = vm.runInContext( + "JSON.stringify(globalThis.__logs || [])", + context, + ); logs = JSON.parse(logsJson); - } catch (e) { /* 忽略日志收集失败 */ } + } catch (e) { + /* 忽略日志收集失败 */ + } const parts = []; if (Array.isArray(logs) && logs.length > 0) { - parts.push(logs.join('\n')); + parts.push(logs.join("\n")); } if (ret !== undefined && ret !== null) { - parts.push(typeof ret === 'string' ? ret : safeStringify(ret)); + parts.push(typeof ret === "string" ? ret : safeStringify(ret)); } - let output = parts.filter(Boolean).join('\n\n'); + let output = parts.filter(Boolean).join("\n\n"); if (output.length > OUTPUT_LIMIT) { - output = output.slice(0, OUTPUT_LIMIT) + '\n...[输出过长已截断]...'; + output = output.slice(0, OUTPUT_LIMIT) + "\n...[输出过长已截断]..."; } - const finalRes = { success: true, output: output || '(脚本执行完成,无输出)\n如需输出请使用 log() 方法' }; + const finalRes = { + success: true, + output: output || "(脚本执行完成,无输出)\n如需输出请使用 log() 方法", + }; if (collectedStats.length > 0) finalRes.stats = collectedStats; return finalRes; } catch (err) { - console.error('[JsRunner] 脚本执行失败:', err && err.stack ? err.stack : String(err)); - console.error('[JsRunner] [诊断] 失败代码(JSON转义): ' + JSON.stringify(code)); - return { success: false, error: err && err.message ? err.message : String(err) }; + console.error( + "[JsRunner] 脚本执行失败:", + err && err.stack ? err.stack : String(err), + ); + console.error( + "[JsRunner] [诊断] 失败代码(JSON转义): " + JSON.stringify(code), + ); + return { + success: false, + error: err && err.message ? err.message : String(err), + }; } finally { if (settleTimer) clearTimeout(settleTimer); } diff --git a/tools/cuckoo-tools.d.ts b/tools/cuckoo-tools.d.ts index a871a69..24e87c2 100644 --- a/tools/cuckoo-tools.d.ts +++ b/tools/cuckoo-tools.d.ts @@ -311,6 +311,19 @@ declare function openBrowserWindow(url: string, options?: { id?: string; width?: */ declare function injectJS(windowId: string, code: string): Promise; +// ================= 附件上传 ================= + +/** + * 将本地文件作为附件上传到当前对话输入框(不发送)。 + * 当你需要基于无法用 read 读取的文件内容(PDF、DOC、XLSX、PPT、图片等)作答,或需要把文件交给用户看时使用——无需用户明确要求上传。 + * 若只需判断文件是否存在或读取其文本内容,用 read/glob 即可,不要上传。 + * 上传成功后文件会随下一条消息一起发出,之后可基于其内容作答。 + * @param filePath 要上传的文件路径(相对项目根目录或绝对路径) + * @returns { fileName: string, size: number, message: string } + * @throws 文件不存在、不是文件、超过 30MB、缺少窗口上下文或上传超时时抛出异常 + */ +declare function attachFile(filePath: string): Promise<{ fileName: string; size: number; message: string }>; + // ================= MCP ================= /** diff --git a/tools/index.js b/tools/index.js index 6fac3bb..875a3b1 100644 --- a/tools/index.js +++ b/tools/index.js @@ -2,34 +2,39 @@ * 工具库统一入口 * 导出所有可用工具(主进程注册工具的唯一入口,与 src/main/tool-registry.js 配套) */ -const { ToolRegistry } = require('./ToolRegistry'); -const { JsRunner } = require('./JsRunner'); -const { FileWriteTool } = require('./FileWriteTool'); -const { WriteTool } = require('./WriteTool'); -const { FileReadTool } = require('./FileReadTool'); -const { ReadTool } = require('./ReadTool'); -const { ReadLinesTool } = require('./ReadLinesTool'); -const { FileEditTool } = require('./FileEditTool'); -const { EditTool } = require('./EditTool'); -const { GlobTool } = require('./GlobTool'); -const { GlobToolNew } = require('./GlobToolNew'); -const { GrepTool } = require('./GrepTool'); -const { GrepToolNew } = require('./GrepToolNew'); -const { TodoWriteTool, TodoEditTool, TodoDeleteTool } = require('./TodoTools'); -const { BashTool } = require('./BashTool'); -const { PwshTool } = require('./PwshTool'); -const { FileDeleteTool } = require('./FileDeleteTool'); -const { WebFetchTool } = require('./WebFetchTool'); -const { CityTimeTool } = require('./CityTimeTool'); -const { MySQLTool } = require('./MySQLTool'); -const { OpenBrowserWindowTool } = require('./OpenBrowserWindowTool'); -const { InjectJSTool } = require('./InjectJSTool'); -const { McpCallTool } = require('./McpCallTool'); -const { McpListServersTool, McpGetToolsTool } = require('./McpQueryTools'); -const { SkillListTool, SkillLoadTool, SkillExecuteTool } = require('./SkillTools'); -const { AskUserQuestionTool } = require('./AskUserQuestionTool'); -const { ReadPhotoTool } = require('./ReadPhotoTool'); -const { ExitPlanModeTool } = require('./ExitPlanModeTool'); +const { ToolRegistry } = require("./ToolRegistry"); +const { JsRunner } = require("./JsRunner"); +const { FileWriteTool } = require("./FileWriteTool"); +const { WriteTool } = require("./WriteTool"); +const { FileReadTool } = require("./FileReadTool"); +const { ReadTool } = require("./ReadTool"); +const { ReadLinesTool } = require("./ReadLinesTool"); +const { FileEditTool } = require("./FileEditTool"); +const { EditTool } = require("./EditTool"); +const { GlobTool } = require("./GlobTool"); +const { GlobToolNew } = require("./GlobToolNew"); +const { GrepTool } = require("./GrepTool"); +const { GrepToolNew } = require("./GrepToolNew"); +const { TodoWriteTool, TodoEditTool, TodoDeleteTool } = require("./TodoTools"); +const { BashTool } = require("./BashTool"); +const { PwshTool } = require("./PwshTool"); +const { FileDeleteTool } = require("./FileDeleteTool"); +const { WebFetchTool } = require("./WebFetchTool"); +const { CityTimeTool } = require("./CityTimeTool"); +const { MySQLTool } = require("./MySQLTool"); +const { OpenBrowserWindowTool } = require("./OpenBrowserWindowTool"); +const { InjectJSTool } = require("./InjectJSTool"); +const { McpCallTool } = require("./McpCallTool"); +const { McpListServersTool, McpGetToolsTool } = require("./McpQueryTools"); +const { + SkillListTool, + SkillLoadTool, + SkillExecuteTool, +} = require("./SkillTools"); +const { AskUserQuestionTool } = require("./AskUserQuestionTool"); +const { ReadPhotoTool } = require("./ReadPhotoTool"); +const { ExitPlanModeTool } = require("./ExitPlanModeTool"); +const { AttachFileTool } = require("./AttachFileTool"); // 创建全局工具注册表 const registry = new ToolRegistry(); @@ -66,6 +71,7 @@ registry.register(new SkillExecuteTool()); registry.register(new AskUserQuestionTool()); registry.register(new ReadPhotoTool()); registry.register(new ExitPlanModeTool()); +registry.register(new AttachFileTool()); // 导出 module.exports = { @@ -99,9 +105,10 @@ module.exports = { AskUserQuestionTool, ReadPhotoTool, ExitPlanModeTool, + AttachFileTool, // 便捷方法 getAllTools: () => registry, getToolDescriptions: () => registry.getDescriptions(), getFormattedToolsForPrompt: () => registry.getFormattedToolsForPrompt(), - executeTool: (name, params) => registry.execute(name, params) + executeTool: (name, params) => registry.execute(name, params), }; From 6ab1cad854bd165ad4142de32858540a6d6bb670 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Thu, 17 Sep 2026 10:25:05 +0300 Subject: [PATCH 3/4] i18n: add delay messages; refactor overlay events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworked i18n keys (consistent formatting) and added localization for send-delay UI (labels, validation and saved message). Imported and used t() in overlay event handlers; changed send-delay inputs to seconds in the template/CSS and updated save handler to convert seconds→ms, validate and persist to localStorage. Also applied various JS cleanups in overlay/events (quote/style consistency, safer try/catch, DOM id queries) and improved token logging/storage helpers. Minor MCP, window-manager and UI string adjustments to use localized strings. --- src/preload/i18n/i18n.js | 817 +++++++++++++++++++++++--------- src/preload/overlay/events.js | 548 +++++++++++++-------- src/preload/overlay/template.js | 12 +- 3 files changed, 932 insertions(+), 445 deletions(-) diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 8454712..654cbfd 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -9,292 +9,632 @@ * При смене языка рекомендуется location.reload() — просто и надёжно. */ -const DEFAULTS = { ru: '', en: '' }; +const DEFAULTS = { ru: "", en: "" }; // ========== Словарь ========== const KEYS = { // ---- Оверлей: заголовки и общие ---- - 'overlay.title': { ru: 'Cookie Code', en: 'Cookie Code' }, - 'overlay.btn.minimize': { ru: 'Свернуть панель', en: 'Collapse panel' }, - 'overlay.label.currentDir': { ru: 'Текущий каталог проекта', en: 'Current project directory' }, - 'overlay.btn.changeDir': { ru: '🔄 Изменить', en: '🔄 Change' }, - 'overlay.btn.changeDir.title':{ ru: 'Изменить каталог проекта', en: 'Change project directory' }, - 'overlay.dir.notSelected': { ru: 'Не выбрано', en: 'Not selected' }, + "overlay.title": { ru: "Cookie Code", en: "Cookie Code" }, + "overlay.btn.minimize": { ru: "Свернуть панель", en: "Collapse panel" }, + "overlay.label.currentDir": { + ru: "Текущий каталог проекта", + en: "Current project directory", + }, + "overlay.btn.changeDir": { ru: "🔄 Изменить", en: "🔄 Change" }, + "overlay.btn.changeDir.title": { + ru: "Изменить каталог проекта", + en: "Change project directory", + }, + "overlay.dir.notSelected": { ru: "Не выбрано", en: "Not selected" }, // ---- Оверлей: кнопки ---- - 'overlay.btn.init': { ru: 'Инициализировать проект', en: 'Initialize project' }, - 'overlay.btn.init.loading': { ru: '⏳ Инициализация...', en: '⏳ Initializing...' }, - 'overlay.btn.windowManager': { ru: 'Окна', en: 'Windows' }, - 'overlay.btn.windowManager.title': { ru: 'Управление окнами', en: 'Manage windows' }, - 'overlay.btn.mcp': { ru: 'MCP', en: 'MCP' }, - 'overlay.btn.mcp.title': { ru: 'Управление MCP-инструментами', en: 'Manage MCP tools' }, - 'overlay.btn.tg': { ru: 'Telegram', en: 'Telegram' }, - 'overlay.btn.tg.title': { ru: 'Настройки Telegram-бота', en: 'Telegram bot settings' }, - 'tg.title': { ru: 'Telegram-бот', en: 'Telegram bot' }, - 'tg.label.token': { ru: 'Токен бота (@BotFather)', en: 'Bot token (@BotFather)' }, - 'tg.label.chatId': { ru: 'Chat ID', en: 'Chat ID' }, - 'tg.label.enabled': { ru: 'Включить бота', en: 'Enable bot' }, - 'tg.label.notifyTools': { ru: 'Уведомления о tool', en: 'Tool notifications' }, - 'tg.label.chatFeed': { ru: 'Принимать сообщения из TG в чат', en: 'Accept messages from TG into chat' }, - 'tg.btn.save': { ru: 'Сохранить', en: 'Save' }, - 'tg.btn.ping': { ru: 'Проверить токен', en: 'Check token' }, - 'tg.btn.test': { ru: 'Тестовое сообщение', en: 'Test message' }, - 'tg.status.off': { ru: 'Выключен', en: 'Disabled' }, - 'tg.status.on': { ru: 'Работает', en: 'Running' }, - 'overlay.btn.genDoc': { ru: 'Создать описание', en: 'Generate docs' }, - 'overlay.btn.genDoc.title': { ru: 'Попросить AI сгенерировать описание проекта (CUCKOO.md)', en: 'Ask AI to generate a project description (CUCKOO.md)' }, - 'overlay.btn.immersive': { ru: 'Погружение', en: 'Immersive' }, - 'overlay.btn.immersive.title':{ ru: 'Переключить в режим погружения', en: 'Switch to immersive mode' }, - 'overlay.btn.manualParse': { ru: 'Разобрать вручную', en: 'Manual parse' }, - 'overlay.btn.manualParse.title': { ru: 'Вручную разобрать вызовы инструментов в текущем ответе', en: 'Manually parse tool calls in the latest reply' }, - 'overlay.btn.manualParse.loading': { ru: 'Разбор...', en: 'Parsing...' }, - 'overlay.toast.executing': { ru: 'Команда уже выполняется, ручной разбор не нужен', en: 'A command is already running, no manual parse needed' }, - 'overlay.toast.manualParseTriggered': { ru: 'Запущен ручной разбор последнего ответа AI', en: 'Manual parse of the latest AI reply triggered' }, - 'overlay.toast.manualParseError': { ru: 'Ошибка ручного разбора: {msg}', en: 'Manual parse error: {msg}' }, - 'overlay.badge.toolDetected': { ru: 'Cookie Code — обнаружен вызов инструмента', en: 'Cookie Code — tool call detected' }, - 'overlay.badge.jsDetected': { ru: 'Cookie Code — обнаружен JS-скрипт', en: 'Cookie Code — JS tool script detected' }, - 'overlay.toast.execStarted': { ru: 'Начато выполнение команды', en: 'Command execution started' }, - 'overlay.preview.toolPrefix': { ru: '[Инструмент] {name}', en: '[Tool] {name}' }, - 'overlay.preview.params': { ru: 'Параметры: {json}', en: 'Parameters: {json}' }, - 'overlay.preview.jsPrefix': { ru: '[JS-скрипт]', en: '[JS tool script]' }, - 'overlay.status.jsFailed': { ru: '❌ Ошибка выполнения JS-скрипта', en: '❌ JS script execution failed' }, - 'overlay.status.sysError': { ru: '❌ Системная ошибка', en: '❌ System error' }, - 'overlay.status.jsOk': { ru: '✅ JS-скрипт выполнен успешно', en: '✅ JS script executed successfully' }, - 'overlay.status.jsExit': { ru: '⚠ JS-скрипт завершён с кодом {code}', en: '⚠ JS script finished with code {code}' }, - 'overlay.status.toolOk': { ru: '✅ Инструмент {name} выполнен успешно', en: '✅ Tool {name} executed successfully' }, - 'overlay.status.toolFailed': { ru: '❌ Инструмент {name} выполнен с ошибкой', en: '❌ Tool {name} failed' }, - 'overlay.output.scriptDone': { ru: '(скрипт выполнен, без вывода)', en: '(script finished, no output)' }, - 'overlay.output.unknownError': { ru: 'Неизвестная ошибка', en: 'Unknown error' }, - 'overlay.output.execFailed': { ru: 'Ошибка выполнения', en: 'Execution failed' }, - 'toolResult.success': { ru: 'Результат', en: 'Result' }, - 'toolResult.error': { ru: 'Ошибка выполнения', en: 'Execution error' }, - 'toolResult.denied': { ru: 'Отклонено пользователем', en: 'Denied by user' }, - 'overlay.output.systemException': { ru: 'Системное исключение: {msg}', en: 'System exception: {msg}' }, + "overlay.btn.init": { + ru: "Инициализировать проект", + en: "Initialize project", + }, + "overlay.btn.init.loading": { + ru: "⏳ Инициализация...", + en: "⏳ Initializing...", + }, + "overlay.btn.windowManager": { ru: "Окна", en: "Windows" }, + "overlay.btn.windowManager.title": { + ru: "Управление окнами", + en: "Manage windows", + }, + "overlay.btn.mcp": { ru: "MCP", en: "MCP" }, + "overlay.btn.mcp.title": { + ru: "Управление MCP-инструментами", + en: "Manage MCP tools", + }, + "overlay.btn.tg": { ru: "Telegram", en: "Telegram" }, + "overlay.btn.tg.title": { + ru: "Настройки Telegram-бота", + en: "Telegram bot settings", + }, + "tg.title": { ru: "Telegram-бот", en: "Telegram bot" }, + "tg.label.token": { + ru: "Токен бота (@BotFather)", + en: "Bot token (@BotFather)", + }, + "tg.label.chatId": { ru: "Chat ID", en: "Chat ID" }, + "tg.label.enabled": { ru: "Включить бота", en: "Enable bot" }, + "tg.label.notifyTools": { + ru: "Уведомления о tool", + en: "Tool notifications", + }, + "tg.label.chatFeed": { + ru: "Принимать сообщения из TG в чат", + en: "Accept messages from TG into chat", + }, + "tg.btn.save": { ru: "Сохранить", en: "Save" }, + "tg.btn.ping": { ru: "Проверить токен", en: "Check token" }, + "tg.btn.test": { ru: "Тестовое сообщение", en: "Test message" }, + "tg.status.off": { ru: "Выключен", en: "Disabled" }, + "tg.status.on": { ru: "Работает", en: "Running" }, + "overlay.btn.genDoc": { ru: "Создать описание", en: "Generate docs" }, + "overlay.btn.genDoc.title": { + ru: "Попросить AI сгенерировать описание проекта (CUCKOO.md)", + en: "Ask AI to generate a project description (CUCKOO.md)", + }, + "overlay.btn.immersive": { ru: "Погружение", en: "Immersive" }, + "overlay.btn.immersive.title": { + ru: "Переключить в режим погружения", + en: "Switch to immersive mode", + }, + "overlay.btn.manualParse": { ru: "Разобрать вручную", en: "Manual parse" }, + "overlay.btn.manualParse.title": { + ru: "Вручную разобрать вызовы инструментов в текущем ответе", + en: "Manually parse tool calls in the latest reply", + }, + "overlay.btn.manualParse.loading": { ru: "Разбор...", en: "Parsing..." }, + "overlay.toast.executing": { + ru: "Команда уже выполняется, ручной разбор не нужен", + en: "A command is already running, no manual parse needed", + }, + "overlay.toast.manualParseTriggered": { + ru: "Запущен ручной разбор последнего ответа AI", + en: "Manual parse of the latest AI reply triggered", + }, + "overlay.toast.manualParseError": { + ru: "Ошибка ручного разбора: {msg}", + en: "Manual parse error: {msg}", + }, + "overlay.badge.toolDetected": { + ru: "Cookie Code — обнаружен вызов инструмента", + en: "Cookie Code — tool call detected", + }, + "overlay.badge.jsDetected": { + ru: "Cookie Code — обнаружен JS-скрипт", + en: "Cookie Code — JS tool script detected", + }, + "overlay.toast.execStarted": { + ru: "Начато выполнение команды", + en: "Command execution started", + }, + "overlay.preview.toolPrefix": { + ru: "[Инструмент] {name}", + en: "[Tool] {name}", + }, + "overlay.preview.params": { + ru: "Параметры: {json}", + en: "Parameters: {json}", + }, + "overlay.preview.jsPrefix": { ru: "[JS-скрипт]", en: "[JS tool script]" }, + "overlay.status.jsFailed": { + ru: "❌ Ошибка выполнения JS-скрипта", + en: "❌ JS script execution failed", + }, + "overlay.status.sysError": { + ru: "❌ Системная ошибка", + en: "❌ System error", + }, + "overlay.status.jsOk": { + ru: "✅ JS-скрипт выполнен успешно", + en: "✅ JS script executed successfully", + }, + "overlay.status.jsExit": { + ru: "⚠ JS-скрипт завершён с кодом {code}", + en: "⚠ JS script finished with code {code}", + }, + "overlay.status.toolOk": { + ru: "✅ Инструмент {name} выполнен успешно", + en: "✅ Tool {name} executed successfully", + }, + "overlay.status.toolFailed": { + ru: "❌ Инструмент {name} выполнен с ошибкой", + en: "❌ Tool {name} failed", + }, + "overlay.output.scriptDone": { + ru: "(скрипт выполнен, без вывода)", + en: "(script finished, no output)", + }, + "overlay.output.unknownError": { + ru: "Неизвестная ошибка", + en: "Unknown error", + }, + "overlay.output.execFailed": { + ru: "Ошибка выполнения", + en: "Execution failed", + }, + "toolResult.success": { ru: "Результат", en: "Result" }, + "toolResult.error": { ru: "Ошибка выполнения", en: "Execution error" }, + "toolResult.denied": { ru: "Отклонено пользователем", en: "Denied by user" }, + "overlay.output.systemException": { + ru: "Системное исключение: {msg}", + en: "System exception: {msg}", + }, // ---- Оверлей: сессии ---- - 'overlay.label.convTokens': { ru: 'Токены диалога', en: 'Conversation tokens' }, - 'overlay.label.sessions': { ru: 'Сессии', en: 'Sessions' }, - 'overlay.btn.refreshSessions':{ ru: '🔄 Обновить', en: '🔄 Refresh' }, - 'overlay.sessions.empty': { ru: 'Нет сессий', en: 'No sessions' }, + "overlay.label.convTokens": { + ru: "Токены диалога", + en: "Conversation tokens", + }, + "overlay.label.sessions": { ru: "Сессии", en: "Sessions" }, + "overlay.btn.refreshSessions": { ru: "🔄 Обновить", en: "🔄 Refresh" }, + "overlay.sessions.empty": { ru: "Нет сессий", en: "No sessions" }, // ---- Оверлей: задержка отправки ---- - 'overlay.label.sendDelay': { ru: 'Задержка отправки', en: 'Send delay' }, - 'overlay.label.delayRange': { ru: 'до', en: 'to' }, - 'overlay.btn.saveDelay': { ru: 'Сохранить задержку', en: 'Save delay' }, + "overlay.label.sendDelay": { ru: "Задержка отправки", en: "Send delay" }, + "overlay.label.delayRange": { ru: "до", en: "to" }, + "overlay.label.delaySeconds": { ru: "сек", en: "sec" }, + "overlay.btn.saveDelay": { ru: "Сохранить задержку", en: "Save delay" }, + "overlay.delay.saved": { + ru: "Задержка сохранена: {min} - {max} сек", + en: "Delay saved: {min} - {max} sec", + }, + "overlay.delay.errMin": { + ru: "Минимум должен быть неотрицательным числом (сек)", + en: "Min must be a non-negative number (sec)", + }, + "overlay.delay.errMax": { + ru: "Максимум не может быть меньше минимума", + en: "Max cannot be less than min", + }, + "overlay.delay.errLimit": { + ru: "Максимум не может превышать 10 секунд", + en: "Max cannot exceed 10 seconds", + }, // ---- Оверлей: задача/результат/история ---- - 'overlay.label.task': { ru: 'Обнаружена задача:', en: 'Task detected:' }, - 'overlay.task.running': { ru: 'Выполняется', en: 'Running' }, - 'overlay.task.kill': { ru: '⏹ Остановить процесс', en: '⏹ Kill process' }, - 'overlay.task.kill.title': { ru: 'Экстренно завершить все активные дочерние процессы', en: 'Emergency-kill all active child processes' }, - 'overlay.task.killed': { ru: 'Процесс остановлен ({count})', en: 'Process stopped ({count})' }, - 'overlay.task.stopped': { ru: 'Задача остановлена', en: 'Task stopped' }, - 'overlay.task.killNone': { ru: 'Нет активных процессов', en: 'No active processes' }, - 'overlay.task.killError': { ru: 'Не удалось остановить процесс', en: 'Failed to kill process' }, - 'overlay.cmd.none': { ru: 'Нет', en: 'None' }, - 'overlay.label.result': { ru: 'Результат:', en: 'Result:' }, - 'overlay.label.history': { ru: 'История', en: 'History' }, - 'overlay.btn.clearHistory': { ru: 'Очистить историю', en: 'Clear history' }, - 'overlay.history.empty': { ru: 'Пока пусто', en: 'No history yet' }, + "overlay.label.task": { ru: "Обнаружена задача:", en: "Task detected:" }, + "overlay.task.running": { ru: "Выполняется", en: "Running" }, + "overlay.task.kill": { ru: "⏹ Остановить процесс", en: "⏹ Kill process" }, + "overlay.task.kill.title": { + ru: "Экстренно завершить все активные дочерние процессы", + en: "Emergency-kill all active child processes", + }, + "overlay.task.killed": { + ru: "Процесс остановлен ({count})", + en: "Process stopped ({count})", + }, + "overlay.task.stopped": { ru: "Задача остановлена", en: "Task stopped" }, + "overlay.task.killNone": { + ru: "Нет активных процессов", + en: "No active processes", + }, + "overlay.task.killError": { + ru: "Не удалось остановить процесс", + en: "Failed to kill process", + }, + "overlay.cmd.none": { ru: "Нет", en: "None" }, + "overlay.label.result": { ru: "Результат:", en: "Result:" }, + "overlay.label.history": { ru: "История", en: "History" }, + "overlay.btn.clearHistory": { ru: "Очистить историю", en: "Clear history" }, + "overlay.history.empty": { ru: "Пока пусто", en: "No history yet" }, // ---- Оверлей: window manager ---- - 'wm.title': { ru: 'Управление окнами', en: 'Window manager' }, - 'wm.btn.close': { ru: 'Закрыть', en: 'Close' }, - 'wm.btn.newWindow': { ru: 'Новое окно', en: 'New window' }, - 'wm.label.list': { ru: 'Окна', en: 'Windows' }, - 'wm.btn.refresh': { ru: '🔄 Обновить', en: '🔄 Refresh' }, - 'wm.empty': { ru: 'Нет окон', en: 'No windows' }, - 'wm.toast.created': { ru: 'Создано новое окно DeepSeek', en: 'New DeepSeek window created' }, + "wm.title": { ru: "Управление окнами", en: "Window manager" }, + "wm.btn.close": { ru: "Закрыть", en: "Close" }, + "wm.btn.newWindow": { ru: "Новое окно", en: "New window" }, + "wm.label.list": { ru: "Окна", en: "Windows" }, + "wm.btn.refresh": { ru: "🔄 Обновить", en: "🔄 Refresh" }, + "wm.empty": { ru: "Нет окон", en: "No windows" }, + "wm.toast.created": { + ru: "Создано новое окно DeepSeek", + en: "New DeepSeek window created", + }, // ---- Оверлей: MCP ---- - 'mcp.title': { ru: 'MCP-инструменты', en: 'MCP tools' }, - 'mcp.label.configured': { ru: 'Настроены', en: 'Configured' }, - 'mcp.empty': { ru: 'Нет', en: 'None' }, - 'mcp.btn.save': { ru: 'Сохранить настройки', en: 'Save config' }, + "mcp.title": { ru: "MCP-инструменты", en: "MCP tools" }, + "mcp.label.configured": { ru: "Настроены", en: "Configured" }, + "mcp.empty": { ru: "Нет", en: "None" }, + "mcp.btn.save": { ru: "Сохранить настройки", en: "Save config" }, // ---- Оверлей: бейдж и первый диалог ---- - 'badge.title': { ru: 'Cookie Code работает', en: 'Cookie Code is running' }, - 'firstTime.text': { ru: 'При первом создании диалога нужно инициализировать проект и выбрать каталог, иначе работа невозможна', en: 'When starting the first conversation, initialize a project and pick a directory — otherwise the app cannot work' }, - 'init.success.text': { ru: 'Проект успешно инициализирован!', en: 'Project initialized successfully!' }, - 'init.success.btn': { ru: 'Отлично', en: 'Great' }, + "badge.title": { ru: "Cookie Code работает", en: "Cookie Code is running" }, + "firstTime.text": { + ru: "При первом создании диалога нужно инициализировать проект и выбрать каталог, иначе работа невозможна", + en: "When starting the first conversation, initialize a project and pick a directory — otherwise the app cannot work", + }, + "init.success.text": { + ru: "Проект успешно инициализирован!", + en: "Project initialized successfully!", + }, + "init.success.btn": { ru: "Отлично", en: "Great" }, // ---- Настройки: общее ---- - 'settings.title': { ru: 'Cookie Code', en: 'Cookie Code' }, - 'settings.subtitle': { ru: 'Настройки интерфейса и фонового изображения', en: 'Interface and background settings' }, - 'settings.section.customization': { ru: 'Кастомизация Cookie Code', en: 'Cookie Code customization' }, - 'settings.customization.enabled': { ru: 'Кастомизация включена', en: 'Customization enabled' }, - 'settings.customization.disabled': { ru: 'Кастомизация выключена', en: 'Customization disabled' }, - 'settings.customization.enable': { ru: 'Включить всё', en: 'Enable everything' }, - 'settings.customization.disable': { ru: 'Выключить всё', en: 'Disable everything' }, - 'settings.customization.reloading': { ru: 'Применение...', en: 'Applying...' }, - 'settings.section.blur': { ru: 'Размытие', en: 'Blur' }, - 'settings.section.opacity': { ru: 'Прозрачность панелей', en: 'Panel opacity' }, - 'settings.section.effects': { ru: 'Эффекты', en: 'Effects' }, - 'settings.section.inputGlass': { ru: 'Стеклянное поле ввода', en: 'Glass input field' }, - 'settings.inputGlass.hint': { ru: 'Матовое стекло для поля ввода сообщения DeepSeek', en: 'Frosted glass for the DeepSeek message input field' }, - 'settings.inputGlass.blur': { ru: 'Размытие поля ввода (стекло)', en: 'Input field blur (glass)' }, - 'settings.inputGlass.opacity': { ru: 'Плотность фона поля ввода', en: 'Input field background density' }, - 'settings.inputGlass.enabled': { ru: 'Стекло поля ввода включено', en: 'Input field glass enabled' }, - 'settings.section.dangerous': { ru: 'Опасные команды (regex, по одной на строку)', en: 'Dangerous commands (regex, one per line)' }, - 'settings.section.background':{ ru: 'Фон страницы', en: 'Page background' }, - 'settings.bg.openFolder': { ru: 'Открыть папку фонов', en: 'Open backgrounds folder' }, - 'settings.bg.refresh': { ru: 'Обновить', en: 'Refresh' }, - 'settings.bg.hint': { ru: 'Свои картинки: положите файлы (.webp/.jpg/.png/.gif) в папку и нажмите «Обновить».', en: 'Custom images: drop files (.webp/.jpg/.png/.gif) into the folder and click Refresh.' }, - 'settings.section.language': { ru: 'Язык', en: 'Language' }, + "settings.title": { ru: "Cookie Code", en: "Cookie Code" }, + "settings.subtitle": { + ru: "Настройки интерфейса и фонового изображения", + en: "Interface and background settings", + }, + "settings.section.customization": { + ru: "Кастомизация Cookie Code", + en: "Cookie Code customization", + }, + "settings.customization.enabled": { + ru: "Кастомизация включена", + en: "Customization enabled", + }, + "settings.customization.disabled": { + ru: "Кастомизация выключена", + en: "Customization disabled", + }, + "settings.customization.enable": { + ru: "Включить всё", + en: "Enable everything", + }, + "settings.customization.disable": { + ru: "Выключить всё", + en: "Disable everything", + }, + "settings.customization.reloading": { + ru: "Применение...", + en: "Applying...", + }, + "settings.section.blur": { ru: "Размытие", en: "Blur" }, + "settings.section.opacity": { + ru: "Прозрачность панелей", + en: "Panel opacity", + }, + "settings.section.effects": { ru: "Эффекты", en: "Effects" }, + "settings.section.inputGlass": { + ru: "Стеклянное поле ввода", + en: "Glass input field", + }, + "settings.inputGlass.hint": { + ru: "Матовое стекло для поля ввода сообщения DeepSeek", + en: "Frosted glass for the DeepSeek message input field", + }, + "settings.inputGlass.blur": { + ru: "Размытие поля ввода (стекло)", + en: "Input field blur (glass)", + }, + "settings.inputGlass.opacity": { + ru: "Плотность фона поля ввода", + en: "Input field background density", + }, + "settings.inputGlass.enabled": { + ru: "Стекло поля ввода включено", + en: "Input field glass enabled", + }, + "settings.section.dangerous": { + ru: "Опасные команды (regex, по одной на строку)", + en: "Dangerous commands (regex, one per line)", + }, + "settings.section.background": { ru: "Фон страницы", en: "Page background" }, + "settings.bg.openFolder": { + ru: "Открыть папку фонов", + en: "Open backgrounds folder", + }, + "settings.bg.refresh": { ru: "Обновить", en: "Refresh" }, + "settings.bg.hint": { + ru: "Свои картинки: положите файлы (.webp/.jpg/.png/.gif) в папку и нажмите «Обновить».", + en: "Custom images: drop files (.webp/.jpg/.png/.gif) into the folder and click Refresh.", + }, + "settings.section.language": { ru: "Язык", en: "Language" }, // ---- Категории / подвкладки настроек ---- - 'settings.tab.theme': { ru: '🎨 Тема и стекло', en: '🎨 Theme & Glass' }, - 'settings.tab.overlay': { ru: '🪟 Панель Cookie', en: '🪟 Cookie Panel' }, - 'settings.tab.bg': { ru: '🖼 Фон страницы', en: '🖼 Page Background' }, - 'settings.tab.telegram': { ru: '🤖 Telegram-бот', en: '🤖 Telegram Bot' }, - 'settings.tab.system': { ru: '⚙️ Система и безопасность', en: '⚙️ System & Security' }, - 'settings.section.maintenance':{ ru: 'Обслуживание и сброс', en: 'Maintenance & Reset' }, - 'settings.section.service': { ru: 'Сервис', en: 'Service' }, + "settings.tab.theme": { ru: "🎨 Тема и стекло", en: "🎨 Theme & Glass" }, + "settings.tab.overlay": { ru: "🪟 Панель Cookie", en: "🪟 Cookie Panel" }, + "settings.tab.bg": { ru: "🖼 Фон страницы", en: "🖼 Page Background" }, + "settings.tab.telegram": { ru: "🤖 Telegram-бот", en: "🤖 Telegram Bot" }, + "settings.tab.system": { + ru: "⚙️ Система и безопасность", + en: "⚙️ System & Security", + }, + "settings.section.maintenance": { + ru: "Обслуживание и сброс", + en: "Maintenance & Reset", + }, + "settings.section.service": { ru: "Сервис", en: "Service" }, // ---- Настройки: диагностика интеграции ---- - 'settings.section.diagnostics': { ru: 'Диагностика интеграции', en: 'Integration diagnostics' }, - 'settings.diagnostics.title': { ru: 'Проверка селекторов и методов провайдера', en: 'Check provider selectors and methods' }, - 'settings.diagnostics.hint': { ru: 'Если после обновления DeepSeek что-то сломалось — запустите диагностику и скопируйте отчёт в issue.', en: 'If something breaks after a DeepSeek update — run diagnostics and paste the report into an issue.' }, - 'settings.diagnostics.run': { ru: 'Запустить диагностику', en: 'Run diagnostics' }, - 'settings.diagnostics.running': { ru: 'Проверка...', en: 'Checking...' }, - 'settings.diagnostics.modalTitle': { ru: 'Отчёт диагностики', en: 'Diagnostics report' }, - 'settings.diagnostics.copy': { ru: '📋 Копировать отчёт', en: '📋 Copy report' }, - 'settings.diagnostics.copied': { ru: '✅ Скопировано', en: '✅ Copied' }, + "settings.section.diagnostics": { + ru: "Диагностика интеграции", + en: "Integration diagnostics", + }, + "settings.diagnostics.title": { + ru: "Проверка селекторов и методов провайдера", + en: "Check provider selectors and methods", + }, + "settings.diagnostics.hint": { + ru: "Если после обновления DeepSeek что-то сломалось — запустите диагностику и скопируйте отчёт в issue.", + en: "If something breaks after a DeepSeek update — run diagnostics and paste the report into an issue.", + }, + "settings.diagnostics.run": { + ru: "Запустить диагностику", + en: "Run diagnostics", + }, + "settings.diagnostics.running": { ru: "Проверка...", en: "Checking..." }, + "settings.diagnostics.modalTitle": { + ru: "Отчёт диагностики", + en: "Diagnostics report", + }, + "settings.diagnostics.copy": { + ru: "📋 Копировать отчёт", + en: "📋 Copy report", + }, + "settings.diagnostics.copied": { ru: "✅ Скопировано", en: "✅ Copied" }, // ---- Настройки: слайдеры ---- - 'settings.blur.bg': { ru: 'Размытие фонового изображения', en: 'Background image blur' }, - 'settings.blur.header': { ru: 'Размытие шапки (стекло)', en: 'Header blur (glass)' }, - 'settings.blur.sidebar': { ru: 'Размытие сайдбара (стекло)', en: 'Sidebar blur (glass)' }, - 'settings.blur.toolblock': { ru: 'Размытие tool-блоков (стекло)', en: 'Tool block blur (glass)' }, - 'settings.opacity.header': { ru: 'Прозрачность шапки', en: 'Header opacity' }, - 'settings.opacity.sidebar': { ru: 'Прозрачность сайдбара', en: 'Sidebar opacity' }, - 'settings.opacity.toolblock': { ru: 'Прозрачность tool-блоков', en: 'Tool block opacity' }, + "settings.blur.bg": { + ru: "Размытие фонового изображения", + en: "Background image blur", + }, + "settings.blur.header": { + ru: "Размытие шапки (стекло)", + en: "Header blur (glass)", + }, + "settings.blur.sidebar": { + ru: "Размытие сайдбара (стекло)", + en: "Sidebar blur (glass)", + }, + "settings.blur.toolblock": { + ru: "Размытие tool-блоков (стекло)", + en: "Tool block blur (glass)", + }, + "settings.opacity.header": { ru: "Прозрачность шапки", en: "Header opacity" }, + "settings.opacity.sidebar": { + ru: "Прозрачность сайдбара", + en: "Sidebar opacity", + }, + "settings.opacity.toolblock": { + ru: "Прозрачность tool-блоков", + en: "Tool block opacity", + }, // ---- Настройки: панель Cookie Code ---- - 'settings.section.overlay': { ru: 'Панель Cookie Code', en: 'Cookie Code Panel' }, - 'settings.overlay.opacity': { ru: 'Прозрачность фона панели', en: 'Panel background opacity' }, - 'settings.overlay.blur': { ru: 'Размытие панели (стекло)', en: 'Panel blur (glass)' }, - 'settings.overlay.width': { ru: 'Ширина панели', en: 'Panel width' }, - 'settings.overlay.bgColor': { ru: 'Цвет фона панели', en: 'Panel background color' }, - 'settings.overlay.btnColor': { ru: 'Основной цвет кнопок', en: 'Primary button color' }, - 'settings.overlay.btnRadius': { ru: 'Скругление кнопок панели', en: 'Button corner radius' }, + "settings.section.overlay": { + ru: "Панель Cookie Code", + en: "Cookie Code Panel", + }, + "settings.overlay.opacity": { + ru: "Прозрачность фона панели", + en: "Panel background opacity", + }, + "settings.overlay.blur": { + ru: "Размытие панели (стекло)", + en: "Panel blur (glass)", + }, + "settings.overlay.width": { ru: "Ширина панели", en: "Panel width" }, + "settings.overlay.bgColor": { + ru: "Цвет фона панели", + en: "Panel background color", + }, + "settings.overlay.btnColor": { + ru: "Основной цвет кнопок", + en: "Primary button color", + }, + "settings.overlay.btnRadius": { + ru: "Скругление кнопок панели", + en: "Button corner radius", + }, // ---- Настройки: эффекты ---- - 'settings.effect.rgb': { ru: 'RGB-переливание ника', en: 'RGB animated username' }, + "settings.effect.rgb": { + ru: "RGB-переливание ника", + en: "RGB animated username", + }, // ---- Настройки: опасные команды ---- - 'settings.dangerous.hint': { ru: 'Пустой список = все команды разрешены', en: 'Empty list = all commands allowed' }, - 'settings.dangerous.save': { ru: 'Сохранить', en: 'Save' }, - 'settings.dangerous.saving': { ru: 'Сохранение...', en: 'Saving...' }, - 'settings.dangerous.saved': { ru: '✅ Сохранено', en: '✅ Saved' }, - 'settings.dangerous.error': { ru: '❌ Ошибка', en: '❌ Error' }, + "settings.dangerous.hint": { + ru: "Пустой список = все команды разрешены", + en: "Empty list = all commands allowed", + }, + "settings.dangerous.save": { ru: "Сохранить", en: "Save" }, + "settings.dangerous.saving": { ru: "Сохранение...", en: "Saving..." }, + "settings.dangerous.saved": { ru: "✅ Сохранено", en: "✅ Saved" }, + "settings.dangerous.error": { ru: "❌ Ошибка", en: "❌ Error" }, // ---- Экспорт ответа AI ---- - 'export.btn.title': { ru: 'Экспорт ответа', en: 'Export response' }, - 'export.menu.pdf': { ru: '📄 Скачать PDF', en: '📄 Download PDF' }, - 'export.menu.docx': { ru: '📝 Скачать DOCX', en: '📝 Download DOCX' }, - 'export.status.working': { ru: 'Сохранение...', en: 'Saving...' }, - 'export.status.ok': { ru: '✅ Сохранено', en: '✅ Saved' }, - 'export.status.err': { ru: '❌ Ошибка', en: '❌ Error' }, + "export.btn.title": { ru: "Экспорт ответа", en: "Export response" }, + "export.menu.pdf": { ru: "📄 Скачать PDF", en: "📄 Download PDF" }, + "export.menu.docx": { ru: "📝 Скачать DOCX", en: "📝 Download DOCX" }, + "export.status.working": { ru: "Сохранение...", en: "Saving..." }, + "export.status.ok": { ru: "✅ Сохранено", en: "✅ Saved" }, + "export.status.err": { ru: "❌ Ошибка", en: "❌ Error" }, // ---- Настройки: кнопки внизу ---- - 'settings.btn.openConfig': { ru: 'Открыть файл настроек', en: 'Open configuration file' }, - 'settings.btn.openConfig.title': { ru: 'Открыть cuckoo-settings.json в системном редакторе', en: 'Open cuckoo-settings.json in the system editor' }, - 'settings.btn.openConfig.opened': { ru: '✅ Файл открыт', en: '✅ File opened' }, - 'settings.btn.openConfig.error': { ru: '❌ Не удалось открыть', en: '❌ Failed to open' }, - 'settings.btn.clearStorage': { ru: 'Очистить мета-данные', en: 'Clear meta data' }, - 'settings.btn.clearStorage.title': { ru: 'Очистить сохранённые мета-данные и историю ошибок', en: 'Clear saved meta data and error history' }, - 'settings.btn.reset': { ru: 'Сбросить настройки', en: 'Reset settings' }, - 'settings.btn.resetting': { ru: 'Сброс...', en: 'Resetting...' }, - 'settings.btn.clearing': { ru: 'Очистка...', en: 'Clearing...' }, + "settings.btn.openConfig": { + ru: "Открыть файл настроек", + en: "Open configuration file", + }, + "settings.btn.openConfig.title": { + ru: "Открыть cuckoo-settings.json в системном редакторе", + en: "Open cuckoo-settings.json in the system editor", + }, + "settings.btn.openConfig.opened": { + ru: "✅ Файл открыт", + en: "✅ File opened", + }, + "settings.btn.openConfig.error": { + ru: "❌ Не удалось открыть", + en: "❌ Failed to open", + }, + "settings.btn.clearStorage": { + ru: "Очистить мета-данные", + en: "Clear meta data", + }, + "settings.btn.clearStorage.title": { + ru: "Очистить сохранённые мета-данные и историю ошибок", + en: "Clear saved meta data and error history", + }, + "settings.btn.reset": { ru: "Сбросить настройки", en: "Reset settings" }, + "settings.btn.resetting": { ru: "Сброс...", en: "Resetting..." }, + "settings.btn.clearing": { ru: "Очистка...", en: "Clearing..." }, // ---- Настройки: язык ---- - 'settings.lang.ru': { ru: 'Русский', en: 'Russian' }, - 'settings.lang.en': { ru: 'Английский', en: 'English' }, + "settings.lang.ru": { ru: "Русский", en: "Russian" }, + "settings.lang.en": { ru: "Английский", en: "English" }, // ---- Slash-команды ---- - 'cmd.menu.title': { ru: 'Команды', en: 'Commands' }, - 'cmd.menu.files': { ru: 'Файлы проекта', en: 'Project files' }, - 'cmd.review.description': { ru: 'Проверить текущие изменения проекта', en: 'Review current project changes' }, - 'cmd.summarize.description': { ru: 'Сделать краткий итог текущей сессии', en: 'Summarize the current session' }, - 'plan.toggle.label': { ru: 'План', en: 'Plan' }, - 'plan.dialog.title': { ru: 'План на утверждение', en: 'Plan for approval' }, - 'plan.dialog.deny': { ru: 'Отказать в плане', en: 'Reject plan' }, - 'plan.dialog.approve': { ru: 'Согласиться', en: 'Approve' }, - 'plan.approve.prompt': { ru: 'Работай в соответствии с планом', en: 'Work according to the plan' }, + "cmd.menu.title": { ru: "Команды", en: "Commands" }, + "cmd.menu.files": { ru: "Файлы проекта", en: "Project files" }, + "cmd.review.description": { + ru: "Проверить текущие изменения проекта", + en: "Review current project changes", + }, + "cmd.summarize.description": { + ru: "Сделать краткий итог текущей сессии", + en: "Summarize the current session", + }, + "plan.toggle.label": { ru: "План", en: "Plan" }, + "plan.dialog.title": { ru: "План на утверждение", en: "Plan for approval" }, + "plan.dialog.deny": { ru: "Отказать в плане", en: "Reject plan" }, + "plan.dialog.approve": { ru: "Согласиться", en: "Approve" }, + "plan.approve.prompt": { + ru: "Работай в соответствии с планом", + en: "Work according to the plan", + }, // ---- Подтверждение tool-вызовов (approval gate) ---- - 'approval.title.tool': { ru: 'Подтверждение вызова инструмента', en: 'Tool call approval' }, - 'approval.title.js': { ru: 'Подтверждение JS-скрипта', en: 'JS script approval' }, - 'approval.subtitle': { ru: 'Cookie Code запрашивает разрешение перед выполнением', en: 'Cookie Code asks for permission before executing' }, - 'approval.label.params': { ru: 'Параметры', en: 'Parameters' }, - 'approval.label.code': { ru: 'Код', en: 'Code' }, - 'approval.name.js': { ru: '[JS-скрипт]', en: '[JS script]' }, - 'approval.approve': { ru: 'Разрешить (Enter)', en: 'Approve (Enter)' }, - 'approval.always': { ru: 'Разрешать «{name}» до перезагрузки', en: 'Always allow "{name}" (this session)' }, - 'approval.deny': { ru: 'Отклонить (Esc)', en: 'Deny (Esc)' }, + "approval.title.tool": { + ru: "Подтверждение вызова инструмента", + en: "Tool call approval", + }, + "approval.title.js": { + ru: "Подтверждение JS-скрипта", + en: "JS script approval", + }, + "approval.subtitle": { + ru: "Cookie Code запрашивает разрешение перед выполнением", + en: "Cookie Code asks for permission before executing", + }, + "approval.label.params": { ru: "Параметры", en: "Parameters" }, + "approval.label.code": { ru: "Код", en: "Code" }, + "approval.name.js": { ru: "[JS-скрипт]", en: "[JS script]" }, + "approval.approve": { ru: "Разрешить (Enter)", en: "Approve (Enter)" }, + "approval.always": { + ru: "Разрешать «{name}» до перезагрузки", + en: 'Always allow "{name}" (this session)', + }, + "approval.deny": { ru: "Отклонить (Esc)", en: "Deny (Esc)" }, // ---- Настройки: агент и приватность ---- - 'settings.section.agent': { ru: 'Агент и приватность', en: 'Agent & Privacy' }, - 'settings.approval.off': { ru: 'Выкл', en: 'Off' }, - 'settings.approval.risky': { ru: 'Рискованные', en: 'Risky only' }, - 'settings.approval.all': { ru: 'Все вызовы', en: 'All calls' }, - 'settings.approval.hint': { ru: 'Запрашивать подтверждение перед выполнением: рискованные инструменты (bash, запись файлов, SQL) или все вызовы', en: 'Ask before executing: risky tools (bash, file writes, SQL) or every call' }, - 'settings.approval.saved': { ru: '✅ Режим сохранён', en: '✅ Mode saved' }, - 'settings.hideSystemMessages': { ru: 'Скрывать служебные сообщения в чате', en: 'Hide service messages in chat' }, - 'settings.hideSystemMessages.hint': { ru: 'Результаты инструментов, JS-сводки и системный промпт по-прежнему уходят в AI, но не отображаются в чате', en: 'Tool results, JS digests and the system prompt still reach the AI but stay invisible in the chat' }, - 'settings.fileChip': { ru: 'Файловые пути как кликабельные чипы', en: 'File paths as clickable chips' }, - 'settings.fileChip.hint': { ru: 'Абсолютные пути (C:\\…, D:\\…) превращаются в чипы. Клик открывает файл в VS Code (или в проводнике)', en: 'Absolute paths (C:\\…, D:\\…) become chips. Click opens the file in VS Code (or in explorer)' }, - 'settings.showProducedFiles': { ru: 'Показывать затронутые файлы под ответом', en: 'Show affected files under AI reply' }, - 'settings.showProducedFiles.hint': { ru: 'Блок «Затронуто # файл» под каждым ответом AI — только успешные write/edit/delete', en: '“Affected # file” block under each AI reply — only successful write/edit/delete' }, - 'settings.showConvTokens': { ru: 'Показывать токены диалога', en: 'Show dialogue tokens' }, - 'settings.showConvTokens.hint': { ru: 'Блок «Токены диалога» в панели Cookie Code (по умолчанию скрыт)', en: '“Dialogue tokens” block in the Cookie Code panel (hidden by default)' }, - 'settings.formatters': { ru: 'Авто-форматирование после write/edit', en: 'Auto-format after write/edit' }, - 'settings.formatters.hint': { ru: 'prettier, biome, gofmt, ruff, rustfmt, shfmt, clang-format — по расширению и конфигу проекта', en: 'prettier, biome, gofmt, ruff, rustfmt, shfmt, clang-format — by file extension and project config' }, + "settings.section.agent": { + ru: "Агент и приватность", + en: "Agent & Privacy", + }, + "settings.approval.off": { ru: "Выкл", en: "Off" }, + "settings.approval.risky": { ru: "Рискованные", en: "Risky only" }, + "settings.approval.all": { ru: "Все вызовы", en: "All calls" }, + "settings.approval.hint": { + ru: "Запрашивать подтверждение перед выполнением: рискованные инструменты (bash, запись файлов, SQL) или все вызовы", + en: "Ask before executing: risky tools (bash, file writes, SQL) or every call", + }, + "settings.approval.saved": { ru: "✅ Режим сохранён", en: "✅ Mode saved" }, + "settings.hideSystemMessages": { + ru: "Скрывать служебные сообщения в чате", + en: "Hide service messages in chat", + }, + "settings.hideSystemMessages.hint": { + ru: "Результаты инструментов, JS-сводки и системный промпт по-прежнему уходят в AI, но не отображаются в чате", + en: "Tool results, JS digests and the system prompt still reach the AI but stay invisible in the chat", + }, + "settings.fileChip": { + ru: "Файловые пути как кликабельные чипы", + en: "File paths as clickable chips", + }, + "settings.fileChip.hint": { + ru: "Абсолютные пути (C:\\…, D:\\…) превращаются в чипы. Клик открывает файл в VS Code (или в проводнике)", + en: "Absolute paths (C:\\…, D:\\…) become chips. Click opens the file in VS Code (or in explorer)", + }, + "settings.showProducedFiles": { + ru: "Показывать затронутые файлы под ответом", + en: "Show affected files under AI reply", + }, + "settings.showProducedFiles.hint": { + ru: "Блок «Затронуто # файл» под каждым ответом AI — только успешные write/edit/delete", + en: "“Affected # file” block under each AI reply — only successful write/edit/delete", + }, + "settings.showConvTokens": { + ru: "Показывать токены диалога", + en: "Show dialogue tokens", + }, + "settings.showConvTokens.hint": { + ru: "Блок «Токены диалога» в панели Cookie Code (по умолчанию скрыт)", + en: "“Dialogue tokens” block in the Cookie Code panel (hidden by default)", + }, + "settings.formatters": { + ru: "Авто-форматирование после write/edit", + en: "Auto-format after write/edit", + }, + "settings.formatters.hint": { + ru: "prettier, biome, gofmt, ruff, rustfmt, shfmt, clang-format — по расширению и конфигу проекта", + en: "prettier, biome, gofmt, ruff, rustfmt, shfmt, clang-format — by file extension and project config", + }, // ---- Diff-панель ---- - 'diff.btn.title': { ru: 'Показать diff изменённых файлов', en: 'Show diff of changed files' }, - 'diff.title': { ru: 'Изменения (git)', en: 'Changes (git)' }, - 'diff.btn.refresh': { ru: 'Обновить', en: 'Refresh' }, - 'diff.btn.close': { ru: 'Закрыть', en: 'Close' }, - 'diff.tab.changes': { ru: 'Изменения', en: 'Changes' }, - 'diff.tab.history': { ru: 'История', en: 'History' }, - 'diff.loading': { ru: 'Загрузка…', en: 'Loading…' }, - 'diff.commit.back': { ru: '← Назад', en: '← Back' }, - 'diff.commit.back.title': { ru: 'Назад к истории', en: 'Back to history' }, - 'diff.commit.full': { ru: 'Весь коммит', en: 'Full commit' }, - 'diff.commit.full.title': { ru: 'Показать весь коммит', en: 'Show the full commit' }, - 'diff.commit.titlePrefix': { ru: 'Весь коммит ', en: 'Full commit ' }, - 'diff.empty': { ru: 'Пустой diff', en: 'Empty diff' }, - 'diff.noChanges': { ru: 'Нет изменённых файлов', en: 'No changed files' }, - 'diff.noCommits': { ru: 'Нет коммитов', en: 'No commits' }, - 'diff.noFiles': { ru: 'Нет файлов', en: 'No files' }, - 'diff.gitNotFound': { ru: 'git не найден', en: 'git not found' }, - 'diff.diffFailed': { ru: 'Не удалось получить diff', en: 'Failed to fetch diff' }, - 'diff.error': { ru: 'Ошибка', en: 'Error' }, - 'diff.errorPrefix': { ru: 'Ошибка: {msg}', en: 'Error: {msg}' }, + "diff.btn.title": { + ru: "Показать diff изменённых файлов", + en: "Show diff of changed files", + }, + "diff.title": { ru: "Изменения (git)", en: "Changes (git)" }, + "diff.btn.refresh": { ru: "Обновить", en: "Refresh" }, + "diff.btn.close": { ru: "Закрыть", en: "Close" }, + "diff.tab.changes": { ru: "Изменения", en: "Changes" }, + "diff.tab.history": { ru: "История", en: "History" }, + "diff.loading": { ru: "Загрузка…", en: "Loading…" }, + "diff.commit.back": { ru: "← Назад", en: "← Back" }, + "diff.commit.back.title": { ru: "Назад к истории", en: "Back to history" }, + "diff.commit.full": { ru: "Весь коммит", en: "Full commit" }, + "diff.commit.full.title": { + ru: "Показать весь коммит", + en: "Show the full commit", + }, + "diff.commit.titlePrefix": { ru: "Весь коммит ", en: "Full commit " }, + "diff.empty": { ru: "Пустой diff", en: "Empty diff" }, + "diff.noChanges": { ru: "Нет изменённых файлов", en: "No changed files" }, + "diff.noCommits": { ru: "Нет коммитов", en: "No commits" }, + "diff.noFiles": { ru: "Нет файлов", en: "No files" }, + "diff.gitNotFound": { ru: "git не найден", en: "git not found" }, + "diff.diffFailed": { + ru: "Не удалось получить diff", + en: "Failed to fetch diff", + }, + "diff.error": { ru: "Ошибка", en: "Error" }, + "diff.errorPrefix": { ru: "Ошибка: {msg}", en: "Error: {msg}" }, // ---- Todo-панель ---- - 'todo.btn.hide': { ru: 'Скрыть', en: 'Hide' }, - 'todo.btn.show': { ru: 'Показать задачи', en: 'Show tasks' }, + "todo.btn.hide": { ru: "Скрыть", en: "Hide" }, + "todo.btn.show": { ru: "Показать задачи", en: "Show tasks" }, // ---- Мета под ответом AI (время / токены / затронутые файлы) ---- - 'meta.time.title': { ru: 'Время ответа', en: 'Response time' }, - 'meta.tokens.title': { ru: 'Оценка токенов в тексте этого ответа', en: 'Estimated tokens in this response' }, - 'meta.produced': { ru: 'Затронуто', en: 'Affected' }, - 'meta.produced.title': { ru: 'Файлы, затронутые за этот ответ', en: 'Files touched in this response' }, + "meta.time.title": { ru: "Время ответа", en: "Response time" }, + "meta.tokens.title": { + ru: "Оценка токенов в тексте этого ответа", + en: "Estimated tokens in this response", + }, + "meta.produced": { ru: "Затронуто", en: "Affected" }, + "meta.produced.title": { + ru: "Файлы, затронутые за этот ответ", + en: "Files touched in this response", + }, }; // ========== Состояние ========== -let currentLang = 'ru'; +let currentLang = "ru"; -const RU_LIKE = ['ru', 'uk', 'be', 'kk', 'ky', 'uz', 'tg', 'hy', 'az', 'mo']; +const RU_LIKE = ["ru", "uk", "be", "kk", "ky", "uz", "tg", "hy", "az", "mo"]; /** * Привести произвольную локаль ('ru-RU', 'uk', 'en-US', ...) к 'ru' | 'en'. * Русскоязычные и близкие локали → 'ru', всё остальное → 'en'. */ function normalizeLang(lang) { - const code = String(lang || '').toLowerCase().split(/[-_]/)[0]; - return RU_LIKE.includes(code) ? 'ru' : 'en'; + const code = String(lang || "") + .toLowerCase() + .split(/[-_]/)[0]; + return RU_LIKE.includes(code) ? "ru" : "en"; } /** @@ -305,13 +645,13 @@ function normalizeLang(lang) { function t(key, params) { const entry = KEYS[key]; if (!entry) { - console.warn('[Cookie Code] i18n: missing key:', key); + console.warn("[Cookie Code] i18n: missing key:", key); return key; } let str = entry[currentLang] || entry.ru || entry.en || key; if (params) { for (const k of Object.keys(params)) { - str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), String(params[k])); + str = str.replace(new RegExp("\\{" + k + "\\}", "g"), String(params[k])); } } return str; @@ -326,7 +666,9 @@ function getLanguage() { */ function setLanguage(lang) { currentLang = normalizeLang(lang); - try { window.__cuckooI18nLang = currentLang; } catch (_) {} + try { + window.__cuckooI18nLang = currentLang; + } catch (_) {} } /** @@ -334,13 +676,20 @@ function setLanguage(lang) { */ async function loadLanguage() { try { - if (!window.electronAPI || typeof window.electronAPI.getCuckooSettings !== 'function') { + if ( + !window.electronAPI || + typeof window.electronAPI.getCuckooSettings !== "function" + ) { return currentLang; } const s = await window.electronAPI.getCuckooSettings(); currentLang = normalizeLang(s && s.language); - } catch (_) { /* оставляем ru по умолчанию */ } - try { window.__cuckooI18nLang = currentLang; } catch (_) {} + } catch (_) { + /* оставляем ru по умолчанию */ + } + try { + window.__cuckooI18nLang = currentLang; + } catch (_) {} return currentLang; } diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index 84f089d..6c0b1bd 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -2,21 +2,39 @@ * 覆盖层按钮事件绑定 * 由原 preload.js 拆分而来,逻辑保持不变。 */ -const state = require('../dom/state'); -const { hideOverlay, showOverlay, renderHistory, commandHistory, showToast, showConfirmDialog, hideFirstTimeDialog, handleKillProcess } = require('./ui'); -const { toggleDiffPanel, closeDiffPanel, renderDiffList, closeDiffViewer, setActiveTab, backToLog, openCommitFullDiff } = require('./diff-panel'); -const todoPanel = require('./todo-panel'); -const { handleInitProject, renderSessions } = require('../dom/session-list'); -const { handleManualParse } = require('../dom/observer'); -const { sendToChat } = require('../dom/chat-input'); -const { getProviderByUrl } = require('../../../src/providers'); -const { estimateTokens } = require('../dom/token-estimator'); +const state = require("../dom/state"); +const { t } = require("../i18n/i18n"); +const { + hideOverlay, + showOverlay, + renderHistory, + commandHistory, + showToast, + showConfirmDialog, + hideFirstTimeDialog, + handleKillProcess, +} = require("./ui"); +const { + toggleDiffPanel, + closeDiffPanel, + renderDiffList, + closeDiffViewer, + setActiveTab, + backToLog, + openCommitFullDiff, +} = require("./diff-panel"); +const todoPanel = require("./todo-panel"); +const { handleInitProject, renderSessions } = require("../dom/session-list"); +const { handleManualParse } = require("../dom/observer"); +const { sendToChat } = require("../dom/chat-input"); +const { getProviderByUrl } = require("../../../src/providers"); +const { estimateTokens } = require("../dom/token-estimator"); /** * 渲染窗口列表(浮动管理面板内) */ async function renderWindowList() { - const list = document.getElementById('cuckoo-window-list'); + const list = document.getElementById("cuckoo-window-list"); if (!list) return; try { const res = await window.electronAPI.listProfiles(); @@ -30,54 +48,68 @@ async function renderWindowList() { try { const pvRes = await window.electronAPI.listProviders(); if (pvRes && pvRes.success) { - (pvRes.providers || []).forEach(pv => { providerMap[pv.id] = pv.name; }); + (pvRes.providers || []).forEach((pv) => { + providerMap[pv.id] = pv.name; + }); } } catch (_) {} - list.innerHTML = profiles.map(p => { - const pname = providerMap[p.providerId] || '平台'; - return '
' + - '' + - '' + p.name + '' + + list.innerHTML = profiles + .map((p) => { + const pname = providerMap[p.providerId] || "平台"; + return ( + '
' + + '' + + '' + + p.name + + "" + '|' + - '' + pname + '' + - '' + - '删除' + - '
'; - }).join(''); - list.querySelectorAll('.cuckoo-window-item').forEach(el => { - el.addEventListener('click', async (e) => { + '' + + pname + + "" + + "
" + + '删除' + + "
" + ); + }) + .join(""); + list.querySelectorAll(".cuckoo-window-item").forEach((el) => { + el.addEventListener("click", async (e) => { // 点击删除按钮不触发切换 - if (e.target.classList.contains('cuckoo-window-del')) return; + if (e.target.classList.contains("cuckoo-window-del")) return; const profileId = el.dataset.profileId; try { const r = await window.electronAPI.openProfileWindow(profileId); if (r && r.success) { - showToast(r.focused ? '已切换到该窗口' : '已打开窗口', 2000); + showToast(r.focused ? "已切换到该窗口" : "已打开窗口", 2000); closeWindowManager(); } else { - showToast((r && r.error) || '打开失败', 3000); + showToast((r && r.error) || "打开失败", 3000); } } catch (err) { - showToast('打开窗口失败: ' + (err.message || err), 3000); + showToast("打开窗口失败: " + (err.message || err), 3000); } }); }); // 绑定删除按钮 - list.querySelectorAll('.cuckoo-window-del').forEach(btn => { - btn.addEventListener('click', async (e) => { + list.querySelectorAll(".cuckoo-window-del").forEach((btn) => { + btn.addEventListener("click", async (e) => { e.stopPropagation(); const profileId = btn.dataset.profileId; try { const r = await window.electronAPI.deleteProfileWindow(profileId); if (r && r.success) { - showToast('已删除窗口', 2000); + showToast("已删除窗口", 2000); await renderWindowList(); } else { - showToast((r && r.error) || '删除失败', 3000); + showToast((r && r.error) || "删除失败", 3000); } } catch (err) { - showToast('删除失败: ' + (err.message || err), 3000); + showToast("删除失败: " + (err.message || err), 3000); } }); }); @@ -90,9 +122,9 @@ async function renderWindowList() { * 打开窗口管理浮动面板 */ function openWindowManager() { - const panel = document.getElementById('cuckoo-window-manager'); + const panel = document.getElementById("cuckoo-window-manager"); if (panel) { - panel.classList.remove('cuckoo-hidden'); + panel.classList.remove("cuckoo-hidden"); renderWindowList(); } } @@ -101,17 +133,18 @@ function openWindowManager() { * 关闭窗口管理浮动面板 */ function closeWindowManager() { - const panel = document.getElementById('cuckoo-window-manager'); - if (panel) panel.classList.add('cuckoo-hidden'); + const panel = document.getElementById("cuckoo-window-manager"); + if (panel) panel.classList.add("cuckoo-hidden"); } /** * 生成项目说明文档按钮点击处理 */ function handleGenerateDoc() { - const message = '根据当前项目生成一个项目说明文件,并将文件放到当前项目 .cuckooCode/CUCKOO.md'; - if (!sendToChat(message, '生成文档', 300)) { - showToast('未找到输入框,请确保已打开聊天界面', 3000); + const message = + "根据当前项目生成一个项目说明文件,并将文件放到当前项目 .cuckooCode/CUCKOO.md"; + if (!sendToChat(message, "生成文档", 300)) { + showToast("未找到输入框,请确保已打开聊天界面", 3000); } } @@ -125,7 +158,7 @@ async function loadMcpConfigToJson() { const mcpServers = {}; for (const s of servers) { const def = {}; - if (s.type === 'http') { + if (s.type === "http") { if (s.url) def.url = s.url; if (s.headers) def.headers = s.headers; } else { @@ -135,7 +168,7 @@ async function loadMcpConfigToJson() { } mcpServers[s.name] = def; } - const jsonInput = document.getElementById('cuckoo-mcp-json'); + const jsonInput = document.getElementById("cuckoo-mcp-json"); if (jsonInput) jsonInput.value = JSON.stringify({ mcpServers }, null, 2); } @@ -143,49 +176,66 @@ async function loadMcpConfigToJson() { * 渲染 MCP server 列表 */ async function renderMcpList() { - const list = document.getElementById('cuckoo-mcp-list'); + const list = document.getElementById("cuckoo-mcp-list"); if (!list) return; try { const res = await window.electronAPI.listMcpServers(); const servers = res && res.success ? res.servers : []; if (!servers || servers.length === 0) { - list.innerHTML = '
暂无 MCP Server
'; + list.innerHTML = + '
暂无 MCP Server
'; return; } - list.innerHTML = servers.map(s => { - const status = s.connected ? '已连接' : (s.enabled ? '未连接' : '已禁用'); - const statusColor = s.connected ? '#4ade80' : (s.enabled ? '#ffc107' : '#5d6280'); - return '
' + - '' + s.name + '' + - '' + - '
'; - }).join(''); - - list.querySelectorAll('.cuckoo-mcp-item').forEach(el => { - el.addEventListener('click', async () => { + list.innerHTML = servers + .map((s) => { + const status = s.connected ? "已连接" : s.enabled ? "未连接" : "已禁用"; + const statusColor = s.connected + ? "#4ade80" + : s.enabled + ? "#ffc107" + : "#5d6280"; + return ( + '
' + + '' + + s.name + + "" + + '' + + "
" + ); + }) + .join(""); + + list.querySelectorAll(".cuckoo-mcp-item").forEach((el) => { + el.addEventListener("click", async () => { const name = el.dataset.mcpName; - const server = servers.find(s => s.name === name); + const server = servers.find((s) => s.name === name); if (!server) return; // 点击后立即显示 loading - const dot = el.querySelector('.cuckoo-mcp-dot'); - if (dot) dot.style.background = '#ffc107'; - el.style.pointerEvents = 'none'; + const dot = el.querySelector(".cuckoo-mcp-dot"); + if (dot) dot.style.background = "#ffc107"; + el.style.pointerEvents = "none"; try { if (server.connected || server.enabled) { // 已连接或已启用 → 断开/禁用 await window.electronAPI.disableMcpServer(name); - showToast('已断开 ' + name, 2000); + showToast("已断开 " + name, 2000); } else { // 未启用 → 连接 await window.electronAPI.enableMcpServer(name); - showToast('已连接 ' + name, 2000); + showToast("已连接 " + name, 2000); } await renderMcpList(); await loadMcpConfigToJson(); } catch (err) { - showToast('操作失败: ' + (err.message || err), 3000); + showToast("操作失败: " + (err.message || err), 3000); await renderMcpList(); } }); @@ -199,9 +249,9 @@ async function renderMcpList() { * 打开 MCP 管理面板 */ function openMcpManager() { - const panel = document.getElementById('cuckoo-mcp-manager'); + const panel = document.getElementById("cuckoo-mcp-manager"); if (panel) { - panel.classList.remove('cuckoo-hidden'); + panel.classList.remove("cuckoo-hidden"); renderMcpList(); loadMcpConfigToJson(); } @@ -211,8 +261,8 @@ function openMcpManager() { * 关闭 MCP 管理面板 */ function closeMcpManager() { - const panel = document.getElementById('cuckoo-mcp-manager'); - if (panel) panel.classList.add('cuckoo-hidden'); + const panel = document.getElementById("cuckoo-mcp-manager"); + if (panel) panel.classList.add("cuckoo-hidden"); } /** @@ -221,9 +271,9 @@ function closeMcpManager() { * @returns {string} */ function formatTokenCount(n) { - if (!Number.isFinite(n) || n < 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(2) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + if (!Number.isFinite(n) || n < 0) return "0"; + if (n >= 1000000) return (n / 1000000).toFixed(2) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "K"; return String(Math.round(n)); } @@ -239,7 +289,7 @@ function formatTokenCount(n) { * @returns {string} */ function convTokenStorageKey(sessionId) { - return 'cuckoo-conv-tokens-' + (sessionId || 'default'); + return "cuckoo-conv-tokens-" + (sessionId || "default"); } /** @@ -249,10 +299,12 @@ function convTokenStorageKey(sessionId) { function getCurrentSessionId() { try { const provider = getProviderByUrl(window.location.href); - if (provider && typeof provider.extractSessionId === 'function') { + if (provider && typeof provider.extractSessionId === "function") { return provider.extractSessionId(window.location.href); } - } catch (_) { /* ignore */ } + } catch (_) { + /* ignore */ + } return null; } @@ -262,28 +314,45 @@ function getCurrentSessionId() { * @returns {number} */ function computeAndSaveConversationTokens() { - let text = ''; + let text = ""; try { const provider = getProviderByUrl(window.location.href); - if (provider && typeof provider.getConversationText === 'function') { - text = provider.getConversationText() || ''; + if (provider && typeof provider.getConversationText === "function") { + text = provider.getConversationText() || ""; } - } catch (_) { /* provider 未就绪 */ } + } catch (_) { + /* provider 未就绪 */ + } // Отладка (один раз в 5 сек, чтобы не спамить) try { const now = Date.now(); - if (!computeAndSaveConversationTokens._lastLog || now - computeAndSaveConversationTokens._lastLog > 5000) { + if ( + !computeAndSaveConversationTokens._lastLog || + now - computeAndSaveConversationTokens._lastLog > 5000 + ) { computeAndSaveConversationTokens._lastLog = now; - const cnt = document.querySelectorAll('.ds-message').length; - console.log('[Cookie Code][tokens] .ds-message=' + cnt + ' textLen=' + text.length + ' estTokens=' + estimateTokens(text)); + const cnt = document.querySelectorAll(".ds-message").length; + console.log( + "[Cookie Code][tokens] .ds-message=" + + cnt + + " textLen=" + + text.length + + " estTokens=" + + estimateTokens(text), + ); } } catch (_) {} const tokens = estimateTokens(text); // Запоминаем только если есть что запоминать (> 0) if (tokens > 0) { try { - localStorage.setItem(convTokenStorageKey(getCurrentSessionId()), String(tokens)); - } catch (_) { /* ignore */ } + localStorage.setItem( + convTokenStorageKey(getCurrentSessionId()), + String(tokens), + ); + } catch (_) { + /* ignore */ + } } return tokens; } @@ -294,10 +363,14 @@ function computeAndSaveConversationTokens() { */ function readSavedConversationTokens() { try { - const raw = localStorage.getItem(convTokenStorageKey(getCurrentSessionId())); + const raw = localStorage.getItem( + convTokenStorageKey(getCurrentSessionId()), + ); const n = raw != null ? parseInt(raw, 10) : 0; return Number.isFinite(n) && n > 0 ? n : 0; - } catch (_) { return 0; } + } catch (_) { + return 0; + } } /** @@ -305,7 +378,7 @@ function readSavedConversationTokens() { * @returns {string} */ function sessionKey() { - return getCurrentSessionId() || 'default'; + return getCurrentSessionId() || "default"; } /** @@ -317,7 +390,10 @@ function sessionKey() { function setServerTokensForCurrentSession(total) { if (!Number.isFinite(total) || total <= 0) return; const sid = sessionKey(); - if (!state.serverTokensBySession || typeof state.serverTokensBySession !== 'object') { + if ( + !state.serverTokensBySession || + typeof state.serverTokensBySession !== "object" + ) { state.serverTokensBySession = {}; } const prev = Number(state.serverTokensBySession[sid]) || 0; @@ -332,7 +408,7 @@ function setServerTokensForCurrentSession(total) { function getServerTokensForCurrentSession() { const sid = sessionKey(); const map = state.serverTokensBySession; - const n = map && typeof map === 'object' ? Number(map[sid]) : 0; + const n = map && typeof map === "object" ? Number(map[sid]) : 0; return Number.isFinite(n) && n > 0 ? n : 0; } @@ -343,7 +419,7 @@ function getServerTokensForCurrentSession() { */ function getLocalEstimateForSession(sid) { const m = state.localEstimateBySession; - const n = m && typeof m === 'object' ? Number(m[sid]) : 0; + const n = m && typeof m === "object" ? Number(m[sid]) : 0; return Number.isFinite(n) && n > 0 ? n : 0; } @@ -354,7 +430,10 @@ function getLocalEstimateForSession(sid) { */ function bumpLocalEstimateForSession(sid, value) { if (!Number.isFinite(value) || value <= 0) return; - if (!state.localEstimateBySession || typeof state.localEstimateBySession !== 'object') { + if ( + !state.localEstimateBySession || + typeof state.localEstimateBySession !== "object" + ) { state.localEstimateBySession = {}; } const prev = Number(state.localEstimateBySession[sid]) || 0; @@ -372,7 +451,7 @@ function bumpLocalEstimateForSession(sid, value) { * это давало прыжки 900 → 4K → 1.9K. */ function updateConversationTokenDisplay() { - const countEl = document.getElementById('cuckoo-conv-token-count'); + const countEl = document.getElementById("cuckoo-conv-token-count"); if (!countEl) return; const sid = sessionKey(); @@ -393,11 +472,13 @@ function updateConversationTokenDisplay() { * Слушаем серверные токены из token-interceptor и обновляем state + панель. */ function registerTokenInterceptorListener() { - window.addEventListener('cuckoo:token-update', (e) => { + window.addEventListener("cuckoo:token-update", (e) => { try { const d = e && e.detail ? e.detail : {}; - if (typeof d.total === 'number' && d.total > 0) setServerTokensForCurrentSession(d.total); - if (typeof d.delta === 'number' && d.delta > 0) state.serverTokenDelta = d.delta; + if (typeof d.total === "number" && d.total > 0) + setServerTokensForCurrentSession(d.total); + if (typeof d.delta === "number" && d.delta > 0) + state.serverTokenDelta = d.delta; } catch (_) {} safeUpdateConversationTokenDisplay(); }); @@ -407,7 +488,9 @@ function registerTokenInterceptorListener() { * Безопасный вызов updateConversationTokenDisplay (не роняет слушателя). */ function safeUpdateConversationTokenDisplay() { - try { updateConversationTokenDisplay(); } catch (_) {} + try { + updateConversationTokenDisplay(); + } catch (_) {} } /** @@ -433,88 +516,98 @@ function bindEvents() { // 从 localStorage 恢复延迟配置 try { - const savedMin = localStorage.getItem('cuckoo-send-delay-min'); - const savedMax = localStorage.getItem('cuckoo-send-delay-max'); + const savedMin = localStorage.getItem("cuckoo-send-delay-min"); + const savedMax = localStorage.getItem("cuckoo-send-delay-max"); if (savedMin) state.sendDelayMin = parseInt(savedMin, 10) || 2000; if (savedMax) state.sendDelayMax = parseInt(savedMax, 10) || 4000; - // 同步到输入框 - const minInput = document.getElementById('cuckoo-delay-min'); - const maxInput = document.getElementById('cuckoo-delay-max'); - if (minInput) minInput.value = state.sendDelayMin; - if (maxInput) maxInput.value = state.sendDelayMax; + // 同步到输入框:存储为毫秒,界面显示秒 + const minInput = document.getElementById("cuckoo-delay-min"); + const maxInput = document.getElementById("cuckoo-delay-max"); + if (minInput) minInput.value = state.sendDelayMin / 1000; + if (maxInput) maxInput.value = state.sendDelayMax / 1000; } catch (e) {} - const minimizeBtn = document.getElementById('cuckoo-btn-minimize'); - const initBtn = document.getElementById('cuckoo-btn-init'); - const clearBtn = document.getElementById('cuckoo-btn-clear'); + const minimizeBtn = document.getElementById("cuckoo-btn-minimize"); + const initBtn = document.getElementById("cuckoo-btn-init"); + const clearBtn = document.getElementById("cuckoo-btn-clear"); - minimizeBtn?.addEventListener('click', hideOverlay); - initBtn?.addEventListener('click', handleInitProject); + minimizeBtn?.addEventListener("click", hideOverlay); + initBtn?.addEventListener("click", handleInitProject); // 首次使用提示浮窗:初始化按钮(与右侧初始化项目逻辑一致) - const firstInitBtn = document.getElementById('cuckoo-btn-first-init'); - firstInitBtn?.addEventListener('click', handleInitProject); + const firstInitBtn = document.getElementById("cuckoo-btn-first-init"); + firstInitBtn?.addEventListener("click", handleInitProject); // 首次使用提示浮窗:关闭按钮 - const firstCloseBtn = document.getElementById('cuckoo-btn-first-close'); - firstCloseBtn?.addEventListener('click', hideFirstTimeDialog); - clearBtn?.addEventListener('click', () => { + const firstCloseBtn = document.getElementById("cuckoo-btn-first-close"); + firstCloseBtn?.addEventListener("click", hideFirstTimeDialog); + clearBtn?.addEventListener("click", () => { commandHistory.length = 0; renderHistory(); }); // 手动解析按钮 - const manualParseBtn = document.getElementById('cuckoo-btn-manual-parse'); - manualParseBtn?.addEventListener('click', handleManualParse); + const manualParseBtn = document.getElementById("cuckoo-btn-manual-parse"); + manualParseBtn?.addEventListener("click", handleManualParse); // Экстренная остановка активных дочерних процессов (кнопка Kill в плашке статуса) - const killBtn = document.getElementById('cuckoo-btn-kill'); - killBtn?.addEventListener('click', handleKillProcess); + const killBtn = document.getElementById("cuckoo-btn-kill"); + killBtn?.addEventListener("click", handleKillProcess); // 窗口管理按钮:打开浮动管理面板 - const windowManagerBtn = document.getElementById('cuckoo-btn-window-manager'); - windowManagerBtn?.addEventListener('click', () => { + const windowManagerBtn = document.getElementById("cuckoo-btn-window-manager"); + windowManagerBtn?.addEventListener("click", () => { openWindowManager(); }); // MCP 按钮:打开 MCP 管理面板 - const mcpBtn = document.getElementById('cuckoo-btn-mcp'); - mcpBtn?.addEventListener('click', openMcpManager); + const mcpBtn = document.getElementById("cuckoo-btn-mcp"); + mcpBtn?.addEventListener("click", openMcpManager); // MCP 面板:关闭 - const mcpCloseBtn = document.getElementById('cuckoo-mcp-close'); - mcpCloseBtn?.addEventListener('click', closeMcpManager); + const mcpCloseBtn = document.getElementById("cuckoo-mcp-close"); + mcpCloseBtn?.addEventListener("click", closeMcpManager); // Diff-панель: кнопка в overlay (toggle) - const diffBtn = document.getElementById('cuckoo-btn-diff'); - diffBtn?.addEventListener('click', toggleDiffPanel); + const diffBtn = document.getElementById("cuckoo-btn-diff"); + diffBtn?.addEventListener("click", toggleDiffPanel); // Diff-панель: закрыть - const diffCloseBtn = document.getElementById('cuckoo-diff-close'); - diffCloseBtn?.addEventListener('click', closeDiffPanel); + const diffCloseBtn = document.getElementById("cuckoo-diff-close"); + diffCloseBtn?.addEventListener("click", closeDiffPanel); // Diff-панель: обновить (в зависимости от активной вкладки) - const diffRefreshBtn = document.getElementById('cuckoo-diff-refresh'); - diffRefreshBtn?.addEventListener('click', () => { - const historyTab = document.getElementById('cuckoo-diff-tab-history'); - if (historyTab && historyTab.classList.contains('active')) { - require('./diff-panel').renderGitLog(); + const diffRefreshBtn = document.getElementById("cuckoo-diff-refresh"); + diffRefreshBtn?.addEventListener("click", () => { + const historyTab = document.getElementById("cuckoo-diff-tab-history"); + if (historyTab && historyTab.classList.contains("active")) { + require("./diff-panel").renderGitLog(); } else { renderDiffList(); } }); // Diff-панель: вкладки - document.getElementById('cuckoo-diff-tab-changes')?.addEventListener('click', () => setActiveTab('changes')); - document.getElementById('cuckoo-diff-tab-history')?.addEventListener('click', () => setActiveTab('history')); + document + .getElementById("cuckoo-diff-tab-changes") + ?.addEventListener("click", () => setActiveTab("changes")); + document + .getElementById("cuckoo-diff-tab-history") + ?.addEventListener("click", () => setActiveTab("history")); // Diff-панель: назад к истории / весь коммит - document.getElementById('cuckoo-commit-back')?.addEventListener('click', backToLog); - document.getElementById('cuckoo-commit-full')?.addEventListener('click', openCommitFullDiff); + document + .getElementById("cuckoo-commit-back") + ?.addEventListener("click", backToLog); + document + .getElementById("cuckoo-commit-full") + ?.addEventListener("click", openCommitFullDiff); // Diff-viewer: закрыть - const diffViewerCloseBtn = document.getElementById('cuckoo-diff-viewer-close'); - diffViewerCloseBtn?.addEventListener('click', closeDiffViewer); + const diffViewerCloseBtn = document.getElementById( + "cuckoo-diff-viewer-close", + ); + diffViewerCloseBtn?.addEventListener("click", closeDiffViewer); // Плавающее окошко «Задачи» todoPanel.start(); @@ -523,60 +616,85 @@ function bindEvents() { startTokenCounter(); // MCP 面板:刷新 - const mcpRefreshBtn = document.getElementById('cuckoo-mcp-refresh'); - mcpRefreshBtn?.addEventListener('click', renderMcpList); + const mcpRefreshBtn = document.getElementById("cuckoo-mcp-refresh"); + mcpRefreshBtn?.addEventListener("click", renderMcpList); // MCP 面板:保存配置 - const mcpSaveBtn = document.getElementById('cuckoo-mcp-save'); - mcpSaveBtn?.addEventListener('click', async () => { - const jsonInput = document.getElementById('cuckoo-mcp-json'); + const mcpSaveBtn = document.getElementById("cuckoo-mcp-save"); + mcpSaveBtn?.addEventListener("click", async () => { + const jsonInput = document.getElementById("cuckoo-mcp-json"); if (!jsonInput || !jsonInput.value.trim()) { - showToast('请输入配置', 3000); + showToast("请输入配置", 3000); return; } try { const parsed = JSON.parse(jsonInput.value); - if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') { - showToast('配置格式错误,需要 mcpServers 对象', 3000); + if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") { + showToast("配置格式错误,需要 mcpServers 对象", 3000); return; } // 校验每个 server 定义是否完整合法(发现错误立即中止,不删旧配置、不覆盖编辑框) for (const [name, def] of Object.entries(parsed.mcpServers)) { - if (!def || typeof def !== 'object' || Array.isArray(def)) { + if (!def || typeof def !== "object" || Array.isArray(def)) { showToast('配置错误:server "' + name + '" 的定义必须是对象', 4000); return; } const hasUrl = def.url !== undefined; const hasCommand = def.command !== undefined; if (hasUrl) { - if (typeof def.url !== 'string' || !def.url.trim()) { - showToast('配置错误:server "' + name + '" 的 url 必须是非空字符串', 4000); + if (typeof def.url !== "string" || !def.url.trim()) { + showToast( + '配置错误:server "' + name + '" 的 url 必须是非空字符串', + 4000, + ); return; } if (hasCommand) { - showToast('配置错误:server "' + name + '" 不能同时指定 url 和 command', 4000); + showToast( + '配置错误:server "' + name + '" 不能同时指定 url 和 command', + 4000, + ); return; } } else if (hasCommand) { - if (typeof def.command !== 'string' || !def.command.trim()) { - showToast('配置错误:server "' + name + '" 的 command 必须是非空字符串', 4000); + if (typeof def.command !== "string" || !def.command.trim()) { + showToast( + '配置错误:server "' + name + '" 的 command 必须是非空字符串', + 4000, + ); return; } } else { - showToast('配置错误:server "' + name + '" 缺少 command 或 url', 4000); + showToast( + '配置错误:server "' + name + '" 缺少 command 或 url', + 4000, + ); return; } if (def.args !== undefined && !Array.isArray(def.args)) { showToast('配置错误:server "' + name + '" 的 args 必须是数组', 4000); return; } - if (def.env !== undefined && (typeof def.env !== 'object' || def.env === null || Array.isArray(def.env))) { + if ( + def.env !== undefined && + (typeof def.env !== "object" || + def.env === null || + Array.isArray(def.env)) + ) { showToast('配置错误:server "' + name + '" 的 env 必须是对象', 4000); return; } - if (def.headers !== undefined && (typeof def.headers !== 'object' || def.headers === null || Array.isArray(def.headers))) { - showToast('配置错误:server "' + name + '" 的 headers 必须是对象', 4000); + if ( + def.headers !== undefined && + (typeof def.headers !== "object" || + def.headers === null || + Array.isArray(def.headers)) + ) { + showToast( + '配置错误:server "' + name + '" 的 headers 必须是对象', + 4000, + ); return; } } @@ -595,112 +713,128 @@ function bindEvents() { for (const [name, def] of Object.entries(parsed.mcpServers)) { const server = { name, - type: def && def.url ? 'http' : 'stdio', + type: def && def.url ? "http" : "stdio", command: def && def.command, - args: def && def.args || [], + args: (def && def.args) || [], url: def && def.url, headers: def && def.headers, env: def && def.env, }; await window.electronAPI.upsertMcpServer(server); } - showToast('配置已保存', 2200); + showToast("配置已保存", 2200); await renderMcpList(); await loadMcpConfigToJson(); // 询问用户是否将 MCP 更新通知发给 AI(不自动发送) try { const confirmed = await showConfirmDialog( - 'MCP 配置已保存。\n\n是否告诉 AI 配置已更新?\n(请确保 AI 当前没有正在进行其他操作)', - { okText: '发送', showCancel: true, cancelText: '取消' } + "MCP 配置已保存。\n\n是否告诉 AI 配置已更新?\n(请确保 AI 当前没有正在进行其他操作)", + { okText: "发送", showCancel: true, cancelText: "取消" }, ); if (!confirmed) return; const res = await window.electronAPI.getMcpTools(); const tools = res && res.success ? res.tools : []; - const serverNames = Array.from(new Set(tools.map(t => t.server))); - let msg = '【MCP 配置已更新】\n\n'; + const serverNames = Array.from(new Set(tools.map((t) => t.server))); + let msg = "【MCP 配置已更新】\n\n"; if (serverNames.length === 0) { - msg += '当前没有已连接的 MCP server。'; + msg += "当前没有已连接的 MCP server。"; } else { - msg += '可用的 MCP server:' + serverNames.join('、') + '。\n'; - msg += '需要时用 mcpListServers() 查看概览,或用 mcpGetTools(serverName) 查看具体工具。'; + msg += "可用的 MCP server:" + serverNames.join("、") + "。\n"; + msg += + "需要时用 mcpListServers() 查看概览,或用 mcpGetTools(serverName) 查看具体工具。"; } - sendToChat(msg, 'MCP信息', 300); + sendToChat(msg, "MCP信息", 300); } catch (err) { - console.error('[Cookie Code] 发送 MCP 信息失败:', err); + console.error("[Cookie Code] 发送 MCP 信息失败:", err); } } catch (err) { - showToast('保存失败: ' + (err.message || err), 3000); + showToast("保存失败: " + (err.message || err), 3000); } }); - - // 浮动面板:新建窗口(всегда DeepSeek, выбор платформы отключён) - const wmNewWindowBtn = document.getElementById('cuckoo-wm-new-window'); - wmNewWindowBtn?.addEventListener('click', async () => { + const wmNewWindowBtn = document.getElementById("cuckoo-wm-new-window"); + wmNewWindowBtn?.addEventListener("click", async () => { try { await window.electronAPI.createProfileWindow(); - showToast('Новое окно DeepSeek создано', 2200); + showToast("Новое окно DeepSeek создано", 2200); await renderWindowList(); } catch (err) { - showToast('Не удалось создать окно: ' + (err.message || err), 3000); + showToast("Не удалось создать окно: " + (err.message || err), 3000); } }); // 浮动面板:关闭 - const wmCloseBtn = document.getElementById('cuckoo-wm-close'); - wmCloseBtn?.addEventListener('click', closeWindowManager); + const wmCloseBtn = document.getElementById("cuckoo-wm-close"); + wmCloseBtn?.addEventListener("click", closeWindowManager); // 浮动面板:刷新列表 - const wmRefreshBtn = document.getElementById('cuckoo-wm-refresh'); - wmRefreshBtn?.addEventListener('click', renderWindowList); + const wmRefreshBtn = document.getElementById("cuckoo-wm-refresh"); + wmRefreshBtn?.addEventListener("click", renderWindowList); // 生成项目说明文档按钮 - const genDocBtn = document.getElementById('cuckoo-btn-gen-doc'); - genDocBtn?.addEventListener('click', handleGenerateDoc); + const genDocBtn = document.getElementById("cuckoo-btn-gen-doc"); + genDocBtn?.addEventListener("click", handleGenerateDoc); // 沉浸式交流按钮 - const immersiveBtn = document.getElementById('cuckoo-btn-immersive'); - immersiveBtn?.addEventListener('click', () => { - const message = '现在你的任何疑问,或没有疑问的选择都需要和我确认 , 确认的方式是 你问一个问题我回答一个问题,然后你再问下一个问题, 最好给我选项, 也要给我个其他的选项, 谢谢 爱你哦'; - if (!sendToChat(message, '沉浸式交流', 300)) { - showToast('未找到输入框,请确保已打开聊天界面', 3000); + const immersiveBtn = document.getElementById("cuckoo-btn-immersive"); + immersiveBtn?.addEventListener("click", () => { + const message = + "现在你的任何疑问,或没有疑问的选择都需要和我确认 , 确认的方式是 你问一个问题我回答一个问题,然后你再问下一个问题, 最好给我选项, 也要给我个其他的选项, 谢谢 爱你哦"; + if (!sendToChat(message, "沉浸式交流", 300)) { + showToast("未找到输入框,请确保已打开聊天界面", 3000); } else { - showToast('已发送沉浸式交流提示', 2200); + showToast("已发送沉浸式交流提示", 2200); } }); // 刷新会话列表按钮 - const refreshSessionsBtn = document.getElementById('cuckoo-btn-refresh-sessions'); - refreshSessionsBtn?.addEventListener('click', renderSessions); + const refreshSessionsBtn = document.getElementById( + "cuckoo-btn-refresh-sessions", + ); + refreshSessionsBtn?.addEventListener("click", renderSessions); // 保存延迟设置按钮 - const saveDelayBtn = document.getElementById('cuckoo-btn-save-delay'); - const delayMinInput = document.getElementById('cuckoo-delay-min'); - const delayMaxInput = document.getElementById('cuckoo-delay-max'); - saveDelayBtn?.addEventListener('click', () => { - const min = parseInt(delayMinInput?.value, 10); - const max = parseInt(delayMaxInput?.value, 10); - if (Number.isNaN(min) || min < 0) { showToast('最小延迟必须是非负整数', 3000); return; } - if (Number.isNaN(max) || max < min) { showToast('最大延迟不能小于最小延迟', 3000); return; } - if (max > 10000) { showToast('最大延迟不能超过 10000ms', 3000); return; } + const saveDelayBtn = document.getElementById("cuckoo-btn-save-delay"); + const delayMinInput = document.getElementById("cuckoo-delay-min"); + const delayMaxInput = document.getElementById("cuckoo-delay-max"); + saveDelayBtn?.addEventListener("click", () => { + // 界面输入为秒,内部/存储统一用毫秒 + const secToMs = (s) => Math.round(parseFloat(s) * 1000); + const min = secToMs(delayMinInput?.value); + const max = secToMs(delayMaxInput?.value); + if (Number.isNaN(min) || min < 0) { + showToast(t("overlay.delay.errMin"), 3000); + return; + } + if (Number.isNaN(max) || max < min) { + showToast(t("overlay.delay.errMax"), 3000); + return; + } + if (max > 10000) { + showToast(t("overlay.delay.errLimit"), 3000); + return; + } state.sendDelayMin = min; state.sendDelayMax = max; - // 保存到 localStorage + // 保存到 localStorage(毫秒) try { - localStorage.setItem('cuckoo-send-delay-min', String(min)); - localStorage.setItem('cuckoo-send-delay-max', String(max)); + localStorage.setItem("cuckoo-send-delay-min", String(min)); + localStorage.setItem("cuckoo-send-delay-max", String(max)); } catch (e) {} - showToast('延迟设置已保存:' + min + ' - ' + max + ' ms', 3000); + showToast( + t("overlay.delay.saved", { min: min / 1000, max: max / 1000 }), + 3000, + ); }); // 悬浮球点击切换面板显隐 - const statusBadge = document.getElementById('cuckoo-status-badge'); - statusBadge?.addEventListener('click', () => { - const overlay = document.getElementById('cuckoo-overlay'); + const statusBadge = document.getElementById("cuckoo-status-badge"); + statusBadge?.addEventListener("click", () => { + const overlay = document.getElementById("cuckoo-overlay"); if (!overlay) return; - if (overlay.classList.contains('cuckoo-hidden')) { + if (overlay.classList.contains("cuckoo-hidden")) { showOverlay(); } else { hideOverlay(); @@ -708,13 +842,13 @@ function bindEvents() { }); // 键盘快捷键 - document.addEventListener('keydown', (e) => { + document.addEventListener("keydown", (e) => { // Ctrl+Shift+C 切换覆盖层显示 - if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.key === 'c')) { + if (e.ctrlKey && e.shiftKey && (e.key === "C" || e.key === "c")) { e.preventDefault(); - const overlay = document.getElementById('cuckoo-overlay'); + const overlay = document.getElementById("cuckoo-overlay"); if (overlay) { - if (overlay.classList.contains('cuckoo-hidden')) { + if (overlay.classList.contains("cuckoo-hidden")) { showOverlay(); } else { hideOverlay(); @@ -722,7 +856,7 @@ function bindEvents() { } } // Esc 隐藏覆盖层和窗口管理面板 - if (e.key === 'Escape') { + if (e.key === "Escape") { hideOverlay(); closeWindowManager(); closeMcpManager(); diff --git a/src/preload/overlay/template.js b/src/preload/overlay/template.js index 228a960..3150fd8 100644 --- a/src/preload/overlay/template.js +++ b/src/preload/overlay/template.js @@ -108,13 +108,15 @@ function buildOverlayHTML() { t("overlay.label.sendDelay") + "", '
', - '
', - ' ', + '
', + ' ', ' ' + t("overlay.label.delayRange") + "", - ' ', - ' ms', + ' ', + ' ' + + t("overlay.label.delaySeconds") + + "", "
", "
", '