Фича: кнопка автогенерации названия заметки через AI #199

Closed
opened 2026-06-25 23:39:53 +03:00 by Ghost · 0 comments

Задача

Добавить в редактор страницы кнопку, по нажатию которой содержимое заметки отправляется AI-агенту с просьбой сгенерировать заголовок, и полученное название применяется к странице.

Зафиксированные продуктовые решения:

  • Механизм — одношотовый generateText (отдельный лёгкий серверный эндпоинт, как существующий generateTitle для чатов), без стриминга, инструментов и истории.
  • UX — применять название сразу (запись в заголовок через updatePage).
  • Источник контента — берётся с клиента из активного TipTap-редактора (включая несохранённые правки), конвертируется в markdown.

Что переиспользуем (исследование)

В проекте уже есть зрелая AI-инфраструктура — фича собирается из существующих кирпичей:

  • Резолвер модели: apps/server/src/integrations/ai/ai.service.tsAiService.getChatModel(workspaceId) разбирается с драйвером/ключами/baseURL и кидает AiNotConfiguredException (HTTP 503), если AI не настроен.
  • Эталон одношотовой генерации: приватный generateTitle() в apps/server/src/core/ai-chat/ai-chat.service.ts (~L797) — ровно то, что нужно, только для первого сообщения чата.
  • Эталон non-streaming эндпоинта: transcribe в apps/server/src/core/ai-chat/ai-chat.controller.ts (~L254) — аутентифицированный, gated через settings.ai.*, throttled через AI_CHAT_THROTTLER, возвращает чистый JSON и мапит ошибки провайдера через describeProviderError.
  • Эталон клиентского вызова: apps/client/src/features/dictation/services/dictation-service.ts + мутации в apps/client/src/features/ai-chat/queries/ai-chat-query.ts (React Query + notifications.show).
  • Заголовок страницы НЕ в Yjs: apps/client/src/features/editor/title-editor.tsx — отдельное поле, сохраняется мутацией useUpdateTitlePageMutationPOST /pages/update (валидация validateCanEdit). Запись названия напрямую не конфликтует с коллаборативным редактированием тела.
  • Извлечение контента на клиенте: pageEditor через pageEditorAtom; markdown получается htmlToMarkdown(pageEditor.getHTML()) (утилита @docmost/editor-ext).
  • Эталон размещения кнопки уровня страницы: DictationGroup рендерится в PageByline внутри apps/client/src/features/editor/full-editor.tsx (~L238), только в режиме редактирования и при включённом флаге воркспейса.

Поток данных (end-to-end)

[Клик «✨ Сгенерировать название»]  (PageByline, только editMode + editable + флаг)
  │
  ├─ читаем тело: md = htmlToMarkdown(pageEditor.getHTML())
  ├─ если md пустой → notification «Заметка пустая», стоп
  │
  ├─ POST /api/ai-chat/generate-page-title  { content: md }
  │     │  [server]
  │     ├─ gate: settings.ai.<flag> === true, иначе 403
  │     ├─ model = ai.getChatModel(workspaceId)        // 503 если не настроен
  │     ├─ generateText(system: "сгенерируй заголовок…", prompt: content.slice(0, 8000))
  │     └─ → { title }                                  // обрезка кавычек/длины
  │
  ├─ on success → updateTitlePageMutation({ pageId, title })  // POST /pages/update, validateCanEdit
  │     └─ → page
  ├─ updatePageData(page)            // обновляем react-query кэш
  ├─ titleEditor.setContent(title)   // мгновенно отражаем в поле заголовка
  ├─ emit(UpdateEvent)               // websocket-броадкаст другим пользователям
  └─ notification «Название сгенерировано»

  Ошибки → notifications: 403 (выключено) / 503 (AI не настроен) / 429 (rate limit) / generic

Ключевое решение: серверный эндпоинт только суммаризирует переданный текст и не трогает страницу — он не читает БД и не пишет заголовок. Фактическая запись названия идёт через существующий POST /pages/update, который сам валидирует право на редактирование (validateCanEdit). Это разделяет ответственность и убирает дублирование проверок доступа.

Бэкенд

(a) DTO — apps/server/src/core/ai-chat/dto/ai-chat.dto.ts

export class GeneratePageTitleDto {
  // Note body as markdown/plain text. Capped to bound the prompt cost and
  // reject abusive payloads; the service truncates again before the model call.
  @IsString()
  @MinLength(1)
  @MaxLength(20000)
  content: string;
}

