diff --git a/CHANGELOG.md b/CHANGELOG.md index af98b5a..8dede1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [4.0.30] - 2026-09-18 + +### Added + +- 🎛 **Тумблер «Показывать дашборд»** — статистику можно копить, но сам дашборд на домашней странице скрыть. Настройки → «Система и безопасность» → «Статистика»: + - выключил — дашборд прячется мгновенно и больше не мешает; сбор статистики при этом продолжается; + - включил обратно — дашборд тут же возвращается на своё место. + +### Changed + +- ⚡ **Дашборд стал заметно легче** — рендер оптимизирован, чтобы не грузить страницу: + - DOM-скелет строится один раз, дальше обновляются только изменившиеся узлы (теплокарта трогает 1–2 ячейки вместо ~200); + - обновление данных пропускается, если сводка не менялась, и не тянется вовсе, пока дашборд скрыт; + - наблюдение за DOM дебаунсится, убраны дублирующиеся подписки — во время стрима ответа страница больше не дёргает дашборд сотни раз в секунду. + +### Fixed + +- 🐛 **Дашборд пропадал не сразу при входе в сессию** — при переходе в чат он висел ещё до 1.5 секунды. Причина: SPA-навигация идёт через `history.pushState`/`replaceState`, которые не вызывают `popstate`/`hashchange`. Теперь эти вызовы перехватываются, и дашборд скрывается мгновенно. + ## [4.0.28] - 2026-09-17 ### Added diff --git a/src/main/ipc.js b/src/main/ipc.js index fee9632..bd6a912 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -14,6 +14,7 @@ const { isDangerous } = require("./dangerous-commands"); const settingsStore = require("./settings-store"); const chatExport = require("./chat-export"); const contextPort = require("./context-port"); +const statsStore = require("./stats-store"); const { decodeOutput, normalizeCommand } = require("../../tools/decodeOutput"); const gitDiff = require("./git-diff"); const todoStore = require("./todo-store"); @@ -570,6 +571,47 @@ function registerIpcHandlers() { } }); + // ========== Статистика использования ========== + ipcMain.handle("stats-get-summary", async (_event, { days } = {}) => { + try { + return { success: true, summary: statsStore.getSummary(days) }; + } catch (err) { + return { success: false, error: err.message, summary: null }; + } + }); + + ipcMain.handle("stats-record-message", async (_event, ev = {}) => { + try { + return { success: true, stats: statsStore.recordMessage(ev) }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle("stats-record-tokens", async (_event, ev = {}) => { + try { + return { success: true, stats: statsStore.recordTokens(ev) }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle("stats-record-session", async (_event, ev = {}) => { + try { + return { success: true, stats: statsStore.recordSession(ev) }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle("stats-reset", async () => { + try { + return { success: true, stats: statsStore.reset() }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // ========== Перенос контекста (Context Port) ========== // Хранилище живёт в main и переживает reload страницы при смене чата. ipcMain.handle( diff --git a/src/main/settings-store.js b/src/main/settings-store.js index 84207c4..ed93b07 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -89,6 +89,13 @@ const DEFAULTS = { petEnabled: true, // Debug-режим пета: разблокирует горячие клавиши F8/F9/F10/F11. petDebugMode: false, + // ===== Статистика использования (дашборд на home-странице) ===== + // Копить локальную статистику (сессии/сообщения/токены/дни). + statsEnabled: true, + // Debug-режим дашборда: горячие клавиши и отладочная рамка. + statsDebugMode: false, + // Показывать сам дашборд на домашней странице (независимо от сбора статистики). + statsDashboardEnabled: true, // ===== Таймаут выполнения JS-скриптов (секунды) ===== // Общий дедлайн для одного cuckoo-блока (RUN_DEADLINE в tools/JsRunner.js). // Минимум 10 сек, максимум 1000 сек. Синхронный vm-таймаут остаётся 30 сек. diff --git a/src/main/stats-store.js b/src/main/stats-store.js new file mode 100644 index 0000000..f61de34 --- /dev/null +++ b/src/main/stats-store.js @@ -0,0 +1,321 @@ +/** + * Статистика использования Cookie Code (локальное накопление). + * + * Данные хранятся в /cuckoo-stats.json и копятся по мере работы: + * - сессии (по sessionId) + * - сообщения (user / assistant) + * - токены (из serverTokensBySession / локальной оценки) + * - активные дни (для heatmap и streak) + * - модель (по возможности из provider / ответа) + * + * Записи идемпотентны на уровне сессии/дня: повторный учёт одного и того же + * события не раздувает счётчики (ключи — sessionId и дата). + */ +const fs = require("fs"); +const path = require("path"); +const { app } = require("electron"); + +let cachedPath = null; + +function getStatsPath() { + if (!cachedPath) { + let dir; + try { + dir = app.getPath("userData"); + } catch (_) { + dir = process.cwd(); + } + cachedPath = path.join(dir, "cuckoo-stats.json"); + } + return cachedPath; +} + +/** YYYY-MM-DD в локальном времени. */ +function dayKey(ts) { + const d = ts ? new Date(ts) : new Date(); + const p = (n) => String(n).padStart(2, "0"); + return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()); +} + +const EMPTY = { + version: 1, + createdAt: 0, + // sessionId -> { firstSeen, lastSeen, messages, userMessages, aiMessages, tokens, model } + sessions: {}, + // 'YYYY-MM-DD' -> { messages, tokens, sessions } + days: {}, + // агрегат по моделям: model -> count + models: {}, + // общий счётчик сообщений и токенов (для быстрого доступа) + totals: { sessions: 0, messages: 0, tokens: 0 }, +}; + +function readStats() { + try { + const file = getStatsPath(); + if (!fs.existsSync(file)) return JSON.parse(JSON.stringify(EMPTY)); + const parsed = JSON.parse(fs.readFileSync(file, "utf-8")); + return { + ...JSON.parse(JSON.stringify(EMPTY)), + ...parsed, + sessions: parsed.sessions || {}, + days: parsed.days || {}, + models: parsed.models || {}, + totals: { ...EMPTY.totals, ...(parsed.totals || {}) }, + }; + } catch (err) { + console.error("[Cookie Code] 读取 статистики失败:", err.message); + return JSON.parse(JSON.stringify(EMPTY)); + } +} + +function writeStats(stats) { + try { + const file = getStatsPath(); + fs.writeFileSync(file, JSON.stringify(stats, null, 2), "utf-8"); + return stats; + } catch (err) { + console.error("[Cookie Code] 写入 статистики失败:", err.message); + return null; + } +} + +/** + * Записать событие сообщения. + * @param {object} ev + * @param {string} [ev.sessionId] + * @param {'user'|'ai'} ev.role + * @param {number} [ev.tokens] дельта токенов (если известна) + * @param {string} [ev.model] + */ +function recordMessage(ev) { + const stats = readStats(); + const now = Date.now(); + const sid = ev && ev.sessionId ? String(ev.sessionId) : "unknown"; + const day = dayKey(now); + + if (!stats.createdAt) stats.createdAt = now; + + // Сессия + if (!stats.sessions[sid]) { + stats.sessions[sid] = { + firstSeen: now, + lastSeen: now, + messages: 0, + userMessages: 0, + aiMessages: 0, + tokens: 0, + model: "", + }; + stats.totals.sessions = Object.keys(stats.sessions).length; + } + const s = stats.sessions[sid]; + s.lastSeen = now; + s.messages++; + stats.totals.messages++; + if (ev.role === "user") s.userMessages++; + else s.aiMessages++; + + if (ev.model) { + s.model = ev.model; + stats.models[ev.model] = (stats.models[ev.model] || 0) + 1; + } + + // День + if (!stats.days[day]) + stats.days[day] = { messages: 0, tokens: 0, sessions: 0 }; + stats.days[day].messages++; + + // Токены + const tk = Number(ev.tokens) || 0; + if (tk > 0) { + s.tokens += tk; + stats.days[day].tokens += tk; + stats.totals.tokens += tk; + } + + writeStats(stats); + return stats; +} + +/** + * Записать токены отдельно (например, из token-interceptor). + * @param {object} ev + * @param {string} [ev.sessionId] + * @param {number} ev.tokens + */ +function recordTokens(ev) { + const stats = readStats(); + const tk = Number(ev && ev.tokens) || 0; + if (tk <= 0) return stats; + const sid = ev && ev.sessionId ? String(ev.sessionId) : "unknown"; + const day = dayKey(); + if (!stats.sessions[sid]) { + stats.sessions[sid] = { + firstSeen: Date.now(), + lastSeen: Date.now(), + messages: 0, + userMessages: 0, + aiMessages: 0, + tokens: 0, + model: "", + }; + stats.totals.sessions = Object.keys(stats.sessions).length; + } + stats.sessions[sid].tokens += tk; + stats.sessions[sid].lastSeen = Date.now(); + if (!stats.days[day]) + stats.days[day] = { messages: 0, tokens: 0, sessions: 0 }; + stats.days[day].tokens += tk; + stats.totals.tokens += tk; + writeStats(stats); + return stats; +} + +/** + * Пометить сессию (учёт активных сессий в дне). + */ +function recordSession(ev) { + const stats = readStats(); + const now = Date.now(); + const sid = ev && ev.sessionId ? String(ev.sessionId) : "unknown"; + const day = dayKey(now); + if (!stats.sessions[sid]) { + stats.sessions[sid] = { + firstSeen: now, + lastSeen: now, + messages: 0, + userMessages: 0, + aiMessages: 0, + tokens: 0, + model: "", + }; + stats.totals.sessions = Object.keys(stats.sessions).length; + } + if (!stats.days[day]) + stats.days[day] = { messages: 0, tokens: 0, sessions: 0 }; + stats.days[day].sessions++; + writeStats(stats); + return stats; +} + +/** + * Сводка для дашборда. + * @param {number} [days] сколько последних дней отдавать (для heatmap). По умолчанию 365. + */ +function getSummary(days) { + const stats = readStats(); + const span = Number(days) > 0 ? Number(days) : 365; + + // Список дней для heatmap, выровненный по неделям (как GitHub): + // 7 строк = дни недели, столбцы = недели. Начинаем с воскресенья, + // предшествующего самому раннему дню периода, чтобы сетка не «съезжала». + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(today); + start.setDate(today.getDate() - (span - 1)); + // Откатываемся к воскресенью (getDay: 0 = вс). + start.setDate(start.getDate() - start.getDay()); + + const dayList = []; + const cursor = new Date(start); + while (cursor <= today) { + const k = dayKey(cursor.getTime()); + const entry = stats.days[k] || { messages: 0, tokens: 0, sessions: 0 }; + dayList.push({ date: k, ...entry }); + cursor.setDate(cursor.getDate() + 1); + } + + // Активные дни (где были сообщения) — для streak. + const activeDays = Object.keys(stats.days) + .filter((k) => stats.days[k].messages > 0) + .sort(); + + // Текущий streak (дни подряд до сегодня). + let currentStreak = 0; + { + const set = new Set(activeDays); + const d = new Date(); + // если сегодня неактивен — streak считаем от вчера + if (!set.has(dayKey(d.getTime()))) d.setDate(d.getDate() - 1); + while (set.has(dayKey(d.getTime()))) { + currentStreak++; + d.setDate(d.getDate() - 1); + } + } + // Самый длинный streak. + let longestStreak = 0; + { + let run = 0; + let prev = null; + for (const k of activeDays) { + if (prev) { + const pd = new Date(prev + "T00:00:00"); + pd.setDate(pd.getDate() + 1); + const next = dayKey(pd.getTime()); + run = next === k ? run + 1 : 1; + } else { + run = 1; + } + if (run > longestStreak) longestStreak = run; + prev = k; + } + } + + // Любимая модель. + let favoriteModel = "—"; + let favCount = 0; + for (const [m, c] of Object.entries(stats.models || {})) { + if (c > favCount) { + favCount = c; + favoriteModel = m; + } + } + + // Пиковый час (по сообщениям — час недоступен без timestamp'ов сообщений, + // поэтому считаем по lastSeen сессий). + let peakHour = "—"; + { + const hours = new Array(24).fill(0); + for (const s of Object.values(stats.sessions || {})) { + if (s.lastSeen) hours[new Date(s.lastSeen).getHours()]++; + } + let best = -1; + for (let h = 0; h < 24; h++) { + if (hours[h] > best && hours[h] > 0) { + best = hours[h]; + peakHour = h; + } + } + } + + return { + totals: stats.totals, + sessions: Object.keys(stats.sessions || {}).length, + activeDays: activeDays.length, + currentStreak, + longestStreak, + peakHour, + favoriteModel, + days: dayList, + }; +} + +/** Полный сброс статистики. */ +function reset() { + const fresh = JSON.parse(JSON.stringify(EMPTY)); + fresh.createdAt = Date.now(); + writeStats(fresh); + return fresh; +} + +module.exports = { + getStatsPath, + readStats, + writeStats, + recordMessage, + recordTokens, + recordSession, + getSummary, + reset, +}; diff --git a/src/preload/api.js b/src/preload/api.js index bc9cad4..28f4eee 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -65,6 +65,22 @@ let electronAPI = { newChat: () => { return ipcRenderer.invoke("new-chat"); }, + // ========== Статистика использования ========== + statsGetSummary: (days) => { + return ipcRenderer.invoke("stats-get-summary", { days }); + }, + statsRecordMessage: (ev) => { + return ipcRenderer.invoke("stats-record-message", ev); + }, + statsRecordTokens: (ev) => { + return ipcRenderer.invoke("stats-record-tokens", ev); + }, + statsRecordSession: (ev) => { + return ipcRenderer.invoke("stats-record-session", ev); + }, + statsReset: () => { + return ipcRenderer.invoke("stats-reset"); + }, // ========== Перенос контекста (Context Port) ========== contextPortGetInitPrompt: () => { return ipcRenderer.invoke("context-port:get-init-prompt"); diff --git a/src/preload/dom/chat-input.js b/src/preload/dom/chat-input.js index 312beb3..34616ac 100644 --- a/src/preload/dom/chat-input.js +++ b/src/preload/dom/chat-input.js @@ -2,11 +2,12 @@ * 聊天输入框交互:查找输入框、填入与发送消息、工具结果回传 * 由原 preload.js 拆分而来,逻辑保持不变。 */ -const { ipcRenderer } = require('electron'); -const state = require('./state'); -const { BT } = require('./js-detector'); -const { isDenialError } = require('./approval'); -const { getProviderByUrl } = require('../../../src/providers'); +const { ipcRenderer } = require("electron"); +const state = require("./state"); +const { BT } = require("./js-detector"); +const { isDenialError } = require("./approval"); +const { getProviderByUrl } = require("../../../src/providers"); +const statsRecorder = require("./stats-recorder"); /** * 根据当前 URL 获取 provider @@ -19,8 +20,10 @@ function getCurrentProvider() { * 生成随机等待时间(ms),范围由 state 配置(默认 2-4 秒) */ function randomDelay() { - const min = typeof state.sendDelayMin === 'number' ? state.sendDelayMin : 2000; - const max = typeof state.sendDelayMax === 'number' ? state.sendDelayMax : 4000; + const min = + typeof state.sendDelayMin === "number" ? state.sendDelayMin : 2000; + const max = + typeof state.sendDelayMax === "number" ? state.sendDelayMax : 4000; if (min >= max) return min; return Math.floor(Math.random() * (max - min)) + min; } @@ -32,25 +35,30 @@ function randomDelay() { */ function setInputContent(input, msg) { try { - if (input.tagName === 'TEXTAREA' || input.tagName === 'INPUT') { + if (input.tagName === "TEXTAREA" || input.tagName === "INPUT") { input.focus(); const nativeSetter = Object.getOwnPropertyDescriptor( - input.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, - 'value' + input.tagName === "TEXTAREA" + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype, + "value", ).set; nativeSetter.call(input, msg); - input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event("input", { bubbles: true })); return true; } - if (input.isContentEditable || input.getAttribute('contenteditable') === 'true') { + if ( + input.isContentEditable || + input.getAttribute("contenteditable") === "true" + ) { input.focus(); - document.execCommand('selectAll', false, null); - document.execCommand('insertText', false, msg); + document.execCommand("selectAll", false, null); + document.execCommand("insertText", false, msg); return true; } return false; } catch (err) { - console.error('[Cookie Code] 设置输入框内容失败:', err.message); + console.error("[Cookie Code] 设置输入框内容失败:", err.message); return false; } } @@ -62,22 +70,39 @@ function setInputContent(input, msg) { * @param {Function} [afterSent] - 发送后回调 * @returns {boolean} 是否成功 */ +// Служебные сообщения (tool-результаты, JS-сводки, контекст) не считаем +// пользовательскими сообщениями в статистике. +function isServiceMessage(msg) { + if (!msg || typeof msg !== "string") return true; + const head = msg.slice(0, 40); + return ( + head.indexOf("【工具执行结果】") !== -1 || + head.indexOf("【JS 执行结果汇总】") !== -1 || + head.indexOf("【КОНТЕКСТ ИЗ ПРЕДЫДУЩЕГО ЧАТА】") !== -1 || + head.indexOf("【Контекст") !== -1 + ); +} + function sendToChat(msg, tag, fixedDelay, afterSent) { const input = findInputArea(); if (!input) { - console.log('[Cookie Code] 找不到输入框,无法发送消息'); + console.log("[Cookie Code] 找不到输入框,无法发送消息"); return false; } if (!setInputContent(input, msg)) { return false; } const sendDelay = fixedDelay !== undefined ? fixedDelay : randomDelay(); - console.log('[Cookie Code] 消息已填入输入框,等待 ' + sendDelay + 'ms 后发送...'); - setTimeout(function() { - console.log('[Cookie Code] 等待结束,开始触发发送'); + console.log( + "[Cookie Code] 消息已填入输入框,等待 " + sendDelay + "ms 后发送...", + ); + setTimeout(function () { + console.log("[Cookie Code] 等待结束,开始触发发送"); triggerSend(input); - console.log('[Cookie Code] 已触发发送, ' + (tag || '') + ', 长度=' + msg.length); - if (typeof afterSent === 'function') afterSent(); + console.log( + "[Cookie Code] 已触发发送, " + (tag || "") + ", 长度=" + msg.length, + ); + if (typeof afterSent === "function") afterSent(); }, sendDelay); return true; } @@ -98,22 +123,45 @@ function sendToolResultToChat(toolCall, result) { if (result.success) { const data = result.data || {}; // 大内容截断保护(20KB),避免超长消息 - if (typeof data.content === 'string' && data.content.length > 20000) { - data.content = data.content.substring(0, 20000) + String.fromCharCode(10) + '...[内容过长已截断]...'; + if (typeof data.content === "string" && data.content.length > 20000) { + data.content = + data.content.substring(0, 20000) + + String.fromCharCode(10) + + "...[内容过长已截断]..."; } - msg = '【工具执行结果】' + toolCall.toolName + ' 执行成功 (callId: ' + (toolCall.callId || '') + ')' + String.fromCharCode(10) + + msg = + "【工具执行结果】" + + toolCall.toolName + + " 执行成功 (callId: " + + (toolCall.callId || "") + + ")" + + String.fromCharCode(10) + JSON.stringify(data, null, 2); } else if (denied) { - msg = '【工具执行结果】' + toolCall.toolName + ' 被用户拒绝 (callId: ' + (toolCall.callId || '') + ')' + String.fromCharCode(10) + - '执行已被用户否决。请不要重新调用该工具,等待用户的进一步指示。'; + msg = + "【工具执行结果】" + + toolCall.toolName + + " 被用户拒绝 (callId: " + + (toolCall.callId || "") + + ")" + + String.fromCharCode(10) + + "执行已被用户否决。请不要重新调用该工具,等待用户的进一步指示。"; } else { - msg = '【工具执行结果】' + toolCall.toolName + ' 执行失败 (callId: ' + (toolCall.callId || '') + ')' + String.fromCharCode(10) + - '错误原因: ' + (result.error || '未知错误') + String.fromCharCode(10) + - '请根据错误原因修正参数后重新调用工具。'; + msg = + "【工具执行结果】" + + toolCall.toolName + + " 执行失败 (callId: " + + (toolCall.callId || "") + + ")" + + String.fromCharCode(10) + + "错误原因: " + + (result.error || "未知错误") + + String.fromCharCode(10) + + "请根据错误原因修正参数后重新调用工具。"; } - console.log('[Cookie Code] 回传工具结果, 消息长度=' + msg.length); - sendMessageToChat(msg, '工具=' + toolCall.toolName); + console.log("[Cookie Code] 回传工具结果, 消息长度=" + msg.length); + sendMessageToChat(msg, "工具=" + toolCall.toolName); } /** * 将 JS 工具脚本执行结果发送回 DeepSeek 聊天,让 AI 看到结果并继续工作 @@ -124,51 +172,73 @@ function sendCombinedJsResultsToChat(results) { const MAX_OUTPUT = 15000; const sep = String.fromCharCode(10); - let msg = '【JS 执行结果汇总】(共 ' + results.length + ' 个脚本)' + sep + sep; + let msg = "【JS 执行结果汇总】(共 " + results.length + " 个脚本)" + sep + sep; for (let i = 0; i < results.length; i++) { const item = results[i]; - msg += '—— 脚本 ' + (i + 1) + ' ——' + sep; + msg += "—— 脚本 " + (i + 1) + " ——" + sep; if (item && item.result && item.result.success) { - let out = (item.result.output || '').trim(); + let out = (item.result.output || "").trim(); if (out.length > MAX_OUTPUT) { - out = out.slice(0, MAX_OUTPUT) + sep + '...[输出过长已截断]...'; + out = out.slice(0, MAX_OUTPUT) + sep + "...[输出过长已截断]..."; } - msg += '✅ 成功' + sep + (out || '(脚本执行完成,无输出)'); + msg += "✅ 成功" + sep + (out || "(脚本执行完成,无输出)"); } else { - const denied = !!(item && item.result && (item.result.denied || isDenialError(item.result.error))); - msg += '❌ 失败' + sep + '错误原因: ' + ((item && item.result && item.result.error) || '未知错误') + sep; + const denied = !!( + item && + item.result && + (item.result.denied || isDenialError(item.result.error)) + ); + msg += + "❌ 失败" + + sep + + "错误原因: " + + ((item && item.result && item.result.error) || "未知错误") + + sep; if (denied) { // 用户拒绝执行:明确告诉 AI 不要重发同一代码块 - msg += '执行已被用户否决。请不要重新输出该代码块,等待用户的进一步指示。' + sep; + msg += + "执行已被用户否决。请不要重新输出该代码块,等待用户的进一步指示。" + + sep; } else { - msg += '本次实际执行的代码(前300字符):' + sep + String((item && item.code) || '').slice(0, 300) + sep; - msg += '请修正 JavaScript 代码后重新输出完整的 ' + BT + BT + BT + 'cuckoo 代码块。'; + msg += + "本次实际执行的代码(前300字符):" + + sep + + String((item && item.code) || "").slice(0, 300) + + sep; + msg += + "请修正 JavaScript 代码后重新输出完整的 " + + BT + + BT + + BT + + "cuckoo 代码块。"; } } msg += sep + sep; } - console.log('[Cookie Code] 回传 JS 汇总执行结果, 消息长度=' + msg.length); - sendMessageToChat(msg, 'JS汇总'); + console.log("[Cookie Code] 回传 JS 汇总执行结果, 消息长度=" + msg.length); + sendMessageToChat(msg, "JS汇总"); } /** * 查找 DeepSeek 的输入框元素 */ function findInputArea() { const provider = getCurrentProvider(); - if (provider && typeof provider.findInput === 'function') { + if (provider && typeof provider.findInput === "function") { const el = provider.findInput(); if (el) return el; } // 通用兜底:找所有可见 textarea - const allTextareas = document.querySelectorAll('textarea'); + const allTextareas = document.querySelectorAll("textarea"); for (const ta of allTextareas) { if (isInputVisible(ta)) return ta; } // 再找 contenteditable 或 textbox - const editable = document.querySelector('div[contenteditable="true"], [role="textbox"]'); + const editable = document.querySelector( + 'div[contenteditable="true"], [role="textbox"]', + ); if (editable && isInputVisible(editable)) return editable; return null; @@ -179,7 +249,11 @@ function findInputArea() { function isInputVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + return ( + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); } /** * 发送初始提示(目录树+systemPrompt)到输入框 @@ -200,9 +274,11 @@ function sendInitialPromptToInput() { } const sendDelay = randomDelay(); - console.log('[Cookie Code] 初始提示已填入,随机等待 ' + sendDelay + 'ms 后发送...'); - setTimeout(function() { - console.log('[Cookie Code] 等待结束,开始发送初始提示'); + console.log( + "[Cookie Code] 初始提示已填入,随机等待 " + sendDelay + "ms 后发送...", + ); + setTimeout(function () { + console.log("[Cookie Code] 等待结束,开始发送初始提示"); triggerSend(input); state.pendingInitialPrompt = false; }, sendDelay); @@ -237,21 +313,29 @@ function triggerSend(input) { const provider = getCurrentProvider(); // 方法 0: 站点原生发送(智谱等免疫合成事件的平台,经主进程注入真实级输入) - if (provider && typeof provider.triggerSend === 'function') { + if (provider && typeof provider.triggerSend === "function") { let result = null; - try { result = provider.triggerSend(input); } catch (_) { /* 站点实现异常时回退通用逻辑 */ } - if (result && typeof result.then === 'function') { - result.then(function (ok) { - if (ok) { - console.log('[Cookie Code] 已通过站点原生发送触发'); - } else { + try { + result = provider.triggerSend(input); + } catch (_) { + /* 站点实现异常时回退通用逻辑 */ + } + if (result && typeof result.then === "function") { + result + .then(function (ok) { + if (ok) { + console.log("[Cookie Code] 已通过站点原生发送触发"); + } else { + fallbackSend(provider, input); + } + }) + .catch(function () { fallbackSend(provider, input); - } - }).catch(function () { fallbackSend(provider, input); }); + }); return; } if (result) { - console.log('[Cookie Code] 已通过站点原生发送触发'); + console.log("[Cookie Code] 已通过站点原生发送触发"); return; } } @@ -264,22 +348,30 @@ function triggerSend(input) { */ function fallbackSend(provider, input) { // 方法 1: 调用平台 Provider 查找发送按钮 - if (provider && typeof provider.findSendButton === 'function') { + if (provider && typeof provider.findSendButton === "function") { const btn = provider.findSendButton(); if (btn) { btn.click(); - console.log('[Cookie Code] 已点击发送按钮'); + console.log("[Cookie Code] 已点击发送按钮"); return; } } // 方法 2: 在输入框上模拟完整 Enter 按键序列(keydown + keypress + keyup) if (input) { - const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, isComposing: false }; - input.dispatchEvent(new KeyboardEvent('keydown', opts)); - input.dispatchEvent(new KeyboardEvent('keypress', opts)); - input.dispatchEvent(new KeyboardEvent('keyup', opts)); - console.log('[Cookie Code] 已通过 Enter 键触发发送 (未找到发送按钮)'); + const opts = { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true, + isComposing: false, + }; + input.dispatchEvent(new KeyboardEvent("keydown", opts)); + input.dispatchEvent(new KeyboardEvent("keypress", opts)); + input.dispatchEvent(new KeyboardEvent("keyup", opts)); + console.log("[Cookie Code] 已通过 Enter 键触发发送 (未找到发送按钮)"); } } /** @@ -287,27 +379,25 @@ function fallbackSend(provider, input) { * 与原 preload.js 顶层注册时机一致:preload 入口加载时同步调用。 */ function registerIpcListeners() { -// 监听主进程发送的初始提示 -ipcRenderer.on('initial-prompt', (_event, content) => { - state.initialPromptContent = content || ''; - state.pendingInitialPrompt = true; - // 如果当前已有新的空会话输入框,立即发送 - if (state.pendingInitialPrompt && state.initialPromptContent) { - try { - const input = findInputArea(); - if (input) { - sendInitialPromptToInput(); - } else { - // 等待输入框出现 - waitForInitialPromptAndSend(); - } - } catch (e) { + // 监听主进程发送的初始提示 + ipcRenderer.on("initial-prompt", (_event, content) => { + state.initialPromptContent = content || ""; + state.pendingInitialPrompt = true; + // 如果当前已有新的空会话输入框,立即发送 + if (state.pendingInitialPrompt && state.initialPromptContent) { + try { + const input = findInputArea(); + if (input) { + sendInitialPromptToInput(); + } else { + // 等待输入框出现 + waitForInitialPromptAndSend(); + } + } catch (e) {} } - } -}); + }); } - module.exports = { randomDelay, setInputContent, diff --git a/src/preload/dom/observer.js b/src/preload/dom/observer.js index 18c283f..6043771 100644 --- a/src/preload/dom/observer.js +++ b/src/preload/dom/observer.js @@ -23,6 +23,7 @@ const { const toolRender = require("./tool-render"); const toolResultInline = require("./tool-result-inline"); const responseMeta = require("./response-meta"); +const statsRecorder = require("./stats-recorder"); const { sendToolResultToChat, @@ -413,6 +414,9 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { return; // 已处理过,跳过 } + // Учёт статистики: одно AI-сообщение на завершённый ответ. + safe("observer.statsRecordAi", () => statsRecorder.recordMessage("ai")); + // 跳过用户消息(其中包含系统提示词里的示例代码块,不应被执行) const providerForUser = getCurrentProvider(); if ( diff --git a/src/preload/dom/settings-tab.js b/src/preload/dom/settings-tab.js index bfb4ff3..fe97f2f 100644 --- a/src/preload/dom/settings-tab.js +++ b/src/preload/dom/settings-tab.js @@ -103,6 +103,8 @@ function activateCuckooTab() { // Шрифт bindFontsSection(); refreshFontWeight(); + // Статистика + bindStatsSection(); // Подвкладки (категории) bindSettingsTabs(); } @@ -665,6 +667,55 @@ function buildContentHTML() { "" + " " + "" + + // ===== Статистика использования ===== + '
' + + '
' + + t("settings.section.stats") + + "
" + + '
' + + ' " + + ' " + + ' " + + "
" + + '
' + + ' " + + "
" + + '
' + + t("settings.stats.hint") + + "
" + + "
" + '
' + '
' + t("settings.section.service") + @@ -1546,6 +1597,92 @@ async function refreshFontSelection() { }); } +/** + * Секция «Статистика»: тумблеры сбора/debug и кнопка сброса. + */ +function bindStatsSection() { + const enabledChk = document.getElementById("cuckoo-stats-enabled"); + const debugChk = document.getElementById("cuckoo-stats-debug"); + const showChk = document.getElementById("cuckoo-stats-show"); + const resetBtn = document.getElementById("cuckoo-stats-reset"); + + if (enabledChk) { + enabledChk.addEventListener("change", async () => { + const on = enabledChk.checked; + state.statsEnabled = on; + try { + await window.electronAPI.setCuckooSetting("statsEnabled", on); + } catch (err) { + console.error("[Cookie Code] statsEnabled save error:", err.message); + } + }); + } + + if (debugChk) { + debugChk.addEventListener("change", async () => { + const on = debugChk.checked; + state.statsDebugMode = on; + try { + await window.electronAPI.setCuckooSetting("statsDebugMode", on); + const dash = require("./stats-dashboard"); + if (dash && typeof dash.setDebugMode === "function") + dash.setDebugMode(on); + } catch (err) { + console.error("[Cookie Code] statsDebugMode save error:", err.message); + } + }); + } + + if (showChk) { + showChk.addEventListener("change", async () => { + const on = showChk.checked; + state.statsDashboardEnabled = on; + try { + await window.electronAPI.setCuckooSetting("statsDashboardEnabled", on); + const dash = require("./stats-dashboard"); + if (dash && typeof dash.setEnabled === "function") dash.setEnabled(on); + } catch (err) { + console.error( + "[Cookie Code] statsDashboardEnabled save error:", + err.message, + ); + } + }); + } + + if (resetBtn) { + resetBtn.addEventListener("click", async () => { + const ui = require("../overlay/ui"); + const ok = + typeof ui.showConfirmDialog === "function" + ? await ui.showConfirmDialog(t("settings.stats.resetConfirm"), { + okText: t("settings.stats.reset"), + showCancel: true, + cancelText: t("contextPort.cancel"), + }) + : true; + if (!ok) return; + try { + await window.electronAPI.statsReset(); + if (typeof ui.showToast === "function") + ui.showToast(t("settings.stats.resetDone"), 2500); + } catch (err) { + console.error("[Cookie Code] stats reset error:", err.message); + } + }); + } + + // Начальные состояния. + window.electronAPI + .getCuckooSettings() + .then((s) => { + if (enabledChk) enabledChk.checked = !s || s.statsEnabled !== false; + if (debugChk) debugChk.checked = !!(s && s.statsDebugMode === true); + if (showChk) showChk.checked = !s || s.statsDashboardEnabled !== false; + }) + .catch(() => {}); +} + /** * Инициализация секции «Шрифт». */ diff --git a/src/preload/dom/state.js b/src/preload/dom/state.js index ab7066e..a2afee7 100644 --- a/src/preload/dom/state.js +++ b/src/preload/dom/state.js @@ -3,7 +3,7 @@ * 由原 preload.js 中的模块级变量拆分而来,各模块通过同一对象共享。 */ module.exports = { - initialPromptContent: '', + initialPromptContent: "", // 是否有待发送的初始提示 pendingInitialPrompt: false, // 待执行的工具调用 @@ -18,7 +18,7 @@ module.exports = { customizationEnabled: true, // Режим подтверждения tool-вызовов: 'off' | 'risky' | 'all'. // Загружается из настроек при init() и обновляется на лету из settings-tab. - toolApprovalMode: 'off', + toolApprovalMode: "off", // Скрывать ли служебные сообщения (результаты инструментов, системный промпт) // в чате. Загружается из настроек при init() и переключается на лету. hideSystemMessages: false, @@ -26,6 +26,11 @@ module.exports = { showProducedFiles: true, // Показывать блок «Токены диалога» в оверлее (по умолчанию выключено). showConvTokens: false, + // Дашборд статистики: собирать ли статистику и включён ли debug-режим. + statsEnabled: true, + statsDebugMode: false, + // Показывать сам дашборд на домашней странице. + statsDashboardEnabled: true, // Режим плана: пока включён — изменяющие инструменты заблокированы, // модель пишет plan.md и завершает режим через exit_plan_mode(). planMode: false, diff --git a/src/preload/dom/stats-dashboard.js b/src/preload/dom/stats-dashboard.js new file mode 100644 index 0000000..ea0e72e --- /dev/null +++ b/src/preload/dom/stats-dashboard.js @@ -0,0 +1,684 @@ +/** + * Дашборд статистики Cookie Code на домашней странице DeepSeek. + * + * Показывается ТОЛЬКО на home (URL совпадает с provider.homeUrlPattern): + * - окно нельзя перетащить и нельзя закрыть; + * - само скрывается, как только начинается сессия (URL перестаёт быть home). + * + * Данные: main-процесс (stats-get-summary), локальное накопление в cuckoo-stats.json. + * + * Горячие клавиши (только в debug-режиме, тумблер в настройках): + * F6 — показать/скрыть отладочную рамку дашборда + * F7 — принудительно перечитать статистику + * + * Оптимизации рендера: + * - видимость и данные разделены (syncVisibility не тянет сводку); + * - в main не ходим, когда дашборд скрыт; + * - MutationObserver дебаунсится, а не дёргает syncVisibility на каждую мутацию; + * - скелет DOM строится один раз, дальше обновляются только изменившиеся узлы; + * - при отсутствии изменений в сводке render пропускается (сравнение сигнатуры); + * - guard от повторного входа в refresh. + */ +const { getProviderByUrl } = require("../../../src/providers"); +const state = require("./state"); + +const ROOT_ID = "cuckoo-stats-dashboard"; +const STYLE_ID = "cuckoo-stats-style"; +const POS_KEY = "cuckoo-stats-dash-pos"; + +// Период heatmap (дней). Метрики при этом считаются за всё время. +const RANGE_DAYS = 180; + +// Минимальный интервал между обновлениями данных при показе (мс). +const MIN_REFRESH_MS = 5000; + +let rootEl = null; +let started = false; +let debugFrame = false; +let debugMode = false; +let refreshTimer = null; +let observerTimer = null; +// Показывать сам дашборд (настройка). Сбор статистики независим. +let enabled = true; + +// Кэш последнего отрисованного состояния. +let lastSignature = ""; +let lastRefreshAt = 0; +let refreshing = false; +// Узлы скелета (строятся один раз). +let ui = null; + +/** + * Открыта ли модалка настроек DeepSeek (тогда дашборд прячем). + */ +function isSettingsOpen() { + try { + return !!document.querySelector( + ".ds-modal-content__main, .ds-modal-focus-lock", + ); + } catch (_) { + return false; + } +} + +/** + * Является ли текущая страница домашней (по провайдеру). + */ +function isHomePage() { + try { + const provider = getProviderByUrl(window.location.href); + if (provider && provider.homeUrlPattern) { + return provider.homeUrlPattern.test(window.location.href); + } + // Фолбэк: DeepSeek home. + return /^https:\/\/chat\.deepseek\.com\/?(\?.*)?$/.test( + window.location.href, + ); + } catch (_) { + return false; + } +} + +function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Внедрить CSS дашборда (однократно). + */ +function injectStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${ROOT_ID} { + position: fixed; + top: 92px; + left: 50%; + transform: translateX(-50%); + z-index: 2147483000; + width: min(560px, 86vw); + background: rgba(20, 22, 38, 0.92); + border: 1px solid rgba(139, 147, 255, 0.28); + border-radius: 18px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + color: #e8eaff; + font-size: 12px; + padding: 14px 16px; + pointer-events: none; + transition: box-shadow 0.18s; + } + #${ROOT_ID}.cuckoo-hidden { display: none; } + /* В debug-режиме окно можно тащить за заголовок. */ + #${ROOT_ID}.ckd-draggable { pointer-events: auto; } + #${ROOT_ID}.ckd-draggable .ckd-head { cursor: grab; } + #${ROOT_ID}.ckd-draggable .ckd-head:active { cursor: grabbing; } + #${ROOT_ID}.ckd-dragging { box-shadow: 0 28px 90px rgba(109,118,255,0.55); } + #${ROOT_ID} .ckd-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 14px; + user-select: none; + } + #${ROOT_ID} .ckd-drag-hint { + font-size: 10.5px; color: #6d76ff; margin-left: 8px; + opacity: 0; transition: opacity 0.15s; + } + #${ROOT_ID}.ckd-draggable .ckd-drag-hint { opacity: 1; } + #${ROOT_ID} .ckd-title { font-size: 13px; font-weight: 700; letter-spacing: .3px; } + #${ROOT_ID} .ckd-range { font-size: 10px; color: #8a90b8; } + #${ROOT_ID} .ckd-grid { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; + } + #${ROOT_ID} .ckd-card { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; padding: 7px 9px; + } + #${ROOT_ID} .ckd-card .k { font-size: 10px; color: #8a90b8; margin-bottom: 3px; } + #${ROOT_ID} .ckd-card .v { font-size: 17px; font-weight: 700; color: #dfe3ff; } + /* Плотная heatmap в стиле GitHub: 7 строк (дни недели), столбцы — недели. */ + #${ROOT_ID} .ckd-heat { + display: grid; grid-auto-flow: column; + grid-template-rows: repeat(7, 9px); + gap: 2px; margin-top: 12px; overflow-x: auto; padding-bottom: 2px; + } + #${ROOT_ID} .ckd-cell { width: 9px; height: 9px; border-radius: 2px; background: rgba(255,255,255,0.05); } + #${ROOT_ID} .ckd-cell.l1 { background: rgba(139,147,255,0.28); } + #${ROOT_ID} .ckd-cell.l2 { background: rgba(139,147,255,0.5); } + #${ROOT_ID} .ckd-cell.l3 { background: rgba(139,147,255,0.72); } + #${ROOT_ID} .ckd-cell.l4 { background: rgba(139,147,255,1); } + #${ROOT_ID} .ckd-foot { margin-top: 10px; font-size: 10.5px; color: #8a90b8; } + #${ROOT_ID}.ckd-debug { outline: 2px dashed rgba(255,107,122,0.8); outline-offset: 4px; } + .ckd-debug-hint { + position: fixed; left: 50%; transform: translateX(-50%); + bottom: 18px; z-index: 2147483001; + background: rgba(255,107,122,0.16); border: 1px solid rgba(255,107,122,0.5); + color: #ffd0d6; font-size: 12px; padding: 6px 12px; border-radius: 999px; + } + `; + (document.head || document.documentElement).appendChild(style); +} + +function levelFor(messages) { + if (!messages) return ""; + if (messages < 3) return "l1"; + if (messages < 10) return "l2"; + if (messages < 30) return "l3"; + return "l4"; +} + +const CARD_LABELS = [ + ["sessions", "Сессии"], + ["messages", "Сообщения"], + ["tokens", "Токены"], + ["activeDays", "Активных дней"], + ["currentStreak", "Текущая серия"], + ["longestStreak", "Макс. серия"], + ["peakHour", "Пик. час"], + ["favoriteModel", "Любимая модель"], +]; + +/** + * Построить скелет дашборда (один раз). Дальше обновляем только значения. + */ +function buildSkeleton() { + if (!rootEl) return; + rootEl.innerHTML = ""; + + const head = document.createElement("div"); + head.className = "ckd-head"; + const title = document.createElement("div"); + title.className = "ckd-title"; + title.textContent = "Cookie Code · Обзор"; + const hint = document.createElement("span"); + hint.className = "ckd-drag-hint"; + hint.textContent = "⠿ тяни (debug)"; + title.appendChild(hint); + const range = document.createElement("div"); + range.className = "ckd-range"; + range.textContent = RANGE_DAYS + " дн · всего за всё время"; + head.appendChild(title); + head.appendChild(range); + + const grid = document.createElement("div"); + grid.className = "ckd-grid"; + const cardNodes = {}; + CARD_LABELS.forEach(([key, label]) => { + const c = document.createElement("div"); + c.className = "ckd-card"; + const k = document.createElement("div"); + k.className = "k"; + k.textContent = label; + const v = document.createElement("div"); + v.className = "v"; + c.appendChild(k); + c.appendChild(v); + grid.appendChild(c); + cardNodes[key] = v; + }); + + const heat = document.createElement("div"); + heat.className = "ckd-heat"; + + const foot = document.createElement("div"); + foot.className = "ckd-foot"; + + rootEl.appendChild(head); + rootEl.appendChild(grid); + rootEl.appendChild(heat); + rootEl.appendChild(foot); + + ui = { cardNodes, heat, foot, heatCells: [] }; +} + +/** + * Компактная сигнатура сводки — чтобы пропускать render без изменений. + */ +function signatureOf(s) { + const t = 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 + + "|" + + t.messages + + "|" + + t.tokens + + "|" + + (s.activeDays || 0) + + "|" + + (s.currentStreak || 0) + + "|" + + (s.longestStreak || 0) + + "|" + + (s.peakHour == null ? "" : s.peakHour) + + "|" + + (s.favoriteModel || "") + + "|" + + (debugMode ? 1 : 0) + + "|" + + acc + ); +} + +/** + * Обновить скелет данными сводки. Трогает только изменившиеся узлы. + */ +function render(summary) { + if (!rootEl) return; + if (!ui) buildSkeleton(); + const s = summary || {}; + const t = s.totals || {}; + const days = Array.isArray(s.days) ? s.days : []; + + const peakLabel = + s.peakHour === "—" || s.peakHour == null + ? "—" + : String(s.peakHour).padStart(2, "0") + ":00"; + + const values = { + sessions: String(t.sessions || 0), + messages: String(t.messages || 0), + tokens: formatTokens(t.tokens || 0), + activeDays: String(s.activeDays || 0), + currentStreak: (s.currentStreak || 0) + " дн", + longestStreak: (s.longestStreak || 0) + " дн", + peakHour: peakLabel, + favoriteModel: s.favoriteModel || "—", + }; + Object.keys(values).forEach((key) => { + const node = ui.cardNodes[key]; + if (node && node.textContent !== values[key]) + node.textContent = values[key]; + }); + + // Heatmap: пересобираем ячейки только если число дней изменилось. + if (ui.heatCells.length !== days.length) { + ui.heat.innerHTML = ""; + ui.heatCells = []; + const frag = document.createDocumentFragment(); + for (let i = 0; i < days.length; i++) { + const cell = document.createElement("div"); + cell.className = "ckd-cell"; + frag.appendChild(cell); + ui.heatCells.push(cell); + } + ui.heat.appendChild(frag); + } + for (let i = 0; i < days.length; i++) { + const d = days[i]; + 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) + " сообщ."; + if (cell.title !== title) cell.title = title; + } + + const foot = + (debugMode ? "F6 — рамка · F7 — обновить · " : "") + + "Метрики — за всё время · теплокарта — " + + RANGE_DAYS + + " дн"; + if (ui.foot.textContent !== foot) ui.foot.textContent = foot; +} + +function formatTokens(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; + return String(n); +} + +/** + * Восстановить сохранённую позицию дашборда из localStorage. + */ +function restorePosition() { + if (!rootEl) return; + try { + const raw = localStorage.getItem(POS_KEY); + if (!raw) return; + const pos = JSON.parse(raw); + // Позиция хранится в ДОЛЯХ окна (ratioX/ratioY 0..1) — так она + // адаптируется под любое разрешение. Фолбэк — старый формат (left/top). + let left, top; + if (typeof pos.ratioX === "number" && typeof pos.ratioY === "number") { + left = pos.ratioX * window.innerWidth; + top = pos.ratioY * window.innerHeight; + } else if (typeof pos.left === "number" && typeof pos.top === "number") { + left = pos.left; + top = pos.top; + } else { + return; + } + const clamped = clampToViewport(left, top); + rootEl.style.left = clamped.left + "px"; + rootEl.style.top = clamped.top + "px"; + rootEl.style.transform = "none"; + logPos("restore", clamped.left, clamped.top); + } catch (_) {} +} + +/** + * Ограничить позицию окна так, чтобы оно не выходило за пределы экрана. + */ +function clampToViewport(left, top) { + const rect = rootEl + ? rootEl.getBoundingClientRect() + : { width: 0, height: 0 }; + const w = rect.width || 300; + const h = rect.height || 120; + const maxLeft = Math.max(0, window.innerWidth - w); + const maxTop = Math.max(0, window.innerHeight - h); + return { + left: Math.max(0, Math.min(maxLeft, left)), + top: Math.max(0, Math.min(maxTop, top)), + }; +} + +/** + * Логировать координаты дашборда в консоль (для отладки позиции). + * @param {string} where метка (restore/drag/resize) + */ +function logPos(where, left, top) { + try { + const rect = rootEl.getBoundingClientRect(); + const l = Math.round(left != null ? left : rect.left); + const t = Math.round(top != null ? top : rect.top); + const rX = (rect.left / window.innerWidth).toFixed(4); + const rY = (rect.top / window.innerHeight).toFixed(4); + // Развёрнутый лог для точного позиционирования: пиксели, доли, + // размеры окна и самого дашборда, отступы от краёв. + console.log( + "[Cookie Code][dashboard] " + + where + + "\n" + + " позиция: left=" + + l + + "px top=" + + t + + "px\n" + + " доли окна: ratioX=" + + rX + + " ratioY=" + + rY + + "\n" + + " окно: " + + window.innerWidth + + "x" + + window.innerHeight + + "px\n" + + " дашборд: " + + Math.round(rect.width) + + "x" + + Math.round(rect.height) + + "px\n" + + " отступы: right=" + + Math.round(window.innerWidth - rect.right) + + "px bottom=" + + Math.round(window.innerHeight - rect.bottom) + + "px\n" + + " (скопируй ratioX/ratioY при необходимости)", + ); + } catch (_) {} +} + +/** + * Сделать дашборд перетаскиваемым за заголовок (только когда активен + * класс ckd-draggable, т.е. в debug-режиме). + */ +function makeDraggable() { + if (!rootEl || rootEl.__dragBound) return; + rootEl.__dragBound = true; + let dragging = false; + let startX = 0, + startY = 0, + startLeft = 0, + startTop = 0; + + rootEl.addEventListener("mousedown", (e) => { + if (!rootEl.classList.contains("ckd-draggable")) return; + const head = e.target.closest && e.target.closest(".ckd-head"); + if (!head) return; + dragging = true; + const rect = rootEl.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + startX = e.clientX; + startY = e.clientY; + rootEl.style.transform = "none"; + rootEl.classList.add("ckd-dragging"); + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + e.preventDefault(); + }); + + const onMove = (e) => { + if (!dragging) return; + let left = startLeft + (e.clientX - startX); + let top = startTop + (e.clientY - startY); + const c = clampToViewport(left, top); + rootEl.style.left = c.left + "px"; + rootEl.style.top = c.top + "px"; + }; + + const onUp = () => { + if (!dragging) return; + dragging = false; + rootEl.classList.remove("ckd-dragging"); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + try { + const rect = rootEl.getBoundingClientRect(); + // Сохраняем в ДОЛЯХ окна — адаптивно к любому разрешению. + const ratioX = rect.left / window.innerWidth; + const ratioY = rect.top / window.innerHeight; + localStorage.setItem(POS_KEY, JSON.stringify({ ratioX, ratioY })); + logPos("drag-saved", rect.left, rect.top); + } catch (_) {} + }; +} + +/** + * Создать контейнер дашборда (если ещё нет). + */ +function ensureRoot() { + if (rootEl && document.body.contains(rootEl)) return rootEl; + rootEl = document.createElement("div"); + rootEl.id = ROOT_ID; + rootEl.className = "cuckoo-hidden"; + (document.body || document.documentElement).appendChild(rootEl); + buildSkeleton(); + restorePosition(); + makeDraggable(); + return rootEl; +} + +/** + * Показать/скрыть в зависимости от текущей страницы. + * Данные тянет только при показе и не чаще MIN_REFRESH_MS (если не force). + */ +async function syncVisibility(force) { + if (!rootEl) return; + // Выключен в настройках — прячем насовсем. + if (!enabled) { + rootEl.classList.add("cuckoo-hidden"); + return; + } + const home = isHomePage(); + // Настройки открыты — прячем дашборд, чтобы не мешал. + if (!home || isSettingsOpen()) { + rootEl.classList.add("cuckoo-hidden"); + return; + } + // На home — показываем; данные обновляем не чаще MIN_REFRESH_MS. + const now = Date.now(); + if (force || now - lastRefreshAt > MIN_REFRESH_MS) { + await refresh(); + } + rootEl.classList.remove("cuckoo-hidden"); + logPos("shown"); +} + +/** + * Перечитать статистику из main и отрендерить. + * Пропускает запрос при скрытом дашборде и повторный вход. + */ +async function refresh(force) { + if (refreshing) return; + if (!force && !enabled) return; + if (!force && rootEl && rootEl.classList.contains("cuckoo-hidden")) return; + refreshing = true; + try { + if ( + !window.electronAPI || + typeof window.electronAPI.statsGetSummary !== "function" + ) + return; + const res = await window.electronAPI.statsGetSummary(RANGE_DAYS); + lastRefreshAt = Date.now(); + if (res && res.success && res.summary) { + const sig = signatureOf(res.summary); + if (sig === lastSignature) return; + lastSignature = sig; + render(res.summary); + } + } catch (err) { + console.warn("[Cookie Code] dashboard refresh error:", err.message); + } finally { + refreshing = false; + } +} + +/** + * Включить/выключить показ дашборда (тумблер в настройках). + */ +function setEnabled(on) { + enabled = !!on; + syncVisibility(true); + return enabled; +} + +/** + * Включить/выключить debug-режим (тумблер в настройках / setDebugMode). + */ +function setDebugMode(on) { + debugMode = !!on; + if (rootEl) { + // В debug-режиме окно можно перетаскивать за заголовок. + rootEl.classList.toggle("ckd-draggable", debugMode); + if (!debugMode) { + rootEl.classList.remove("ckd-debug", "ckd-dragging"); + debugFrame = false; + } + } + // Сбрасываем сигнатуру, чтобы подпись debug-подсказки перерисовалась. + lastSignature = ""; + if (rootEl) refresh(); + return debugMode; +} + +function toggleDebugFrame() { + debugFrame = !debugFrame; + if (rootEl) rootEl.classList.toggle("ckd-debug", debugFrame); +} + +/** + * Запуск: создаём дашборд и следим за URL (SPA-навигация). + */ +function start() { + if (started) return; + started = true; + // Применяем настройку показа до первого syncVisibility. + enabled = state.statsDashboardEnabled !== false; + injectStyle(); + ensureRoot(); + // Применяем debug-режим (draggable) из настроек. + setDebugMode(state.statsDebugMode === true); + syncVisibility(true); + + // Регулярная проверка (URL меняется в SPA без перезагрузки). + refreshTimer = setInterval(() => syncVisibility(), 1500); + + // Мгновенная реакция на открытие/закрытие настроек. + // Дебаунс: во время стриминга ответа мутаций сотни в секунду, + // а нам нужна лишь реакция на появление/исчезновение модалки. + try { + const mo = new MutationObserver(() => { + if (observerTimer) return; + observerTimer = setTimeout(() => { + observerTimer = null; + syncVisibility(); + }, 250); + }); + mo.observe(document.body || document.documentElement, { + childList: true, + subtree: true, + }); + } catch (_) {} + + window.addEventListener("popstate", () => syncVisibility(true)); + window.addEventListener("hashchange", () => syncVisibility(true)); + + // SPA-навигация через history.pushState/replaceState не вызывает + // popstate/hashchange — оборачиваем, чтобы дашборд скрывался сразу + // при входе в сессию (а не через 1.5 с по таймеру). + try { + const wrap = (name) => { + const orig = history[name]; + if (typeof orig !== "function" || orig.__ckWrapped) return; + const wrapped = function () { + const ret = orig.apply(this, arguments); + try { + syncVisibility(); + } catch (_) {} + return ret; + }; + wrapped.__ckWrapped = true; + history[name] = wrapped; + }; + wrap("pushState"); + wrap("replaceState"); + } catch (_) {} + + // При изменении размера окна — пересчитываем позицию из сохранённых долей, + // чтобы окно оставалось в том же относительном месте на любом разрешении. + window.addEventListener("resize", () => { + if (!rootEl) return; + if (localStorage.getItem(POS_KEY)) { + restorePosition(); + } + logPos("resize"); + }); + + // Горячие клавиши — только в debug-режиме. + window.addEventListener( + "keydown", + (e) => { + if (!debugMode) return; + if (e.key === "F6") { + e.preventDefault(); + toggleDebugFrame(); + } else if (e.key === "F7") { + e.preventDefault(); + refresh(true); + } + }, + true, + ); + + // Загружаем debug-режим из настроек. + try { + if (window.electronAPI && window.electronAPI.getCuckooSettings) { + window.electronAPI.getCuckooSettings().then((s) => { + debugMode = !!(s && s.statsDebugMode); + }); + } + } catch (_) {} +} + +module.exports = { start, refresh, setDebugMode, setEnabled }; diff --git a/src/preload/dom/stats-recorder.js b/src/preload/dom/stats-recorder.js new file mode 100644 index 0000000..eaead7a --- /dev/null +++ b/src/preload/dom/stats-recorder.js @@ -0,0 +1,94 @@ +/** + * Запись статистики использования в main-процесс. + * + * Точки вызова: + * - токены: подписка на событие 'cuckoo:token-update' (token-interceptor); + * - сообщения: observer при завершении ответа AI (ai) и при отправке + * пользователем (user — через chat-input). + * + * sessionId берётся из URL через провайдер. + */ +const { getProviderByUrl } = require("../../../src/providers"); +const state = require("./state"); + +let started = false; + +function currentSessionId() { + try { + const provider = getProviderByUrl(window.location.href); + if (provider && typeof provider.extractSessionId === "function") { + return provider.extractSessionId(window.location.href) || "unknown"; + } + } catch (_) {} + return "unknown"; +} + +function enabled() { + return state.statsEnabled !== false; +} + +/** Записать сообщение (user / ai). */ +function recordMessage(role, extra) { + if (!enabled()) return; + try { + if ( + !window.electronAPI || + typeof window.electronAPI.statsRecordMessage !== "function" + ) + return; + window.electronAPI.statsRecordMessage({ + sessionId: currentSessionId(), + role, + model: (extra && extra.model) || "", + tokens: (extra && extra.tokens) || 0, + }); + } catch (err) { + console.warn("[Cookie Code] stats recordMessage error:", err.message); + } +} + +/** Записать дельту токенов. */ +function recordTokens(tokens) { + if (!enabled()) return; + const tk = Number(tokens) || 0; + if (tk <= 0) return; + try { + if ( + !window.electronAPI || + typeof window.electronAPI.statsRecordTokens !== "function" + ) + return; + window.electronAPI.statsRecordTokens({ + sessionId: currentSessionId(), + tokens: tk, + }); + } catch (err) { + console.warn("[Cookie Code] stats recordTokens error:", err.message); + } +} + +/** Пометить сессию (учёт активных сессий). */ +function recordSession() { + if (!enabled()) return; + try { + if ( + !window.electronAPI || + typeof window.electronAPI.statsRecordSession !== "function" + ) + return; + window.electronAPI.statsRecordSession({ sessionId: currentSessionId() }); + } catch (_) {} +} + +/** Запуск: подписка на токены + отметка сессии. */ +function start() { + if (started) return; + started = true; + window.addEventListener("cuckoo:token-update", (e) => { + const d = (e && e.detail) || {}; + recordTokens(d.delta); + }); + recordSession(); +} + +module.exports = { start, recordMessage, recordTokens, recordSession }; diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index a9ae443..19455fd 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -483,6 +483,48 @@ const KEYS = { en: "Maintenance & Reset", }, "settings.section.service": { ru: "Сервис", en: "Service" }, + // ---- Статистика ---- + "settings.section.stats": { ru: "Статистика", en: "Statistics" }, + "settings.stats.enabled": { + ru: "Собирать статистику", + en: "Collect statistics", + }, + "settings.stats.enabledHint": { + ru: "Локальный учёт сессий, сообщений, токенов и активных дней (дашборд на домашней странице).", + en: "Local tracking of sessions, messages, tokens and active days (dashboard on the home page).", + }, + "settings.stats.debugTitle": { + ru: "Debug-режим статистики", + en: "Statistics debug mode", + }, + "settings.stats.debugHint": { + ru: "Горячие клавиши: F6 — рамка дашборда, F7 — обновить данные.", + en: "Hotkeys: F6 — dashboard frame, F7 — refresh data.", + }, + "settings.stats.showTitle": { + ru: "Показывать дашборд", + en: "Show dashboard", + }, + "settings.stats.showHint": { + ru: "Отображение дашборда на домашней странице. Сбор статистики продолжается независимо от этого.", + en: "Whether the dashboard is shown on the home page. Statistics collection continues regardless.", + }, + "settings.stats.reset": { + ru: "Сбросить статистику", + en: "Reset statistics", + }, + "settings.stats.resetConfirm": { + ru: "Сбросить всю накопленную статистику? Это необратимо.", + en: "Reset all collected statistics? This cannot be undone.", + }, + "settings.stats.resetDone": { + ru: "Статистика сброшена", + en: "Statistics reset", + }, + "settings.stats.hint": { + ru: "Дашборд виден только на домашней странице и сам скрывается при входе в чат.", + en: "The dashboard is visible only on the home page and hides itself when you enter a chat.", + }, // ---- Настройки: диагностика интеграции ---- "settings.section.diagnostics": { ru: "Диагностика интеграции", diff --git a/src/preload/index.js b/src/preload/index.js index 368b3db..ff816e2 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -31,6 +31,8 @@ const qrOverride = require("./dom/qr-override"); const fileChip = require("./dom/file-chip"); const contextPort = require("./dom/context-port"); const fonts = require("./dom/fonts"); +const statsDashboard = require("./dom/stats-dashboard"); +const statsRecorder = require("./dom/stats-recorder"); const whatsNew = require("./dom/whats-new"); const i18n = require("./i18n/i18n"); const state = require("./dom/state"); @@ -69,6 +71,13 @@ async function init() { state.showConvTokens = Boolean( settings && settings.showConvTokens === true, ); + // Дашборд статистики: включён ли сбор и debug-режим. + state.statsEnabled = !settings || settings.statsEnabled !== false; + state.statsDebugMode = Boolean( + settings && settings.statsDebugMode === true, + ); + state.statsDashboardEnabled = + !settings || settings.statsDashboardEnabled !== false; } catch (_) {} // Прокидываем флаг в shared state: парсинг работает всегда, @@ -98,6 +107,15 @@ async function init() { // Перенос контекста: если мы в середине операции (после reload) — продолжаем. safe("init.contextPortResume", () => contextPort.resume()); + // Сбор статистики использования (токены + сообщения). + safe("init.statsRecorderStart", () => statsRecorder.start()); + + // Дашборд статистики на домашней странице. + safe("init.statsDashboardStart", () => statsDashboard.start()); + safe("init.statsDashboardDebug", () => { + statsDashboard.setDebugMode(state.statsDebugMode === true); + }); + // Кастомный шрифт интерфейса и страницы — до остальной отрисовки. safe("init.fontsLoadAndApply", () => fonts.loadAndApply()); safe("init.fontsSettingsListener", () => fonts.installSettingsListener()); diff --git a/test/main/stats-store.test.js b/test/main/stats-store.test.js new file mode 100644 index 0000000..fdfe89f --- /dev/null +++ b/test/main/stats-store.test.js @@ -0,0 +1,66 @@ +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +// Изолируем userData во временную папку ДО require модуля. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cuckoo-stats-")); +const Module = require("module"); +const origLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === "electron") { + return { app: { getPath: () => tmp } }; + } + return origLoad.apply(this, arguments); +}; + +const stats = require("../../src/main/stats-store"); + +test("stats: пустая статистика возвращает нули", () => { + const s = stats.getSummary(30); + assert.strictEqual(s.totals.messages, 0); + assert.strictEqual(s.totals.tokens, 0); + assert.strictEqual(s.sessions, 0); + // Heatmap выровнен по неделям: дней не меньше периода, первый день — воскресенье. + assert.ok(s.days.length >= 30, "days >= span"); + const firstDow = new Date(s.days[0].date + "T00:00:00").getDay(); + assert.strictEqual(firstDow, 0, "первый день heatmap — воскресенье"); +}); + +test("stats: recordMessage учитывает сообщения и сессии", () => { + stats.recordMessage({ sessionId: "s1", role: "user" }); + stats.recordMessage({ sessionId: "s1", role: "ai" }); + const s = stats.getSummary(30); + assert.strictEqual(s.totals.messages, 2); + assert.strictEqual(s.sessions, 1); +}); + +test("stats: recordTokens накапливает токены", () => { + stats.recordTokens({ sessionId: "s1", tokens: 100 }); + stats.recordTokens({ sessionId: "s1", tokens: 50 }); + const s = stats.getSummary(30); + assert.strictEqual(s.totals.tokens, 150); +}); + +test("stats: второй message в другой сессии увеличивает sessions", () => { + stats.recordMessage({ sessionId: "s2", role: "ai" }); + const s = stats.getSummary(30); + assert.strictEqual(s.sessions, 2); +}); + +test("stats: reset обнуляет", () => { + stats.reset(); + const s = stats.getSummary(30); + assert.strictEqual(s.totals.messages, 0); + assert.strictEqual(s.totals.tokens, 0); + assert.strictEqual(s.sessions, 0); +}); + +test.after(() => { + Module._load = origLoad; + try { + fs.rmSync(tmp, { recursive: true, force: true }); + } catch (_) {} +});