feat(ai): generate page title from content (#199) #210
Reference in New Issue
Block a user
Delete Branch "feat/199-ai-generate-title"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #199
Adds an AI button that generates a note title from the live editor content and applies it immediately.
Server
POST /ai-chat/generate-page-title(mirrors the chatgenerateTitlepath).settings.ai.generative(403 when off), throttled viaAI_CHAT_THROTTLER(20/min/user).AiNotConfiguredExceptionwhen AI unconfigured),generateText, returns{ title }./pages/updateroute (which enforcesvalidateCanEdit), so access checks are not duplicated.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 theUpdateEvent(mirrorsTitleEditor.saveTitle). Empty-note / empty-result guards; 403/503/429/generic error notifications.GenerateTitleGroupsparkles button rendered next to dictation in the byline, edit-mode +settings.ai.generativegated (double-gated with the server).Tests
cleanGeneratedTitlecases + controller gate / delegation / HttpException passthrough / non-HTTP -> 503 mapping (11 tests).Verify
pnpm --filter server exec tsc --noEmit— passpnpm --filter server exec jest(new spec + existing ai-chat specs, 63 tests) — passpnpm --filter client exec tsc -b— pass🤖 Generated with Claude Code
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>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-base3ddc329b), 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 }, пробросHttpException503, маппинг прочих ошибок в 503).useGeneratePageTitle—apps/client/src/features/editor/hooks/use-generate-page-title.ts:33-104Новая логика (104 строки) тестов не имеет: пустой markdown → жёлтое уведомление и ранний выход; пустой ответ модели → заголовок не трогается; happy-path с
updateTitle+updatePageData+ emitUpdateEvent; гард!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-86vsapps/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 })для boilerplategetChatModel+generateText(effort: S). Pros: единый протестированный путь очистки везде; методы сводятся к «system-промпт + кап входа»; правки формата делаются однажды. Cons: трогает chat-title путь (другой кап и чуть другой промпт), нужна проверка сохранения поведения; спорно для feature-PR.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.md:55-60добавлен буллет в[Unreleased]/Added: кнопка в byline читает живой контент редактора, генерирует черезPOST /ai-chat/generate-page-title, применяет через/pages/update, под флагомai.generativeи с throttling per-user. Описание точное, соответствует коду.editor.storage.pageIdстампится вpage-editor.tsx:341(onCreate), а условие зеркалитTitleEditor(title-editor.tsx:117,163:activePageId !== pageId+!isFocused). DB-запись остаётся корректной (по захваченномуpageId), broadcast не меняется — подавляется только ошибочная запись в чужое поле.Must fix before merge
isFocused/isDestroyedguard'а —use-generate-page-title.ts:91-97веткаliveTitleEditor.isFocused === true(подавлениеsetContentпри сфокусированном поле заголовка) и ранний выход приpageEditor.isDestroyedнапрямую не тестируются. Логика тривиальна и зеркалит проверенныйTitleEditor. Fix: при желании добавить кейс сisFocused: true, проверяющий, чтоsetContentне вызван.550977f840to9632146d23