(b) Сервисный метод — apps/server/src/core/ai-chat/ai-chat.service.ts

/**
 * One-shot page-title generation from the note's content. No tools, no
 * streaming — mirrors generateTitle() but for an arbitrary note body. The
 * content is truncated to keep the prompt cheap and within context limits.
 */
async generatePageTitle(workspaceId: string, content: string): Promise<string> {
  const model = await this.ai.getChatModel(workspaceId);
  const { text } = await generateText({
    model,
    system:
      'You generate a single concise, descriptive title for a note based on ' +
      'its content. Reply with the title only — at most 8 words, no quotes, ' +
      'no trailing punctuation, written in the same language as the note.',
    prompt: content.slice(0, 8000),
  });
  return text.trim().replace(/^["']|["']$/g, '').slice(0, 255);
}

(c) Маршрут контроллера — apps/server/src/core/ai-chat/ai-chat.controller.ts

/**
 * Generate a page title from supplied note content. One-shot, non-streaming.
 * Gated by the workspace AI flag; returns { title }. The endpoint never writes
 * the page — the client applies the title via the existing /pages/update route
 * (which enforces edit permission).
 */
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } })
@Post('generate-page-title')
async generatePageTitle(
  @Body() dto: GeneratePageTitleDto,
  @AuthWorkspace() workspace: Workspace,
): Promise<{ title: string }> {
  const settings = (workspace.settings ?? {}) as { ai?: { generative?: boolean } };
  if (settings.ai?.generative !== true) {
    throw new ForbiddenException('AI title generation is disabled');
  }
  try {
    const title = await this.aiChatService.generatePageTitle(
      workspace.id,
      dto.content,
    );
    return { title };
  } catch (err) {
    if (err instanceof HttpException) throw err; // AiNotConfiguredException -> 503
    this.logger.error('AI title generation failed', err as Error);
    throw new ServiceUnavailableException(describeProviderError(err));
  }
}

Выбор фича-флага (мелкое открытое решение): рекомендуется переиспользовать существующий settings.ai.generative (он уже семантически = «генеративные AI-фичи на странице», им gated AskAiGroup) — тогда не нужно трогать админ-UI настроек. Альтернатива — отдельный settings.ai.titleGen для независимого включения (потребует правки ai-provider-settings.tsx и схемы настроек).

Фронтенд

(a) Сервисная функция — apps/client/src/features/ai-chat/services/ai-chat-service.ts

/** Generate a page title from note content (markdown). Returns the suggestion. */
export async function generatePageTitle(content: string): Promise<string> {
  const req = await api.post<{ title: string }>(
    "/ai-chat/generate-page-title",
    { content },
  );
  return req.data.title;
}

(b) Хук-обёртка «сгенерировать + применить» — новый apps/client/src/features/editor/hooks/use-generate-page-title.ts

// Generates a title for the given page from the live editor content, then
// applies it immediately (per product decision). Returns a mutation-like API
// with isPending for the button's loading state.
export function useGeneratePageTitle(pageId: string) {
  const { t } = useTranslation();
  const pageEditor = useAtomValue(pageEditorAtom);
  const titleEditor = useAtomValue(titleEditorAtom);
  const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
  const emit = useQueryEmit();

  return useMutation<void, Error, void>({
    mutationFn: async () => {
      if (!pageEditor || pageEditor.isDestroyed) return;
      const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
      if (!markdown) {
        notifications.show({ message: t("The note is empty"), color: "yellow" });
        return;
      }
      const title = (await generatePageTitle(markdown)).trim();
      if (!title) {
        notifications.show({ message: t("Could not generate a title"), color: "yellow" });
        return;
      }
      const page = await updateTitle({ pageId, title });   // POST /pages/update (validateCanEdit)
      updatePageData(page);                                 // refresh react-query cache
      // Reflect immediately in the title field (button is in the byline, so the
      // title editor is not focused — safe to setContent; stays undoable via History).
      if (titleEditor && !titleEditor.isDestroyed && !titleEditor.isFocused) {
        titleEditor.commands.setContent(page.title);
      }
      emit({ /* same UpdateEvent shape as TitleEditor.saveTitle */ } as UpdateEvent);
      notifications.show({ message: t("Title generated") });
    },
    onError: (err) => {
      // Map known statuses to friendly messages (403 disabled / 503 not
      // configured / 429 rate-limited), fall back to a generic message.
      notifications.show({ message: t("Failed to generate title"), color: "red" });
    },
  });
}

