diff --git a/CHANGELOG.md b/CHANGELOG.md index fda564c..af98b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [4.0.28] - 2026-09-17 + +### Added + +- 🔤 **Шрифт на выбор — теперь и для интерфейса, и для самой страницы** — в настройках появилась отдельная секция «Шрифт»: + - встроенный **Anthropic Mono** — выбирается одним кликом; по умолчанию всё работает на системном шрифте, ничего не навязывается; + - **свои шрифты** — положи файлы `.ttf` / `.otf` / `.woff` / `.woff2` в папку (кнопка «Открыть папку шрифтов») и нажми «Обновить» — шрифт появится в сетке превью; + - **слайдер жирности** (100–900) — работает и для системного, и для любого кастомного шрифта; + - выбранный шрифт применяется мгновенно ко всему интерфейсу Cookie Code **и** к странице DeepSeek (иконки и эмодзи не ломаются). +- 🔁 **Перенос контекста в новый чат** — длинный диалог можно продолжить в свежем чате, не теряя наработок: + - кнопка **«Перенести контекст»** в панели Cookie Code; + - история текущего чата читается из DOM, **AI сжимает её в структурный конспект** (цель, решения, изменённые файлы, открытые вопросы); + - вместе с конспектом в новый чат переносится **промпт инициализации проекта** (дерево каталога + системный промпт), поэтому AI сразу «знает» проект; + - длинные истории автоматически подрезаются по оценке токенов (до 50 000). +- 🗂 **Настройки разбиты на категории** — вместо одной длинной простыни появились подвкладки: 🎨 Тема и стекло, 🪟 Панель Cookie, 🖼 Фон страницы, 🤖 Telegram-бот, ⚙️ Система и безопасность: + - иконки подвкладок — аккуратные SVG (без эмодзи), рядом с «Cookie Code» — иконка печенья; + - панель подвкладок прячется при прокрутке вниз и возвращается при прокрутке вверх. + +### Changed + +- **Иконки подвкладок настроек** — эмодзи заменены на инлайн-SVG, которые наследуют цвет кнопки (в т.ч. белый у активной). + +### Fixed + +- Перенос контекста: надёжное ожидание ответа AI (по появлению нового сообщения и стабилизации текста, а не только по кнопке «стоп») — больше не «зависает» на коротких ответах. +- Перенос контекста: промпт инициализации теперь **пересобирается в главном процессе** из сохранённого каталога проекта, поэтому не теряется при перезагрузке страницы. + ## [4.0.27] - 2026-09-17 ### Added diff --git a/CHANGELOG_GUIDE.md b/CHANGELOG_GUIDE.md new file mode 100644 index 0000000..ac24a53 --- /dev/null +++ b/CHANGELOG_GUIDE.md @@ -0,0 +1,201 @@ +# CHANGELOG — инструкция по ведению + +> Документ для людей и AI-агентов. Описывает **как правильно писать записи** в `CHANGELOG.md` +> этого проекта, чтобы их корректно распарсил встроенный механизм **What's New** и чтобы CI +> собрал релиз без ошибок. + +--- + +## TL;DR + +1. Добавляй новую запись **в самый верх** файла, сразу под `# Changelog`. +2. Формат заголовка: `## [X.Y.Z] - YYYY-MM-DD` — **строго** с квадратными скобками и дефисом. +3. Секции: `### Added`, `### Changed`, `### Fixed`, `### Removed` (можно не все). +4. Пункты — строки, начинающиеся с `- ` или `* `. +5. **НЕ меняй версию в `package.json`** — её выставит CI из git-тега. +6. Версия в CHANGELOG должна совпадать с тегом, который запушишь (например `v4.0.25` → `4.0.25`). +7. ФОРМАТ ДРУЖЕЛЮБНЫЙ А НЕ ТЕХНИЧЕСКА КАША!!! С ЭМОДЗИ ЭМОТИКОИНАМИ ПО ТИПУ :) + +--- + +## Формат, который понимает парсер + +Парсер — `src/main/changelog-parser.js`. Он **не универсальный** (не полный Keep a Changelog), +а поддерживает ровно тот формат, что в нашем репо. Регулярные выражения: + +```js +const VER_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/; // ## [4.0.25] - 2026-09-17 +const SECTION_RE = /^###\s+(.+?)\s*$/; // ### Added +const ITEM_RE = /^\s*[-*]\s+(.+)$/; // - пункт +``` + +### Что это значит на практике + +| Элемент | Правильно | Неправильно | +| ---------------- | -------------------------------------------------------------- | ---------------------------------------- | +| Заголовок версии | `## [4.0.25] - 2026-09-17` | `## 4.0.25`, `## [4.0.25]`, `# [4.0.25]` | +| Дата | `- 2026-09-17` (опционально) | `(2026-09-17)`, `2026/09/17` | +| Секция | `### Added` | `## Added`, `**Added**` | +| Пункт | `- Текст` или `* Текст` | `1. Текст`, `Текст` (без маркера) | +| Unreleased | `## [Unreleased]` — парсится, но **пропускается** в What's New | — | + +> ⚠️ Заголовок версии обязательно на уровне `##` (два решётки), секции — на `###` (три). +> Если перепутать — парсер не увидит ни версию, ни секцию. + +--- + +## Как парсер выбирает, что показать в What's New + +Функция `extractSince(entries, from, to)`: + +- `from` — версия, **с которой** обновился пользователь (не включается); +- `to` — текущая версия (включается); +- выбираются версии в диапазоне `(from, to]`, сортируются по убыванию; +- записи `[Unreleased]` **пропускаются**; +- версии без semver-формата (`\d+.\d+.\d+`) игнорируются. + +**Вывод:** все промежуточные версии между старой и новой покажутся пользователю. +Держи записи осмысленными — их реально увидят при обновлении. + +--- + +## Процесс релиза + +Версия в `package.json` **не бампается вручную**. Это делает CI: + +```yaml +# .github/workflows/release.yml +on: + push: + tags: ["v*"] + +jobs: + build: + steps: + - name: Sync version from tag + run: node scripts/sync-version.js + env: + GITHUB_REF_NAME: ${{ github.ref_name }} +``` + +`scripts/sync-version.js` берёт версию из имени тега (`v4.0.25` → `4.0.25`) и прописывает +её в `package.json` и `package-lock.json`. + +### Правильный порядок + +1. Внести изменения в код. +2. **Добавить запись в `CHANGELOG.md`** (в самый верх). +3. Закоммитить изменения. +4. Запушить тег: `git tag v4.0.25 && git push origin v4.0.25`. +5. CI сам выставит версию и соберёт билд. + +> ❌ **Не редактируй `package.json` вручную ради версии** — это создаст конфликт с CI. + +--- + +## Шаблон записи + +Скопируй и заполни: + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +### Added + +- **Краткое название фичи** — что она делает и зачем. Детали: + - подпункт (вложенный `- ` с отступом); + - ещё подпункт. + +### Changed + +- **Что изменилось** — было → стало. Причины. + +### Fixed + +- Что было сломано и как починили. + +### Removed + +- Что удалили и почему. +``` + +--- + +## Стиль написания + +- **Язык:** русский (как весь существующий CHANGELOG). +- **Формат пункта:** `- **Жирный заголовок** — пояснение.` Название фичи выделяй `**...**`. +- **Код/имена** — в обратных кавычках: `attach_file`, `package.json`, `localStorage`. +- **Детали** — вложенными пунктами (отступ 2 пробела + `- `), не перегружай главную строку. +- **Время** — настоящее (`добавлено`, `исправлено`), не будущее. +- Пиши **что и зачем**, а не «обновлён файл X». +- Не дублируй пункты между секциями. +- Одна версия = одна дата = один блок `## [X.Y.Z]`. + +### Хорошо + +```markdown +### Added + +- **Инструмент `attach_file` — загрузка файлов-вложений в чат** — AI может прикрепить локальный + файл (PDF, Word, Excel, PPT, изображения) прямо в поле ввода: + - скрытый `input[type=file]` заполняется через `DataTransfer` + событие `change`; + - лимит 30 МБ, авто-определение MIME по расширению. +``` + +### Плохо + +```markdown +### Added + +- Добавил новый инструмент. +- Изменил ipc.js. +- Много всего. +``` + +--- + +## Чек-лист перед коммитом + +- [ ] Запись добавлена **в самый верх** (под `# Changelog`). +- [ ] Заголовок `## [X.Y.Z] - YYYY-MM-DD` (квадратные скобки, дефис, ISO-дата). +- [ ] Версия — следующая после последней в файле (semver, patch/minor/major по смыслу). +- [ ] Секции на `###`, пункты на `- `. +- [ ] `package.json` **не тронут**. +- [ ] Версия совпадает с будущим тегом (`vX.Y.Z`). + +--- + +## Проверка (опционально) + +Убедиться, что парсер видит новую версию: + +```bash +node -e "const fs=require('fs'); const {parseChangelog}=require('./src/main/changelog-parser'); const e=parseChangelog(fs.readFileSync('CHANGELOG.md','utf8')); console.log(e.map(x=>x.version).join(', '));" +``` + +Ожидаемый вывод (пример): `4.0.25, 4.0.24` — новая версия **первой**. + +--- + +## Частые ошибки + +| Ошибка | Последствие | Как исправить | +| -------------------------------- | --------------------------------- | ------------------------------------------- | +| `## 4.0.25` без скобок | Парсер не видит версию | Добавить `[ ]`: `## [4.0.25]` | +| `## [4.0.25]` без даты | Дата = `null`, в UI без даты | Добавить `- YYYY-MM-DD` | +| Секция `## Added` (2 решётки) | Пункты теряются | Сделать `### Added` | +| Пункт без `- ` | Пункт не попадает в список | Добавить маркер `- ` | +| Ручной бамп `package.json` | Конфликт с CI `sync-version.js` | Откатить версию в package.json | +| Запись в середине/конце файла | Нарушен порядок (свежие — сверху) | Перенести наверх | +| `[Unreleased]` как основной блок | Не покажется в What's New | Заменить на конкретную версию перед релизом | + +--- + +## Ссылки на код + +- Парсер: `src/main/changelog-parser.js` (`parseChangelog`, `extractSince`, `toMarkdown`) +- What's New: `src/main/whats-new.js`, `src/preload/dom/whats-new.js` +- CI-релиз: `.github/workflows/release.yml` +- Синхронизация версии: `scripts/sync-version.js` +- Сам файл: `CHANGELOG.md` diff --git a/README.md b/README.md index 3fb266d..a7bcf59 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It embeds the DeepSeek web chat into a native Electron window, injects a side ov ## Why it exists -Web chats are great at *thinking*, but they cannot *act* on your machine. Cookie Code closes that loop: +Web chats are great at _thinking_, but they cannot _act_ on your machine. Cookie Code closes that loop: - **Zero token cost** — everything goes through the DeepSeek web UI, no API calls. - **Real agent loop** — Think → Act → Observe → Repeat. File I/O, code search, shell commands, database queries, MCP tools. @@ -100,6 +100,22 @@ Under each AI reply you get an automatic badge: Change instantly, no reload required. +### Fonts + +Full control over the font of the Cookie Code UI **and** the DeepSeek page: + +- **System font** by default — nothing forced +- **Built-in Anthropic Mono** — one-click choice +- **Your own fonts** — drop files (`.ttf` / `.otf` / `.woff` / `.woff2`) into `/fonts` (button **"Open fonts folder"**) and click **"Refresh"** — the font appears in the preview grid +- **Weight slider** (100–900) — works for both the system font and any custom font +- Pick from a preview grid in **Settings → Cookie Code**; applies instantly to the whole UI and the page + +All settings persist in `cuckoo-settings.json` (`font`, `fontWeight`). + +

