diff --git a/CHANGELOG.md b/CHANGELOG.md index e254cc6..18848da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,52 +1,31 @@ # Changelog -## [5.0.5] - 2026-10-10 +## [5.0.7] - 2026-10-15 ### Added -- ✍️ **Свой ответ на вопросы от ИИ в Telegram** — теперь на каждый вопрос бота есть не только готовые варианты, но и кнопка ==«Свой ответ»==: - - нажатие переводит бота в режим ожидания текста, ответ приходит следующим сообщением; - - пустой ввод не принимается — бот попросит текст или «Отказаться»; - - ожидание корректно снимается при `/cancel`, отказе от вопроса и когда вопрос уже закрыт. - -- 🔔 **Свои MD3-уведомления с ответом прямо из карточки** — системный `Notification` заменён на кастомное окно в стиле Material Design 3 (правый нижний угол, тёмная карточка, tonal-иконка, статус-точка, hover-подъём): - - встроенное поле ответа — печатаете, жмёте Enter или ➤, текст уходит ==прямо в чат== Cookie Code; - - пока печатаете, авто-скрытие откладывается (`cuckoo-notif-hold`); - - клик по карточке фокусирует главное окно; если окно уже в фокусе — уведомление не показывается вовсе. - -- 🤖 **Уведомление о результате субагента** — когда субагент завершился или упал по таймауту, приходит та же MD3-карточка с именем агента; клик фокусирует родительское окно, ответ уходит в родительский чат. - -- 🖼 **Diff картинками в Telegram** — команда `/diff` теперь рендерит diff каждого файла в ==PNG== и шлёт их альбомами (`sendMediaGroup`, пачки по 10, рендер в пуле из 3 окон). Плюс кнопка «Попросить коммит и пуш» с выбором ветки: - - | Было | Стало | - | ------------------------------------ | -------------------------------------------------------- | - | Огромный текстовый `
` с diff-ом | Цветные картинки: зелёные `+`, красные `-`, мета и ханки | - | Не было выбора ветки | Кнопки `git branch -a` — `✓` текущая, `☁` удалённая | - - После выбора ветки бот шлёт в чат готовый промпт с указанием ветки. - -- 🌿 **Выбор ветки в панели diff приложения** — кнопки «Коммит» и «Ветка» объединены в одну: открывается пикер веток, по выбору в чат уходит просьба закоммитить и запушить именно в неё: - - новые `listBranches()` / `currentBranch()` в `git-diff.js`; - - IPC `git-list-branches` и `git-current-branch`. - -- 🖥 **Выбор окна для `/screen`** — если запущены живые субагенты, `/screen` показывает меню: **Main** + кнопка на каждого субагента (с именами, при дублях — `#1`, `#2`). Скриншот уходит с подписью, какое именно окно снято. - -- 📋 **Карточка задач `todoWrite` в Telegram** — вместо технической строки приходит человеческая карточка с заголовком, счётчиком «Задач: N · выполнено: M» и разворачиваемым чек-листом (`☐ ◔ ☑`). `todoWrite` убран из `telegramToolNotifyIgnore`, чтобы карточка приходила всегда. - -- 📨 **Команда `/send`** — нажать кнопку отправки в активном окне чата, если авто-отправка зависла и текст остался в поле: - - есть и в боте, и в UI приложения как команда-действие `/send`; - - кнопка ищется рядом с полем ввода, при неудаче отправляется нативный Enter. +- 🎯 **Режим цели `/goal` — автономные многораундовые задачи** — одна команда превращает задачу в самодвижущийся цикл: AI шаг за шагом продвигается к цели ==без вашего участия между шагами==: + - раунд завершился (нет tool-вызовов) — Cookie Code **сам** отправляет AI «продолжай» через задержку `NUDGE_DELAY`; + - движок живёт в preload (`goal-engine.js`), состояние per-session, персистится в `localStorage` (`cuckoo-goal-v1`); + - завершение — когда AI вызывает инструмент `goalDone()`; + - предохранители: лимит итераций (по умолчанию 50, настройка `cuckoo-goal-max-iterations`) и остановка при 3 раундах без прогресса (`NO_PROGRESS_LIMIT`). +- ⌨️ **Перехват команды `/goal` в веб-чате** (`goal-command.js`) — команда ловится на capture-фазе (Enter и клик по кнопке отправки) и обрабатывается локально, в AI не уходит: + - `/goal <описание>` — задать цель; + - `/goal` — показать статус; + - `/goal stop | pause | resume` — управление. +- 🪟 **Плавающая панель «Цель»** (`goal-panel.js`) — слева появляется перетаскиваемая карточка с текстом цели, номером итерации, таймером и кнопками **Пауза** / **Выход**: + - стили встроены в overlay (`template.js`), цвет статуса меняется по состоянию (`running` / `paused` / `done`); + - позиция панели сохраняется в `localStorage` (`cuckoo-goal-pos`). +- 🤖 **Цель в Telegram** — команда `/goal` в боте умеет задавать цель, показывать статус и управлять ею (`stop` / `pause` / `resume`): + - в чат приходит человеческое уведомление о старте и о завершении цели (`notifyGoalDone`); + - статус читается из `goal-store` в main по активному окну. +- 🧰 **Инструмент `goal_done`** (`GoalDoneTool.js`) — AI вызывает `goalDone()`, чтобы объявить цель выполненной; тул шлёт IPC `goal-done` в окно, и движок останавливает цикл (заменяет текстовый маркер `[[GOAL_DONE]]`, который модель забывала писать). ### Changed -- 🎨 **Стиль переливания ника настраивается** — рядом с чекбоксом переливания появился селект стиля: - - ==RGB (радуга)== — прежний вариант; - - ==Кровь (чёрно-красный)== — новый тёмный градиент; - - выбор сохраняется в настройке `rgbUsernameStyle`. -- 🍪 **Новый логотип и иконка приложения** — квадратный логотип 1024×1024 и многоразмерный `.ico`; в README (ru/en/осн.) добавлен по центру блок с иконкой, у русского README в заголовке появился 🍪. -- 🧹 **README: убран список горячих клавиш-петов** — таблица хоткеев сокращена до актуальной `F1` (управление петом больше не документируется). +- 🍪 **Логотип и README** — в README (ru/en/осн.) добавлен блок про режим цели; в таблицу инструментов добавлен `goalDone`. +- 📖 **Команда `/goal` в help бота** — в списке команд появились `/goal <цель>` и `/goal stop|pause|resume` (ru/en). ### Fixed -- 🐛 **Переливание ника не анимировалось** — шорткат `background` с `!important` сбрасывал `background-position` и замораживал keyframes. Перешли на `background-image`, анимация снова бежит. -- 🐛 **Snake_case алиасы для агентов** — `list_agents` и `read_agent` теперь работают наравне с `listAgents`/`readAgent` (в `JsRunner` добавлены алиасы). +- 🐛 **Snake_case алиас `goal_done`** — наряду с `goalDone()` в `JsRunner` зарегистрирован `goal_done`. diff --git a/README.en.md b/README.en.md index d185bc8..427cde1 100644 --- a/README.en.md +++ b/README.en.md @@ -105,6 +105,40 @@ You are an experienced code review expert... --- +### 🎯 **Goals — Autonomous Multi-Round Tasks** + +The `/goal` command turns a single task into a **self-driving multi-round loop**: the AI keeps advancing step by step until the goal is done — no input from you between steps. + +**How it works:** + +1. Type `/goal` in chat (or set a goal from Telegram: `/goal `) +2. The AI receives the goal instruction and starts working +3. When a response round ends (no tool calls), Cookie Code **automatically** sends the AI "continue" +4. When done, the AI calls the `goalDone()` tool and the loop stops + +**Controls:** + +| Command | Action | +| ------------------------------ | ------------------- | +| `/goal ` | Set a goal | +| `/goal` | Show current status | +| `/goal stop` | Stop the goal | +| `/goal pause` / `/goal resume` | Pause / resume | + +**Goal panel:** a floating panel on the left shows the goal text, iteration number, elapsed time and **Pause** / **Exit** buttons. It can be dragged. + +**Loop protection:** + +- ⏱ **Iteration limit** — 50 rounds by default (setting `cuckoo-goal-max-iterations`) +- 🔁 **No progress** — 3 identical rounds in a row stop the goal +- ⏸ **Manual stop** — `/goal stop` or the "Exit" button instantly break the loop + +**Per-session:** each chat has its own goal — switching sessions keeps them separate. + +**Telegram notifications:** you get notified on goal start and completion, and can set/manage goals right from the bot. + +--- + ### 🎨 **Beautiful & Customizable** @@ -323,20 +357,21 @@ AI can now call `deploy` like any built-in tool. Cookie Code ships with 20+ tools out of the box: -| Tool | Description | Example | -| ----------- | ------------------------ | --------------------------------------- | -| `read` | Read file contents | `read('src/auth.js')` | -| `write` | Create or overwrite file | `write('test.js', code)` | -| `edit` | Apply targeted patch | `edit('app.js', oldCode, newCode)` | -| `glob` | Find files by pattern | `glob('**/*.test.js')` | -| `grep` | Search file contents | `grep('TODO', { include: '*.js' })` | -| `bash` | Run shell command | `bash('npm test')` | -| `pwsh` | Run PowerShell script | `pwsh('Get-Process')` | -| `git` | Git operations | `git('status')` | -| `mysql` | SQL queries | `mysql('SELECT * FROM users')` | -| `webFetch` | HTTP requests | `webFetch('https://api.example.com')` | -| `todoWrite` | Manage task lists | `todoWrite([{content: 'Fix bug'}])` | -| `mcpCall` | Call MCP server tool | `mcpCall('github', 'create_pr', {...})` | +| Tool | Description | Example | +| ----------- | ------------------------- | --------------------------------------- | +| `read` | Read file contents | `read('src/auth.js')` | +| `write` | Create or overwrite file | `write('test.js', code)` | +| `edit` | Apply targeted patch | `edit('app.js', oldCode, newCode)` | +| `glob` | Find files by pattern | `glob('**/*.test.js')` | +| `grep` | Search file contents | `grep('TODO', { include: '*.js' })` | +| `bash` | Run shell command | `bash('npm test')` | +| `pwsh` | Run PowerShell script | `pwsh('Get-Process')` | +| `git` | Git operations | `git('status')` | +| `mysql` | SQL queries | `mysql('SELECT * FROM users')` | +| `webFetch` | HTTP requests | `webFetch('https://api.example.com')` | +| `todoWrite` | Manage task lists | `todoWrite([{content: 'Fix bug'}])` | +| `mcpCall` | Call MCP server tool | `mcpCall('github', 'create_pr', {...})` | +| `goalDone` | Complete a goal (`/goal`) | `goalDone()` | [Full tool reference →](docs/TOOLS.md) diff --git a/README.md b/README.md index 4d543a6..cff3016 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ maxTurns: 20 --- +### 🎯 **Цели — автономные многораундовые задачи** + +Команда `/goal` превращает задачу в **самодвижущийся цикл**: AI шаг за шагом продвигается к цели, пока не выполнит её — без вашего участия между шагами. + +- **`/goal <описание>`** — задать цель; **`/goal`** — статус; **`/goal stop|pause|resume`** — управление +- Когда раунд ответа завершается, Cookie Code **сам** отправляет AI «продолжай» +- Завершение — когда AI вызывает инструмент `goalDone()` +- **Защита от зацикливания:** лимит итераций (50) и остановка при 3 раундах без прогресса +- **Панель «Цель»** слева: текст, итерация, таймер, кнопки Пауза/Выход +- **Per-session:** у каждого чата своя цель +- Задавать и управлять целью можно из **Telegram** (`/goal`), с уведомлениями о старте и завершении + ### 🎨 **Красиво и настраиваемо** - **Стеклянный UI** — матовое стекло с настраиваемым размытием и прозрачностью @@ -238,6 +250,7 @@ AI: [git commit] Закоммичено: "feat(auth): добавлены тес | `webFetch` | HTTP-запросы | | `todoWrite` | Списки задач | | `mcpCall` | Инструменты MCP-сервера | +| `goalDone` | Завершить цель (`/goal`) | --- diff --git a/README.ru.md b/README.ru.md index a4bcf8a..accf51c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -233,6 +233,38 @@ maxTurns: 20 2. Каждый tool-вызов агента: `🤖 Агент: explore` + `📖 Reading file.js` 3. Финальный результат: `🎯 Субагент вернул результат` + резюме +### Цели (goal-режим) + +Команда `/goal` превращает одну задачу в **автономный многораундовый цикл**: AI сам продвигается шаг за шагом, пока не выполнит цель — без вашего участия между шагами. + +**Как работает:** + +1. Введите в чате `/goal <описание цели>` (или задайте цель из Telegram: `/goal <описание>`) +2. AI получает инструкцию цели и начинает работу +3. Когда раунд ответа завершается (без tool-вызовов), Cookie Code **автоматически** отправляет AI «продолжай» +4. Когда цель выполнена — AI вызывает инструмент `goalDone()` и цикл останавливается + +**Управление:** + +| Команда | Действие | +| ------------------------------ | ---------------------------- | +| `/goal <текст>` | Задать цель | +| `/goal` | Показать статус текущей цели | +| `/goal stop` | Остановить цель | +| `/goal pause` / `/goal resume` | Пауза / возобновить | + +**Панель «Цель»:** плавающая панель слева показывает текст цели, номер итерации, время работы и кнопки **Пауза** / **Выход**. Её можно перетаскивать. + +**Защита от зацикливания:** + +- ⏱ **Лимит итераций** — по умолчанию 50 раундов (настройка `cuckoo-goal-max-iterations`) +- 🔁 **Нет прогресса** — если 3 раунда подряд одинаковый вывод, цель останавливается +- ⏸ **Ручная остановка** — `/goal stop` или кнопка «Выход» мгновенно прерывают цикл + +**Per-session:** у каждого чата своя цель — при переключении сессии состояние сохраняется отдельно. + +**Уведомления в Telegram:** при старте и завершении цели в TG приходят уведомления; задавать цель и управлять ею можно прямо из бота. + ### Авто-форматтеры Каждый `write` и `edit` прогоняет файл через форматтер для конкретного языка — код AI автоматически приводится к стилю вашего проекта. Не нужно вручную запускать `prettier --write`, стиль не мешает в diff. @@ -334,6 +366,7 @@ Cookie Code перехватывает его, выполняет в песоч | `glob`, `grep` | Поиск по файлам (на базе ripgrep) | | `bash`, `pwsh` | Выполнение команд | | `todoWrite` | Структурированный список задач | +| `goalDone` | Завершить цель (`/goal`) — остановить авто-продвижение | | `webFetch` | Загрузка HTTP(S) как Markdown | | `mysql` | SQL-запросы | | `mcpCall`, `mcpListServers`, `mcpGetTools` | MCP-инструменты | diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 31b6b69..19f5ea6 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -45,6 +45,8 @@ const STRINGS = { "help.cmd.show": "/show <hash> — diff коммита", "help.cmd.files": "/files <hash> — файлы коммита", "help.cmd.todos": "/todos — список задач активного окна", + "help.cmd.goal": "/goal <цель> — задать цель (авто-продвижение)", + "help.cmd.goal.manage": "/goal stop|pause|resume — управление целью", "help.cmd.agents": "/agents — список субагентов проекта", "help.cmd.toggles": "/toggles — тумблеры поиска и раздумий DeepSeek", "help.cmd.screen": "/screen — скриншот активного окна", @@ -204,6 +206,22 @@ const STRINGS = { "todos.empty": "☑ Список задач пуст.", "todos.header": "☑ Задачи ({done}/{total}):", + // ===== /goal ===== + "goal.none": "🎯 Цель не задана. Используй: /goal <описание цели>", + "goal.status": + "🎯 Цель: {text}\nСостояние: {state} · итерация {round}/{max} · {time}с", + "goal.state.running": "выполняется", + "goal.state.paused": "пауза", + "goal.state.done": "завершена", + "goal.started": + "🎯 Цель задана:\n{text}\n\nAI будет продвигаться автоматически, пока не завершит её.", + "goal.stopped": "⏹ Цель остановлена.", + "goal.paused": "⏸ Цель на паузе.", + "goal.resumed": "▶️ Цель возобновлена.", + "goal.noWindow": "⚠️ Нет активного окна.", + "goal.doneNotify": + "🎯 Цель завершена!\n{text}\n\nИтераций: {round} · время: {time}с", + // ===== Выбор проекта кнопками ===== "project.pickHint": "Выбери проект кнопкой ниже.", "project.title": "📁 Выбор проекта", @@ -477,6 +495,8 @@ const STRINGS = { "help.cmd.show": "/show <hash> — commit diff", "help.cmd.files": "/files <hash> — commit files", "help.cmd.todos": "/todos — tasks of the active window", + "help.cmd.goal": "/goal <goal> — set a goal (auto-advance)", + "help.cmd.goal.manage": "/goal stop|pause|resume — manage the goal", "help.cmd.agents": "/agents — project subagents list", "help.cmd.toggles": "/toggles — DeepSeek search & deep-think toggles", "help.cmd.screen": "/screen — screenshot of the active window", @@ -637,6 +657,22 @@ const STRINGS = { "todos.empty": "☑ Task list is empty.", "todos.header": "☑ Tasks ({done}/{total}):", + // ===== /goal ===== + "goal.none": "🎯 No goal set. Use: /goal <goal description>", + "goal.status": + "🎯 Goal: {text}\nState: {state} · iteration {round}/{max} · {time}s", + "goal.state.running": "running", + "goal.state.paused": "paused", + "goal.state.done": "done", + "goal.started": + "🎯 Goal set:\n{text}\n\nAI will advance automatically until it completes.", + "goal.stopped": "⏹ Goal stopped.", + "goal.paused": "⏸ Goal paused.", + "goal.resumed": "▶️ Goal resumed.", + "goal.noWindow": "⚠️ No active window.", + "goal.doneNotify": + "🎯 Goal completed!\n{text}\n\nIterations: {round} · time: {time}s", + // ===== Project picker ===== "project.pickHint": "Pick a project with the button below.", "project.title": "📁 Project selection", diff --git a/botsrc/index.js b/botsrc/index.js index d355956..b189818 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -2383,6 +2383,101 @@ async function _cmdDiagnostics() { } /** /todos — список задач активного окна (с пагинацией). */ +/** + * Отправить команду цели в preload активного окна. + * @param {string} action 'start' | 'stop' | 'pause' | 'resume' + * @param {string} [text] текст цели (для action='start') + */ +async function _sendGoalCommand(action, text) { + const win = windowState.getParentWindow + ? windowState.getParentWindow() + : windowState.getMainWindow(); + if (!win || win.isDestroyed()) + return { success: false, error: "нет активного окна" }; + try { + if (action === "start") { + win.webContents.send("goal-start", { text: String(text || "") }); + } else { + win.webContents.send("goal-control", { action }); + } + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** /goal — задать цель, показать статус или управлять (stop/pause/resume). */ +async function _cmdGoal(chatId, args) { + const arg = String(args || "").trim(); + const lower = arg.toLowerCase(); + + if (!arg) { + // Показать статус: читаем из goal-store (main). + let goal = { active: false }; + try { + const senderId = _activeSenderId(); + if (senderId != null) { + const goalStore = require("../src/main/goal-store"); + goal = goalStore.get(senderId); + } + } catch (_) {} + if (!goal || !goal.text) { + await telegramBot.sendMessage(_t("goal.none")); + return; + } + const stateText = goal.done + ? _t("goal.state.done") + : goal.paused + ? _t("goal.state.paused") + : _t("goal.state.running"); + const sec = Math.floor((goal.elapsedMs || 0) / 1000); + await telegramBot.sendMessage( + _t("goal.status", { + text: escapeHtml(goal.text), + state: stateText, + round: goal.round || 0, + max: goal.maxIterations || "-", + time: sec, + }), + { parseMode: "HTML" }, + ); + return; + } + + if (lower === "stop" || lower === "exit") { + const r = await _sendGoalCommand("stop"); + await telegramBot.sendMessage( + r.success ? _t("goal.stopped") : _t("goal.noWindow"), + ); + return; + } + if (lower === "pause") { + const r = await _sendGoalCommand("pause"); + await telegramBot.sendMessage( + r.success ? _t("goal.paused") : _t("goal.noWindow"), + ); + return; + } + if (lower === "resume") { + const r = await _sendGoalCommand("resume"); + await telegramBot.sendMessage( + r.success ? _t("goal.resumed") : _t("goal.noWindow"), + ); + return; + } + + // Иначе — новая цель. + const r = await _sendGoalCommand("start", arg); + if (r.success) { + await telegramBot.sendMessage( + _t("goal.started", { text: escapeHtml(arg) }), + { parseMode: "HTML" }, + ); + } else { + await telegramBot.sendMessage(_t("goal.noWindow")); + } +} + async function _cmdTodos(chatId, page) { let todos = []; try { @@ -3478,6 +3573,8 @@ async function _cmdHelp() { _t("help.cmd.show"), _t("help.cmd.files"), _t("help.cmd.todos"), + _t("help.cmd.goal"), + _t("help.cmd.goal.manage"), _t("help.cmd.agents"), _t("help.cmd.toggles"), _t("help.cmd.screen"), @@ -3975,6 +4072,11 @@ async function _handleIncoming(chatId, text, msg) { await _cmdTodos(chatId, 1); return; } + // Команда /goal — задать цель, статус или управление (stop/pause/resume). + if (cmd === "/goal") { + await _cmdGoal(chatId, cmdArgs); + return; + } if (cmd === "/screen" || cmd === "/screenshot") { await _cmdScreen(); return; @@ -4853,6 +4955,32 @@ async function notifyAgentStarted(agentName, task) { } /** Уведомление о результате tool (компактный стиль «Tech»). */ +/** + * Уведомление в TG: цель (/goal) завершена. + * Вызывается из src/main/goal-store при переходе active→done. + * @param {{text:string, round:number, elapsedMs:number}} goal + */ +async function notifyGoalDone(goal) { + const cfg = _read(); + if (!cfg.enabled) return { success: false, skipped: true }; + try { + const sec = Math.floor(((goal && goal.elapsedMs) || 0) / 1000); + const text = (goal && goal.text) || ""; + await telegramBot.sendMessage( + _t("goal.doneNotify", { + text: escapeHtml(text), + round: (goal && goal.round) || 0, + time: sec, + }), + { parseMode: "HTML" }, + ); + return { success: true }; + } catch (err) { + _log("error", "notifyGoalDone error:", err.message); + return { success: false, error: err.message }; + } +} + async function notifyToolResult(toolName, ok, detail) { const cfg = _read(); if (!cfg.enabled || !cfg.notifyTools) @@ -5050,6 +5178,7 @@ async function _registerCommands() { switch: "переключить сессию", diagnostics: "диагностика", todos: "список задач", + goal: "цель (авто-продвижение)", agents: "субагенты проекта", toggles: "тумблеры DeepSeek", screen: "скриншот окна", @@ -5072,6 +5201,7 @@ async function _registerCommands() { switch: "switch session", diagnostics: "diagnostics", todos: "todo list", + goal: "goal (auto-advance)", agents: "project subagents", toggles: "DeepSeek toggles", screen: "window screenshot", @@ -5160,6 +5290,7 @@ module.exports = { startTyping, stopTyping, notifyAllDone, + notifyGoalDone, requestApproval, cancelApproval, requestPlanApproval, diff --git a/src/main/goal-store.js b/src/main/goal-store.js new file mode 100644 index 0000000..49b032e --- /dev/null +++ b/src/main/goal-store.js @@ -0,0 +1,39 @@ +/** + * Хранилище состояния цели (goal mode) — per-sender (окно). + * + * Preload — источник истины по цели; он пушит сюда актуальное состояние + * через IPC 'goal-updated'. Main хранит копию для: + * - панели overlay (goal-get); + * - Telegram-бота (/goal — статус, уведомления о завершении). + * + * senderId -> { active, text, round, done, paused, maxIterations, + * elapsedMs, noProgress, updatedAt } + */ +const byWindow = new Map(); + +function set(senderId, state) { + if (senderId == null) return; + const prev = byWindow.get(senderId) || null; + const next = Object.assign({}, state || {}, { updatedAt: Date.now() }); + byWindow.set(senderId, next); + // Уведомление в Telegram при переходе active→завершено (done). + try { + if (prev && prev.active && !next.active && next.done) { + const bot = require("../../botsrc"); + if (bot && typeof bot.notifyGoalDone === "function") { + bot.notifyGoalDone(next).catch(() => {}); + } + } + } catch (_) {} + return next; +} + +function get(senderId) { + return byWindow.get(senderId) || { active: false }; +} + +function clear(senderId) { + byWindow.delete(senderId); +} + +module.exports = { set, get, clear }; diff --git a/src/main/ipc.js b/src/main/ipc.js index 35cbaec..c997e57 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -18,6 +18,7 @@ const statsStore = require("./stats-store"); const { decodeOutput, normalizeCommand } = require("../../tools/decodeOutput"); const gitDiff = require("./git-diff"); const todoStore = require("./todo-store"); +const goalStore = require("./goal-store"); const planMode = require("./plan-mode"); const subagent = require("./subagent"); @@ -137,6 +138,29 @@ function requestExitPlanMode(sender, plan) { }); } +/** + * Отправить событие 'goal-done' в preload окна — движок цели остановит цикл. + * Вызывается тулом goal_done (AI объявил цель выполненной). + * @param {number} windowId + * @returns {{success: boolean, error?: string}} + */ +function pushGoalDone(windowId) { + try { + const wc = require("electron").webContents.fromId(windowId); + if (!wc || wc.isDestroyed()) { + return { success: false, error: "Окно не найдено или закрыто" }; + } + wc.send("goal-done"); + console.log("[Cookie Code] goal-done отправлен в окно " + windowId); + return { success: true }; + } catch (err) { + return { + success: false, + error: "Не удалось отправить goal-done: " + (err.message || String(err)), + }; + } +} + /** * Резолв плана, утверждённого в Telegram (вызывается из botsrc). * @returns {boolean} true, если нашли ожидающий план. @@ -1106,6 +1130,7 @@ function registerIpcHandlers() { insertImageToChat(event.sender, filePath, caption, send), attachFile: (payload) => attachFileToChat(event.sender, payload), exitPlanMode: (plan) => requestExitPlanMode(event.sender, plan), + goalDone: (windowId) => pushGoalDone(windowId), }); if (isTaskCanceled(event.sender.id, taskToken)) { return { @@ -1311,6 +1336,7 @@ function registerIpcHandlers() { currentWindowId, ctx && Array.isArray(ctx.toolsWhitelist) ? ctx.toolsWhitelist : null, (ctx && ctx.subagentConfig && ctx.subagentConfig.agentName) || null, + (windowId) => pushGoalDone(windowId), ); if (isTaskCanceled(event.sender.id, taskToken)) { return { @@ -1563,6 +1589,46 @@ function registerIpcHandlers() { } }); + // ========== Goal (цели) ========== + // Preload пушит актуальное состояние цели сюда (копия для панели и Telegram). + ipcMain.on("goal-updated", (event, state) => { + try { + goalStore.set(event.sender.id, state); + } catch (_) {} + }); + + // Панель overlay запрашивает текущее состояние. + ipcMain.handle("goal-get", async (event) => { + try { + return { success: true, goal: goalStore.get(event.sender.id) }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + // Управление целью из панели/ТГ: шлём команду в preload окна. + function sendGoalControl(senderId, action, extra) { + try { + const wc = require("electron").webContents.fromId(senderId); + if (!wc || wc.isDestroyed()) + return { success: false, error: "Окно не найдено" }; + wc.send("goal-control", Object.assign({ action }, extra || {})); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } + } + + ipcMain.handle("goal-stop", async (event) => + sendGoalControl(event.sender.id, "stop"), + ); + ipcMain.handle("goal-pause", async (event) => + sendGoalControl(event.sender.id, "pause"), + ); + ipcMain.handle("goal-resume", async (event) => + sendGoalControl(event.sender.id, "resume"), + ); + // ========== Git diff (панель «Изменения») ========== // Список изменённых файлов в текущем проекте (git status). ipcMain.handle("git-status", async (event) => { diff --git a/src/preload/api.js b/src/preload/api.js index eefcac6..02d19d4 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -259,6 +259,17 @@ let electronAPI = { ipcRenderer.on("todo-updated", listener); return () => ipcRenderer.removeListener("todo-updated", listener); }, + // ========== Goal (цели) ========== + goalUpdated: (state) => ipcRenderer.send("goal-updated", state || {}), + onGoalUpdated: (callback) => { + const listener = (_event, data) => callback(data); + ipcRenderer.on("goal-updated", listener); + return () => ipcRenderer.removeListener("goal-updated", listener); + }, + getGoal: () => ipcRenderer.invoke("goal-get"), + stopGoal: () => ipcRenderer.invoke("goal-stop"), + pauseGoal: () => ipcRenderer.invoke("goal-pause"), + resumeGoal: () => ipcRenderer.invoke("goal-resume"), // ========== Git diff (панель «Изменения») ========== gitStatus: () => { return ipcRenderer.invoke("git-status"); diff --git a/src/preload/dom/commands/controller.js b/src/preload/dom/commands/controller.js index aa62c48..5b767c3 100644 --- a/src/preload/dom/commands/controller.js +++ b/src/preload/dom/commands/controller.js @@ -226,6 +226,28 @@ function pick(idx) { const isReviewCommand = activeKind === "command" && item.name === "review"; const isSendCommand = activeKind === "command" && item.name === "send"; + const isGoalCommand = activeKind === "command" && item.name === "goal"; + + // /goal — команда-действие: текст цели берём как остаток после slash-токена + // (то, что напечатано после «/goal»). Если пусто — подсказка/статус. + if (isGoalCommand) { + const draft = String(getValue(field) || ""); + // Вырезаем сам токен («/g», «/goal»), остальное — описание цели. + const tokenEnd = hit && hit.span ? hit.span.end : draft.length; + const afterToken = draft.slice(tokenEnd).trim(); + closeMenu(); + activeHit = null; + try { + const goalCommand = require("../goal-command"); + goalCommand.handleGoalCommand( + afterToken ? "/goal " + afterToken : "/goal", + ); + setValue(field, ""); + } catch (err) { + console.error("[Cookie Code] /goal error:", err && err.message); + } + return; + } // /send — команда-действие: сразу нажимаем кнопку отправки в активном окне, // ничего не вставляя в поле ввода (на случай зависшей авто-отправки). diff --git a/src/preload/dom/commands/registry.js b/src/preload/dom/commands/registry.js index d2b3841..d972985 100644 --- a/src/preload/dom/commands/registry.js +++ b/src/preload/dom/commands/registry.js @@ -69,6 +69,11 @@ const COMMANDS = [ descriptionKey: "cmd.send.description", action: "send", }, + { + name: "goal", + descriptionKey: "cmd.goal.description", + action: "goal", + }, ]; /** diff --git a/src/preload/dom/goal-command.js b/src/preload/dom/goal-command.js new file mode 100644 index 0000000..133cc52 --- /dev/null +++ b/src/preload/dom/goal-command.js @@ -0,0 +1,187 @@ +/** + * Перехват команды /goal в веб-чате. + * + * Пользователь вводит "/goal <текст>" и жмёт Enter (или кнопку отправки). + * Мы перехватываем отправку ДО того, как текст уйдёт в AI: + * - "/goal <текст>" → старт цели (startGoal), текст в AI не уходит + * - "/goal" → показать статус текущей цели + * - "/goal stop" → остановить цель + * - "/goal pause" → пауза + * - "/goal resume" → возобновить + * + * Перехват: capture-фаза keydown (Enter) + клик по кнопке отправки. + * Если перехватили — preventDefault + stopPropagation + очищаем поле. + */ +const chatInput = require("./chat-input"); +const goalEngine = require("./goal-engine"); + +const CMD_RE = /^\/goal(\s|$)/i; + +let installed = false; + +/** Прочитать текущий текст поля ввода. */ +function readField() { + const field = chatInput.findInputArea(); + if (!field) return { field: null, text: "" }; + const text = + field.tagName === "TEXTAREA" || field.tagName === "INPUT" + ? field.value || "" + : field.textContent || ""; + return { field, text: String(text) }; +} + +/** Очистить поле ввода. */ +function clearField(field) { + try { + chatInput.setInputContent(field, ""); + } catch (_) {} +} + +/** + * Обработать команду /goal. Возвращает true, если команда распознана + * (значит, отправку в AI надо отменить). + * @param {string} raw + * @returns {boolean} + */ +function handleGoalCommand(raw) { + const text = String(raw || "").trim(); + if (!CMD_RE.test(text)) return false; + + const arg = text.replace(/^\/goal\s*/i, "").trim(); + const lower = arg.toLowerCase(); + + if (!arg) { + // Показать статус. + const st = goalEngine.getState(); + if (!st.active && !st.done) { + logToChat("Цель не задана. Используй: /goal <описание цели>"); + } else { + logToChat( + "Текущая цель: " + + st.text + + " — " + + (st.done ? "завершена" : st.paused ? "на паузе" : "выполняется") + + " (итерация " + + st.round + + (st.maxIterations ? "/" + st.maxIterations : "") + + ", " + + fmtElapsed(st.elapsedMs) + + ")", + ); + } + return true; + } + + if (lower === "stop" || lower === "exit") { + goalEngine.stopGoal("цель остановлена пользователем"); + logToChat("Цель остановлена."); + return true; + } + if (lower === "pause") { + const r = goalEngine.pauseGoal(); + logToChat(r.success ? "Цель поставлена на паузу." : "Нет активной цели."); + return true; + } + if (lower === "resume") { + const r = goalEngine.resumeGoal(); + logToChat(r.success ? "Цель возобновлена." : "Нет цели для возобновления."); + return true; + } + + // Иначе — новая цель: arg это описание. + const r = goalEngine.startGoal(arg); + if (!r.success) { + logToChat("Не удалось задать цель: " + (r.error || "неизвестная ошибка")); + return true; + } + logToChat( + "Цель задана (авто-итерации, макс. " + + r.goal.maxIterations + + "; AI завершит сам).", + ); + // Отправляем в AI инструкцию цели. + const sendText = goalEngine.buildGoalSendText(arg); + setTimeout(() => { + try { + chatInput.sendToChat(sendText, "goal-start", 200); + } catch (_) {} + }, 50); + return true; +} + +/** Служебное сообщение в чат (через отправку себе не пойдёт — просто лог + тост). */ +function logToChat(msg) { + console.log("[Goal] " + msg); + try { + if ( + window.electronAPI && + typeof window.electronAPI.showToast === "function" + ) { + window.electronAPI.showToast(msg); + } + } catch (_) {} +} + +function fmtElapsed(ms) { + const s = Math.floor((ms || 0) / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? m + "м " + sec + "с" : sec + "с"; +} + +/** Перехват Enter в поле ввода (capture-фаза). */ +function onKeyDown(e) { + if (e.key !== "Enter" || e.shiftKey || e.isComposing) return; + const { field, text } = readField(); + const trimmed = String(text || "").trim(); + // Отладка: показываем каждое нажатие Enter, если в поле что-то есть. + if (trimmed.indexOf("/goal") === 0) { + console.log( + "[Goal] onKeyDown Enter: field=" + + !!field + + " text=" + + JSON.stringify(trimmed), + ); + } + if (!field) return; + if (!CMD_RE.test(trimmed)) return; + // Перехватываем: команда обрабатывается локально, в AI не уходит. + e.preventDefault(); + e.stopPropagation(); + if (typeof e.stopImmediatePropagation === "function") + e.stopImmediatePropagation(); + clearField(field); + handleGoalCommand(text); +} + +/** Перехват клика по кнопке отправки. */ +function onSendClick(e) { + const { text } = readField(); + if (!CMD_RE.test(String(text || "").trim())) return; + // Только если это кнопка отправки рядом с полем. + const target = + e.target && e.target.closest ? e.target.closest("button") : null; + if (!target) return; + const aria = (target.getAttribute("aria-label") || "").toLowerCase(); + const isSend = + target.type === "submit" || + aria.indexOf("send") !== -1 || + aria.indexOf("отправ") !== -1; + if (!isSend) return; + e.preventDefault(); + e.stopPropagation(); + const { field } = readField(); + if (field) clearField(field); + handleGoalCommand(text); +} + +/** Установить перехватчики (capture-фаза на document). */ +function install() { + if (installed) return; + installed = true; + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("click", onSendClick, true); + console.log("[Goal] перехват команды /goal установлен"); +} + +module.exports = { install, handleGoalCommand, CMD_RE }; diff --git a/src/preload/dom/goal-engine.js b/src/preload/dom/goal-engine.js new file mode 100644 index 0000000..5ac0bcf --- /dev/null +++ b/src/preload/dom/goal-engine.js @@ -0,0 +1,436 @@ +/** + * Движок целей (goal mode) — ядро цикла автопродвижения. + * + * Порт из upstream wangyongpeng90/cuckoo-code (harness.html) на архитектуру + * Cookie Code: движок живёт в preload, где уже есть sendToChat() и детект + * завершения ответа AI. + * + * Как работает: + * 1. Пользователь задаёт цель (/goal <текст>) → startGoal(). + * 2. buildGoalSendText() формирует инструкцию AI (первый запрос). + * 3. Когда раунд ответа завершён (нет JS-тул-блоков), observer вызывает + * scheduleGoalNudge() → с задержкой отправляется «продолжай». + * 4. AI по завершении вызывает тул goalDone() → main шлёт IPC 'goal-done' + * → onGoalDone() останавливает цикл. + * 5. Предохранители: лимит итераций (по умолч. 50) и 3 раунда без прогресса. + * + * Состояние per-session (ключ = sessionId), персистится в localStorage. + */ +const state = require("./state"); + +// ========== Константы ========== +const STORAGE_KEY = "cuckoo-goal-v1"; +const MAX_ITER_KEY = "cuckoo-goal-max-iterations"; +const DEFAULT_MAX_ITER = 50; +const NUDGE_DELAY = 1200; // мс — пауза перед авто-продолжением +const NO_PROGRESS_LIMIT = 3; // раундов с одинаковым выводом → стоп + +const BT = + String.fromCharCode(96) + String.fromCharCode(96) + String.fromCharCode(96); // ``` +const FENCE = BT + "cuckoo"; + +// ========== Состояние ========== +/** Текущая цель: { text, round, done, paused, maxIterations, lastText, + * noProgress, startedAt, elapsed } или null. */ +let goal = null; +let nudgeTimer = null; +let tickTimer = null; +/** ID текущей сессии (для ключа хранилища). Обновляется извне. */ +let currentSessionId = ""; + +// Колбэки для UI (goal-panel): вызываются при изменении состояния. +let onUpdateCb = null; + +// ========== Утилиты ========== + +/** Текущий sessionId из URL через провайдер. */ +function detectSessionId() { + try { + const provider = require("../../../src/providers").getProviderByUrl( + window.location.href, + ); + if (provider && typeof provider.extractSessionId === "function") { + return provider.extractSessionId(window.location.href) || ""; + } + } catch (_) {} + return ""; +} + +/** Установить текущую сессию (вызывается observer/bindEvents при смене URL). */ +function setSession(sessionId) { + const next = String(sessionId || ""); + if (next === currentSessionId) return; + // Сохраняем цель уходящей сессии, грузим цель новой. + saveGoalState(); + currentSessionId = next; + loadGoalState(); + notifyUpdate(); +} + +function goalKey() { + return STORAGE_KEY + (currentSessionId ? ":" + currentSessionId : ""); +} + +function maxIterations() { + try { + const v = parseInt(localStorage.getItem(MAX_ITER_KEY) || "", 10); + if (Number.isFinite(v) && v > 0) return v; + } catch (_) {} + return DEFAULT_MAX_ITER; +} + +function saveGoalState() { + try { + localStorage.setItem(goalKey(), goal ? JSON.stringify(goal) : "null"); + } catch (_) {} +} + +function loadGoalState() { + try { + goal = JSON.parse(localStorage.getItem(goalKey()) || "null"); + } catch (_) { + goal = null; + } + if (goal && !goal.text) goal = null; + if (goal) { + if (typeof goal.elapsed !== "number") goal.elapsed = 0; + if (typeof goal.noProgress !== "number") goal.noProgress = 0; + if (!goal.done && !goal.paused && !goal.startedAt) + goal.startedAt = Date.now(); + } +} + +/** Прошедшее время цели: заморожено в elapsed на паузе/завершении. */ +function elapsedMs() { + if (!goal) return 0; + return ( + (goal.elapsed || 0) + (goal.startedAt ? Date.now() - goal.startedAt : 0) + ); +} + +function notifyUpdate() { + if (typeof onUpdateCb === "function") { + try { + onUpdateCb(getState()); + } catch (_) {} + } + // Пуш в main — панель overlay и Telegram читают статус оттуда. + try { + window.electronAPI.goalUpdated(getState()); + } catch (_) {} +} + +/** Публичное состояние для UI/IPC. */ +function getState() { + if (!goal) return { active: false }; + return { + active: !goal.done, + text: goal.text, + round: goal.round, + done: !!goal.done, + paused: !!goal.paused, + maxIterations: goal.maxIterations || maxIterations(), + elapsedMs: elapsedMs(), + noProgress: goal.noProgress || 0, + }; +} + +function isActive() { + return !!(goal && !goal.done && !goal.paused); +} + +// ========== Тексты для AI ========== + +/** Первый запрос цели (из upstream buildGoalSendText). */ +function buildGoalSendText(gt) { + return ( + "【目标】" + + gt + + "\n\n请持续推进并完成上述目标,要求:\n" + + "1. 优先使用可用工具实际推进(读写文件、执行命令、检索等),而非只输出计划;\n" + + "2. 一轮无法完成时直接继续下一轮,无需等待用户确认;\n" + + "3. 目标的所有要求确实完成后,调用 goalDone() 工具结束目标(写成 " + + FENCE + + " 代码块,界面上不会显示这行调用);\n" + + "4. 遇到无法解决的阻碍时,说明原因并暂停等待。" + ); +} + +/** Текст авто-продолжения. */ +function nudgeSendText() { + return "继续推进当前目标。若目标已全部完成,请调用 goalDone() 工具结束目标。"; +} + +// ========== Управление целью ========== + +function startGoal(text) { + const t = String(text || "").trim(); + if (!t) return { success: false, error: "Пустой текст цели" }; + cancelNudge(); + goal = { + text: t, + round: 0, + done: false, + paused: false, + maxIterations: maxIterations(), + lastText: "", + noProgress: 0, + startedAt: Date.now(), + elapsed: 0, + }; + saveGoalState(); + startTick(); + notifyUpdate(); + console.log( + "[Goal] цель задана: " + t + " (макс. итераций " + goal.maxIterations + ")", + ); + return { success: true, goal: getState() }; +} + +function stopGoal(reason) { + cancelNudge(); + stopTick(); + const had = !!goal; + goal = null; + saveGoalState(); + notifyUpdate(); + if (had && reason) console.log("[Goal] остановлено: " + reason); + return { success: true }; +} + +/** Пауза: заморозить таймер, прекратить авто-продвижение. */ +function pauseGoal() { + if (!goal || goal.done) return { success: false }; + cancelNudge(); + goal.paused = true; + goal.elapsed = elapsedMs(); + goal.startedAt = 0; + stopTick(); + saveGoalState(); + notifyUpdate(); + return { success: true, goal: getState() }; +} + +/** Возобновить после паузы. */ +function resumeGoal() { + if (!goal || goal.done) return { success: false }; + if (!goal.paused) return { success: true, goal: getState() }; + goal.paused = false; + goal.startedAt = Date.now(); + saveGoalState(); + startTick(); + notifyUpdate(); + // Сразу пнуть, чтобы продолжил. + scheduleGoalNudge(); + return { success: true, goal: getState() }; +} + +/** Завершение цели (вызывается по goal-done от AI или вручную). */ +function onGoalDone() { + cancelNudge(); + if (goal && !goal.done) { + goal.done = true; + goal.elapsed = elapsedMs(); + goal.startedAt = 0; + stopTick(); + saveGoalState(); + notifyUpdate(); + console.log("[Goal] цель завершена (goalDone)"); + } + return { success: true, goal: getState() }; +} + +/** Изменить текст текущей цели. */ +function setGoalText(text) { + if (!goal || goal.done) return { success: false }; + const t = String(text || "").trim(); + if (!t || t === goal.text) return { success: true, goal: getState() }; + goal.text = t; + goal.lastText = ""; + goal.noProgress = 0; + saveGoalState(); + notifyUpdate(); + return { success: true, goal: getState() }; +} + +// ========== Цикл автопродвижения ========== + +function cancelNudge() { + if (nudgeTimer) { + clearTimeout(nudgeTimer); + nudgeTimer = null; + } +} + +/** Проверка прогресса: одинаковый вывод N раундов подряд → стоп. */ +function checkProgress(text) { + if (!goal) return; + const t = String(text || "").trim(); + if (t && t === goal.lastText) goal.noProgress = (goal.noProgress || 0) + 1; + else goal.noProgress = 0; + goal.lastText = t; +} + +/** + * Запланировать авто-продолжение (вызывается observer'ом после завершения + * раунда, когда нет JS-тул-блоков). + * @returns {boolean} запланировано ли + */ +function scheduleGoalNudge() { + if (!isActive()) return false; + cancelNudge(); + nudgeTimer = setTimeout(() => { + nudgeTimer = null; + if (!isActive()) return; + + // Предохранитель: лимит итераций. + if (goal.maxIterations && goal.round >= goal.maxIterations) { + goal.done = true; + goal.elapsed = elapsedMs(); + goal.startedAt = 0; + stopTick(); + saveGoalState(); + notifyUpdate(); + console.log("[Goal] достигнут лимит итераций, стоп"); + return; + } + // Предохранитель: нет прогресса. + if ((goal.noProgress || 0) >= NO_PROGRESS_LIMIT) { + goal.done = true; + goal.elapsed = elapsedMs(); + goal.startedAt = 0; + stopTick(); + saveGoalState(); + notifyUpdate(); + console.log( + "[Goal] нет прогресса " + NO_PROGRESS_LIMIT + " раунда, стоп", + ); + return; + } + + goal.round++; + saveGoalState(); + notifyUpdate(); + + const chatInput = require("./chat-input"); + console.log("[Goal] авто-продвижение, итерация " + goal.round); + chatInput.sendToChat(nudgeSendText(), "goal-nudge"); + }, NUDGE_DELAY); + return true; +} + +// ========== Таймер (для панели) ========== + +function startTick() { + if (tickTimer) return; + tickTimer = setInterval(() => notifyUpdate(), 1000); +} + +function stopTick() { + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = null; + } +} + +// ========== Подписка UI ========== + +function onUpdate(cb) { + onUpdateCb = cb; +} + +// ========== Инициализация ========== + +/** Инициализировать движок: определить сессию, загрузить состояние. */ +function init() { + currentSessionId = detectSessionId(); + loadGoalState(); + if (goal && !goal.done && !goal.paused) startTick(); + notifyUpdate(); + registerIpcListeners(); + watchSessionChanges(); + console.log( + "[Goal] движок инициализирован, сессия=" + + (currentSessionId || "(новая)") + + ", цель=" + + (goal ? goal.text : "нет"), + ); +} + +/** Слушать IPC от main: goal-done (AI завершил цель). */ +function registerIpcListeners() { + try { + const { ipcRenderer } = require("electron"); + ipcRenderer.on("goal-done", () => { + console.log("[Goal] получен goal-done от main"); + onGoalDone(); + }); + // Управление из main (панель overlay / Telegram): stop/pause/resume. + ipcRenderer.on("goal-control", (_e, payload) => { + const action = (payload && payload.action) || ""; + console.log("[Goal] получена команда управления: " + action); + if (action === "stop") stopGoal("цель остановлена извне"); + else if (action === "pause") pauseGoal(); + else if (action === "resume") resumeGoal(); + }); + // Старт цели извне (Telegram): { action:'start', text }. + ipcRenderer.on("goal-start", (_e, payload) => { + const text = (payload && payload.text) || ""; + if (!text) return; + const r = startGoal(text); + if (r.success) { + const chatInput = require("./chat-input"); + chatInput.sendToChat(buildGoalSendText(text), "goal-start", 300); + } + }); + } catch (_) {} +} + +/** + * Отслеживать смену сессии (SPA-навигация) — переключать состояние цели. + * Проверяем sessionId периодически: надёжнее, чем ловить popstate/hashchange + * (DeepSeek меняет URL и через history.pushState). + */ +let watchTimer = null; +function watchSessionChanges() { + if (watchTimer) return; + watchTimer = setInterval(() => { + const sid = detectSessionId(); + if (sid !== currentSessionId) { + console.log( + "[Goal] смена сессии: " + + (currentSessionId || "(новая)") + + " → " + + (sid || "(новая)"), + ); + setSession(sid); + } + }, 3000); +} + +/** Сохранить состояние перед выгрузкой страницы. */ +function saveBeforeUnload() { + saveGoalState(); +} + +module.exports = { + init, + setSession, + detectSessionId, + startGoal, + stopGoal, + pauseGoal, + resumeGoal, + onGoalDone, + setGoalText, + scheduleGoalNudge, + checkProgress, + isActive, + getState, + buildGoalSendText, + nudgeSendText, + onUpdate, + saveBeforeUnload, + maxIterations, + cancelNudge, +}; diff --git a/src/preload/dom/observer.js b/src/preload/dom/observer.js index 466c978..398c3ec 100644 --- a/src/preload/dom/observer.js +++ b/src/preload/dom/observer.js @@ -305,9 +305,12 @@ async function executeJsBlocksWithRetry(initialBlocks, markdown, force) { // оставляем пусто — не показываем 0/0 } catch (_) {} } - const diff = r && Array.isArray(r.stats) - ? r.stats.flatMap((s) => Array.isArray(s.diff) ? s.diff : []) - : (r && r.stats && Array.isArray(r.stats.diff) ? r.stats.diff : []); + const diff = + r && Array.isArray(r.stats) + ? r.stats.flatMap((s) => (Array.isArray(s.diff) ? s.diff : [])) + : r && r.stats && Array.isArray(r.stats.diff) + ? r.stats.diff + : []; if (added || removed || diff.length) toolRender.markToolBlockDiff(item.code, { added, removed, diff }); } catch (_) {} @@ -771,6 +774,15 @@ async function processLatestAIResponseInner(retryCount = 0, force = false) { lastNotifiedText = text; window.electronAPI.showAiNotification().catch(() => {}); } + // Goal mode: раунд завершён (нет тул-блоков) → планируем авто-продолжение. + // Импорт ленивый — движок не должен грузиться, если goal не используется. + try { + const goalEngine = require("./goal-engine"); + if (goalEngine.isActive()) { + goalEngine.checkProgress(text); + goalEngine.scheduleGoalNudge(); + } + } catch (_) {} } } } diff --git a/src/preload/i18n/i18n.js b/src/preload/i18n/i18n.js index da50c77..1c701b7 100644 --- a/src/preload/i18n/i18n.js +++ b/src/preload/i18n/i18n.js @@ -822,6 +822,10 @@ const KEYS = { ru: "Нажать кнопку отправки в чате (если отправка зависла)", en: "Click the send button in chat (if sending got stuck)", }, + "cmd.goal.description": { + ru: "Задать цель: /goal <описание> (авто-продвижение)", + en: "Set a goal: /goal
(auto-advance)", + }, "plan.toggle.label": { ru: "План", en: "Plan" }, "plan.dialog.title": { ru: "План на утверждение", en: "Plan for approval" }, "plan.dialog.deny": { ru: "Отказать в плане", en: "Reject plan" }, diff --git a/src/preload/index.js b/src/preload/index.js index 75cd081..920eb17 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -31,6 +31,8 @@ const forceDarkTheme = require("./dom/force-dark-theme"); const qrOverride = require("./dom/qr-override"); const fileChip = require("./dom/file-chip"); const contextPort = require("./dom/context-port"); +const goalEngine = require("./dom/goal-engine"); +const goalCommand = require("./dom/goal-command"); const fonts = require("./dom/fonts"); const statsDashboard = require("./dom/stats-dashboard"); const statsRecorder = require("./dom/stats-recorder"); @@ -107,6 +109,8 @@ async function init() { whatsNew.registerWhatsNewListener(), ); safe("init.planModeToggleStart", () => planModeToggle.startWatch()); + safe("init.goalEngine", () => goalEngine.init()); + safe("init.goalCommand", () => goalCommand.install()); // Базовая UI-инфраструктура нужна всегда: оверлей (кнопка), стили, события safe("init.injectCSS", () => ui.injectCSS()); diff --git a/src/preload/overlay/events.js b/src/preload/overlay/events.js index 74d8d6d..ad8b168 100644 --- a/src/preload/overlay/events.js +++ b/src/preload/overlay/events.js @@ -24,6 +24,7 @@ const { openCommitFullDiff, } = require("./diff-panel"); const todoPanel = require("./todo-panel"); +const goalPanel = require("./goal-panel"); const { handleInitProject, renderSessions } = require("../dom/session-list"); const { handleManualParse } = require("../dom/observer"); const contextPort = require("../dom/context-port"); @@ -649,6 +650,7 @@ function bindEvents() { // Плавающее окошко «Задачи» todoPanel.start(); + goalPanel.start(); // 启动输入框 token 估算 startTokenCounter(); diff --git a/src/preload/overlay/goal-panel.js b/src/preload/overlay/goal-panel.js new file mode 100644 index 0000000..10488a9 --- /dev/null +++ b/src/preload/overlay/goal-panel.js @@ -0,0 +1,225 @@ +/** + * Плавающая панель «Цель» (goalbar). + * Порт из upstream cuckoo-code (harness goalbar) на overlay-архитектуру. + * - Появляется, когда есть активная/завершённая цель. + * - Перетаскивается за заголовок; позиция в localStorage. + * - Данные: main (goal-get) + пуш goal-updated из preload движка. + */ + +const PANEL_ID = "cuckoo-goal-panel"; +const TEXT_ID = "cuckoo-goal-text"; +const STATE_ID = "cuckoo-goal-state"; +const ROUND_ID = "cuckoo-goal-round"; +const DRAG_ID = "cuckoo-goal-drag"; +const CLOSE_ID = "cuckoo-goal-close"; +const PAUSE_ID = "cuckoo-goal-pause"; +const EXIT_ID = "cuckoo-goal-exit"; +const POS_KEY = "cuckoo-goal-pos"; + +let current = { active: false }; +let userClosed = false; + +/** Отформатировать прошедшее время. */ +function fmtElapsed(ms) { + const s = Math.floor((ms || 0) / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? m + "м " + sec + "с" : sec + "с"; +} + +/** Отрисовать панель по состоянию. */ +function render(goal) { + const panel = document.getElementById(PANEL_ID); + if (!panel) return; + current = goal || { active: false }; + + // Не показываем, если цели нет или пользователь скрыл вручную. + const hasGoal = !!(current && current.text); + if (!hasGoal) { + panel.classList.add("cuckoo-hidden"); + userClosed = false; + return; + } + if (userClosed) { + panel.classList.add("cuckoo-hidden"); + return; + } + panel.classList.remove("cuckoo-hidden"); + + const state = current.done ? "done" : current.paused ? "paused" : "running"; + panel.className = "cuckoo-goal-panel " + state; + + const stateEl = document.getElementById(STATE_ID); + if (stateEl) + stateEl.textContent = current.done + ? "завершена" + : current.paused + ? "пауза" + : "выполняется"; + + const textEl = document.getElementById(TEXT_ID); + if (textEl && document.activeElement !== textEl) { + textEl.value = current.text || ""; + textEl.readOnly = !!current.done; + } + + const roundEl = document.getElementById(ROUND_ID); + if (roundEl) { + roundEl.textContent = + "итерация " + + (current.round || 0) + + (current.maxIterations ? " / " + current.maxIterations : "") + + " · " + + fmtElapsed(current.elapsedMs); + } + + const pauseBtn = document.getElementById(PAUSE_ID); + if (pauseBtn) { + pauseBtn.textContent = current.done + ? "Ок" + : current.paused + ? "Продолжить" + : "Пауза"; + pauseBtn.disabled = !!current.done; + } + const exitBtn = document.getElementById(EXIT_ID); + if (exitBtn) exitBtn.textContent = current.done ? "Скрыть" : "Выход"; +} + +/** Загрузить состояние из main. */ +async function refresh() { + try { + const res = await window.electronAPI.getGoal(); + render(res && res.success ? res.goal : { active: false }); + } catch (_) { + render({ active: false }); + } +} + +/** Восстановить позицию. */ +function restorePosition() { + const panel = document.getElementById(PANEL_ID); + if (!panel) return; + try { + const raw = localStorage.getItem(POS_KEY); + if (!raw) return; + const pos = JSON.parse(raw); + if (typeof pos.left === "number" && typeof pos.top === "number") { + panel.style.left = pos.left + "px"; + panel.style.top = pos.top + "px"; + } + } catch (_) {} +} + +/** Сделать панель перетаскиваемой. */ +function makeDraggable() { + const panel = document.getElementById(PANEL_ID); + const handle = document.getElementById(DRAG_ID); + if (!panel || !handle) return; + let dragging = false; + let startX = 0, + startY = 0, + startLeft = 0, + startTop = 0; + const onDown = (e) => { + dragging = true; + const rect = panel.getBoundingClientRect(); + startLeft = rect.left; + startTop = rect.top; + startX = e.clientX; + startY = e.clientY; + 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); + left = Math.max(0, Math.min(window.innerWidth - 60, left)); + top = Math.max(0, Math.min(window.innerHeight - 40, top)); + panel.style.left = left + "px"; + panel.style.top = top + "px"; + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + try { + const rect = panel.getBoundingClientRect(); + localStorage.setItem( + POS_KEY, + JSON.stringify({ left: rect.left, top: rect.top }), + ); + } catch (_) {} + }; + handle.addEventListener("mousedown", onDown); +} + +/** Инициализация панели. */ +function start() { + restorePosition(); + makeDraggable(); + + const closeBtn = document.getElementById(CLOSE_ID); + if (closeBtn) + closeBtn.addEventListener("click", () => { + userClosed = true; + const panel = document.getElementById(PANEL_ID); + if (panel) panel.classList.add("cuckoo-hidden"); + }); + + const pauseBtn = document.getElementById(PAUSE_ID); + if (pauseBtn) + pauseBtn.addEventListener("click", async () => { + try { + if (current.done) return; + if (current.paused) await window.electronAPI.resumeGoal(); + else await window.electronAPI.pauseGoal(); + } catch (_) {} + }); + + const exitBtn = document.getElementById(EXIT_ID); + if (exitBtn) + exitBtn.addEventListener("click", async () => { + try { + if (current.done) { + userClosed = true; + const panel = document.getElementById(PANEL_ID); + if (panel) panel.classList.add("cuckoo-hidden"); + return; + } + await window.electronAPI.stopGoal(); + } catch (_) {} + }); + + // Правка текста цели по Enter/blur. + const textEl = document.getElementById(TEXT_ID); + if (textEl) { + const commit = () => { + const v = (textEl.value || "").trim(); + if (v && current && !current.done && v !== current.text) { + // Обновляем через движок: шлём команду set через goal-control не поддержан — + // пока просто локально (текст цели меняется редко). + } + }; + textEl.addEventListener("blur", commit); + textEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + textEl.blur(); + } + }); + } + + // Подписка на пуш из preload-движка. + try { + window.electronAPI.onGoalUpdated((state) => { + render(state); + }); + } catch (_) {} + refresh(); +} + +module.exports = { start, refresh, render }; diff --git a/src/preload/overlay/template.js b/src/preload/overlay/template.js index d002052..5841eb4 100644 --- a/src/preload/overlay/template.js +++ b/src/preload/overlay/template.js @@ -165,6 +165,25 @@ function buildOverlayHTML() { " ", " ", "", + // ===== Панель цели (goalbar) ===== + ' ', + '", '', + ' ', + ' Цель', + ' выполняется', + ' ', + "", + '', + ' ', + ' ", + '", + "', + ' ', + ' ', + "", + "', '', ' ' + t("wm.title") + "", @@ -443,6 +462,41 @@ const OVERLAY_CSS = [ ".cuckoo-mcp-right { flex: 1; display: flex; flex-direction: column; gap: 10px; }", ".cuckoo-mcp-json { flex: 1; min-height: 55vh; resize: vertical; }", ".cuckoo-mcp-right .cuckoo-btn { flex: 0 0 auto; align-self: flex-start; }", + // ===== Панель цели (goalbar) ===== + ".cuckoo-goal-panel {", + " position: fixed; left: 16px; top: 90px; z-index: 2147483646;", + " width: 320px; max-width: 80vw;", + " background: rgba(17, 19, 34, 0.97);", + " border: 1px solid var(--ck-border); border-radius: 12px;", + " display: flex; flex-direction: column; overflow: hidden;", + " box-shadow: 0 10px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(139, 147, 255, 0.08);", + "}", + ".cuckoo-goal-panel.cuckoo-hidden { display: none; }", + ".cuckoo-goal-header {", + " display: flex; align-items: center; gap: 7px;", + " padding: 9px 12px; cursor: move; user-select: none;", + " border-bottom: 1px solid var(--ck-border);", + " background: rgba(255, 255, 255, 0.02);", + "}", + ".cuckoo-goal-icon { display: flex; color: #7c8cff; flex-shrink: 0; }", + ".cuckoo-goal-title { font-size: 12px; font-weight: 600; color: #c8ccff; }", + ".cuckoo-goal-state { font-size: 10.5px; color: #8a90b8; margin-left: auto; }", + ".cuckoo-goal-panel.running .cuckoo-goal-state { color: #ffb454; }", + ".cuckoo-goal-panel.done .cuckoo-goal-state { color: var(--ck-green); }", + ".cuckoo-goal-panel.paused .cuckoo-goal-state { color: #8a90b8; }", + ".cuckoo-goal-header .cuckoo-btn-icon { flex-shrink: 0; }", + ".cuckoo-goal-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }", + ".cuckoo-goal-text {", + " width: 100%; box-sizing: border-box;", + " background: var(--ck-surface); border: 1px solid var(--ck-border);", + " border-radius: 7px; padding: 6px 9px; color: #d4d8ff;", + " font-size: 12px; font-family: inherit; outline: none;", + "}", + ".cuckoo-goal-text:focus { border-color: #7c8cff; }", + ".cuckoo-goal-text[readonly] { cursor: default; opacity: 0.8; }", + ".cuckoo-goal-meta { font-size: 10.5px; color: #8a90b8; }", + ".cuckoo-goal-actions { display: flex; gap: 6px; }", + ".cuckoo-goal-actions .cuckoo-btn { flex: 1; }", ".cuckoo-window-manager {", " position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);", " z-index: 2147483647; width: 520px; height: 80vh; max-height: 85vh;", diff --git a/tools/GoalDoneTool.js b/tools/GoalDoneTool.js new file mode 100644 index 0000000..d181339 --- /dev/null +++ b/tools/GoalDoneTool.js @@ -0,0 +1,79 @@ +/** + * Инструмент goal_done — явное завершение цели (goal mode). + * + * Порт из upstream wangyongpeng90/cuckoo-code (tools/impl/goal-done.ts). + * + * AI вызывает goalDone() когда все требования /goal-цели выполнены. Тул шлёт + * IPC 'goal-done' в окно (senderId) → preload останавливает цикл автопродвижения. + * Заменяет старый текстовый маркер [[GOAL_DONE]] (модель его забывала писать). + */ +const { Tool, ToolResult } = require("./ToolRegistry"); + +class GoalDoneTool extends Tool { + constructor() { + super( + "goal_done", + "Объявить, что текущая цель (/goal — автоматическая многораундовая задача) " + + "полностью выполнена, и завершить автопродвижение. " + + "Вызывай только когда все требования цели действительно выполнены; " + + "перед вызовом дай в тексте итог выполнения. " + + "В обычном диалоге (не режим цели) этот инструмент не нужен.", + { + type: "object", + properties: {}, + additionalProperties: false, + }, + "goalDone()", + ); + } + + getPromptSection() { + return { + name: "tool:goal_done", + order: 115, + text: + "Когда все требования /goal-цели действительно выполнены, вызови goalDone() " + + "чтобы завершить автопродвижение; перед вызовом дай в тексте итог выполнения. " + + "Если цель не завершена или встретилось препятствие — не вызывай, продолжай " + + "или объясни причину. В обычном диалоге (не режим цели) не вызывай этот инструмент.", + }; + } + + async execute(params) { + const { goalDone, senderId, currentWindowId } = params || {}; + const targetWindowId = + currentWindowId !== undefined && currentWindowId !== null + ? currentWindowId + : senderId; + if (targetWindowId === undefined || targetWindowId === null) { + return ToolResult.error( + "Нет контекста окна — не удалось определить текущий чат", + ); + } + if (typeof goalDone !== "function") { + return ToolResult.error("Инструмент goal_done недоступен в текущем окне"); + } + try { + const res = await goalDone(targetWindowId); + if (res && res.success) { + console.log( + "[GoalDoneTool] AI объявил цель выполненной (window " + + targetWindowId + + ")", + ); + return ToolResult.success({ + message: "Цель выполнена, автопродвижение завершено.", + }); + } + return ToolResult.error( + (res && res.error) || "Не удалось завершить цель", + ); + } catch (err) { + return ToolResult.error( + "Не удалось завершить цель: " + (err.message || String(err)), + ); + } + } +} + +module.exports = { GoalDoneTool }; diff --git a/tools/JsRunner.js b/tools/JsRunner.js index 8833583..3c72ca5 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -221,6 +221,10 @@ const BOOTSTRAP = [ " return await __call('exit_plan_mode', { plan: plan });", " };", " globalThis.exit_plan_mode = globalThis.exitPlanMode;", + " globalThis.goalDone = async function () {", + " return await __call('goal_done', {});", + " };", + " globalThis.goal_done = globalThis.goalDone;", " globalThis.openBrowserWindow = async function (url, options) {", " options = options || {};", " return await __call('open_browser_window', {", @@ -380,6 +384,7 @@ class JsRunner { currentWindowId, toolsWhitelist, agentName, + goalDone, ) { if (!code || typeof code !== "string" || !code.trim()) { return { success: false, error: "无效的 JS 代码" }; @@ -461,6 +466,7 @@ class JsRunner { pasteImage, exitPlanMode, attachFile, + goalDone, }), ); } catch (err) { diff --git a/tools/cuckoo-tools.d.ts b/tools/cuckoo-tools.d.ts index 3461f61..b6f55a8 100644 --- a/tools/cuckoo-tools.d.ts +++ b/tools/cuckoo-tools.d.ts @@ -429,3 +429,12 @@ declare function skillLoad(name: string): Promise; * @throws Skill 未加载或函数不存在时抛出异常 */ declare function skillExecute(skill: string, functionName: string, args?: Record ): Promise ; + +/** + * Объявить текущую цель (/goal) полностью выполненной, завершить автопродвижение. + * Вызывать только когда все требования цели действительно выполнены; перед вызовом + * дать в тексте итог. В обычном диалоге (не режим цели) не вызывать. + * @returns Сообщение о завершении цели + * @throws Если нет контекста окна или инструмент недоступен + */ +declare function goalDone(): Promise ; diff --git a/tools/index.js b/tools/index.js index 8e23099..bcea450 100644 --- a/tools/index.js +++ b/tools/index.js @@ -35,6 +35,7 @@ const { const { AskUserQuestionTool } = require("./AskUserQuestionTool"); const { ReadPhotoTool } = require("./ReadPhotoTool"); const { ExitPlanModeTool } = require("./ExitPlanModeTool"); +const { GoalDoneTool } = require("./GoalDoneTool"); const { AttachFileTool } = require("./AttachFileTool"); const { AttachTelegramTool } = require("./AttachTelegramTool"); const { @@ -82,6 +83,7 @@ registry.register(new SkillExecuteTool()); registry.register(new AskUserQuestionTool()); registry.register(new ReadPhotoTool()); registry.register(new ExitPlanModeTool()); +registry.register(new GoalDoneTool()); registry.register(new AttachFileTool()); registry.register(new AttachTelegramTool()); registry.register(new MemorySaveTool()); @@ -124,6 +126,7 @@ module.exports = { AskUserQuestionTool, ReadPhotoTool, ExitPlanModeTool, + GoalDoneTool, AttachFileTool, AttachTelegramTool, InjectPageJSTool,