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`).
+
+
+
+
+
### 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
+
+
### 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`).
+
+
+
+
+
### Блюр и прозрачность
Полный контроль над стеклянным эффектом интерфейса:
@@ -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 создан для того, чтобы его перестраив
+
+
### Внешний вид
@@ -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.
+ *
+ * Инжектится один