feat(ai): generate page title from content (#199) #210

Merged
vvzvlad merged 3 commits from feat/199-ai-generate-title into develop 2026-06-26 20:55:36 +03:00

Closes #199

Adds an AI button that generates a note title from the live editor content and applies it immediately.

Server

  • New one-shot, non-streaming POST /ai-chat/generate-page-title (mirrors the chat generateTitle path).
  • Gated by settings.ai.generative (403 when off), throttled via AI_CHAT_THROTTLER (20/min/user).
  • Resolves the workspace chat model (503 AiNotConfiguredException when AI unconfigured), generateText, returns { title }.
  • Endpoint never writes the page; the client applies the title via the existing /pages/update route (which enforces validateCanEdit), so access checks are not duplicated.
  • Pure exported cleanGeneratedTitle (trim / strip quotes / drop trailing period / cap 255).

Client

  • ai-chat-service.generatePageTitle(content).
  • useGeneratePageTitle(pageId) hook: htmlToMarkdown(pageEditor.getHTML()) -> endpoint -> updateTitle + updatePageData, reflects in the unfocused title editor, emits the UpdateEvent (mirrors TitleEditor.saveTitle). Empty-note / empty-result guards; 403/503/429/generic error notifications.
  • GenerateTitleGroup sparkles button rendered next to dictation in the byline, edit-mode + settings.ai.generative gated (double-gated with the server).
  • i18n: en-US + ru-RU strings.

Tests

  • Server: cleanGeneratedTitle cases + controller gate / delegation / HttpException passthrough / non-HTTP -> 503 mapping (11 tests).

Verify

  • pnpm --filter server exec tsc --noEmit — pass
  • pnpm --filter server exec jest (new spec + existing ai-chat specs, 63 tests) — pass
  • pnpm --filter client exec tsc -b — pass

🤖 Generated with Claude Code

Closes #199 Adds an AI button that generates a note title from the live editor content and applies it immediately. ## Server - New one-shot, non-streaming `POST /ai-chat/generate-page-title` (mirrors the chat `generateTitle` path). - Gated by `settings.ai.generative` (403 when off), throttled via `AI_CHAT_THROTTLER` (20/min/user). - Resolves the workspace chat model (503 `AiNotConfiguredException` when AI unconfigured), `generateText`, returns `{ title }`. - Endpoint never writes the page; the client applies the title via the existing `/pages/update` route (which enforces `validateCanEdit`), so access checks are not duplicated. - Pure exported `cleanGeneratedTitle` (trim / strip quotes / drop trailing period / cap 255). ## Client - `ai-chat-service.generatePageTitle(content)`. - `useGeneratePageTitle(pageId)` hook: `htmlToMarkdown(pageEditor.getHTML())` -> endpoint -> `updateTitle` + `updatePageData`, reflects in the unfocused title editor, emits the `UpdateEvent` (mirrors `TitleEditor.saveTitle`). Empty-note / empty-result guards; 403/503/429/generic error notifications. - `GenerateTitleGroup` sparkles button rendered next to dictation in the byline, edit-mode + `settings.ai.generative` gated (double-gated with the server). - i18n: en-US + ru-RU strings. ## Tests - Server: `cleanGeneratedTitle` cases + controller gate / delegation / HttpException passthrough / non-HTTP -> 503 mapping (11 tests). ## Verify - `pnpm --filter server exec tsc --noEmit` — pass - `pnpm --filter server exec jest` (new spec + existing ai-chat specs, 63 tests) — pass - `pnpm --filter client exec tsc -b` — pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 06:21:26 +03:00
Add an AI button in the page byline that generates a note's title from the
live editor content (including unsaved edits) and applies it immediately.

Server: one-shot, non-streaming POST /ai-chat/generate-page-title mirroring
the chat generateTitle path — gated by settings.ai.generative, throttled via
AI_CHAT_THROTTLER, resolves the workspace chat model and returns { title }.
The endpoint never touches the page; the client applies the title through the
existing /pages/update route (which enforces edit permission).

Client: ai-chat-service.generatePageTitle, a useGeneratePageTitle hook that
converts the editor HTML to markdown, calls the endpoint, applies the title
via updateTitle + updatePageData, reflects it in the unfocused title editor,
and broadcasts the UpdateEvent (mirroring TitleEditor.saveTitle). A sparkles
button (GenerateTitleGroup) renders next to dictation, edit-mode + flag gated.

Tests: pure cleanGeneratedTitle helper + controller gate/delegation/error-map.
i18n: en-US + ru-RU strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 15:49:30 +03:00
Owner

Code review — PR #210: AI-генерация заголовка страницы из её содержимого (#199)

Вердикт: Approve with comments. Серверная часть (эндпоинт POST /ai-chat/generate-page-title, gating по settings.ai.generative, throttle 20/60с, очистка заголовка) корректна и покрыта тестами; код чистый, security/regressions/conventions — без замечаний. Единственный must-fix формальный — отсутствует запись в CHANGELOG, которую репозиторий держит обязательной для каждой пользовательской фичи.

Объём: дифф developfeat/199-ai-generate-title (merge-base 3ddc329b), 10 файлов, +395/−2. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

  • [documentation] Добавить запись в CHANGELOG про фичу генерации заголовка (#199)CHANGELOG.md:11-58
    Проверено: в ## [Unreleased] нет упоминания #199, при этом раздел ведётся по Keep-a-Changelog и каждая пользовательская фича там фиксируется (#183/#174, #175/#177, #168 и др.), а недавние ревью-коммиты это прямо требуют. PR добавляет заметную пользовательскую фичу (кнопка-«sparkles» в byline + новый эндпоинт POST /ai-chat/generate-page-title, гейтится settings.ai.generative) — ledger рассинхронизирован с изменением. Fix: добавить пункт под ## [Unreleased]### Added: кнопка «сгенерировать заголовок из содержимого» читает живой контент редактора (включая несохранённые правки), генерирует заголовок через AI-провайдер воркспейса и применяет его через существующий маршрут /pages/update; гейтится флагом settings.ai.generative и троттлится на пользователя. Ссылка (#199).

  • [stability] Защитить запись в title-editor от навигации между страницами во время генерацииapps/client/src/features/editor/hooks/use-generate-page-title.ts:53-71
    Хук захватывает глобальные pageEditorAtom/titleEditorAtom на рендере, а между await generatePageTitle и titleEditor.commands.setContent(page.title) проходит 1–3с. Если за это окно пользователь уйдёт на другую страницу, byline перемонтируется и глобальные атомы теперь указывают на редакторы НОВОЙ страницы: запись в БД остаётся корректной (используется захваченный проп pageId), но setContent впишет сгенерированный заголовок страницы A в видимое поле страницы B. Гард !isFocused это не ловит (поле принадлежит другой странице и не в фокусе); самокорректируется на следующем рендере. Транзиентный низковероятный кросс-страничный глюк, которого штатный TitleEditor избегает через проверку activePageId !== pageId / editor.storage.pageId. Fix: перед setContent (и перед чтением pageEditor.getHTML()) проверять, что живой редактор всё ещё относится к этой странице — например, выходить, если pageEditor.storage?.pageId !== pageId (его выставляет page-editor.tsx), зеркаля гард TitleEditor.

Test coverage

Серверная логика покрыта полноценно: apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts тестирует cleanGeneratedTitle (trim, снятие кавычек, удаление точки, кап 255, пустой ввод) и AiChatController.generatePageTitle (forbid при выключенном флаге, forbid при значении не ровно true, успешный { title }, проброс HttpException 503, маппинг прочих ошибок в 503).

  • [test-coverage] Покрыть тестами клиентский хук useGeneratePageTitleapps/client/src/features/editor/hooks/use-generate-page-title.ts:33-104
    Новая логика (104 строки) тестов не имеет: пустой markdown → жёлтое уведомление и ранний выход; пустой ответ модели → заголовок не трогается; happy-path с updateTitle + updatePageData + emit UpdateEvent; гард !isFocused для setContent; маппинг статусов 403/503/429/прочее в onError. Это вся ветвистая часть фичи на клиенте. Fix: добавить unit-тест на хук (мок generatePageTitle/useUpdateTitlePageMutation/useQueryEmit), покрыв перечисленные ветки; как минимум проверить early-returns и маппинг ошибок.

Architecture & design (forward-looking, non-blocking)

  • Дублирование «применить заголовок страницы + разослать по websocket»apps/client/src/features/editor/hooks/use-generate-page-title.ts:57-86 vs apps/client/src/features/editor/title-editor.tsx:129-149
    Новый хук почти дословно повторяет блок из TitleEditor.saveTitle: useUpdateTitlePageMutationupdatePageData(page) → сборка идентичного UpdateEvent с payload { title, slugId, parentPageId, icon }localEmitter.emit('message', event) + emit(event), плюс тот же гард «применять setContent, только если не в фокусе». Та же форма payload повторяется и в page-tree utils. Сейчас дефекта нет (копия корректна), но это maintenance-связность: любое изменение payload события PAGE_UPDATED придётся вносить в 2+ местах, и копии могут разойтись (автор сам аннотировал блок как «mirroring TitleEditor.saveTitle»).
    извлечь общий applyPageTitleUpdate(page, { titleEditor, emit }) и вызывать из saveTitle и нового хука (effort: S). Pros: единый источник payload и гарда setContent; хук сжимается до «generate → updateTitle → applyPageTitleUpdate»; будущие точки изменения заголовка переиспользуют одну протестированную функцию; нет дрейфа. Cons: трогает устоявшийся saveTitle, надо сохранить его race-гард title!==titleEditor.getText() под тестом; чуть расширяет blast radius PR.

  • Две реализации очистки заголовка модели на сервереapps/server/src/core/ai-chat/ai-chat.service.ts:78-87 (cleanGeneratedTitle) vs приватный generateTitle (chat-title, ~810+)
    PR извлёк и протестировал общий пост-процессор cleanGeneratedTitle, но подключил его только в новый generatePageTitle; пред­существующий generateTitle по-прежнему инлайнит свой .trim().replace(/^["']|["']$/g,'').slice(0,120). Итог: один протестированный «чистильщик» и одна нетронутая ad-hoc копия той же идеи. Дефекта нет — generateTitle этот PR трогать не обязан; это smell-асимметрия, из-за которой будущие правки формата заголовка попадут только в один путь.
    навести generateTitle на cleanGeneratedTitle (параметризовать кап, если 120 vs 255 должны различаться), опционально вынести общий generateShortTitle({ system, prompt, maxChars }) для boilerplate getChatModel+generateText (effort: S). Pros: единый протестированный путь очистки везде; методы сводятся к «system-промпт + кап входа»; правки формата делаются однажды. Cons: трогает chat-title путь (другой кап и чуть другой промпт), нужна проверка сохранения поведения; спорно для feature-PR.

## Code review — PR #210: AI-генерация заголовка страницы из её содержимого (#199) **Вердикт: Approve with comments.** Серверная часть (эндпоинт `POST /ai-chat/generate-page-title`, gating по `settings.ai.generative`, throttle 20/60с, очистка заголовка) корректна и покрыта тестами; код чистый, security/regressions/conventions — без замечаний. Единственный must-fix формальный — отсутствует запись в CHANGELOG, которую репозиторий держит обязательной для каждой пользовательской фичи. _Объём: дифф `develop`…`feat/199-ai-generate-title` (merge-base `3ddc329b`), 10 файлов, +395/−2. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge - **[documentation] Добавить запись в CHANGELOG про фичу генерации заголовка (#199)** — `CHANGELOG.md:11-58` Проверено: в `## [Unreleased]` нет упоминания #199, при этом раздел ведётся по Keep-a-Changelog и каждая пользовательская фича там фиксируется (#183/#174, #175/#177, #168 и др.), а недавние ревью-коммиты это прямо требуют. PR добавляет заметную пользовательскую фичу (кнопка-«sparkles» в byline + новый эндпоинт `POST /ai-chat/generate-page-title`, гейтится `settings.ai.generative`) — ledger рассинхронизирован с изменением. Fix: добавить пункт под `## [Unreleased]` → `### Added`: кнопка «сгенерировать заголовок из содержимого» читает живой контент редактора (включая несохранённые правки), генерирует заголовок через AI-провайдер воркспейса и применяет его через существующий маршрут `/pages/update`; гейтится флагом `settings.ai.generative` и троттлится на пользователя. Ссылка (#199). - **[stability] Защитить запись в title-editor от навигации между страницами во время генерации** — `apps/client/src/features/editor/hooks/use-generate-page-title.ts:53-71` Хук захватывает глобальные `pageEditorAtom`/`titleEditorAtom` на рендере, а между `await generatePageTitle` и `titleEditor.commands.setContent(page.title)` проходит 1–3с. Если за это окно пользователь уйдёт на другую страницу, byline перемонтируется и глобальные атомы теперь указывают на редакторы НОВОЙ страницы: запись в БД остаётся корректной (используется захваченный проп `pageId`), но `setContent` впишет сгенерированный заголовок страницы A в видимое поле страницы B. Гард `!isFocused` это не ловит (поле принадлежит другой странице и не в фокусе); самокорректируется на следующем рендере. Транзиентный низковероятный кросс-страничный глюк, которого штатный `TitleEditor` избегает через проверку `activePageId !== pageId` / `editor.storage.pageId`. Fix: перед `setContent` (и перед чтением `pageEditor.getHTML()`) проверять, что живой редактор всё ещё относится к этой странице — например, выходить, если `pageEditor.storage?.pageId !== pageId` (его выставляет `page-editor.tsx`), зеркаля гард `TitleEditor`. ### Test coverage Серверная логика покрыта полноценно: `apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts` тестирует `cleanGeneratedTitle` (trim, снятие кавычек, удаление точки, кап 255, пустой ввод) и `AiChatController.generatePageTitle` (forbid при выключенном флаге, forbid при значении не ровно `true`, успешный `{ title }`, проброс `HttpException` 503, маппинг прочих ошибок в 503). - **[test-coverage] Покрыть тестами клиентский хук `useGeneratePageTitle`** — `apps/client/src/features/editor/hooks/use-generate-page-title.ts:33-104` Новая логика (104 строки) тестов не имеет: пустой markdown → жёлтое уведомление и ранний выход; пустой ответ модели → заголовок не трогается; happy-path с `updateTitle` + `updatePageData` + emit `UpdateEvent`; гард `!isFocused` для `setContent`; маппинг статусов 403/503/429/прочее в `onError`. Это вся ветвистая часть фичи на клиенте. Fix: добавить unit-тест на хук (мок `generatePageTitle`/`useUpdateTitlePageMutation`/`useQueryEmit`), покрыв перечисленные ветки; как минимум проверить early-returns и маппинг ошибок. ### Architecture & design (forward-looking, non-blocking) - **Дублирование «применить заголовок страницы + разослать по websocket»** — `apps/client/src/features/editor/hooks/use-generate-page-title.ts:57-86` vs `apps/client/src/features/editor/title-editor.tsx:129-149` Новый хук почти дословно повторяет блок из `TitleEditor.saveTitle`: `useUpdateTitlePageMutation` → `updatePageData(page)` → сборка идентичного `UpdateEvent` с payload `{ title, slugId, parentPageId, icon }` → `localEmitter.emit('message', event)` + `emit(event)`, плюс тот же гард «применять `setContent`, только если не в фокусе». Та же форма payload повторяется и в page-tree utils. Сейчас дефекта нет (копия корректна), но это maintenance-связность: любое изменение payload события `PAGE_UPDATED` придётся вносить в 2+ местах, и копии могут разойтись (автор сам аннотировал блок как «mirroring TitleEditor.saveTitle»). извлечь общий `applyPageTitleUpdate(page, { titleEditor, emit })` и вызывать из `saveTitle` и нового хука (effort: S). Pros: единый источник payload и гарда `setContent`; хук сжимается до «generate → updateTitle → applyPageTitleUpdate»; будущие точки изменения заголовка переиспользуют одну протестированную функцию; нет дрейфа. Cons: трогает устоявшийся `saveTitle`, надо сохранить его race-гард `title!==titleEditor.getText()` под тестом; чуть расширяет blast radius PR. - **Две реализации очистки заголовка модели на сервере** — `apps/server/src/core/ai-chat/ai-chat.service.ts:78-87` (`cleanGeneratedTitle`) vs приватный `generateTitle` (chat-title, ~810+) PR извлёк и протестировал общий пост-процессор `cleanGeneratedTitle`, но подключил его только в новый `generatePageTitle`; пред­существующий `generateTitle` по-прежнему инлайнит свой `.trim().replace(/^["']|["']$/g,'').slice(0,120)`. Итог: один протестированный «чистильщик» и одна нетронутая ad-hoc копия той же идеи. Дефекта нет — `generateTitle` этот PR трогать не обязан; это smell-асимметрия, из-за которой будущие правки формата заголовка попадут только в один путь. навести `generateTitle` на `cleanGeneratedTitle` (параметризовать кап, если 120 vs 255 должны различаться), опционально вынести общий `generateShortTitle({ system, prompt, maxChars })` для boilerplate `getChatModel`+`generateText` (effort: S). Pros: единый протестированный путь очистки везде; методы сводятся к «system-промпт + кап входа»; правки формата делаются однажды. Cons: трогает chat-title путь (другой кап и чуть другой промпт), нужна проверка сохранения поведения; спорно для feature-PR.
Ghost added 1 commit 2026-06-26 17:25:50 +03:00
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
  "generate title from content" byline button (reads live editor
  content, generates via the workspace AI provider, applies through
  /pages/update, gated by settings.ai.generative, throttled per user).

- use-generate-page-title: guard the visible title write against page
  navigation during generation. The mutation awaits the model for 1-3s;
  its closure captures the editors from the starting render, but the
  global page/title atoms re-point on navigation. We now keep a live ref
  to the current editors and skip setContent unless the live page editor
  still belongs to the page the title was generated for
  (editor.storage.pageId === pageId, mirroring TitleEditor's
  activePageId guard). The DB write stays correct (keyed by the captured
  pageId) and the websocket broadcast is unchanged, so only the wrong-page
  field write is suppressed.

- Add a vitest suite for the hook: empty content, empty model response,
  happy path, the navigation guard, and 403/503/429/other onError mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review (re-review) — PR #210: генерация заголовка страницы из контента (#199)

Вердикт: Approve. Прошлый блокер (отсутствующая запись в CHANGELOG) закрыт; дельта добавляет навигационный guard и полноценный vitest-сьют для хука — новых blocking-находок нет.

Ре-ревью дельты 5ee03f51..e8c6037d (3 файлов, +270/−2). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge).

Статус прошлых блокеров

  • CHANGELOG (#199) — закрыт по существу. В CHANGELOG.md:55-60 добавлен буллет в [Unreleased]/Added: кнопка в byline читает живой контент редактора, генерирует через POST /ai-chat/generate-page-title, применяет через /pages/update, под флагом ai.generative и с throttling per-user. Описание точное, соответствует коду.
  • Approve with comments (прочие замечания) — закрыты. Добавлен навигационный guard: запись в видимое поле заголовка подавляется, если пользователь ушёл на другую страницу за время генерации (1-3с). Инвариант проверен — editor.storage.pageId стампится в page-editor.tsx:341 (onCreate), а условие зеркалит TitleEditor (title-editor.tsx:117,163: activePageId !== pageId + !isFocused). DB-запись остаётся корректной (по захваченному pageId), broadcast не меняется — подавляется только ошибочная запись в чужое поле.

Must fix before merge

  • [test-coverage] Покрыть ветку isFocused/isDestroyed guard'аuse-generate-page-title.ts:91-97 ветка liveTitleEditor.isFocused === true (подавление setContent при сфокусированном поле заголовка) и ранний выход при pageEditor.isDestroyed напрямую не тестируются. Логика тривиальна и зеркалит проверенный TitleEditor. Fix: при желании добавить кейс с isFocused: true, проверяющий, что setContent не вызван.
## Code review (re-review) — PR #210: генерация заголовка страницы из контента (#199) **Вердикт: Approve.** Прошлый блокер (отсутствующая запись в CHANGELOG) закрыт; дельта добавляет навигационный guard и полноценный vitest-сьют для хука — новых blocking-находок нет. _Ре-ревью дельты `5ee03f51..e8c6037d` (3 файлов, +270/−2). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **CHANGELOG (#199) — закрыт по существу.** В `CHANGELOG.md:55-60` добавлен буллет в `[Unreleased]/Added`: кнопка в byline читает живой контент редактора, генерирует через `POST /ai-chat/generate-page-title`, применяет через `/pages/update`, под флагом `ai.generative` и с throttling per-user. Описание точное, соответствует коду. - **Approve with comments (прочие замечания) — закрыты.** Добавлен навигационный guard: запись в видимое поле заголовка подавляется, если пользователь ушёл на другую страницу за время генерации (1-3с). Инвариант проверен — `editor.storage.pageId` стампится в `page-editor.tsx:341` (onCreate), а условие зеркалит `TitleEditor` (`title-editor.tsx:117,163`: `activePageId !== pageId` + `!isFocused`). DB-запись остаётся корректной (по захваченному `pageId`), broadcast не меняется — подавляется только ошибочная запись в чужое поле. ### Must fix before merge - **[test-coverage] Покрыть ветку `isFocused`/`isDestroyed` guard'а** — `use-generate-page-title.ts:91-97` ветка `liveTitleEditor.isFocused === true` (подавление `setContent` при сфокусированном поле заголовка) и ранний выход при `pageEditor.isDestroyed` напрямую не тестируются. Логика тривиальна и зеркалит проверенный `TitleEditor`. Fix: при желании добавить кейс с `isFocused: true`, проверяющий, что `setContent` не вызван.
Ghost added 1 commit 2026-06-26 18:04:55 +03:00
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feat/199-ai-generate-title from 550977f840 to 9632146d23 2026-06-26 20:41:22 +03:00 Compare
vvzvlad merged commit 83e64bad1a into develop 2026-06-26 20:55:36 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#210