diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dede1d..39c82f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- 🧪 **Инструмент `inject_page_js` — JS прямо на странице (как F12)** — AI может выполнять код в main world текущей страницы и получать результат: + - по умолчанию работает в главном окне (там, где идёт чат), опционально — в любом окне, открытом через `open_browser_window`; + - доступны DOM, `window.*`, `localStorage`, `fetch` от имени страницы — удобно собирать данные со страницы; + - `window.electronAPI` в этом контексте недоступен (это честный main world); + - код — либо блок с `return` в начале, либо выражение (например IIFE); поддержка `await`, результат JSON-совместимый. - 🎛 **Тумблер «Показывать дашборд»** — статистику можно копить, но сам дашборд на домашней странице скрыть. Настройки → «Система и безопасность» → «Статистика»: - выключил — дашборд прячется мгновенно и больше не мешает; сбор статистики при этом продолжается; - включил обратно — дашборд тут же возвращается на своё место. diff --git a/src/preload/dom/stats-dashboard.js b/src/preload/dom/stats-dashboard.js index ea0e72e..1e8e7f6 100644 --- a/src/preload/dom/stats-dashboard.js +++ b/src/preload/dom/stats-dashboard.js @@ -20,6 +20,7 @@ * - guard от повторного входа в refresh. */ const { getProviderByUrl } = require("../../../src/providers"); +const { t } = require("../i18n/i18n"); const state = require("./state"); const ROOT_ID = "cuckoo-stats-dashboard"; @@ -172,15 +173,16 @@ function levelFor(messages) { return "l4"; } +// Ключ значения → ключ i18n подписи карточки. const CARD_LABELS = [ - ["sessions", "Сессии"], - ["messages", "Сообщения"], - ["tokens", "Токены"], - ["activeDays", "Активных дней"], - ["currentStreak", "Текущая серия"], - ["longestStreak", "Макс. серия"], - ["peakHour", "Пик. час"], - ["favoriteModel", "Любимая модель"], + ["sessions", "dashboard.card.sessions"], + ["messages", "dashboard.card.messages"], + ["tokens", "dashboard.card.tokens"], + ["activeDays", "dashboard.card.activeDays"], + ["currentStreak", "dashboard.card.currentStreak"], + ["longestStreak", "dashboard.card.longestStreak"], + ["peakHour", "dashboard.card.peakHour"], + ["favoriteModel", "dashboard.card.favoriteModel"], ]; /** @@ -194,14 +196,14 @@ function buildSkeleton() { head.className = "ckd-head"; const title = document.createElement("div"); title.className = "ckd-title"; - title.textContent = "Cookie Code · Обзор"; + title.textContent = t("dashboard.title"); const hint = document.createElement("span"); hint.className = "ckd-drag-hint"; - hint.textContent = "⠿ тяни (debug)"; + hint.textContent = t("dashboard.dragHint"); title.appendChild(hint); const range = document.createElement("div"); range.className = "ckd-range"; - range.textContent = RANGE_DAYS + " дн · всего за всё время"; + range.textContent = t("dashboard.range", { days: RANGE_DAYS }); head.appendChild(title); head.appendChild(range); @@ -213,7 +215,7 @@ function buildSkeleton() { c.className = "ckd-card"; const k = document.createElement("div"); k.className = "k"; - k.textContent = label; + k.textContent = t(label); const v = document.createElement("div"); v.className = "v"; c.appendChild(k); @@ -240,16 +242,16 @@ function buildSkeleton() { * Компактная сигнатура сводки — чтобы пропускать render без изменений. */ function signatureOf(s) { - const t = s.totals || {}; + const tot = s.totals || {}; const days = Array.isArray(s.days) ? s.days : []; let acc = ""; for (let i = 0; i < days.length; i++) acc += (days[i].messages || 0) + ","; return ( - t.sessions + + tot.sessions + "|" + - t.messages + + tot.messages + "|" + - t.tokens + + tot.tokens + "|" + (s.activeDays || 0) + "|" + @@ -274,23 +276,24 @@ function render(summary) { if (!rootEl) return; if (!ui) buildSkeleton(); const s = summary || {}; - const t = s.totals || {}; + const tot = s.totals || {}; const days = Array.isArray(s.days) ? s.days : []; + const dash = t("dashboard.placeholder"); const peakLabel = s.peakHour === "—" || s.peakHour == null - ? "—" + ? dash : String(s.peakHour).padStart(2, "0") + ":00"; const values = { - sessions: String(t.sessions || 0), - messages: String(t.messages || 0), - tokens: formatTokens(t.tokens || 0), + sessions: String(tot.sessions || 0), + messages: String(tot.messages || 0), + tokens: formatTokens(tot.tokens || 0), activeDays: String(s.activeDays || 0), - currentStreak: (s.currentStreak || 0) + " дн", - longestStreak: (s.longestStreak || 0) + " дн", + currentStreak: t("dashboard.unit.days", { n: s.currentStreak || 0 }), + longestStreak: t("dashboard.unit.days", { n: s.longestStreak || 0 }), peakHour: peakLabel, - favoriteModel: s.favoriteModel || "—", + favoriteModel: s.favoriteModel || dash, }; Object.keys(values).forEach((key) => { const node = ui.cardNodes[key]; @@ -316,15 +319,16 @@ function render(summary) { const cell = ui.heatCells[i]; const cls = "ckd-cell " + levelFor(d.messages); if (cell.className !== cls) cell.className = cls; - const title = d.date + ": " + (d.messages || 0) + " сообщ."; + const title = t("dashboard.cellTitle", { + date: d.date, + n: d.messages || 0, + }); if (cell.title !== title) cell.title = title; } const foot = - (debugMode ? "F6 — рамка · F7 — обновить · " : "") + - "Метрики — за всё время · теплокарта — " + - RANGE_DAYS + - " дн"; + (debugMode ? t("dashboard.foot.debug") : "") + + t("dashboard.foot.main", { days: RANGE_DAYS }); if (ui.foot.textContent !== foot) ui.foot.textContent = foot; } @@ -385,10 +389,12 @@ function clampToViewport(left, top) { * @param {string} where метка (restore/drag/resize) */ function logPos(where, left, top) { + // Логи только в debug-режиме, чтобы не засорять консоль. + if (!debugMode) return; try { const rect = rootEl.getBoundingClientRect(); const l = Math.round(left != null ? left : rect.left); - const t = Math.round(top != null ? top : rect.top); + const tp = Math.round(top != null ? top : rect.top); const rX = (rect.left / window.innerWidth).toFixed(4); const rY = (rect.top / window.innerHeight).toFixed(4); // Развёрнутый лог для точного позиционирования: пиксели, доли, @@ -400,7 +406,7 @@ function logPos(where, left, top) { " позиция: left=" + l + "px top=" + - t + + tp + "px\n" + " доли окна: ratioX=" + rX + diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index 19455fd..490ed74 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -525,6 +525,41 @@ const KEYS = { ru: "Дашборд виден только на домашней странице и сам скрывается при входе в чат.", en: "The dashboard is visible only on the home page and hides itself when you enter a chat.", }, + // ---- Дашборд статистики (home-страница) ---- + "dashboard.title": { + ru: "Cookie Code · Обзор", + en: "Cookie Code · Overview", + }, + "dashboard.dragHint": { ru: "⠿ тяни (debug)", en: "⠿ drag (debug)" }, + "dashboard.range": { + ru: "{days} дн · всего за всё время", + en: "{days} d · all time", + }, + "dashboard.card.sessions": { ru: "Сессии", en: "Sessions" }, + "dashboard.card.messages": { ru: "Сообщения", en: "Messages" }, + "dashboard.card.tokens": { ru: "Токены", en: "Tokens" }, + "dashboard.card.activeDays": { ru: "Активных дней", en: "Active days" }, + "dashboard.card.currentStreak": { ru: "Текущая серия", en: "Current streak" }, + "dashboard.card.longestStreak": { ru: "Макс. серия", en: "Longest streak" }, + "dashboard.card.peakHour": { ru: "Пик. час", en: "Peak hour" }, + "dashboard.card.favoriteModel": { + ru: "Любимая модель", + en: "Favorite model", + }, + "dashboard.unit.days": { ru: "{n} дн", en: "{n} d" }, + "dashboard.cellTitle": { + ru: "{date}: {n} сообщ.", + en: "{date}: {n} msgs", + }, + "dashboard.foot.debug": { + ru: "F6 — рамка · F7 — обновить · ", + en: "F6 — frame · F7 — refresh · ", + }, + "dashboard.foot.main": { + ru: "Метрики — за всё время · теплокарта — {days} дн", + en: "Metrics — all time · heatmap — {days} d", + }, + "dashboard.placeholder": { ru: "—", en: "—" }, // ---- Настройки: диагностика интеграции ---- "settings.section.diagnostics": { ru: "Диагностика интеграции", diff --git a/test/tools/InjectPageJSTool.test.js b/test/tools/InjectPageJSTool.test.js new file mode 100644 index 0000000..51ae33b --- /dev/null +++ b/test/tools/InjectPageJSTool.test.js @@ -0,0 +1,37 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert"); +const { installElectronMock } = require("../helpers/mock-electron"); + +installElectronMock(); + +const { InjectPageJSTool } = require("../../tools/InjectPageJSTool"); + +test("InjectPageJSTool 构造器", () => { + const t = new InjectPageJSTool(); + assert.strictEqual(t.name, "inject_page_js"); + assert.strictEqual(t.jsApi, "injectPageJS(code, windowId?)"); + assert.ok(t.parameters.required.includes("code")); +}); + +test("InjectPageJSTool getPromptSection", () => { + const t = new InjectPageJSTool(); + const s = t.getPromptSection(); + assert.strictEqual(s.name, "tool:inject_page_js"); + assert.strictEqual(s.order, 114); + assert.match(s.text, /injectPageJS/); +}); + +test("InjectPageJSTool 缺 code 返回错误", async () => { + const t = new InjectPageJSTool(); + const r = await t.execute({}); + assert.strictEqual(r.success, false); + assert.match(r.error, /code/); +}); + +test("InjectPageJSTool 未知 windowId 返回错误", async () => { + const t = new InjectPageJSTool(); + const r = await t.execute({ code: "return 1", windowId: "no-such-window" }); + assert.strictEqual(r.success, false); + assert.match(r.error, /不存在/); +}); diff --git a/tools/InjectPageJSTool.js b/tools/InjectPageJSTool.js new file mode 100644 index 0000000..44313da --- /dev/null +++ b/tools/InjectPageJSTool.js @@ -0,0 +1,86 @@ +const { Tool, ToolResult } = require("./ToolRegistry"); +const windowManager = require("./browser-window-manager"); + +/** + * Инструмент inject_page_js: выполнить JS в main world страницы (то, что видно + * в DevTools/F12). По умолчанию — в главном окне (там, где идёт чат с AI); + * опционально можно указать windowId окна, открытого через open_browser_window. + * + * Контекст — main world страницы: доступны DOM, window.*, localStorage, + * performance, fetch от имени страницы. window.electronAPI (IPC Cookie Code) + * в этом контексте НЕ доступен. + */ +class InjectPageJSTool extends Tool { + constructor() { + super( + "inject_page_js", + "Выполнить JS в main world страницы (как в DevTools/F12). По умолчанию — в главном окне с чатом; вернёт значение, которое вернул код (JSON-совместимое).", + { + type: "object", + properties: { + code: { + type: "string", + description: + "JS-код. Либо блок со return в начале, либо выражение (например IIFE). Поддерживается await. Результат должен быть JSON-совместимым.", + }, + windowId: { + type: "string", + description: + "Опционально: ID окна, открытого через open_browser_window. Если не указан — используется главное окно.", + }, + }, + required: ["code"], + additionalProperties: false, + }, + "injectPageJS(code, windowId?)", + ); + } + + getPromptSection() { + return { + name: "tool:inject_page_js", + order: 114, + text: + "使用 injectPageJS(code, windowId?) 在当前 странице (main world, как в F12) выполнить JS и получить результат. " + + "Без windowId — главное окно с чатом; с windowId — окно из open_browser_window. " + + "Доступны DOM/window/localStorage страницы, но НЕ window.electronAPI. " + + "code: либо блок с return в начале, либо выражение (например IIFE); поддерживается await. " + + "Результат должен быть JSON-совместимым. Удобно для сбора данных со страницы: тексты, ссылки, элементы, localStorage и т.п.", + }; + } + + async execute(params) { + const { code, windowId } = params; + if (!code || typeof code !== "string") { + return ToolResult.error("参数 code обязателен и должен быть строкой"); + } + + let webContents; + if (windowId) { + try { + const win = windowManager.getWindow(windowId); + webContents = win.webContents; + } catch (err) { + return ToolResult.error(err.message); + } + } else { + // Ленивый require, чтобы не тянуть main-модуль на этапе регистрации. + const windowState = require("../src/main/window"); + const mainWin = windowState.getMainWindow(); + if (!mainWin || mainWin.isDestroyed()) { + return ToolResult.error("Главное окно недоступно"); + } + webContents = mainWin.webContents; + } + + const result = await windowManager.executeInWebContents(webContents, code, { + throwOnError: false, + }); + if (result && result.ok === false) { + return ToolResult.error(result.error); + } + return ToolResult.success(result ? result.value : undefined); + } +} + +module.exports = { InjectPageJSTool }; diff --git a/tools/JsRunner.js b/tools/JsRunner.js index e4dba2c..a6f8f35 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -203,6 +203,9 @@ const BOOTSTRAP = [ " globalThis.injectJS = async function (windowId, code) {", " return await __call('inject_js', { windowId: windowId, code: code });", " };", + " globalThis.injectPageJS = async function (code, windowId) {", + " return await __call('inject_page_js', { code: code, windowId: windowId });", + " };", "", " if (!globalThis.projectDir) {", " globalThis.log('[提示] 尚未初始化项目目录,相对路径将基于系统目录解析。可点击覆盖层“初始化项目”。');", diff --git a/tools/browser-window-manager.js b/tools/browser-window-manager.js index 884e09a..ee491c9 100644 --- a/tools/browser-window-manager.js +++ b/tools/browser-window-manager.js @@ -1,4 +1,4 @@ -const { BrowserWindow } = require('electron'); +const { BrowserWindow } = require("electron"); class BrowserWindowManager { constructor() { @@ -10,7 +10,9 @@ class BrowserWindowManager { let id; if (customId) { if (this.windows.has(customId)) { - throw new Error(`窗口 ID "${customId}" 已存在,请换一个 ID 或复用现有窗口`); + throw new Error( + `窗口 ID "${customId}" 已存在,请换一个 ID 或复用现有窗口`, + ); } id = customId; } else { @@ -22,36 +24,63 @@ class BrowserWindowManager { const win = new BrowserWindow({ width: options.width || 1200, height: options.height || 800, - ...options + ...options, }); // 设置与主窗口一致的 Chrome 130 普通 UA,避免暴露 Electron 标识 const userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; win.webContents.setUserAgent(userAgent); if (url) win.loadURL(url); this.windows.set(id, win); - win.on('closed', () => this.windows.delete(id)); + win.on("closed", () => this.windows.delete(id)); return id; } async injectJS(id, jsCode) { const win = this.windows.get(id); if (!win) throw new Error(`窗口 ID "${id}" 不存在`); + const res = await this.executeInWebContents(win.webContents, jsCode, { + throwOnError: true, + }); + return res.value; + } + + /** + * Выполнить JS в main world указанного webContents (то, что видит страница). + * Общая логика обёртки кода: поддержка return-блоков и выражений (IIFE). + */ + async executeInWebContents(webContents, jsCode, options = {}) { + const throwOnError = options.throwOnError !== false; + if (!webContents) throw new Error("webContents не передан"); + + // Выбираем форму обёртки: + // - «блок» — если код содержит return или начинается с ключевого слова + // (const/let/var/if/for/while/await/...) или занимает несколько строк. + // Тогда return в любом месте работает, а ошибки ловит try/catch. + // - «выражение» — одиночная строка-выражение (например IIFE), её await-им. + const trimmed = jsCode.trim(); + const looksLikeBlock = + /(^|[\s;{(])return\b/.test(jsCode) || + /^(const|let|var|if|for|while|do|switch|try|throw|await|function|class)\b/.test( + trimmed, + ) || + trimmed.includes("\n"); - // 先检查语法错误(解析阶段错误无法被 try-catch 捕获) - // 包裹为 async 函数体,避免 await 被误报 + // Предварительная проверка синтаксиса (ошибки парсинга не ловятся try/catch). try { - new Function('return (async () => {\n' + jsCode + '\n})'); + new Function( + looksLikeBlock + ? "return (async () => {\n" + jsCode + "\n})" + : "return (async () => (\n" + jsCode + "\n))", + ); } catch (err) { throw new Error(`JS 语法错误: ${err.message}`); } - const hasReturn = /^\s*return\b/.test(jsCode); let wrapped; - if (hasReturn) { - // 用户代码以 return 开头,保持原来的函数体包装 + if (looksLikeBlock) { wrapped = `(async () => { try { const __result = await (async () => { @@ -63,7 +92,6 @@ ${jsCode} } })()`; } else { - // 用户代码是表达式(如 IIFE),直接 await 表达式捕获返回值 wrapped = `(async () => { try { const __result = await (${jsCode}); @@ -73,11 +101,12 @@ ${jsCode} } })()`; } - const result = await win.webContents.executeJavaScript(wrapped, true); + const result = await webContents.executeJavaScript(wrapped, true); if (result && result.ok === false) { - throw new Error(result.error); + if (throwOnError) throw new Error(result.error); + return { ok: false, error: result.error }; } - return result ? result.value : undefined; + return { ok: true, value: result ? result.value : undefined }; } getWindow(id) { diff --git a/tools/cuckoo-tools.d.ts b/tools/cuckoo-tools.d.ts index 24e87c2..87a64c1 100644 --- a/tools/cuckoo-tools.d.ts +++ b/tools/cuckoo-tools.d.ts @@ -311,6 +311,16 @@ declare function openBrowserWindow(url: string, options?: { id?: string; width?: */ declare function injectJS(windowId: string, code: string): Promise; +/** + * 在当前页面(main world,等同于 DevTools/F12 控制台)执行 JS 并返回结果。 + * 默认作用于主窗口(即运行本对话的窗口);也可通过 windowId 指定 open_browser_window 打开的窗口。 + * 可用 DOM、window.*、localStorage 等页面能力;不提供 window.electronAPI。 + * @param code JS 代码:以 return 开头的语句块,或表达式(如 IIFE,其返回值会被捕获);支持 await + * @param windowId 可选,目标窗口 ID;不传则使用主窗口 + * @returns code 的返回值(需为 JSON 兼容值) + */ +declare function injectPageJS(code: string, windowId?: string): Promise; + // ================= 附件上传 ================= /** diff --git a/tools/index.js b/tools/index.js index 875a3b1..ff3abf0 100644 --- a/tools/index.js +++ b/tools/index.js @@ -24,6 +24,7 @@ const { CityTimeTool } = require("./CityTimeTool"); const { MySQLTool } = require("./MySQLTool"); const { OpenBrowserWindowTool } = require("./OpenBrowserWindowTool"); const { InjectJSTool } = require("./InjectJSTool"); +const { InjectPageJSTool } = require("./InjectPageJSTool"); const { McpCallTool } = require("./McpCallTool"); const { McpListServersTool, McpGetToolsTool } = require("./McpQueryTools"); const { @@ -62,6 +63,7 @@ registry.register(new CityTimeTool()); registry.register(new MySQLTool()); registry.register(new OpenBrowserWindowTool()); registry.register(new InjectJSTool()); +registry.register(new InjectPageJSTool()); registry.register(new McpCallTool()); registry.register(new McpListServersTool()); registry.register(new McpGetToolsTool()); @@ -106,6 +108,7 @@ module.exports = { ReadPhotoTool, ExitPlanModeTool, AttachFileTool, + InjectPageJSTool, // 便捷方法 getAllTools: () => registry, getToolDescriptions: () => registry.getDescriptions(),