From 41c005125abc24b3634ba5bffd0e8f12552e6a54 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 15 Sep 2026 13:04:34 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20token=20usage=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D0=B8=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=BC=20AI=20+=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20Claude=20=D0=B8=20?= =?UTF-8?q?network-hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Панель «Токены диалога»: оценка всего диалога из DOM (CJK 0.6 / ASCII 0.3), сохранение по sessionId в localStorage, обновление раз в секунду, на popstate/hashchange и beforeunload - Блок под ответом AI: оценка токенов именно этого ответа - Формат токенов: K/M вместо китайского «万» - Удалён Claude-провайдер (src/providers/claude.js, prompt, logo, тесты) - Удалён мёртвый код network-hook (src/interceptor/, intercept-observer.js, serverTokenUsage, prevAccumulatedTokens, useIntercept) --- src/main/ipc.js | 2 +- src/main/mcp-config.js | 2 +- src/main/session-store.js | 7 +- src/preload/dom/response-meta.js | 13 ++- src/preload/dom/token-estimator.js | 31 ++++++ src/preload/i18n/i18n.js | 3 +- src/preload/index.js | 15 ++- src/preload/overlay/events.js | 122 ++++++++++++++++++++++- src/preload/overlay/template.js | 12 +++ src/prompt/claude.md | 154 ----------------------------- src/providers/claude.js | 144 --------------------------- src/providers/deepseek.js | 11 +++ src/providers/index.js | 3 +- src/ui/logos/claude.svg | 1 - test/main/providers.test.js | 43 +------- test/main/window.test.js | 4 +- 16 files changed, 209 insertions(+), 358 deletions(-) create mode 100644 src/preload/dom/token-estimator.js delete mode 100644 src/prompt/claude.md delete mode 100644 src/providers/claude.js delete mode 100644 src/ui/logos/claude.svg diff --git a/src/main/ipc.js b/src/main/ipc.js index 98a9f95..fd6d540 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -401,7 +401,7 @@ function registerIpcHandlers() { const ctx = windowState.getContextByWebContents(event.sender); const win = ctx ? ctx.win : null; if (!win || win.isDestroyed()) return { success: false, error: '窗口已关闭' }; - // 按当前 provider 拼会话 URL(智谱 cid=、DeepSeek /chat/s/、Claude /chat/) + // 按当前 provider 拼会话 URL(DeepSeek /chat/s/ 等) let url = null; try { const { getProviderByUrl } = require('../providers'); diff --git a/src/main/mcp-config.js b/src/main/mcp-config.js index 2309459..dd7a3b3 100644 --- a/src/main/mcp-config.js +++ b/src/main/mcp-config.js @@ -1,7 +1,7 @@ /** * MCP 配置管理 * - * mcp.json 采用主流 Claude Desktop 格式(可直接分享/导入): + * mcp.json 采用主流 MCP 格式(可直接分享/导入): * { * "mcpServers": { * "filesystem": { "command": "npx", "args": [...] }, // stdio diff --git a/src/main/session-store.js b/src/main/session-store.js index 5a13b6e..a32f438 100644 --- a/src/main/session-store.js +++ b/src/main/session-store.js @@ -50,7 +50,7 @@ function createSessionStore(profileId, storeDir, windowState) { function extractSessionIdFromUrl(url) { if (!url) return null; - // 平台 provider 优先(智谱 cid=、Claude /chat/ 等) + // 平台 provider 优先(DeepSeek /chat/s/ 等) try { const provider = getProviderByUrl(url); if (provider && typeof provider.extractSessionId === 'function') { @@ -58,11 +58,6 @@ function createSessionStore(profileId, storeDir, windowState) { if (sid) return sid; } } catch (_) { /* provider 异常时回退旧逻辑 */ } - // Claude: https://claude.ai/chat/xxx - if (url.includes('claude.ai')) { - const m = url.match(/\/chat\/([a-zA-Z0-9_-]+)/i); - return m ? m[1] : null; - } // DeepSeek: https://chat.deepseek.com/a/chat/s/xxx const match = url.match(/\/chat\/s\/([a-f0-9-]+)/i); if (match) return match[1]; diff --git a/src/preload/dom/response-meta.js b/src/preload/dom/response-meta.js index 441b1ac..fd599c2 100644 --- a/src/preload/dom/response-meta.js +++ b/src/preload/dom/response-meta.js @@ -3,7 +3,8 @@ * ⏱ 9.2s · ~308 tok · Затронуто # file.txt * * Время измеряется локально (от появления нового AI-сообщения до его завершения). - * Токены — грубая оценка: длина текста / 4. + * Токены — дельта accumulated_token_usage за этот ответ (серверные); + * fallback — грубая оценка: длина текста / 4. * Затронуто — список файлов, затронутых за этот ответ (write/edit) — только успешные. * Данные сохраняются в localStorage (переживают Ctrl+R). */ @@ -18,6 +19,9 @@ try { ({ t } = require('../i18n/i18n')); } catch (_) {} let state = null; try { state = require('./state'); } catch (_) { state = { showProducedFiles: true }; } +let estimateTokens = (s) => (s ? Math.ceil(s.length / 4) : 0); +try { ({ estimateTokens } = require('./token-estimator')); } catch (_) {} + // Активные замеры: messageEl -> { start } let activeTimers = new WeakMap(); @@ -437,7 +441,7 @@ function renderMetaPanel(messageEl, seconds, tokensEstimate, files) { meta.className = META_CLASS; // i18n с фолбэком на русский, если t() недоступен let timeTitle = 'Время ответа'; - let tokensTitle = 'Оценка количества токенов (chars / 4)'; + let tokensTitle = 'Токены ответа (серверные, при недоступности — оценка chars / 4)'; let producedLabel = 'Затронуто'; let producedTitle = 'Файлы, затронутые за этот ответ'; try { @@ -541,7 +545,10 @@ function finishTimer(messageEl) { const markdown = messageEl.querySelector('.ds-markdown'); const text = markdown ? (markdown.textContent || '') : ''; - const tokensEstimate = Math.max(0, Math.round(text.length / 4)); + // Токены текста этого ответа — локальная оценка (CJK 0.6 / ASCII 0.3). + // Серверное accumulated_token_usage у DeepSeek считает только входной промпт + // и не отражает объём ответа, поэтому не используем его здесь. + const tokensEstimate = estimateTokens(text); const seconds = (elapsedMs / 1000).toFixed(1); // Проверяем подтверждённые файлы (если уже есть успешные выполнения) let files = null; diff --git a/src/preload/dom/token-estimator.js b/src/preload/dom/token-estimator.js new file mode 100644 index 0000000..ba392f1 --- /dev/null +++ b/src/preload/dom/token-estimator.js @@ -0,0 +1,31 @@ +/** + * Token 估算(近似) + * 依据 DeepSeek 官方启发式规则: + * 1 个 CJK 字符 ≈ 0.6 token,1 个 ASCII 字符 ≈ 0.3 token。 + * 纯函数、零依赖,供输入框 token 计数等场景复用。 + */ + +/** + * 估算文本的 token 数(浮点 unit) + * @param {string} text + * @returns {number} + */ +function estimateTokenUnits(text) { + if (!text) return 0; + let tokens = 0; + for (const char of text) { + tokens += char.charCodeAt(0) > 0x7F ? 0.6 : 0.3; + } + return tokens; +} + +/** + * 估算文本的 token 数(向上取整) + * @param {string} text + * @returns {number} + */ +function estimateTokens(text) { + return Math.ceil(estimateTokenUnits(text)); +} + +module.exports = { estimateTokenUnits, estimateTokens }; diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index f92e9f2..42a4e2e 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -72,6 +72,7 @@ const KEYS = { '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' }, @@ -266,7 +267,7 @@ const KEYS = { // ---- Мета под ответом AI (время / токены / затронутые файлы) ---- 'meta.time.title': { ru: 'Время ответа', en: 'Response time' }, - 'meta.tokens.title': { ru: 'Оценка количества токенов (chars / 4)', en: 'Estimated tokens (chars / 4)' }, + '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' }, }; diff --git a/src/preload/index.js b/src/preload/index.js index bc73aa1..8f81658 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -76,9 +76,20 @@ async function init() { safe('init.bindEvents', () => bindEvents()); safe('init.updateHomeMode', () => ui.updateHomeMode()); + // Сохранить токены текущего диалога перед уходом/закрытием. + window.addEventListener('beforeunload', () => { + safe('init.saveTokensBeforeUnload', () => bindEvents.computeAndSaveConversationTokens()); + }); + // 监听 URL 变化(SPA 路由) - window.addEventListener('popstate', ui.updateHomeMode); - window.addEventListener('hashchange', ui.updateHomeMode); + window.addEventListener('popstate', () => { + safe('init.updateHomeModePop', () => ui.updateHomeMode()); + safe('init.refreshTokensPop', () => bindEvents.updateConversationTokenDisplay()); + }); + window.addEventListener('hashchange', () => { + safe('init.updateHomeModeHash', () => ui.updateHomeMode()); + safe('init.refreshTokensHash', () => bindEvents.updateConversationTokenDisplay()); + }); setInterval(() => safe('init.updateHomeModeInterval', () => ui.updateHomeMode()), 5000); // 首次延迟执行,确保 overlay 已注入 setTimeout(() => safe('init.updateHomeModeDelayed', () => ui.updateHomeMode()), 500); diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index da47006..23933c8 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -9,6 +9,8 @@ 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'); /** * 渲染窗口列表(浮动管理面板内) @@ -107,7 +109,7 @@ function closeWindowManager() { * 生成项目说明文档按钮点击处理 */ function handleGenerateDoc() { - const message = '根据当前项目生成一个类似 claude.md 的项目说明文件,并将文件放到当前项目 .cuckooCode/CUCKOO.md'; + const message = '根据当前项目生成一个项目说明文件,并将文件放到当前项目 .cuckooCode/CUCKOO.md'; if (!sendToChat(message, '生成文档', 300)) { showToast('未找到输入框,请确保已打开聊天界面', 3000); } @@ -213,6 +215,119 @@ function closeMcpManager() { if (panel) panel.classList.add('cuckoo-hidden'); } +/** + * 格式化 token 数:>=1M → "x.xxM",>=1K → "x.xK",иначе原样 + * @param {number} n + * @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'; + return String(Math.round(n)); +} + +/** + * 刷新面板里的「对话 Token」显示 + * 优先使用服务端权威值(accumulated_token_usage,含 prompt+输出); + * 若 state 中值为空 — пробуем восстановить из localStorage (по текущей сессии); + * 服务端值缺失时回退到页面文本本地估算. + */ +/** + * Ключ localStorage для сохранения токенов диалога по сессии. + * @param {string|null} sessionId + * @returns {string} + */ +function convTokenStorageKey(sessionId) { + return 'cuckoo-conv-tokens-' + (sessionId || 'default'); +} + +/** + * Определить sessionId текущей страницы через provider. + * @returns {string|null} + */ +function getCurrentSessionId() { + try { + const provider = getProviderByUrl(window.location.href); + if (provider && typeof provider.extractSessionId === 'function') { + return provider.extractSessionId(window.location.href); + } + } catch (_) { /* ignore */ } + return null; +} + +/** + * Оценить токены всего диалога из DOM и сохранить результат в localStorage + * по текущему sessionId. Возвращает оценку. + * @returns {number} + */ +function computeAndSaveConversationTokens() { + let text = ''; + try { + const provider = getProviderByUrl(window.location.href); + if (provider && typeof provider.getConversationText === 'function') { + text = provider.getConversationText() || ''; + } + } catch (_) { /* provider 未就绪 */ } + // Отладка (один раз в 5 сек, чтобы не спамить) + try { + const now = Date.now(); + 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)); + } + } catch (_) {} + const tokens = estimateTokens(text); + // Запоминаем только если есть что запоминать (> 0) + if (tokens > 0) { + try { + localStorage.setItem(convTokenStorageKey(getCurrentSessionId()), String(tokens)); + } catch (_) { /* ignore */ } + } + return tokens; +} + +/** + * Прочитать сохранённую оценку токенов для текущей сессии. + * @returns {number} + */ +function readSavedConversationTokens() { + try { + 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; } +} + +/** + * Обновить панель: локальная оценка токенов всего диалога (user + AI из DOM). + * Если в DOM пока ничего нет — берём сохранённое значение по сессии. + * Серверное accumulated_token_usage у DeepSeek считает только входной промпт, + * поэтому не используем его здесь. + */ +function updateConversationTokenDisplay() { + const countEl = document.getElementById('cuckoo-conv-token-count'); + if (!countEl) return; + + const fresh = computeAndSaveConversationTokens(); + if (fresh > 0) { + countEl.textContent = formatTokenCount(fresh); + return; + } + + const saved = readSavedConversationTokens(); + countEl.textContent = saved > 0 ? formatTokenCount(saved) : '0'; +} + +/** + * 启动对话 token 显示(每秒刷新) + */ +function startTokenCounter() { + setInterval(updateConversationTokenDisplay, 1000); + updateConversationTokenDisplay(); +} + /** * 绑定覆盖层所有 UI 事件 * 包括按钮点击、键盘快捷键、状态徽章点击等 @@ -313,6 +428,9 @@ function bindEvents() { // Плавающее окошко «Задачи» todoPanel.start(); + // 启动输入框 token 估算 + startTokenCounter(); + // MCP 面板:刷新 const mcpRefreshBtn = document.getElementById('cuckoo-mcp-refresh'); mcpRefreshBtn?.addEventListener('click', renderMcpList); @@ -524,4 +642,6 @@ function bindEvents() { }); } +bindEvents.updateConversationTokenDisplay = updateConversationTokenDisplay; +bindEvents.computeAndSaveConversationTokens = computeAndSaveConversationTokens; module.exports = bindEvents; diff --git a/src/preload/overlay/template.js b/src/preload/overlay/template.js index b1adb7b..7fdb60b 100644 --- a/src/preload/overlay/template.js +++ b/src/preload/overlay/template.js @@ -25,6 +25,14 @@ function buildOverlayHTML() { ' ', ' ', '
', +'
', +' ', +'
', +' 0', +' tokens', +'
', +'
', +'
', '
', ' ', '
', @@ -444,6 +452,10 @@ const OVERLAY_CSS = [ '.cuckoo-btn-change-dir:hover { background: rgba(139,147,255,0.28); color: #fff; border-color: rgba(139,147,255,0.75); }', '.cuckoo-project-dir-display { margin-top: 4px; font-size: 12px; font-family: "Consolas", monospace; color: #8a90b8; word-break: break-all; }', '.cuckoo-dir-path { color: #a7f3c0; }', +'.cuckoo-token-section { background: var(--ck-surface); border-radius: 10px; padding: 8px 10px; border: 1px solid var(--ck-border); }', +'.cuckoo-token-display { display: flex; align-items: baseline; gap: 6px; margin-top: 2px; }', +'.cuckoo-token-count { font-size: 20px; font-weight: 700; color: #a7f3c0; font-family: "Consolas", monospace; }', +'.cuckoo-token-unit { font-size: 11px; color: var(--ck-text-dim); }', '.cuckoo-divider { border-top: 1px solid var(--ck-border); margin: 2px 0; }', '.cuckoo-session-section {', ' background: rgba(255,255,255,0.04); border-radius: 10px;', diff --git a/src/prompt/claude.md b/src/prompt/claude.md deleted file mode 100644 index f55ea0e..0000000 --- a/src/prompt/claude.md +++ /dev/null @@ -1,154 +0,0 @@ -## 背景 -现在我有个机器因为权限问题,只能通过一个古老的后管进行操作. -这个后管只能提供了 -- 一个js窗口 -- 一个结果展示框. 需要展示结果可以使用 log()方法进行展示 -- 一个操作目录选择: 现在我选择的是:{{PROJECT_DIR}} -- 还有个使用说明, 我会在后面附上 - -只能在里面写js并只能用他提供的js方法来操作文件. -可我并不懂如何操作 , 希望你能帮助我操作 -我的那台机器的操作系统环境是 - {{PLATFORM_INFO}} - -## 回复风格 - -- 简明、专业,直接展示代码和命令输出,少说废话 -- 如果是多步骤任务,先给计划,然后一步一步做 -- 在不确定时主动向用户提问,而不是猜测 - - -## 关于js -- 由于我是个小白,如果你想让我在执行窗口里执行js代码, 需要以 ```cuckoo 开头、以 ``` 结尾的代码块中,便于我分辨 -- 所有工具函数都是异步的,调用时必须使用 await -- 多行文本一律使用反引号(`)模板字符串,直接换行,不需要任何转义 -- 不要输出 JSON 格式的工具调用,也不要对字符串做 JSON 转义 - -## 使用说明 - -当你被要求编写或修改代码时, -应将上述准则内化于心。 -它们会影响你的推理过程、生成的代码以及输出的 diff。 -除非被问及,否则不要在回复中显式引用这些规则, -而是让它们默默地指导你的行为。 - ---- - -## 使用说明 - -以下是 js窗口中可用的全部全局工具函数与数据类型的 TypeScript 声明, -帮助你写出正确的调用代码: - -- 所有函数都是异步的,调用时必须使用 await -- 相对路径基于当前项目根目录(projectDir)解析 -- 工具出错时抛出异常(Error.message 为错误描述); -- 唯一例外是 bash()/pwsh():非零退出不抛异常,通过返回文本中的 [exit code]标记报告 -- 此部分与 tools/cuckoo-tools.d.ts 保持一致 - -```ts -{{TOOL_API_TYPES}} -``` - ---- - -### 工具使用指导 -{{TOOL_SECTIONS}} - ---- - -### 工具使用规则 - -#### 核心原则 - -1. 因为我只有执行js一个途径来操作我的目录,尽量不要让我手动操作 -2. **一次一小步** - 每次回复完成一个小任务,等待执行结果后再决定下一步 -3. **参数准确** - 严格按照函数签名传参,字符串使用双引号或反引号(`) -4. **信任结果** - 工具返回的结果是真实的,直接基于结果继续工作 -5. **JS 层与 shell 层隔离** - log() 等 JS 。bash()/pwsh() 的命令参数是独立 shell 脚本,不能在命令字符串里调用 log()、read() 等 JS 函数。shell 脚本用 echo / Write-Output 输出。 - -### 调用格式 - -```cuckoo -const content = await read("src/utils/helper.js"); -await write("src/utils/helper.js", content.replace("formatDate", "formatTime")); -``` - -### 输出前自查清单 - -1. 代码块以 ```cuckoo 开头、以 ``` 结尾 -3. 每个工具函数调用前都写 await -4. 多行文本一律使用反引号(`)模板字符串,直接换行,不做 \n 转义 -5. 不要输出 JSON、不要使用 JSON.stringify、不要转义引号 -6. 相对路径基于当前项目根目录解析 -7. 需要把中间结果告诉用户或下一步时,使用 log() 输出 - -### 可用工具函数 - -> 每个工具函数都是异步的,必须用 await 调用。log() 用于输出中间结果,脚本最后的 return 值也会作为结果返回。 -> 完整的类型声明(每个函数的参数、返回值、抛错行为)见 systemPrompt.md 末尾的「工具 API 类型定义(TypeScript)」。 - -### 读取文件内容 - -- **优先使用 read 工具**读取文件内容。它返回带行号的窗口,支持 offset/limit 分段读取大文件;读取后根据 footer 提示决定是否继续。 -- 若必须用 bash 执行 PowerShell 读取文件,系统会自动为 `Get-Content` 补充 `-Encoding UTF8`(也可以手动写),避免中文乱码。 - -### 执行流程示例 - -**你的回复**(仅工具代码): - -```cuckoo -const r = await write("src/greeting.txt", "Hello, world!"); -log(r); -``` - -**系统返回**: - -``` -【JS 执行结果汇总】(共 1 个脚本) - -—— 脚本 1 —— -✅ 成功 -src/greeting.txt -file - -Created file - -``` - -> 若脚本中未调用 log() 打印返回值,系统只会提示“(脚本执行完成,无输出) 如需输出请使用 log() 方法”,不会自动显示工具的返回内容。 - -### 多步任务示例 - -**第一步 - 读取文件**: - -```cuckoo -const content = await read("src/index.js"); -log(content); -``` - -**第二步 - 基于读取结果编辑文件**: - -```cuckoo -const r = await edit("src/index.js", "const a = 1;", "const a = 2;"); -log(r); -``` - -### 错误处理 - -如果脚本执行失败,系统会返回错误信息。你应该: -1. 分析错误原因(文件不存在、old_string 不匹配、语法错误等) -2. 修正代码后重新输出完整的 ```cuckoo 代码块 - - -## 当前项目目录 -当前项目路径: {{PROJECT_DIR}} - - ---- -## 当前目录说明 -{{PROJECT_INTRO_SECTION}} - -{{SKILL_SECTION}} - ---- - diff --git a/src/providers/claude.js b/src/providers/claude.js deleted file mode 100644 index f2d880f..0000000 --- a/src/providers/claude.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Claude Provider 定义 - * 基于 claude.ai 页面结构,输入框为 ProseMirror(contenteditable)。 - */ -let stopBtnVisible = false; - -module.exports = { - id: 'claude', - name: 'Claude', - homeUrl: 'https://claude.ai/new', - sessionUrlBase: 'https://claude.ai/chat/', - - // 判断元素是否可见(offsetWidth/offsetHeight > 0) - isElementVisible(el) { - if (!el) return false; - return el.offsetWidth > 0 && el.offsetHeight > 0; - }, - - // 查找可见的聊天输入框(Claude 用 ProseMirror contenteditable) - findInput() { - const selectors = [ - 'div[role="textbox"].tiptap', - 'div[role="textbox"]', - 'div.ProseMirror', - 'div[contenteditable="true"]', - 'textarea', - ]; - for (const sel of selectors) { - try { - const el = document.querySelector(sel); - if (this.isElementVisible(el)) return el; - } catch (_) {} - } - return null; - }, - - // 查找可见且未禁用的发送按钮 - findSendButton() { - const selectors = [ - 'button[aria-label="Send message"]', - '[data-testid="chat-input-send"]', - 'button[aria-label*="send"]', - 'button[aria-label*="Send"]', - ]; - for (const sel of selectors) { - try { - const btn = document.querySelector(sel); - if (this.isElementVisible(btn) && !btn.disabled) return btn; - } catch (_) {} - } - return null; - }, - - // 提取当前用户信息文本(左下角账号名) - extractUserInfo() { - const el = document.querySelector('.df-user-menu-btn span.whitespace-nowrap.text-secondary'); - return el ? el.textContent.trim() : ''; - }, - - // 首页判断正则(https://claude.ai/new 或 https://claude.ai/) - homeUrlPattern: /^https:\/\/claude\.ai(\/new)?\/?(\?.*)?$/, - - // 从 URL 提取会话 ID(Claude 是 /chat/xxx 格式) - extractSessionId(url) { - if (!url) return null; - const match = url.match(/\/chat\/([a-zA-Z0-9_-]+)/i); - if (match) return match[1]; - return null; - }, - - // 判断 URL 是否属于本平台 - matchesUrl(url) { - return url.includes('claude.ai'); - }, - - // ========== 自动解析相关方法 ========== - - // 判断 AI 是否已完成回复(基于停止按钮的边沿触发) - // 回答中:button[aria-label="Stop response"] 存在 - // 回答完成:该按钮消失;从存在到消失的边沿才返回 true,避免持续触发 - async isResponseComplete() { - const stopBtn = document.querySelector('button[aria-label="Stop response"]'); - const visible = !!stopBtn; - - if (visible) { - stopBtnVisible = true; - return false; - } - - // 上一次可见、本次不可见 → 回答刚结束 - if (stopBtnVisible) { - stopBtnVisible = false; - console.log('[' + new Date().toISOString() + '] [Cookie Code] Claude 回复完成,等待 500ms 后解析'); - await new Promise(resolve => setTimeout(resolve, 500)); - console.log('[' + new Date().toISOString() + '] [Cookie Code] Claude 500ms 等待结束'); - return true; - } - - return false; - }, - - // 获取当前页面所有 AI 消息容器(排除用户消息) - getMessageCandidates() { - return Array.from(document.querySelectorAll('[class*="message-row"]')).filter(el => !this.isUserMessage(el)); - }, - - // 从消息容器中取回复内容根节点 - getMessageMarkdown(messageEl) { - return messageEl.querySelector('[class*="standard-markdown"]') || - messageEl.querySelector('div[class*="prose"]') || - messageEl; - }, - - // 判断节点是否位于用户消息区域内 - isUserMessage(node) { - let current = node; - while (current) { - const testid = current.getAttribute?.('data-testid') || ''; - if (testid === 'user-message') return true; - const role = current.getAttribute?.('data-role') || current.getAttribute?.('data-author') || ''; - if (role === 'user' || role === 'human') return true; - current = current.parentElement; - } - const rowEl = node && node.closest ? node.closest('[class*="message-row"]') : null; - if (rowEl && rowEl.querySelector('[data-testid="user-message"]')) return true; - const text = (node.textContent || node.innerText || '').substring(0, 200); - return text.includes('我已选择目录:') || text.includes('系统提示词:') || text.includes('工具使用规则:'); - }, - - // 提取代码块的语言标记(Claude 使用 code[class*="language-"]) - getCodeBlockLanguage(pre) { - if (!pre) return ''; - const codeEl = pre.querySelector('code'); - const els = [codeEl, pre].filter(Boolean); - for (const el of els) { - const cls = el.className || ''; - if (typeof cls === 'string') { - const langMatch = cls.match(/language-([\w-]+)/); - if (langMatch) return langMatch[1].toLowerCase(); - } - } - return ''; - }, -}; diff --git a/src/providers/deepseek.js b/src/providers/deepseek.js index f2cea83..a436a15 100644 --- a/src/providers/deepseek.js +++ b/src/providers/deepseek.js @@ -161,6 +161,17 @@ module.exports = { }, false); }, + // 获取整个对话的纯文本(用户 + AI 消息按 DOM 顺序拼接),用于 token 估算 + getConversationText() { + return domSafe('getConversationText', () => { + const msgs = Array.from(document.querySelectorAll('.ds-message')); + return msgs + .map(el => (el.textContent || el.innerText || '').trim()) + .filter(Boolean) + .join('\n'); + }, ''); + }, + // 获取当前页面所有 AI 消息容器(排除用户消息) getMessageCandidates() { return domSafe('getMessageCandidates', () => { diff --git a/src/providers/index.js b/src/providers/index.js index 9209aba..5f5804f 100644 --- a/src/providers/index.js +++ b/src/providers/index.js @@ -3,10 +3,9 @@ * 加载所有内置的 AI 平台 Provider 定义,以及用户导入的自定义 Provider。 */ const deepseek = require('./deepseek'); -const claude = require('./claude'); const { loadCustomProviders } = require('./custom/loader'); -const builtinProviders = [deepseek, claude]; +const builtinProviders = [deepseek]; function getAllProviders() { return [...builtinProviders, ...loadCustomProviders()]; diff --git a/src/ui/logos/claude.svg b/src/ui/logos/claude.svg deleted file mode 100644 index 6c120e8..0000000 --- a/src/ui/logos/claude.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/test/main/providers.test.js b/test/main/providers.test.js index d3dd6e3..09ea230 100644 --- a/test/main/providers.test.js +++ b/test/main/providers.test.js @@ -4,18 +4,15 @@ const assert = require('node:assert'); const { getProvider, getAllProviders, getProviderByUrl } = require('../../src/providers'); const deepseek = require('../../src/providers/deepseek'); -const claude = require('../../src/providers/claude'); -test('getAllProviders 包含 deepseek 和 claude', () => { +test('getAllProviders 包含 deepseek', () => { const all = getAllProviders(); - assert.ok(all.length >= 2); + assert.ok(all.length >= 1); assert.ok(all.some(p => p.id === 'deepseek')); - assert.ok(all.some(p => p.id === 'claude')); }); test('getProvider 按 id 查找', () => { assert.strictEqual(getProvider('deepseek').id, 'deepseek'); - assert.strictEqual(getProvider('claude').id, 'claude'); assert.strictEqual(getProvider('unknown'), null); }); @@ -24,11 +21,6 @@ test('getProviderByUrl 识别 DeepSeek URL', () => { assert.strictEqual(getProviderByUrl('https://chat.deepseek.com/a/chat/s/abc').id, 'deepseek'); }); -test('getProviderByUrl 识别 Claude URL', () => { - assert.strictEqual(getProviderByUrl('https://claude.ai/new').id, 'claude'); - assert.strictEqual(getProviderByUrl('https://claude.ai/chat/abc').id, 'claude'); -}); - test('getProviderByUrl 未识别返回 null', () => { assert.strictEqual(getProviderByUrl('https://example.com'), null); assert.strictEqual(getProviderByUrl(null), null); @@ -36,12 +28,7 @@ test('getProviderByUrl 未识别返回 null', () => { test('deepseek matchesUrl', () => { assert.strictEqual(deepseek.matchesUrl('https://chat.deepseek.com/'), true); - assert.strictEqual(deepseek.matchesUrl('https://claude.ai'), false); -}); - -test('claude matchesUrl', () => { - assert.strictEqual(claude.matchesUrl('https://claude.ai/new'), true); - assert.strictEqual(claude.matchesUrl('https://chat.deepseek.com'), false); + assert.strictEqual(deepseek.matchesUrl('https://example.com'), false); }); test('deepseek extractSessionId', () => { @@ -50,11 +37,6 @@ test('deepseek extractSessionId', () => { assert.strictEqual(deepseek.extractSessionId('https://chat.deepseek.com/'), null); }); -test('claude extractSessionId', () => { - assert.strictEqual(claude.extractSessionId('https://claude.ai/chat/abc123'), 'abc123'); - assert.strictEqual(claude.extractSessionId('https://claude.ai/new'), null); -}); - test('deepseek isUserMessage 检测 data-role=user', () => { const node = { parentElement: null, getAttribute: (n) => n === 'data-role' ? 'user' : '' }; assert.strictEqual(deepseek.isUserMessage(node), true); @@ -65,11 +47,6 @@ test('deepseek isUserMessage 检测普通节点返回 false', () => { assert.strictEqual(deepseek.isUserMessage(node), false); }); -test('claude isUserMessage 检测 data-testid=user-message', () => { - const node = { parentElement: null, getAttribute: (n) => n === 'data-testid' ? 'user-message' : '' }; - assert.strictEqual(claude.isUserMessage(node), true); -}); - test('deepseek getCodeBlockLanguage 从 data-language 提取', () => { const pre = { getAttribute: (n) => n === 'data-language' ? 'cuckoo' : '', @@ -80,25 +57,11 @@ test('deepseek getCodeBlockLanguage 从 data-language 提取', () => { assert.strictEqual(deepseek.getCodeBlockLanguage(pre), 'cuckoo'); }); -test('claude getCodeBlockLanguage 从 language-* 提取', () => { - const pre = { - querySelector: () => ({ className: 'language-cuckoo' }), - }; - assert.strictEqual(claude.getCodeBlockLanguage(pre), 'cuckoo'); -}); - test('deepseek getMessageMarkdown 提取消息根节点', () => { const msg = { querySelector: (sel) => sel === ':scope > .ds-markdown' ? 'MARKDOWN' : null }; assert.strictEqual(deepseek.getMessageMarkdown(msg), 'MARKDOWN'); }); -test('claude getMessageMarkdown 优先 standard-markdown', () => { - const msg = { - querySelector: (sel) => sel === '[class*="standard-markdown"]' ? 'STD' : null, - }; - assert.strictEqual(claude.getMessageMarkdown(msg), 'STD'); -}); - test('渲染进程经注入的 userData 路径可加载自定义 Provider', () => { const os = require('node:os'); const path = require('node:path'); diff --git a/test/main/window.test.js b/test/main/window.test.js index 3459e12..67e0ff4 100644 --- a/test/main/window.test.js +++ b/test/main/window.test.js @@ -37,7 +37,7 @@ test('getContextByWebContents 查找对应上下文', () => { const winA = { id: 'a', on: () => {}, webContents: wcA, isDestroyed: () => false }; const winB = { id: 'b', on: () => {}, webContents: wcB, isDestroyed: () => false }; windowState.addWindow(winA, 'p1', 'deepseek', { a: 1 }); - windowState.addWindow(winB, 'p2', 'claude', { b: 2 }); + windowState.addWindow(winB, 'p2', 'demo', { b: 2 }); assert.strictEqual(windowState.getContextByWebContents(wcA).profileId, 'p1'); assert.strictEqual(windowState.getContextByWebContents(wcB).profileId, 'p2'); assert.strictEqual(windowState.getContextByWebContents({ id: 'nope' }), null); @@ -65,7 +65,7 @@ test('getAllWindows 返回窗口数组', () => { const winA = { id: 'a', on: () => {}, isDestroyed: () => false }; const winB = { id: 'b', on: () => {}, isDestroyed: () => false }; windowState.addWindow(winA, 'p1', 'deepseek', {}); - windowState.addWindow(winB, 'p2', 'claude', {}); + windowState.addWindow(winB, 'p2', 'demo', {}); const all = windowState.getAllWindows(); assert.strictEqual(all.length, 2); assert.ok(all.includes(winA)); From 09afc1a9008a8df815e0f59515df78e5e568556e Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Tue, 15 Sep 2026 15:05:35 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(tokens):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=B2=D0=B0=D1=82=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=20DeepSeek=20+=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.js | 13 ++ src/main/settings-store.js | 2 + src/main/token-interceptor-inject.js | 146 +++++++++++++ src/preload/dom/response-meta.js | 38 +++- src/preload/dom/settings-tab.js | 42 ++++ src/preload/dom/state.js | 14 ++ src/preload/dom/token-interceptor.js | 276 +++++++++++++++++++++++++ src/preload/i18n/i18n.js | 2 + src/preload/index.js | 17 ++ src/preload/overlay/events.js | 112 +++++++++- test/preload/token-interceptor.test.js | 132 ++++++++++++ 11 files changed, 780 insertions(+), 14 deletions(-) create mode 100644 src/main/token-interceptor-inject.js create mode 100644 src/preload/dom/token-interceptor.js create mode 100644 test/preload/token-interceptor.test.js diff --git a/src/main/index.js b/src/main/index.js index 28af711..a8aa265 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -127,8 +127,21 @@ function createWindow(profile) { // Всегда открываем homeUrl провайдера (DeepSeek). Выбор платформы отключён. mainWindow.loadURL(provider.homeUrl); + // Инжект перехватчика токенов в ОСНОВНОЙ мир: preload в изолированном мире + // (contextIsolation: true) не видит window.fetch сайта, поэтому патч ставим + // через executeJavaScript и шлём данные в preload через postMessage. + const injectTokenInterceptor = () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + const { buildTokenInterceptorScript } = require('./token-interceptor-inject'); + mainWindow.webContents.executeJavaScript(buildTokenInterceptorScript(), true).catch(() => {}); + } catch (_) {} + }; + mainWindow.webContents.on('dom-ready', injectTokenInterceptor); + mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { + injectTokenInterceptor(); mainWindow.webContents.send('page-loaded'); sessionStore.tryRestoreSessionFromUrl(mainWindow); } diff --git a/src/main/settings-store.js b/src/main/settings-store.js index cbe4e53..2cb7cd3 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -63,6 +63,8 @@ const DEFAULTS = { fileChipEnabled: true, // Показывать под ответом AI блок «Затронуто» с файлами из успешных write/edit showProducedFiles: true, + // Показывать блок «Токены диалога» в оверлее (по умолчанию выключено). + showConvTokens: false, }; let cachedPath = null; diff --git a/src/main/token-interceptor-inject.js b/src/main/token-interceptor-inject.js new file mode 100644 index 0000000..2d221d6 --- /dev/null +++ b/src/main/token-interceptor-inject.js @@ -0,0 +1,146 @@ +/** + * Инжект перехватчика токенов в ОСНОВНОЙ мир страницы (main world). + * + * Preload выполняется в изолированном мире (contextIsolation: true) и не видит + * window.fetch сайта, поэтому патч из preload бесполезен. Этот скрипт + * выполняется через webContents.executeJavaScript() в main world, перехватывает + * запросы completion и шлёт результат в preload через window.postMessage() + * (события message доставляются в оба мира). + */ + +/** + * Собрать исходник скрипта для executeJavaScript(). + * @returns {string} + */ +function buildTokenInterceptorScript() { + return '(' + function () { + if (window.__cuckooTokenInterceptorInstalled) return; + window.__cuckooTokenInterceptorInstalled = true; + + var COMPLETION = '/api/v0/chat/completion'; + var lastTotal = 0; + + function pick(o, keys) { + for (var i = 0; i < keys.length; i++) { + var v = o[keys[i]]; + if (typeof v === 'number' && isFinite(v)) return v; + } + return null; + } + + function extractUsage(obj) { + if (!obj || typeof obj !== 'object') return null; + + // BATCH патч-операции: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":82}, ...]} + if (Object.prototype.toString.call(obj.v) === '[object Array]') { + for (var i = obj.v.length - 1; i >= 0; i--) { + var op = obj.v[i]; + if (op && typeof op === 'object' && op.p === 'accumulated_token_usage') { + var n = pick(op, ['v', 'value']); + if (n != null) return { total: n, source: 'accumulated' }; + } + } + } + + // Снимок WIP: {"v":{"response":{"accumulated_token_usage":50}}} + var snap = obj.v && typeof obj.v === 'object' && Object.prototype.toString.call(obj.v) !== '[object Array]' ? obj.v.response : null; + if (snap && typeof snap === 'object') { + var n2 = pick(snap, ['accumulated_token_usage']); + if (n2 != null) return { total: n2, source: 'accumulated' }; + } + + var acc = obj.accumulated_token_usage; + if (typeof acc === 'number' && isFinite(acc)) return { total: acc, source: 'accumulated' }; + if (acc && typeof acc === 'object') { + var t = pick(acc, ['total_tokens', 'total', 'value']); + if (t != null) return { total: t, source: 'accumulated' }; + } + + var usage = obj.usage; + if (usage && typeof usage === 'object') { + var t2 = pick(usage, ['total_tokens', 'total']); + if (t2 != null) return { total: t2, source: 'usage' }; + } + return null; + } + + function publish(u) { + if (!u || typeof u.total !== 'number' || !isFinite(u.total)) return; + var delta = u.total - lastTotal; + if (delta < 0) { lastTotal = u.total; return; } + lastTotal = u.total; + if (delta === 0) return; + try { + window.postMessage({ __cuckoo: 'token-update', total: u.total, delta: delta, source: u.source }, '*'); + } catch (e) {} + } + + function parseSseText(text) { + if (!text || typeof text !== 'string') return null; + var found = null; + var lines = text.split(/\r?\n/); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line || line.indexOf('data:') !== 0) continue; + var payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + var parsed = null; + try { parsed = JSON.parse(payload); } catch (e) { continue; } + var u = extractUsage(parsed); + if (u) found = u; + } + return found; + } + + function isCompletion(url) { + return typeof url === 'string' && url.indexOf(COMPLETION) !== -1; + } + + // ---- патч fetch ---- + try { + var originalFetch = window.fetch; + window.fetch = function (input, init) { + var url = typeof input === 'string' ? input : (input && input.url) || ''; + var p = originalFetch.apply(this, arguments); + if (!isCompletion(url)) return p; + return p.then(function (response) { + try { + var clone = response.clone(); + clone.text().then(function (body) { + var u = parseSseText(body); + if (u) publish(u); + }).catch(function () {}); + } catch (e) {} + return response; + }); + }; + } catch (e) {} + + // ---- патч XHR ---- + try { + var XHR = window.XMLHttpRequest; + var originalOpen = XHR.prototype.open; + var originalSend = XHR.prototype.send; + XHR.prototype.open = function (method, url) { + try { this.__cuckooUrl = url; } catch (e) {} + return originalOpen.apply(this, arguments); + }; + XHR.prototype.send = function () { + try { + var self = this; + if (isCompletion(this.__cuckooUrl || '')) { + this.addEventListener('load', function () { + try { + var u = parseSseText(self.responseText); + if (u) publish(u); + } catch (e) {} + }); + } + } catch (e) {} + return originalSend.apply(this, arguments); + }; + } catch (e) {} + }.toString() + ')();'; +} + +module.exports = { buildTokenInterceptorScript }; diff --git a/src/preload/dom/response-meta.js b/src/preload/dom/response-meta.js index fd599c2..23862de 100644 --- a/src/preload/dom/response-meta.js +++ b/src/preload/dom/response-meta.js @@ -25,6 +25,29 @@ try { ({ estimateTokens } = require('./token-estimator')); } catch (_) {} // Активные замеры: messageEl -> { start } let activeTimers = new WeakMap(); +// Последняя серверная дельта токенов (из token-interceptor) и момент её прихода. +// Если дельта пришла во время замера ответа — используем её вместо локальной оценки. +let lastServerDelta = { value: 0, at: 0 }; +let serverDeltaListenerBound = false; + +/** + * Подписаться на событие cuckoo:token-update (однократно). + */ +function bindServerDeltaListener() { + if (serverDeltaListenerBound) return; + serverDeltaListenerBound = true; + try { + window.addEventListener('cuckoo:token-update', (e) => { + try { + const d = e && e.detail ? e.detail : {}; + if (typeof d.delta === 'number' && d.delta > 0) { + lastServerDelta = { value: d.delta, at: performance.now() }; + } + } catch (_) {} + }); + } catch (_) {} +} + // Подтверждённые файлы по сообщению: messageEl -> Array<{path, op, status}> let confirmedMap = new WeakMap(); @@ -103,6 +126,7 @@ function writeMetaStore(store) { */ function startTimer(messageEl) { if (!messageEl) return; + bindServerDeltaListener(); if (activeTimers.has(messageEl)) return; activeTimers.set(messageEl, { start: performance.now() }); } @@ -545,10 +569,15 @@ function finishTimer(messageEl) { const markdown = messageEl.querySelector('.ds-markdown'); const text = markdown ? (markdown.textContent || '') : ''; - // Токены текста этого ответа — локальная оценка (CJK 0.6 / ASCII 0.3). - // Серверное accumulated_token_usage у DeepSeek считает только входной промпт - // и не отражает объём ответа, поэтому не используем его здесь. - const tokensEstimate = estimateTokens(text); + // Токены ответа: приоритет — серверная дельта accumulated_token_usage, + // если она пришла во время этого замера (после rec.start). + // Иначе — локальная оценка (CJK 0.6 / ASCII 0.3). + let tokensEstimate = estimateTokens(text); + try { + if (lastServerDelta.value > 0 && (!rec || lastServerDelta.at >= rec.start)) { + tokensEstimate = lastServerDelta.value; + } + } catch (_) {} const seconds = (elapsedMs / 1000).toFixed(1); // Проверяем подтверждённые файлы (если уже есть успешные выполнения) let files = null; @@ -736,6 +765,7 @@ function clearStorage() { function resetForTests() { activeTimers = new WeakMap(); confirmedMap = new WeakMap(); + lastServerDelta = { value: 0, at: 0 }; cachedStore = null; cachedStoreTime = 0; producedEnabled = true; diff --git a/src/preload/dom/settings-tab.js b/src/preload/dom/settings-tab.js index 26dd092..99650dc 100644 --- a/src/preload/dom/settings-tab.js +++ b/src/preload/dom/settings-tab.js @@ -296,6 +296,12 @@ function buildContentHTML() { ' ' + ' ' + ' ' + + '