+ Custom Anthropic Mono font on the DeepSeek page +

+ ### Blur & transparency Full control over the UI glass effect: @@ -141,6 +157,15 @@ Control and monitor Cookie Code from your phone: No Electron system menu — the app opens straight into DeepSeek. All standard keyboard shortcuts (Ctrl+C/V, Ctrl+R, F12) still work. +### Context transfer + +Continue a long chat in a new one without losing progress: + +- **"Transfer context"** button in the Cookie Code panel +- The current chat history is read from the DOM and **the AI compresses it into a structured summary** (goal, decisions, changed files, open questions) +- A new chat opens automatically and the summary is injected as context — work continues from the same point +- Long histories are auto-trimmed by token estimate + ### Project initialization Pick a project directory once — the AI receives the directory tree and a system prompt tailored to the real project. Every tool call then resolves paths relative to that directory. @@ -159,15 +184,15 @@ Every `write` and `edit` runs the file through a language-specific formatter so Built-in formatters: -| Formatter | Trigger | What it needs | -|-----------|---------|---------------| -| `prettier` | `.js .jsx .ts .tsx .json .css .md .yaml` … | `prettier` in the nearest `package.json` + binary in `node_modules/.bin` or `PATH` | -| `biome` | same as prettier | `biome.json` / `biome.jsonc` in the project | -| `gofmt` | `.go` | `gofmt` in `PATH` | -| `ruff` | `.py .pyi` | `ruff` in `PATH` + `[tool.ruff]` in `pyproject.toml` (or `ruff.toml`) | -| `rustfmt` | `.rs` | `rustfmt` in `PATH` | -| `shfmt` | `.sh .bash` | `shfmt` in `PATH` | -| `clang-format` | `.c .cpp .h` … | `.clang-format` config + `clang-format` in `PATH` | +| Formatter | Trigger | What it needs | +| -------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- | +| `prettier` | `.js .jsx .ts .tsx .json .css .md .yaml` … | `prettier` in the nearest `package.json` + binary in `node_modules/.bin` or `PATH` | +| `biome` | same as prettier | `biome.json` / `biome.jsonc` in the project | +| `gofmt` | `.go` | `gofmt` in `PATH` | +| `ruff` | `.py .pyi` | `ruff` in `PATH` + `[tool.ruff]` in `pyproject.toml` (or `ruff.toml`) | +| `rustfmt` | `.rs` | `rustfmt` in `PATH` | +| `shfmt` | `.sh .bash` | `shfmt` in `PATH` | +| `clang-format` | `.c .cpp .h` … | `.clang-format` config + `clang-format` in `PATH` | - Detection is **config-aware**: ruff won't run in a project without a `[tool.ruff]` section; prettier won't run without a `package.json` dependency. No unexpected reformatting of foreign code. - Formatter errors are swallowed — a failed formatter never blocks `write`/`edit`. @@ -246,19 +271,19 @@ Cookie Code intercepts it, executes it in a sandbox, and returns the result to t ## Available tools -| Tool | Description | -|------|-------------| -| `read`, `readLines` | Read files (with line numbers, offset/limit) | -| `write`, `edit` | Create / modify files (auto-formatted on save — see below) | -| `deleteFile` | Delete a file | -| `glob`, `grep` | File search (ripgrep-backed) | -| `bash`, `pwsh` | Execute shell commands | -| `todoWrite` | Structured task list | -| `webFetch` | Fetch HTTP(S) content as Markdown | -| `mysql` | Run SQL queries | -| `mcpCall`, `mcpListServers`, `mcpGetTools` | MCP tools | -| `skillList`, `skillLoad`, `skillExecute` | Custom skills | -| `openBrowserWindow`, `injectJS` | Electron browser window + JS injection | +| Tool | Description | +| ------------------------------------------ | ---------------------------------------------------------- | +| `read`, `readLines` | Read files (with line numbers, offset/limit) | +| `write`, `edit` | Create / modify files (auto-formatted on save — see below) | +| `deleteFile` | Delete a file | +| `glob`, `grep` | File search (ripgrep-backed) | +| `bash`, `pwsh` | Execute shell commands | +| `todoWrite` | Structured task list | +| `webFetch` | Fetch HTTP(S) content as Markdown | +| `mysql` | Run SQL queries | +| `mcpCall`, `mcpListServers`, `mcpGetTools` | MCP tools | +| `skillList`, `skillLoad`, `skillExecute` | Custom skills | +| `openBrowserWindow`, `injectJS` | Electron browser window + JS injection | Full TypeScript declarations are shipped at `tools/cuckoo-tools.d.ts`. @@ -274,6 +299,8 @@ Cookie Code is built to be reshaped: swap wallpapers, tune the glass effect, cha Cookie Code — customization settings
Cookie Code — custom theme +
+ Cookie Code — custom Anthropic Mono font on the DeepSeek page

