Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-совместимый.
- 🎛 **Тумблер «Показывать дашборд»** — статистику можно копить, но сам дашборд на домашней странице скрыть. Настройки → «Система и безопасность» → «Статистика»:
- выключил — дашборд прячется мгновенно и больше не мешает; сбор статистики при этом продолжается;
- включил обратно — дашборд тут же возвращается на своё место.
Expand Down
68 changes: 37 additions & 31 deletions src/preload/dom/stats-dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
];

/**
Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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) +
"|" +
Expand All @@ -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];
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
// Развёрнутый лог для точного позиционирования: пиксели, доли,
Expand All @@ -400,7 +406,7 @@ function logPos(where, left, top) {
" позиция: left=" +
l +
"px top=" +
t +
tp +
"px\n" +
" доли окна: ratioX=" +
rX +
Expand Down
35 changes: 35 additions & 0 deletions src/preload/i18n/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "Диагностика интеграции",
Expand Down
37 changes: 37 additions & 0 deletions test/tools/InjectPageJSTool.test.js
Original file line number Diff line number Diff line change
@@ -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, /不存在/);
});
86 changes: 86 additions & 0 deletions tools/InjectPageJSTool.js
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 3 additions & 0 deletions tools/JsRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('[提示] 尚未初始化项目目录,相对路径将基于系统目录解析。可点击覆盖层“初始化项目”。');",
Expand Down
Loading
Loading