Архитектурная заметка: логика «записать заголовок → updatePageDataemit(UpdateEvent)» дублирует saveTitle из title-editor.tsx (~L116). Рек��мендуется вынести её в общий хелпер applyPageTitle(pageId, title) и использовать в обоих местах, чтобы избежать расхождения.

(c) Компонент кнопки — новый apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx

export const GenerateTitleGroup: FC<{ pageId: string }> = ({ pageId }) => {
  const { t } = useTranslation();
  const gen = useGeneratePageTitle(pageId);
  return (
    <Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
      <ActionIcon
        variant="subtle" color="gray"
        aria-label={t("Generate title with AI")}
        loading={gen.isPending}
        onClick={() => gen.mutate()}
      >
        <IconSparkles size={20} stroke={1.5} />
      </ActionIcon>
    </Tooltip>
  );
};

(d) Монтаж в байлайн — apps/client/src/features/editor/full-editor.tsx

Пробросить pageId в PageByline, добавить isTitleGenEnabled = workspace?.settings?.ai?.generative === true и отрисовать кнопку рядом с DictationGroup в той же <Group gap={4}> (~L225–241), под тем же условием видимости editable && isEditMode.

(e) i18n — apps/client/public/locales/en-US/translation.json и ru-RU/translation.json

Ключи: "Generate title with AI", "Title generated", "Failed to generate title", "The note is empty", "Could not generate a title" (+ русские: «Сгенерировать название через AI», «Название сгенерировано», «Не удалось сгенерировать название», «Заметка пустая», «Не удалось придумать название»).

Точные точки изменений

# Файл Что добавить
1 apps/server/src/core/ai-chat/dto/ai-chat.dto.ts GeneratePageTitleDto
2 apps/server/src/core/ai-chat/ai-chat.service.ts public generatePageTitle(workspaceId, content)
3 apps/server/src/core/ai-chat/ai-chat.controller.ts @Post('generate-page-title') (gate + throttle + error-map)
4 apps/client/src/features/ai-chat/services/ai-chat-service.ts generatePageTitle(content)
5 новый apps/client/src/features/editor/hooks/use-generate-page-title.ts useGeneratePageTitle(pageId)
6 новый .../fixed-toolbar/groups/generate-title-group.tsx GenerateTitleGroup
7 apps/client/src/features/editor/full-editor.tsx проброс pageId в PageByline + рендер кнопки рядом с DictationGroup
8 (опц.) общий applyPageTitle хелпер устранить дублирование с TitleEditor.saveTitle
9 en-US / ru-RU translation.json новые строки
10 (опц., если новый флаг) ai-provider-settings.tsx + схема настроек тоггл settings.ai.titleGen

Краевые случаи и тонкости

  • Пустая заметка → не дёргаем модель, показываем уведомление.
  • AI не настроенgetChatModel кидает AiNotConfiguredException (503) → дружелюбное уведомление.
  • Фича выключена → 403; кнопка не рендерится при выключенном флаге (двойная защита).
  • Rate limit → 429 от AI_CHAT_THROTTLER (20/мин на пользователя).
  • Модель вернула пусто/мусор → если итоговый title пуст, старый не трогаем, уведомляем.
  • Коллаборативность: заголовок — отдельное поле (не Yjs), запись безопасна; конкурентные правки заголовка — «последний выиграл».
  • Несохранённый контент: берём getHTML() из живого редактора → модель видит самые свежие правки тела.
  • Тело без названия: pageEditor.getHTML() отдаёт только тело (заголовок — отдельный редактор), промпт не «заражается» старым названием.
  • Применение «сразу» обратимо: setContent идёт через History-расширение титульного редактора → Ctrl/Cmd+Z откатывает автозамену.
  • Язык: системный промпт требует «на языке заметки» → RU-заметка получит RU-заголовок.
  • Безопасность: серверный эндпоинт не пишет страницу и не читает чужой контент из БД — он суммаризирует ровно тот текст, что прислал клиент; запись названия проходит штатную validateCanEdit.