### Appearance @@ -282,6 +309,7 @@ Everything visual lives in **Settings → Cookie Code** and persists in `cuckoo- - **Backgrounds** — 27 built-in wallpapers, or drop your own image into `src/ui/backgrounds/` and register it in `registry.json`. - **Glass effect** — background / header / sidebar blur, opacity, and tool-block glass blur. +- **Fonts** — system by default, built-in Anthropic Mono, or your own fonts from a folder; weight slider (100–900). Applies to both the UI and the DeepSeek page. - **RGB username** — animated rainbow gradient in the sidebar, toggled under **Effects**. --- @@ -305,6 +333,8 @@ User settings live in `cuckoo-settings.json` under the app's userData directory: { "background": "miku", "backgroundBlur": 0, + "font": "system", + "fontWeight": 400, "headerBlur": 12, "sidebarBlur": 12, "headerOpacity": 45, @@ -357,6 +387,8 @@ src/ │ │ ├── response-meta.js ⏱ badge under replies │ │ ├── settings-tab.js Cookie Code tab in Settings │ │ ├── background.js Wallpaper & blur engine +│ │ ├── context-port.js Chat context transfer +│ │ ├── fonts.js UI & page font engine │ │ └── ... │ └── overlay/ Overlay panel UI │ ├── template.js buildOverlayHTML() + OVERLAY_CSS @@ -366,6 +398,7 @@ src/ │ └── deepseek.js Platform adapter ├── ui/ │ ├── backgrounds/ 27 wallpapers + registry.json +│ ├── fonts/ Built-in fonts (Anthropic Mono) │ └── logos/ tools/ Tool implementations (run in main process) └── cuckoo-tools.d.ts Type declarations for the AI diff --git a/README.ru.md b/README.ru.md index 19c7ff4..ce61b64 100644 --- a/README.ru.md +++ b/README.ru.md @@ -66,6 +66,22 @@ - Выбор через сетку превью в **Настройки → Cookie Code** - Смена мгновенная, без перезагрузки +### Шрифт + +Полный контроль над шрифтом интерфейса Cookie Code **и** страницы DeepSeek: + +- **Системный шрифт** по умолчанию — ничего не навязывается +- **Встроенный Anthropic Mono** — на выбор одним кликом +- **Свои шрифты** — положите файлы (`.ttf` / `.otf` / `.woff` / `.woff2`) в папку `/fonts` (кнопка **«Открыть папку шрифтов»**) и нажмите **«Обновить»** — шрифт появится в сетке превью +- **Слайдер жирности** (100–900) — работает и для системного, и для любого кастомного шрифта +- Выбор через сетку превью в **Настройки → Cookie Code**, применяется мгновенно и ко всему интерфейсу, и к странице DeepSeek + +Все настройки сохраняются в `cuckoo-settings.json` (`font`, `fontWeight`). + +

+ Кастомный шрифт Anthropic Mono на странице DeepSeek +

+ ### Блюр и прозрачность Полный контроль над стеклянным эффектом интерфейса: @@ -106,6 +122,15 @@ Без системного меню Electron — приложение сразу открывается в DeepSeek. Все стандартные горячие клавиши (Ctrl+C/V, Ctrl+R, F12) работают. +### Перенос контекста + +Длинный чат можно продолжить в новом, не теряя наработок: + +- Кнопка **«Перенести контекст»** в панели Cookie Code +- История текущего чата читается из DOM, **AI сжимает её в структурный конспект** (цель, решения, изменённые файлы, открытые вопросы) +- Новый чат открывается автоматически, конспект вставляется как контекст — работа продолжается с того же места +- Длинные истории автоматически подрезаются по оценке токенов + ### Инициализация проекта Выберите каталог проекта один раз — AI получит дерево каталога и системный промпт, адаптированный к реальному проекту. Все дальнейшие вызовы инструментов разрешают пути относительно этого каталога. @@ -124,15 +149,15 @@ Встроенные форматтеры: -| Форматтер | Для чего | Что нужно | -|-----------|----------|-----------| -| `prettier` | `.js .jsx .ts .tsx .json .css .md .yaml` … | `prettier` в ближайшем `package.json` + бинарь в `node_modules/.bin` или `PATH` | -| `biome` | то же, что prettier | `biome.json` / `biome.jsonc` в проекте | -| `gofmt` | `.go` | `gofmt` в `PATH` | -| `ruff` | `.py .pyi` | `ruff` в `PATH` + `[tool.ruff]` в `pyproject.toml` (или `ruff.toml`) | -| `rustfmt` | `.rs` | `rustfmt` в `PATH` | -| `shfmt` | `.sh .bash` | `shfmt` в `PATH` | -| `clang-format` | `.c .cpp .h` … | конфиг `.clang-format` + `clang-format` в `PATH` | +| Форматтер | Для чего | Что нужно | +| -------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | +| `prettier` | `.js .jsx .ts .tsx .json .css .md .yaml` … | `prettier` в ближайшем `package.json` + бинарь в `node_modules/.bin` или `PATH` | +| `biome` | то же, что prettier | `biome.json` / `biome.jsonc` в проекте | +| `gofmt` | `.go` | `gofmt` в `PATH` | +| `ruff` | `.py .pyi` | `ruff` в `PATH` + `[tool.ruff]` в `pyproject.toml` (или `ruff.toml`) | +| `rustfmt` | `.rs` | `rustfmt` в `PATH` | +| `shfmt` | `.sh .bash` | `shfmt` в `PATH` | +| `clang-format` | `.c .cpp .h` … | конфиг `.clang-format` + `clang-format` в `PATH` | - Определение **учитывает конфиг проекта**: ruff не запустится без `[tool.ruff]`, prettier — без зависимости в `package.json`. Никакого неожиданного форматирования чужого кода. - Ошибки форматтера глушатся — падение форматтера никогда не блокирует `write`/`edit`. @@ -211,19 +236,19 @@ Cookie Code перехватывает его, выполняет в песоч ## Доступные инструменты -| Инструмент | Описание | -|-----------|----------| -| `read`, `readLines` | Чтение файлов (с номерами строк, offset/limit) | -| `write`, `edit` | Создание / изменение файлов (авто-форматирование при сохранении — см. ниже) | -| `deleteFile` | Удаление файла | -| `glob`, `grep` | Поиск по файлам (на базе ripgrep) | -| `bash`, `pwsh` | Выполнение команд | -| `todoWrite` | Структурированный список задач | -| `webFetch` | Загрузка HTTP(S) как Markdown | -| `mysql` | SQL-запросы | -| `mcpCall`, `mcpListServers`, `mcpGetTools` | MCP-инструменты | -| `skillList`, `skillLoad`, `skillExecute` | Пользовательские скиллы | -| `openBrowserWindow`, `injectJS` | Окно браузера Electron + инъекция JS | +| Инструмент | Описание | +| ------------------------------------------ | --------------------------------------------------------------------------- | +| `read`, `readLines` | Чтение файлов (с номерами строк, offset/limit) | +| `write`, `edit` | Создание / изменение файлов (авто-форматирование при сохранении — см. ниже) | +| `deleteFile` | Удаление файла | +| `glob`, `grep` | Поиск по файлам (на базе ripgrep) | +| `bash`, `pwsh` | Выполнение команд | +| `todoWrite` | Структурированный список задач | +| `webFetch` | Загрузка HTTP(S) как Markdown | +| `mysql` | SQL-запросы | +| `mcpCall`, `mcpListServers`, `mcpGetTools` | MCP-инструменты | +| `skillList`, `skillLoad`, `skillExecute` | Пользовательские скиллы | +| `openBrowserWindow`, `injectJS` | Окно браузера Electron + инъекция JS | Полные TypeScript-декларации — в `tools/cuckoo-tools.d.ts`. @@ -239,6 +264,8 @@ Cookie Code создан для того, чтобы его перестраив Cookie Code — настройки кастомизации
Cookie Code — собственная тема +
+ Cookie Code — кастомный шрифт Anthropic Mono на странице DeepSeek

### Внешний вид @@ -247,6 +274,7 @@ Cookie Code создан для того, чтобы его перестраив - **Фоны** — 27 встроенных обоев, либо положите своё изображение в `src/ui/backgrounds/` и зарегистрируйте его в `registry.json`. - **Стеклянный эффект** — размытие фона / шапки / сайдбара, прозрачность и размытие стекла tool-блоков. +- **Шрифт** — системный по умолчанию, встроенный Anthropic Mono или свои шрифты из папки; слайдер жирности (100–900). Применяется и к интерфейсу, и к странице DeepSeek. - **RGB-никнейм** — анимированный радужный градиент в сайдбаре, переключается в разделе **Effects**. ### Пользовательские скиллы @@ -290,6 +318,8 @@ AI получает к нему доступ через `skillList`, `skillLoad` { "background": "miku", "backgroundBlur": 0, + "font": "system", + "fontWeight": 400, "headerBlur": 12, "sidebarBlur": 12, "headerOpacity": 45, @@ -340,6 +370,8 @@ src/ │ │ ├── response-meta.js Бейдж ⏱ под ответом │ │ ├── settings-tab.js Вкладка Cookie Code в настройках │ │ ├── background.js Обои и блюр +│ │ ├── context-port.js Перенос контекста чата +│ │ ├── fonts.js Шрифт интерфейса и страницы │ │ └── ... │ └── overlay/ Оверлей-панель │ ├── template.js buildOverlayHTML() + OVERLAY_CSS @@ -349,6 +381,7 @@ src/ │ └── deepseek.js Адаптер платформы ├── ui/ │ ├── backgrounds/ 27 обоев + registry.json +│ ├── fonts/ Встроенные шрифты (Anthropic Mono) │ └── logos/ tools/ Реализация инструментов (в главном процессе) └── cuckoo-tools.d.ts TypeScript-декларации для AI diff --git a/assets/settings-subtabs.jpg b/assets/settings-subtabs.jpg new file mode 100644 index 0000000..0e5d114 Binary files /dev/null and b/assets/settings-subtabs.jpg differ diff --git a/src/main/context-port.js b/src/main/context-port.js new file mode 100644 index 0000000..d3d2568 --- /dev/null +++ b/src/main/context-port.js @@ -0,0 +1,53 @@ +/** + * Context Port — перенос контекста из старого чата DeepSeek в новый. + * + * Прямого API у DeepSeek нет, поэтому перенос идёт через DOM: + * 1) в старом чате читаем историю и кладём её сюда (IPC context-port:start); + * 2) навигацией на homeUrl открываем новый чат (перезагрузка страницы); + * 3) в новом чате preload забирает историю (IPC context-port:take), + * просит модель сжать её в конспект, кладёт конспект сюда (context-port:summary); + * 4) снова новый чат → preload забирает конспект и вставляет его как контекст. + * + * Хранилище — in-memory, привязано к webContents.id. Переживает reload + * страницы (в отличие от состояния preload), но не переживает закрытие окна. + */ +const store = new Map(); // webContentsId -> { stage, history, summary, createdAt } + +const TTL_MS = 10 * 60 * 1000; // 10 минут + +/** + * Положить данные для окна. + */ +function set(webContentsId, data) { + store.set(webContentsId, { + stage: data.stage || "history", + history: data.history || "", + summary: data.summary || "", + // Промпт инициализации проекта (дерево каталога + системный промпт), + // переносится вместе с контекстом, чтобы новый чат «знал» проект. + initPrompt: data.initPrompt || "", + createdAt: Date.now(), + }); +} + +/** + * Забрать данные для окна (без удаления — этапов может быть несколько). + */ +function get(webContentsId) { + const entry = store.get(webContentsId); + if (!entry) return null; + if (Date.now() - entry.createdAt > TTL_MS) { + store.delete(webContentsId); + return null; + } + return entry; +} + +/** + * Очистить данные окна. + */ +function clear(webContentsId) { + store.delete(webContentsId); +} + +module.exports = { set, get, clear }; diff --git a/src/main/index.js b/src/main/index.js index bbc1ef5..5fde39f 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -36,6 +36,11 @@ const CUSTOM_BACKGROUNDS_DIR = path.join( ); fs.mkdirSync(CUSTOM_BACKGROUNDS_DIR, { recursive: true }); +// Папка для пользовательских шрифтов: /fonts +// Пользователь кладёт туда .ttf/.otf — они появляются в выборе шрифта в настройках. +const CUSTOM_FONTS_DIR = path.join(app.getPath("userData"), "fonts"); +fs.mkdirSync(CUSTOM_FONTS_DIR, { recursive: true }); + // Папка для спрайтов петов: /pets // Пользователь кладёт туда PNG/GIF чубриков — они появляются в выборе пета. const CUSTOM_PETS_DIR = path.join(app.getPath("userData"), "pets"); diff --git a/src/main/ipc.js b/src/main/ipc.js index 309c94b..fee9632 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -13,6 +13,7 @@ const { initProject } = require("./project-context"); const { isDangerous } = require("./dangerous-commands"); const settingsStore = require("./settings-store"); const chatExport = require("./chat-export"); +const contextPort = require("./context-port"); const { decodeOutput, normalizeCommand } = require("../../tools/decodeOutput"); const gitDiff = require("./git-diff"); const todoStore = require("./todo-store"); @@ -548,6 +549,81 @@ function registerIpcHandlers() { } }); + // Отдать текущий промпт инициализации проекта (для переноса контекста). + // Пересобирается из сохранённого projectDir, а не из памяти renderer. + ipcMain.handle("context-port:get-init-prompt", async (event) => { + try { + const ctx = windowState.getContextByWebContents(event.sender); + const store = ctx ? ctx.sessionStore : null; + const projectDir = store ? store.state.selectedProjectDir : null; + if (!projectDir) return { success: true, prompt: "" }; + const { buildInitPrompt } = require("./project-context"); + const providerId = (ctx && ctx.providerId) || ""; + const prompt = await buildInitPrompt(projectDir, providerId); + return { success: true, prompt: prompt || "" }; + } catch (err) { + console.error( + "[Cookie Code] context-port:get-init-prompt error:", + err.message, + ); + return { success: false, error: err.message, prompt: "" }; + } + }); + + // ========== Перенос контекста (Context Port) ========== + // Хранилище живёт в main и переживает reload страницы при смене чата. + ipcMain.handle( + "context-port:start", + async (event, { history, stage, initPrompt }) => { + try { + const wcId = event.sender.id; + contextPort.set(wcId, { + history: history || "", + stage: stage || "history", + initPrompt: initPrompt || "", + }); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } + }, + ); + + ipcMain.handle("context-port:summary", async (event, { summary }) => { + try { + const wcId = event.sender.id; + const cur = contextPort.get(wcId) || {}; + contextPort.set(wcId, { + stage: "summary", + history: cur.history || "", + summary: summary || "", + initPrompt: cur.initPrompt || "", + }); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + ipcMain.handle("context-port:take", async (event) => { + try { + const wcId = event.sender.id; + const data = contextPort.get(wcId); + return { success: true, data: data || null }; + } catch (err) { + return { success: false, error: err.message, data: null }; + } + }); + + ipcMain.handle("context-port:clear", async (event) => { + try { + contextPort.clear(event.sender.id); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // 执行命令 ipcMain.handle("execute-command", async (event, { command, id }) => { if (!command || typeof command !== "string") { @@ -1190,6 +1266,60 @@ function registerIpcHandlers() { } }); + // ========== Пользовательские шрифты (userData/fonts) ========== + // Папка: /fonts — пользователь кладёт туда .ttf/.otf/.woff/.woff2, + // они автоматически появляются в выборе шрифта в настройках. + const CUSTOM_FONT_EXT = [".ttf", ".otf", ".woff", ".woff2"]; + const getCustomFontsDir = () => path.join(app.getPath("userData"), "fonts"); + + ipcMain.handle("cuckoo-fonts-list", async () => { + try { + const fs = require("fs"); + const dir = getCustomFontsDir(); + fs.mkdirSync(dir, { recursive: true }); + const files = fs.readdirSync(dir).filter((f) => { + return CUSTOM_FONT_EXT.includes(path.extname(f).toLowerCase()); + }); + const list = files.map((f) => { + const ext = path.extname(f); + const base = f.slice(0, -ext.length); + return { + id: "custom:" + base, + label: base, + file: path.join(dir, f), + custom: true, + }; + }); + return { success: true, dir, fonts: list }; + } catch (err) { + console.error( + "[Cookie Code] 读取 пользовательских шрифтов失败:", + err.message, + ); + return { + success: false, + error: err.message, + dir: getCustomFontsDir(), + fonts: [], + }; + } + }); + + // Открыть папку с пользовательскими шрифтами в системном проводнике. + ipcMain.handle("cuckoo-fonts-open-folder", async () => { + try { + const fs = require("fs"); + const dir = getCustomFontsDir(); + fs.mkdirSync(dir, { recursive: true }); + const errMsg = await shell.openPath(dir); + if (errMsg) return { success: false, error: errMsg }; + return { success: true, path: dir }; + } catch (err) { + console.error("[Cookie Code] 打开 папку шрифтов失败:", err.message); + return { success: false, error: err.message }; + } + }); + // ========== Спрайты петов (userData/pets) ========== // Папка: /pets — пользователь кладёт туда PNG/GIF чубриков. const PET_EXT = [".webp", ".jpg", ".jpeg", ".png", ".gif"]; diff --git a/src/main/project-context.js b/src/main/project-context.js index 3da0c4b..7f286f7 100644 --- a/src/main/project-context.js +++ b/src/main/project-context.js @@ -2,17 +2,17 @@ * 项目初始化:目录选择、目录树、系统提示词组合与发送 * 由原 main.js 拆分而来,逻辑保持不变。 */ -const { dialog } = require('electron'); -const fs = require('fs'); -const path = require('path'); +const { dialog } = require("electron"); +const fs = require("fs"); +const path = require("path"); -const windowState = require('./window'); -const { toolRegistry } = require('./tool-registry'); -const mcpClient = require('./mcp-client'); -const { getSkillManager } = require('../main/skill-manager'); +const windowState = require("./window"); +const { toolRegistry } = require("./tool-registry"); +const mcpClient = require("./mcp-client"); +const { getSkillManager } = require("../main/skill-manager"); // 提示词模板目录 -const PROMPT_DIR = path.join(__dirname, '..', 'prompt'); +const PROMPT_DIR = path.join(__dirname, "..", "prompt"); /** * 递归获取目录树结构字符串 @@ -22,13 +22,28 @@ const PROMPT_DIR = path.join(__dirname, '..', 'prompt'); */ // 需要忽略的目录(依赖、构建产物、版本控制等) const IGNORED_DIRS = new Set([ - 'node_modules', 'target', 'build', 'dist', 'out', - '.git', '.svn', '.hg', - '__pycache__', '.pytest_cache', '.coverage', - 'vendor', 'bower_components', 'jspm_packages', - '.idea', '.vscode', '.vs', - 'logs', 'tmp', 'temp', - 'bin', 'obj', + "node_modules", + "target", + "build", + "dist", + "out", + ".git", + ".svn", + ".hg", + "__pycache__", + ".pytest_cache", + ".coverage", + "vendor", + "bower_components", + "jspm_packages", + ".idea", + ".vscode", + ".vs", + "logs", + "tmp", + "temp", + "bin", + "obj", ]); /** @@ -37,28 +52,29 @@ const IGNORED_DIRS = new Set([ * @param {string} prefix 当前行前缀(用于绘制树形结构) * @returns {string} 目录树字符串 */ -function getDirectoryTree(dir, prefix = '') { +function getDirectoryTree(dir, prefix = "") { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); // 过滤:跳过隐藏文件和忽略的目录 const visibleEntries = entries - .filter(e => !e.name.startsWith('.')) - .filter(e => !e.isDirectory() || !IGNORED_DIRS.has(e.name)) + .filter((e) => !e.name.startsWith(".")) + .filter((e) => !e.isDirectory() || !IGNORED_DIRS.has(e.name)) .sort((a, b) => { // 目录优先,然后按名称排序 - if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; + if (a.isDirectory() !== b.isDirectory()) + return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); - let tree = ''; + let tree = ""; visibleEntries.forEach((entry, index) => { const isLast = index === visibleEntries.length - 1; - const connector = isLast ? '└── ' : '├── '; - const childPrefix = prefix + (isLast ? ' ' : '│ '); + const connector = isLast ? "└── " : "├── "; + const childPrefix = prefix + (isLast ? " " : "│ "); - tree += `${prefix}${connector}${entry.name}${entry.isDirectory() ? '/' : ''}\n`; + tree += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}\n`; if (entry.isDirectory()) { tree += getDirectoryTree(path.join(dir, entry.name), childPrefix); @@ -67,7 +83,7 @@ function getDirectoryTree(dir, prefix = '') { return tree; } catch (err) { - console.error('[Cookie Code] 读取目录失败:', err.message); + console.error("[Cookie Code] 读取目录失败:", err.message); return `${prefix}└── [无法读取目录: ${dir}]\n`; } } @@ -82,13 +98,13 @@ async function initProject(skipPrompt = false, windowContext = null) { const mainWindow = ctx ? ctx.win : windowState.getMainWindow(); const sessionStore = ctx ? ctx.sessionStore : null; // providerId 来自窗口上下文(可能为空,表示未确定平台) - const providerId = (ctx && ctx.providerId) || ''; + const providerId = (ctx && ctx.providerId) || ""; // 先让用户选择目录 const result = dialog.showOpenDialogSync(mainWindow, { - properties: ['openDirectory'], - buttonLabel: '选择目录', - title: '请选择要分析的项目目录', + properties: ["openDirectory"], + buttonLabel: "选择目录", + title: "请选择要分析的项目目录", }); // 无论用户是否选择目录,对话框关闭后都恢复主窗口焦点(避免输入框失效) @@ -98,12 +114,12 @@ async function initProject(skipPrompt = false, windowContext = null) { } if (!result || result.length === 0) { - console.log('[Cookie Code] 用户取消了目录选择'); - return { success: false, message: '用户取消了目录选择' }; + console.log("[Cookie Code] 用户取消了目录选择"); + return { success: false, message: "用户取消了目录选择" }; } const selectedDir = result[0]; - console.log('[Cookie Code] 用户选择目录:', selectedDir); + console.log("[Cookie Code] 用户选择目录:", selectedDir); // 保存选中的项目目录(若该窗口有独立的 sessionStore) if (sessionStore) { @@ -112,8 +128,13 @@ async function initProject(skipPrompt = false, windowContext = null) { // ========== 持久化存储会话-目录映射 ========== // 如果当前有会话ID,保存映射 if (sessionStore.state.currentSessionId) { - sessionStore.saveSessionDirMapping(sessionStore.state.currentSessionId, selectedDir); - console.log(`[Cookie Code] 已保存会话 ${sessionStore.state.currentSessionId} -> ${selectedDir}`); + sessionStore.saveSessionDirMapping( + sessionStore.state.currentSessionId, + selectedDir, + ); + console.log( + `[Cookie Code] 已保存会话 ${sessionStore.state.currentSessionId} -> ${selectedDir}`, + ); } else { // 如果未能获取会话ID,尝试从当前URL提取 let sessionId = null; @@ -124,48 +145,86 @@ async function initProject(skipPrompt = false, windowContext = null) { if (sessionId) { sessionStore.state.currentSessionId = sessionId; sessionStore.saveSessionDirMapping(sessionId, selectedDir); - console.log(`[Cookie Code] 从URL提取会话ID并保存: ${sessionId} -> ${selectedDir}`); + console.log( + `[Cookie Code] 从URL提取会话ID并保存: ${sessionId} -> ${selectedDir}`, + ); } else { // 无法获取会话ID,暂存项目目录,等待URL变化后绑定 sessionStore.state.pendingProjectDir = selectedDir; - console.log(`[Cookie Code] 暂存项目目录 ${selectedDir},等待会话ID出现后绑定`); + console.log( + `[Cookie Code] 暂存项目目录 ${selectedDir},等待会话ID出现后绑定`, + ); } } } // 发送目录更新事件到渲染进程 if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('project-dir-updated', selectedDir); + mainWindow.webContents.send("project-dir-updated", selectedDir); } // 如果只是修改目录,跳过发送初始提示 if (skipPrompt) { - return { success: true, message: '项目目录已更新' }; + return { success: true, message: "项目目录已更新" }; } - // 初始化项目时读取对应平台模板并替换占位符 - // 模板选择优先级: - // 1. provider.getPromptTemplate() 返回的非空字符串 - // 2. src/prompt/{providerId}.md - // 3. src/prompt/default.md - const provider = require('../providers').getProvider(providerId); - let templateContent = ''; - let templatePath = ''; + // Собираем промпт (та же логика используется при переносе контекста). + // MCP: убеждаемся, что включённые серверы подключены (8с таймаут). + try { + await Promise.race([ + mcpClient.connectEnabledServers(), + new Promise((resolve) => setTimeout(resolve, 8000)), + ]); + } catch (err) { + console.error("[MCP] 初始化时连接失败:", err.message); + } + + const combined = await buildInitPrompt(selectedDir, providerId); + + console.log( + "[Cookie Code] 准备发送初始提示(不含目录树),长度:", + combined.length, + ); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("initial-prompt", combined); + } + + return { + success: true, + message: "初始化完成,已发送系统提示词、工具规则和工具库", + }; +} + +/** + * Собрать текст промпта инициализации проекта (без диалога и без отправки). + * Используется и в initProject(), и при переносе контекста в новый чат. + * @param {string} selectedDir каталог проекта + * @param {string} providerId id провайдера (может быть пустым) + * @returns {Promise} готовый промпт (пустая строка при ошибке) + */ +async function buildInitPrompt(selectedDir, providerId) { + // Выбор шаблона: provider.getPromptTemplate() → src/prompt/.md → default.md + const provider = require("../providers").getProvider(providerId); + let templateContent = ""; + let templatePath = ""; - if (provider && typeof provider.getPromptTemplate === 'function') { + if (provider && typeof provider.getPromptTemplate === "function") { try { const fromMethod = provider.getPromptTemplate(); - if (fromMethod && typeof fromMethod === 'string' && fromMethod.trim()) { + if (fromMethod && typeof fromMethod === "string" && fromMethod.trim()) { templateContent = fromMethod; - templatePath = '(provider.getPromptTemplate)'; + templatePath = "(provider.getPromptTemplate)"; } } catch (err) { - console.warn('[Cookie Code] 调用 provider.getPromptTemplate 失败:', err.message); + console.warn( + "[Cookie Code] 调用 provider.getPromptTemplate 失败:", + err.message, + ); } } if (!templateContent && providerId) { - const candidate = path.join(PROMPT_DIR, providerId + '.md'); + const candidate = path.join(PROMPT_DIR, providerId + ".md"); if (fs.existsSync(candidate)) { templatePath = candidate; } @@ -173,133 +232,127 @@ async function initProject(skipPrompt = false, windowContext = null) { if (!templateContent && templatePath) { try { - templateContent = fs.readFileSync(templatePath, 'utf-8'); + templateContent = fs.readFileSync(templatePath, "utf-8"); } catch (err) { - console.error('[Cookie Code] 读取提示词模板失败:', err.message); - return { success: false, message: '读取提示词模板失败: ' + err.message }; + console.error("[Cookie Code] 读取提示词模板失败:", err.message); + return ""; } } if (!templateContent) { - templatePath = path.join(PROMPT_DIR, 'default.md'); + templatePath = path.join(PROMPT_DIR, "default.md"); try { - templateContent = fs.readFileSync(templatePath, 'utf-8'); - console.warn('[Cookie Code] 未找到平台模板,使用默认模板:', templatePath); + templateContent = fs.readFileSync(templatePath, "utf-8"); + console.warn("[Cookie Code] 未找到平台模板,使用默认模板:", templatePath); } catch (err) { - console.error('[Cookie Code] 读取默认模板失败:', err.message); - return { success: false, message: '读取默认提示词模板失败: ' + err.message }; + console.error("[Cookie Code] 读取默认模板失败:", err.message); + return ""; } } - console.log('[Cookie Code] 已读取提示词模板:', templatePath); + console.log("[Cookie Code] 已读取提示词模板:", templatePath); - // 读取工具 API 类型定义(从 d.ts 文件读取,避免与模板重复维护) - let toolApiTypes = ''; + // Тип-дефиниции инструментов + let toolApiTypes = ""; try { - toolApiTypes = fs.readFileSync(path.join(__dirname, '..', '..', 'tools', 'cuckoo-tools.d.ts'), 'utf-8'); + toolApiTypes = fs.readFileSync( + path.join(__dirname, "..", "..", "tools", "cuckoo-tools.d.ts"), + "utf-8", + ); } catch (err) { - console.error('[Cookie Code] 读取 cuckoo-tools.d.ts 失败:', err.message); + console.error("[Cookie Code] 读取 cuckoo-tools.d.ts 失败:", err.message); } - // 获取工具库描述(JS API 格式:AI 通过生成 JS 代码调用这些函数) const toolsDescription = toolRegistry.getFormattedJsApiForPrompt(); - - // 获取工具使用指导(section 机制,仿 dsh) const promptSections = toolRegistry.getFormattedPromptSections(); - // 确保已启用的 MCP server 已连接(8 秒超时,避免阻塞初始化) - try { - await Promise.race([ - mcpClient.connectEnabledServers(), - new Promise(resolve => setTimeout(resolve, 8000)) - ]); - } catch (err) { - console.error('[MCP] 初始化时连接失败:', err.message); - } - - // MCP 章节:按需查看模式,不在提示词中全量注入工具列表 + // MCP — только подключение (без блокирующего ожидания при повторной сборке) const mcpSection = [ - '## MCP 能力', - '', - '本应用支持 MCP(Model Context Protocol)外部工具扩展。', - '', - '使用 MCP 前,请先查询可用能力:', - '1. 调用 mcpListServers() 查看当前已配置的 MCP server 列表(含启用/连接状态)', - '2. 调用 mcpGetTools(serverName) 查看指定 server 提供的工具和参数', - '3. 确认后通过 mcpCall(server, tool, args) 调用具体工具', - '', - '注意:MCP server 可能未连接或未启用,以 mcpListServers() 的实时返回为准。' - ].join('\n'); - - // 动态生成平台信息(不硬编码,根据实际运行环境) + "## MCP 能力", + "", + "本应用支持 MCP(Model Context Protocol)外部工具扩展。", + "", + "使用 MCP 前,请先查询可用能力:", + "1. 调用 mcpListServers() 查看当前已配置的 MCP server 列表(含启用/连接状态)", + "2. 调用 mcpGetTools(serverName) 查看指定 server 提供的工具和参数", + "3. 确认后通过 mcpCall(server, tool, args) 调用具体工具", + "", + "注意:MCP server 可能未连接或未启用,以 mcpListServers() 的实时返回为准。", + ].join("\n"); + + // Платформенная информация const platform = process.platform; const arch = process.arch; - let platformInfo = ''; - if (platform === 'win32') { - platformInfo = '- 操作系统:Windows(' + arch + ')\n - bash 使用 cmd.exe(Windows 命令:cd / dir / echo %cd% / type / findstr)\n - pwsh 使用 PowerShell(Get-Location / $env:VAR / Get-ChildItem)\n - 路径分隔符为反斜杠 \\,传给工具的相对路径统一用正斜杠 /'; - } else if (platform === 'darwin') { - platformInfo = '- 操作系统:macOS(' + arch + ')\n - bash 使用 zsh/bash(Unix 命令:pwd / ls / cat / grep)\n - 路径分隔符为正斜杠 /'; + let platformInfo = ""; + if (platform === "win32") { + platformInfo = + "- 操作系统:Windows(" + + arch + + ")\n - bash 使用 cmd.exe(Windows 命令:cd / dir / echo %cd% / type / findstr)\n - pwsh 使用 PowerShell(Get-Location / $env:VAR / Get-ChildItem)\n - 路径分隔符为反斜杠 \\,传给工具的相对路径统一用正斜杠 /"; + } else if (platform === "darwin") { + platformInfo = + "- 操作系统:macOS(" + + arch + + ")\n - bash 使用 zsh/bash(Unix 命令:pwd / ls / cat / grep)\n - 路径分隔符为正斜杠 /"; } else { - platformInfo = '- 操作系统:Linux(' + arch + ')\n - bash 使用 bash(Unix 命令:pwd / ls / cat / grep)\n - 路径分隔符为正斜杠 /'; + platformInfo = + "- 操作系统:Linux(" + + arch + + ")\n - bash 使用 bash(Unix 命令:pwd / ls / cat / grep)\n - 路径分隔符为正斜杠 /"; } - // 读取项目介绍(CUCKOO.md) - let projectIntro = ''; - const cuckooMdPath = path.join(selectedDir, '.cuckooCode', 'CUCKOO.md'); + // CUCKOO.md — введение проекта + let projectIntro = ""; + const cuckooMdPath = path.join(selectedDir, ".cuckooCode", "CUCKOO.md"); if (fs.existsSync(cuckooMdPath)) { try { - projectIntro = fs.readFileSync(cuckooMdPath, 'utf-8'); - console.log('[Cookie Code] 已读取 CUCKOO.md 内容'); + projectIntro = fs.readFileSync(cuckooMdPath, "utf-8"); + console.log("[Cookie Code] 已读取 CUCKOO.md 内容"); } catch (err) { - console.error('[Cookie Code] 读取 CUCKOO.md 失败:', err.message); + console.error("[Cookie Code] 读取 CUCKOO.md 失败:", err.message); } } - - // 项目介绍占位符:无内容则整体置空 const projectIntroSection = projectIntro - ? '---\n## 项目介绍\n' + projectIntro - : ''; + ? "---\n## 项目介绍\n" + projectIntro + : ""; - // Skill 按需加载:AI 通过 skillList() 发现、skillLoad() 加载、skillExecute() 执行 + // Skill — только подсказка о наличии const skillManager = getSkillManager(); skillManager.unloadAll(); - - // Skill 章节:仅提示可用性,不自动加载 const skillSection = [ - '---', - '## 自定义 Skill', - '', - '本项目支持自定义 Skill,可按需加载执行。', - '', - '- 调用 skillList() 查看当前项目可用的 Skill 列表', - '- 调用 skillLoad(name) 加载需要的 Skill(读取 SKILL.md 指令和 tool.js 函数)', - '- 调用 skillExecute(skill, function, args) 执行已加载 Skill 的函数', - '', - 'Skill 位于 /.cuckoo/skills//,其中 SKILL.md 为指令文件,tool.js 可选导出可执行函数。', - ].join('\n'); - - // 统一替换模板中的双花括号占位符(全量替换,支持同一占位符多次出现) + "---", + "## 自定义 Skill", + "", + "本项目支持自定义 Skill,可按需加载执行。", + "", + "- 调用 skillList() 查看当前项目可用的 Skill 列表", + "- 调用 skillLoad(name) 加载需要的 Skill(读取 SKILL.md 指令和 tool.js 函数)", + "- 调用 skillExecute(skill, function, args) 执行已加载 Skill 的函数", + "", + "Skill 位于 /.cuckoo/skills//,其中 SKILL.md 为指令文件,tool.js 可选导出可执行函数。", + ].join("\n"); + const placeholders = { - '{{TOOL_API_TYPES}}': toolApiTypes, - '{{TOOLS_LIST}}': toolsDescription, - '{{TOOL_SECTIONS}}': promptSections, - '{{PLATFORM_INFO}}': platformInfo, - '{{PROJECT_DIR}}': selectedDir, - '{{PROJECT_INTRO_SECTION}}': projectIntroSection, - '{{SKILL_SECTION}}': skillSection, - '{{MCP_SECTION}}': mcpSection, + "{{TOOL_API_TYPES}}": toolApiTypes, + "{{TOOLS_LIST}}": toolsDescription, + "{{TOOL_SECTIONS}}": promptSections, + "{{PLATFORM_INFO}}": platformInfo, + "{{PROJECT_DIR}}": selectedDir, + "{{PROJECT_INTRO_SECTION}}": projectIntroSection, + "{{SKILL_SECTION}}": skillSection, + "{{MCP_SECTION}}": mcpSection, }; let combined = templateContent; for (const [key, value] of Object.entries(placeholders)) { combined = combined.split(key).join(value); } - - console.log('[Cookie Code] 准备发送初始提示(不含目录树),长度:', combined.length); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('initial-prompt', combined); - } - - return { success: true, message: '初始化完成,已发送系统提示词、工具规则和工具库' }; + return combined; } -module.exports = { PROMPT_DIR, IGNORED_DIRS, getDirectoryTree, initProject }; +module.exports = { + PROMPT_DIR, + IGNORED_DIRS, + getDirectoryTree, + initProject, + buildInitPrompt, +}; diff --git a/src/main/settings-store.js b/src/main/settings-store.js index cf6b7ba..84207c4 100644 --- a/src/main/settings-store.js +++ b/src/main/settings-store.js @@ -25,6 +25,11 @@ const DEFAULT_DANGEROUS_PATTERNS = [ const DEFAULTS = { customizationEnabled: true, background: "miku", + // Шрифт интерфейса Cookie Code и страницы DeepSeek. + // "system" — системный по умолчанию; "" — встроенный или "custom:<имя>". + font: "system", + // Жирность шрифта (100–900). Применяется и к системному, и к кастомному. + fontWeight: 400, backgroundBlur: 0, // px — размытие самой картинки фона headerBlur: 12, // px — стекло верхней панели sidebarBlur: 12, // px — стекло левого сайдбара diff --git a/src/preload/api.js b/src/preload/api.js index 9b5ac13..bc9cad4 100644 --- a/src/preload/api.js +++ b/src/preload/api.js @@ -65,6 +65,26 @@ let electronAPI = { newChat: () => { return ipcRenderer.invoke("new-chat"); }, + // ========== Перенос контекста (Context Port) ========== + contextPortGetInitPrompt: () => { + return ipcRenderer.invoke("context-port:get-init-prompt"); + }, + contextPortStart: (history, stage, initPrompt) => { + return ipcRenderer.invoke("context-port:start", { + history, + stage, + initPrompt, + }); + }, + contextPortSummary: (summary) => { + return ipcRenderer.invoke("context-port:summary", { summary }); + }, + contextPortTake: () => { + return ipcRenderer.invoke("context-port:take"); + }, + contextPortClear: () => { + return ipcRenderer.invoke("context-port:clear"); + }, createProfileWindow: () => { return ipcRenderer.invoke("create-profile-window"); }, @@ -149,6 +169,13 @@ let electronAPI = { openCustomBackgroundsFolder: () => { return ipcRenderer.invoke("cuckoo-backgrounds-open-folder"); }, + // ========== Пользовательские шрифты (userData/fonts) ========== + listCustomFonts: () => { + return ipcRenderer.invoke("cuckoo-fonts-list"); + }, + openCustomFontsFolder: () => { + return ipcRenderer.invoke("cuckoo-fonts-open-folder"); + }, // ========== Спрайты петов (userData/pets) ========== listPets: () => { return ipcRenderer.invoke("cuckoo-pets-list"); diff --git a/src/preload/dom/context-port.js b/src/preload/dom/context-port.js new file mode 100644 index 0000000..cf39715 --- /dev/null +++ b/src/preload/dom/context-port.js @@ -0,0 +1,391 @@ +/** + * Context Port (renderer) — перенос контекста из старого чата DeepSeek в новый. + * + * Прямого API у DeepSeek нет, поэтому: + * 1) читаем историю текущего чата из DOM (markdown-сообщения); + * 2) кладём историю в main (переживает reload) и открываем новый чат; + * 3) в новом чате просим модель сжать историю в конспект, читаем ответ, + * кладём конспект в main и снова открываем новый чат; + * 4) в новом чате вставляем конспект как контекст и продолжаем работу. + * + * Состояние между перезагрузками страницы живёт в main-процессе + * (см. src/main/context-port.js) — preload после reload сам решает, какой шаг. + */ +const { getProviderByUrl } = require("../../../src/providers"); +const chatInput = require("./chat-input"); +const state = require("./state"); +const { isAIResponseComplete } = require("./ai-response"); +const { estimateTokens } = require("./token-estimator"); + +// Префикс служебного сообщения, чтобы AI отличал его от пользовательского. +const BT = String.fromCharCode(96); +const FENCE = BT + BT + BT; + +// Верхняя граница переносимой истории (по оценке токенов). +const MAX_HISTORY_TOKENS = 50000; + +// Ключ в sessionStorage: защита от повторного запуска шага после reload. +const FLAG_KEY = "cuckoo-context-port-flag"; + +/** + * Текущий провайдер по URL. + */ +function provider() { + return getProviderByUrl(window.location.href); +} + +/** + * Прочитать историю текущего чата из DOM в виде markdown. + * Возвращает строку вида: + * Пользователь: ... + * Ассистент: ... + * @returns {string} + */ +function exportHistory() { + try { + const p = provider(); + const nodes = Array.from(document.querySelectorAll(".ds-message")); + if (nodes.length === 0) return ""; + const parts = []; + for (const el of nodes) { + const isUser = + p && typeof p.isUserMessage === "function" + ? p.isUserMessage(el) + : false; + const mdEl = + p && typeof p.getMessageMarkdown === "function" + ? p.getMessageMarkdown(el) + : el; + const root = mdEl || el; + const text = (root.innerText || root.textContent || "").trim(); + if (!text) continue; + parts.push((isUser ? "Пользователь: " : "Ассистент: ") + text); + } + return parts.join("\n\n"); + } catch (err) { + console.error( + "[Cookie Code][context-port] exportHistory error:", + err.message, + ); + return ""; + } +} + +/** + * Ужать историю по токенам: если слишком длинная — оставляем хвост. + * @param {string} history + * @returns {string} + */ +function clampHistory(history) { + if (!history) return ""; + if (estimateTokens(history) <= MAX_HISTORY_TOKENS) return history; + // Берём хвост: последние ~MAX_HISTORY_TOKENS токенов. + const approxChars = MAX_HISTORY_TOKENS * 2; + const tail = history.slice(-approxChars); + const idx = tail.indexOf("\n\n"); + return ( + "[…начало истории опущено…]\n\n" + (idx > 0 ? tail.slice(idx + 2) : tail) + ); +} + +/** + * Промпт для суммаризации истории (шаг 2). + */ +function buildSummarizePrompt(history) { + return ( + "Ниже — история другого чата. Сожми её в структурный конспект на русском, " + + "чтобы по нему можно было продолжить работу. Формат:\n" + + "1. Цель/задача\n2. Что уже сделано (решения)\n3. Изменённые/созданные файлы\n" + + "4. Открытые вопросы и следующий шаг\n5. Важные детали/ограничения\n" + + "Без воды. Только конспект.\n\n" + + "=== ИСТОРИЯ ===\n" + + history + ); +} + +/** + * Финальный промпт-контекст (шаг 4): промпт инициализации проекта (если есть) + * + конспект истории, объединённые в одно сообщение. + * @param {string} summary конспект истории + * @param {string} [initPrompt] промпт инициализации проекта + */ +function buildContextPrompt(summary, initPrompt) { + const parts = []; + if (initPrompt) { + parts.push(initPrompt); + } + parts.push( + "【КОНТЕКСТ ИЗ ПРЕДЫДУЩЕГО ЧАТА】\n" + + "Ниже — конспект ранее проделанной работы. Учти его и продолжай с этого места.\n\n" + + summary, + ); + return parts.join("\n\n---\n\n"); +} + +/** + * Дождаться ответа AI после отправки сообщения. + * + * Надёжная логика (не полагается только на isResponseComplete, который + * требует stop-кнопку и может «пропустить» короткий ответ): + * 1) ждём появления НОВОГО сообщения (число .ds-message выросло); + * 2) ждём, пока текст перестанет меняться N опросов подряд (стабилизация); + * 3) возвращаем текст последнего AI-сообщения. + * + * @param {number} baselineCount сколько .ds-message было ДО отправки + * @param {number} timeoutMs + * @returns {Promise} + */ +async function waitForAnswer(baselineCount, timeoutMs) { + const start = Date.now(); + const limit = timeoutMs || 180000; + const base = typeof baselineCount === "number" ? baselineCount : 0; + let sawNew = false; + let lastText = ""; + let stableCount = 0; + const STABLE_NEEDED = 4; // ~4.8 c без изменений — считаем ответ завершённым + + while (Date.now() - start < limit) { + await sleep(1200); + const p = provider(); + const candidates = + p && typeof p.getMessageCandidates === "function" + ? p.getMessageCandidates() + : []; + if (candidates.length <= base && !sawNew) { + // Ответ ещё не начал появляться. + continue; + } + sawNew = true; + const last = candidates[candidates.length - 1]; + if (!last) continue; + const mdEl = + p && typeof p.getMessageMarkdown === "function" + ? p.getMessageMarkdown(last) + : last; + const root = mdEl || last; + const text = (root.innerText || root.textContent || "").trim(); + + if (text && text === lastText) { + stableCount++; + } else { + stableCount = 0; + lastText = text; + } + // Дополнительно: если платформа сообщает «готово» — доверяем ей. + let complete = false; + try { + complete = await isAIResponseComplete(); + } catch (_) {} + + if (text && (stableCount >= STABLE_NEEDED || complete)) { + return text; + } + } + return lastText; +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Запустить перенос: экспорт истории → main → новый чат. + * Вызывается из старого чата (шаг 1). + */ +async function startTransfer() { + const history = clampHistory(exportHistory()); + if (!history) { + console.warn( + "[Cookie Code][context-port] история пуста — нечего переносить", + ); + return { success: false, error: "empty-history" }; + } + // Промпт инициализации проекта (если проект инициализирован) — + // переносим вместе с контекстом, чтобы новый чат «знал» проект. + // Берём его из main (пересобирается из projectDir), т.к. состояние + // renderer теряется при reload и не всегда содержит промпт. + let initPrompt = ""; + try { + if (typeof window.electronAPI.contextPortGetInitPrompt === "function") { + const r = await window.electronAPI.contextPortGetInitPrompt(); + if (r && r.success && r.prompt) initPrompt = r.prompt; + } + } catch (err) { + console.warn( + "[Cookie Code][context-port] не удалось получить промпт инициализации:", + err.message, + ); + } + // Фолбэк: если main не дал промпт, но он есть в памяти preload — используем. + if (!initPrompt && state.initialPromptContent) { + initPrompt = state.initialPromptContent; + } + console.log( + "[Cookie Code][context-port] startTransfer: history =", + history.length, + "initPrompt =", + initPrompt.length, + ); + try { + await window.electronAPI.contextPortStart(history, "history", initPrompt); + // После reload preload сам выполнит шаг 2 (суммаризация). + await window.electronAPI.newChat(); + return { success: true }; + } catch (err) { + console.error( + "[Cookie Code][context-port] startTransfer error:", + err.message, + ); + return { success: false, error: err.message }; + } +} + +/** + * Шаг 2: в новом чате получаем историю, просим модель сжать, читаем конспект. + */ +async function runSummarizeStage(history) { + const prompt = buildSummarizePrompt(history); + console.log( + "[Cookie Code][context-port] summarize: длина промпта =", + prompt.length, + ); + // Считаем, сколько AI-сообщений уже есть (на новом чате их 0). + const p = provider(); + const baseline = + p && typeof p.getMessageCandidates === "function" + ? p.getMessageCandidates().length + : 0; + // Поле ввода может ещё не быть готово — несколько попыток отправки. + let ok = false; + for (let i = 0; i < 10 && !ok; i++) { + ok = chatInput.sendMessageToChat(prompt, "context-port:summarize"); + if (!ok) { + console.warn( + "[Cookie Code][context-port] summarize: input не найден, попытка", + i + 1, + ); + await sleep(1500); + } + } + if (!ok) { + console.warn( + "[Cookie Code][context-port] не удалось отправить запрос суммаризации", + ); + return; + } + console.log("[Cookie Code][context-port] summarize: отправлено, ждём ответ"); + const answer = await waitForAnswer(baseline, 180000); + console.log( + "[Cookie Code][context-port] summarize: ответ получен, длина =", + (answer || "").length, + ); + if (!answer) { + console.warn("[Cookie Code][context-port] пустой конспект — прерываю"); + await window.electronAPI.contextPortClear(); + return; + } + try { + await window.electronAPI.contextPortSummary(answer); + await window.electronAPI.newChat(); + } catch (err) { + console.error( + "[Cookie Code][context-port] summarize stage error:", + err.message, + ); + } +} + +/** + * Шаг 4: в новом чате вставляем конспект как контекст. + */ +async function runInjectStage(summary, initPrompt) { + if (!summary) return; + const prompt = buildContextPrompt(summary, initPrompt); + console.log( + "[Cookie Code][context-port] inject: длина промпта =", + prompt.length, + "(initPrompt:", + (initPrompt || "").length + ")", + ); + let ok = false; + for (let i = 0; i < 10 && !ok; i++) { + ok = chatInput.sendMessageToChat(prompt, "context-port:inject"); + if (!ok) { + console.warn( + "[Cookie Code][context-port] inject: input не найден, попытка", + i + 1, + ); + await sleep(1500); + } + } + if (ok) { + console.log("[Cookie Code][context-port] контекст перенесён в новый чат"); + } + await window.electronAPI.contextPortClear(); +} + +/** + * Точка входа после загрузки страницы: определить текущий этап переноса + * и выполнить нужный шаг. Вызывается из init() preload. + */ +async function resume() { + try { + if ( + !window.electronAPI || + typeof window.electronAPI.contextPortTake !== "function" + ) { + console.log("[Cookie Code][context-port] resume: API недоступен"); + return; + } + const res = await window.electronAPI.contextPortTake(); + console.log( + "[Cookie Code][context-port] resume: take =", + JSON.stringify({ + success: res && res.success, + stage: res && res.data && res.data.stage, + hasHistory: !!(res && res.data && res.data.history), + hasSummary: !!(res && res.data && res.data.summary), + hasInitPrompt: !!(res && res.data && res.data.initPrompt), + }), + ); + if (!res || !res.success || !res.data) return; + const data = res.data; + // Ждём появления поля ввода (после reload интерфейс грузится не сразу). + const ready = await waitForInput(60000); + console.log("[Cookie Code][context-port] resume: input ready =", ready); + if (!ready) { + console.warn( + "[Cookie Code][context-port] resume: поле ввода не появилось за 60с", + ); + return; + } + // Небольшая пауза, чтобы React-интерфейс окончательно инициализировался. + await sleep(1500); + if (data.stage === "history" && data.history) { + await runSummarizeStage(data.history); + } else if (data.stage === "summary" && data.summary) { + await runInjectStage(data.summary, data.initPrompt); + } + } catch (err) { + console.error("[Cookie Code][context-port] resume error:", err.message); + } +} + +/** + * Ждать появления поля ввода. + */ +async function waitForInput(timeoutMs) { + const start = Date.now(); + const limit = timeoutMs || 30000; + while (Date.now() - start < limit) { + if (chatInput.findInputArea()) return true; + await sleep(500); + } + return false; +} + +module.exports = { + startTransfer, + resume, + exportHistory, +}; diff --git a/src/preload/dom/fonts.js b/src/preload/dom/fonts.js new file mode 100644 index 0000000..ce1b1f5 --- /dev/null +++ b/src/preload/dom/fonts.js @@ -0,0 +1,419 @@ +/** + * Управление шрифтом интерфейса Cookie Code и страницы DeepSeek. + * + * Шрифты читаются из src/ui/fonts/ (встроенные) и /fonts (пользовательские) + * напрямую в preload через fs и кодируются в base64 data-URI. Это единственный + * надёжный способ: file:// заблокирован Chromium со страницы chat.deepseek.com, + * а кастомный cuckoo-asset:// не проходит через fetch API даже с bypassCSP. + * + * Инжектится один