## Задача Добавить в редактор страницы кнопку, по нажатию которой содержимое заметки отправляется AI-агенту с просьбой сгенерировать заголовок, и полученное название применяется к странице. **Зафиксированные продуктовые решения:** - **Механизм** — одношотовый `generateText` (отдельный лёгкий серверный эндпоинт, как существующий `generateTitle` для чатов), без стриминга, инструментов и истории. - **UX** — применять название **сразу** (запись в заголовок через `updatePage`). - **Источник контента** — берётся **с клиента** из активного TipTap-редактора (включая несохранённые правки), конвертируется в markdown. ## Что переиспользуем (исследование) В проекте уже есть зрелая AI-инфраструктура — фича собирается из существующих кирпичей: - **Резолвер модели**: `apps/server/src/integrations/ai/ai.service.ts` — `AiService.getChatModel(workspaceId)` разбирается с драйвером/ключами/baseURL и кидает `AiNotConfiguredException` (HTTP 503), если AI не настроен. - **Эталон одношотовой генерации**: приватный `generateTitle()` в `apps/server/src/core/ai-chat/ai-chat.service.ts` (~L797) — ровно то, что нужно, только для первого сообщения чата. - **Эталон non-streaming эндпоинта**: `transcribe` в `apps/server/src/core/ai-chat/ai-chat.controller.ts` (~L254) — аутентифицированный, gated через `settings.ai.*`, throttled через `AI_CHAT_THROTTLER`, возвращает чистый JSON и мапит ошибки провайдера через `describeProviderError`. - **Эталон клиентского вызова**: `apps/client/src/features/dictation/services/dictation-service.ts` + мутации в `apps/client/src/features/ai-chat/queries/ai-chat-query.ts` (React Query + `notifications.show`). - **Заголовок страницы НЕ в Yjs**: `apps/client/src/features/editor/title-editor.tsx` — отдельное поле, сохраняется мутацией `useUpdateTitlePageMutation` → `POST /pages/update` (валидация `validateCanEdit`). Запись названия напрямую **не конфликтует** с коллаборативным редактированием тела. - **Извлечение контента на клиенте**: `pageEditor` через `pageEditorAtom`; markdown получается `htmlToMarkdown(pageEditor.getHTML())` (утилита `@docmost/editor-ext`). - **Эталон размещения кнопки уровня страницы**: `DictationGroup` рендерится в `PageByline` внутри `apps/client/src/features/editor/full-editor.tsx` (~L238), только в режиме редактирования и при включённом флаге воркспейса. ## Поток данных (end-to-end) ``` [Клик «✨ Сгенерировать название»] (PageByline, только editMode + editable + флаг) │ ├─ читаем тело: md = htmlToMarkdown(pageEditor.getHTML()) ├─ если md пустой → notification «Заметка пустая», стоп │ ├─ POST /api/ai-chat/generate-page-title { content: md } │ │ [server] │ ├─ gate: settings.ai.<flag> === true, иначе 403 │ ├─ model = ai.getChatModel(workspaceId) // 503 если не настроен │ ├─ generateText(system: "сгенерируй заголовок…", prompt: content.slice(0, 8000)) │ └─ → { title } // обрезка кавычек/длины │ ├─ on success → updateTitlePageMutation({ pageId, title }) // POST /pages/update, validateCanEdit │ └─ → page ├─ updatePageData(page) // обновляем react-query кэш ├─ titleEditor.setContent(title) // мгновенно отражаем в поле заголовка ├─ emit(UpdateEvent) // websocket-броадкаст другим пользователям └─ notification «Название сгенерировано» Ошибки → notifications: 403 (выключено) / 503 (AI не настроен) / 429 (rate limit) / generic ``` **Ключевое решение:** серверный эндпоинт только суммаризирует переданный текст и **не трогает страницу** — он не читает БД и не пишет заголовок. Фактическая запись названия идёт через существующий `POST /pages/update`, который сам валидирует право на редактирование (`validateCanEdit`). Это разделяет ответственность и убирает дублирование проверок доступа. ## Бэкенд ### (a) DTO — `apps/server/src/core/ai-chat/dto/ai-chat.dto.ts` ```ts export class GeneratePageTitleDto { // Note body as markdown/plain text. Capped to bound the prompt cost and // reject abusive payloads; the service truncates again before the model call. @IsString() @MinLength(1) @MaxLength(20000) content: string; } ``` ### (b) Сервисный метод — `apps/server/src/core/ai-chat/ai-chat.service.ts` ```ts /** * One-shot page-title generation from the note's content. No tools, no * streaming — mirrors generateTitle() but for an arbitrary note body. The * content is truncated to keep the prompt cheap and within context limits. */ async generatePageTitle(workspaceId: string, content: string): Promise<string> { const model = await this.ai.getChatModel(workspaceId); const { text } = await generateText({ model, system: 'You generate a single concise, descriptive title for a note based on ' + 'its content. Reply with the title only — at most 8 words, no quotes, ' + 'no trailing punctuation, written in the same language as the note.', prompt: content.slice(0, 8000), }); return text.trim().replace(/^["']|["']$/g, '').slice(0, 255); } ``` ### (c) Маршрут контроллера — `apps/server/src/core/ai-chat/ai-chat.controller.ts` ```ts /** * Generate a page title from supplied note content. One-shot, non-streaming. * Gated by the workspace AI flag; returns { title }. The endpoint never writes * the page — the client applies the title via the existing /pages/update route * (which enforces edit permission). */ @HttpCode(HttpStatus.OK) @UseGuards(JwtAuthGuard, UserThrottlerGuard) @Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } }) @Post('generate-page-title') async generatePageTitle( @Body() dto: GeneratePageTitleDto, @AuthWorkspace() workspace: Workspace, ): Promise<{ title: string }> { const settings = (workspace.settings ?? {}) as { ai?: { generative?: boolean } }; if (settings.ai?.generative !== true) { throw new ForbiddenException('AI title generation is disabled'); } try { const title = await this.aiChatService.generatePageTitle( workspace.id, dto.content, ); return { title }; } catch (err) { if (err instanceof HttpException) throw err; // AiNotConfiguredException -> 503 this.logger.error('AI title generation failed', err as Error); throw new ServiceUnavailableException(describeProviderError(err)); } } ``` **Выбор фича-флага** (мелкое открытое решение): рекомендуется переиспользовать существующий `settings.ai.generative` (он уже семантически = «генеративные AI-фичи на странице», им gated `AskAiGroup`) — тогда не нужно трогать админ-UI настроек. Альтернатива — отдельный `settings.ai.titleGen` для независимого включения (потребует правки `ai-provider-settings.tsx` и схемы настроек). ## Фронтенд ### (a) Сервисная функция — `apps/client/src/features/ai-chat/services/ai-chat-service.ts` ```ts /** Generate a page title from note content (markdown). Returns the suggestion. */ export async function generatePageTitle(content: string): Promise<string> { const req = await api.post<{ title: string }>( "/ai-chat/generate-page-title", { content }, ); return req.data.title; } ``` ### (b) Хук-обёртка «сгенерировать + применить» — новый `apps/client/src/features/editor/hooks/use-generate-page-title.ts` ```ts // Generates a title for the given page from the live editor content, then // applies it immediately (per product decision). Returns a mutation-like API // with isPending for the button's loading state. export function useGeneratePageTitle(pageId: string) { const { t } = useTranslation(); const pageEditor = useAtomValue(pageEditorAtom); const titleEditor = useAtomValue(titleEditorAtom); const { mutateAsync: updateTitle } = useUpdateTitlePageMutation(); const emit = useQueryEmit(); return useMutation<void, Error, void>({ mutationFn: async () => { if (!pageEditor || pageEditor.isDestroyed) return; const markdown = htmlToMarkdown(pageEditor.getHTML()).trim(); if (!markdown) { notifications.show({ message: t("The note is empty"), color: "yellow" }); return; } const title = (await generatePageTitle(markdown)).trim(); if (!title) { notifications.show({ message: t("Could not generate a title"), color: "yellow" }); return; } const page = await updateTitle({ pageId, title }); // POST /pages/update (validateCanEdit) updatePageData(page); // refresh react-query cache // Reflect immediately in the title field (button is in the byline, so the // title editor is not focused — safe to setContent; stays undoable via History). if (titleEditor && !titleEditor.isDestroyed && !titleEditor.isFocused) { titleEditor.commands.setContent(page.title); } emit({ /* same UpdateEvent shape as TitleEditor.saveTitle */ } as UpdateEvent); notifications.show({ message: t("Title generated") }); }, onError: (err) => { // Map known statuses to friendly messages (403 disabled / 503 not // configured / 429 rate-limited), fall back to a generic message. notifications.show({ message: t("Failed to generate title"), color: "red" }); }, }); } ``` > **Архитектурная заметка:** логика «записать заголовок → `updatePageData` → `emit(UpdateEvent)`» дублирует `saveTitle` из `title-editor.tsx` (~L116). Рек��мендуется вынести её в общий хелпер `applyPageTitle(pageId, title)` и использовать в обоих местах, чтобы избежать расхождения. ### (c) Компонент кнопки — новый `apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx` ```tsx export const GenerateTitleGroup: FC<{ pageId: string }> = ({ pageId }) => { const { t } = useTranslation(); const gen = useGeneratePageTitle(pageId); return ( <Tooltip label={t("Generate title with AI")} withArrow openDelay={250}> <ActionIcon variant="subtle" color="gray" aria-label={t("Generate title with AI")} loading={gen.isPending} onClick={() => gen.mutate()} > <IconSparkles size={20} stroke={1.5} /> </ActionIcon> </Tooltip> ); }; ``` ### (d) Монтаж в байлайн — `apps/client/src/features/editor/full-editor.tsx` Пробросить `pageId` в `PageByline`, добавить `isTitleGenEnabled = workspace?.settings?.ai?.generative === true` и отрисовать кнопку рядом с `DictationGroup` в той же `<Group gap={4}>` (~L225–241), под тем же условием видимости `editable && isEditMode`. ### (e) i18n — `apps/client/public/locales/en-US/translation.json` и `ru-RU/translation.json` Ключи: `"Generate title with AI"`, `"Title generated"`, `"Failed to generate title"`, `"The note is empty"`, `"Could not generate a title"` (+ русские: «Сгенерировать название через AI», «Название сгенерировано», «Не удалось сгенерировать название», «Заметка пустая», «Не удалось придумать название»). ## Точные точки изменений | # | Файл | Что добавить | |---|------|--------------| | 1 | `apps/server/src/core/ai-chat/dto/ai-chat.dto.ts` | `GeneratePageTitleDto` | | 2 | `apps/server/src/core/ai-chat/ai-chat.service.ts` | public `generatePageTitle(workspaceId, content)` | | 3 | `apps/server/src/core/ai-chat/ai-chat.controller.ts` | `@Post('generate-page-title')` (gate + throttle + error-map) | | 4 | `apps/client/src/features/ai-chat/services/ai-chat-service.ts` | `generatePageTitle(content)` | | 5 | новый `apps/client/src/features/editor/hooks/use-generate-page-title.ts` | `useGeneratePageTitle(pageId)` | | 6 | новый `.../fixed-toolbar/groups/generate-title-group.tsx` | `GenerateTitleGroup` | | 7 | `apps/client/src/features/editor/full-editor.tsx` | проброс `pageId` в `PageByline` + рендер кнопки рядом с `DictationGroup` | | 8 | (опц.) общий `applyPageTitle` хелпер | устранить дублирование с `TitleEditor.saveTitle` | | 9 | `en-US` / `ru-RU` `translation.json` | новые строки | | 10 | (опц., если новый флаг) `ai-provider-settings.tsx` + схема настроек | тоггл `settings.ai.titleGen` | ## Краевые случаи и тонкости - **Пустая заметка** → не дёргаем модель, показываем уведомление. - **AI не настроен** → `getChatModel` кидает `AiNotConfiguredException` (503) → дружелюбное уведомление. - **Фича выключена** → 403; кнопка не рендерится при выключенном флаге (двойная защита). - **Rate limit** → 429 от `AI_CHAT_THROTTLER` (20/мин на пользователя). - **Модель вернула пусто/мусор** → если итоговый title пуст, старый не трогаем, уведомляем. - **Коллаборативность**: заголовок — отдельное поле (не Yjs), запись безопасна; конкурентные правки заголовка — «последний выиграл». - **Несохранённый контент**: берём `getHTML()` из живого редактора → модель видит самые свежие правки тела. - **Тело без названия**: `pageEditor.getHTML()` отдаёт только тело (заголовок — отдельный редактор), промпт не «заражается» старым названием. - **Применение «сразу» обратимо**: `setContent` идёт через `History`-расширение титульного редактора → `Ctrl/Cmd+Z` откатывает автозамену. - **Язык**: системный промпт требует «на языке заметки» → RU-заметка получит RU-заголовок. - **Безопасность**: серверный эндпоинт не пишет страницу и не читает чужой контент из БД — он суммаризирует ровно тот текст, что прислал клиент; запись названия проходит штатную `validateCanEdit`.
vvzvlad added the feature label 2026-06-26 00:31:49 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#199