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

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>
This commit is contained in:
claude code agent 227
2026-06-26 06:20:53 +03:00
parent 3ddc329bba
commit 5ee03f51e2
10 changed files with 395 additions and 2 deletions

View File

@@ -1315,5 +1315,13 @@
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)"
"OpenAI (official)": "OpenAI (official)",
"Generate title with AI": "Generate title with AI",
"Title generated": "Title generated",
"Failed to generate title": "Failed to generate title",
"The note is empty": "The note is empty",
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later"
}

View File

@@ -1169,5 +1169,13 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
"OpenAI (official)": "OpenAI (официальный)",
"Generate title with AI": "Сгенерировать название через AI",
"Title generated": "Название сгенерировано",
"Failed to generate title": "Не удалось сгенерировать название",
"The note is empty": "Заметка пустая",
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
}

View File

@@ -68,6 +68,19 @@ export async function exportAiChat(
return req.data.markdown;
}
/**
* Generate a page title from note content (markdown). One-shot, non-streaming
* (#199): the server only summarizes the supplied text and returns a suggestion;
* it never writes the page. The caller applies the title via /pages/update.
*/
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;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only

View File

@@ -0,0 +1,39 @@
import { FC } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
interface Props {
pageId: string;
color?: string;
iconSize?: number;
}
/**
* AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's generative AI flag is on.
*/
export const GenerateTitleGroup: FC<Props> = ({
pageId,
color = "gray",
iconSize = 20,
}) => {
const { t } = useTranslation();
const gen = useGeneratePageTitle(pageId);
return (
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color={color}
aria-label={t("Generate title with AI")}
loading={gen.isPending}
onClick={() => gen.mutate()}
>
<IconSparkles size={iconSize} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
};

View File

@@ -32,6 +32,7 @@ import {
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -74,6 +75,9 @@ export function FullEditor({
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation reuses the generative AI flag (same gate as the on-page
// generative menu); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
@@ -111,11 +115,13 @@ export function FullEditor({
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
@@ -128,19 +134,23 @@ export function FullEditor({
}
type PageBylineProps = {
pageId: string;
creator?: PageUser;
contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
isTitleGenEnabled?: boolean;
};
function PageByline({
pageId,
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
isTitleGenEnabled,
}: PageBylineProps) {
const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details");
@@ -148,6 +158,9 @@ function PageByline({
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const showTitleGen = Boolean(
isTitleGenEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -238,6 +251,11 @@ function PageByline({
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
{/* Shown only in edit mode when the workspace's generative AI flag is on,
so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
)}
</Group>
</Group>
);

View File

@@ -0,0 +1,104 @@
import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query.ts";
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
// Maximum length we send to the model. The server truncates again; this is a
// cheap client-side bound so we never ship a huge body over the wire.
const MAX_CONTENT_CHARS = 20000;
/**
* Generate a title for the given page from the LIVE editor content (#199),
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
* server endpoint only summarizes the supplied markdown — it never writes the
* page; the actual title write goes through the existing /pages/update mutation
* (which enforces edit permission), and is mirrored to the title field + other
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
* button can show a loading state via `isPending`.
*/
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.slice(0, MAX_CONTENT_CHARS))
).trim();
if (!title) {
// The model returned nothing usable — keep the existing title untouched.
notifications.show({
message: t("Could not generate a title"),
color: "yellow",
});
return;
}
const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache
// Reflect the new title in the field immediately. The button lives in the
// byline, so the title editor is not focused — setContent is safe and stays
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
if (titleEditor && !titleEditor.isDestroyed && !titleEditor.isFocused) {
titleEditor.commands.setContent(page.title);
}
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
localEmitter.emit("message", event);
emit(event);
notifications.show({ message: t("Title generated") });
},
onError: (err) => {
// Map known HTTP statuses to friendly messages, falling back to generic.
const status = (err as { response?: { status?: number } })?.response
?.status;
const message =
status === 403
? t("AI title generation is disabled")
: status === 503
? t("AI is not configured")
: status === 429
? t("Too many requests, please try again later")
: t("Failed to generate title");
notifications.show({ message, color: "red" });
},
});
}

View File

@@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
import {
ChatIdDto,
ExportChatDto,
GeneratePageTitleDto,
GetChatMessagesDto,
RenameChatDto,
} from './dto/ai-chat.dto';
@@ -316,6 +317,43 @@ export class AiChatController {
return { text };
}
/**
* Generate a page title from supplied note content (#199). One-shot,
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
* the same flag that gates the on-page generative AI menu); returns { title }.
* The endpoint NEVER writes the page — the client applies the title via the
* existing /pages/update route (which enforces edit permission), so access
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
*/
@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) {
// Preserve meaningful HTTP errors (e.g. AiNotConfiguredException -> 503).
if (err instanceof HttpException) throw err;
// Surface the real provider/transport reason instead of an opaque 500.
this.logger.error('AI title generation failed', err as Error);
throw new ServiceUnavailableException(describeProviderError(err));
}
}
/**
* Ensure the chat exists, belongs to this workspace, AND was created by the
* requesting user (per-user isolation). Throws ForbiddenException otherwise.

View File

@@ -0,0 +1,122 @@
import {
ForbiddenException,
HttpException,
ServiceUnavailableException,
} from '@nestjs/common';
import { AiChatController } from './ai-chat.controller';
import { cleanGeneratedTitle } from './ai-chat.service';
import type { Workspace } from '@docmost/db/types/entity.types';
/**
* Pure post-processing of a model-generated title (#199): trims, strips a single
* pair of surrounding quotes, drops a trailing period, and hard-caps the length.
*/
describe('cleanGeneratedTitle', () => {
it('trims surrounding whitespace', () => {
expect(cleanGeneratedTitle(' Hello world ')).toBe('Hello world');
});
it('strips a single pair of surrounding double quotes', () => {
expect(cleanGeneratedTitle('"My note"')).toBe('My note');
});
it('strips surrounding single quotes', () => {
expect(cleanGeneratedTitle("'My note'")).toBe('My note');
});
it('drops a trailing period', () => {
expect(cleanGeneratedTitle('A complete sentence.')).toBe(
'A complete sentence',
);
});
it('caps the result at 255 characters (the page-title column bound)', () => {
expect(cleanGeneratedTitle('x'.repeat(400))).toHaveLength(255);
});
it('returns an empty string for blank/garbage input', () => {
expect(cleanGeneratedTitle(' ')).toBe('');
expect(cleanGeneratedTitle('""')).toBe('');
});
});
/**
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
* gate on settings.ai.generative (403 when off), delegate to the service when on,
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
* any other provider/transport fault to a 503. Exercised by instantiating the
* controller with hand-rolled mocks — no Nest graph, no DB.
*/
describe('AiChatController.generatePageTitle', () => {
const enabledWorkspace = {
id: 'ws1',
settings: { ai: { generative: true } },
} as unknown as Workspace;
function makeController(generate: jest.Mock) {
const aiChatService = { generatePageTitle: generate };
const controller = new AiChatController(
aiChatService as never,
{} as never,
{} as never,
{} as never,
);
return { controller, aiChatService };
}
it('forbids when the generative AI flag is off', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, disabled),
).rejects.toBeInstanceOf(ForbiddenException);
expect(generate).not.toHaveBeenCalled();
});
it('forbids when settings.ai.generative is anything but exactly true', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const ws = {
id: 'ws1',
settings: { ai: { generative: 'yes' } },
} as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, ws),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('returns { title } from the service when enabled', async () => {
const generate = jest.fn().mockResolvedValue('Generated Title');
const { controller } = makeController(generate);
const res = await controller.generatePageTitle(
{ content: 'some markdown body' },
enabledWorkspace,
);
expect(generate).toHaveBeenCalledWith('ws1', 'some markdown body');
expect(res).toEqual({ title: 'Generated Title' });
});
it('rethrows an HttpException from the service verbatim (e.g. 503 not configured)', async () => {
const notConfigured = new ServiceUnavailableException('AI not configured');
const generate = jest.fn().mockRejectedValue(notConfigured);
const { controller } = makeController(generate);
await expect(
controller.generatePageTitle({ content: 'body' }, enabledWorkspace),
).rejects.toBe(notConfigured);
});
it('maps a non-HTTP provider error to a 503', async () => {
const generate = jest.fn().mockRejectedValue(new Error('socket hang up'));
const { controller } = makeController(generate);
// Silence the expected error log.
jest
.spyOn((controller as unknown as { logger: { error: () => void } }).logger, 'error')
.mockImplementation(() => undefined);
const err = await controller
.generatePageTitle({ content: 'body' }, enabledWorkspace)
.catch((e) => e);
expect(err).toBeInstanceOf(ServiceUnavailableException);
expect(err).toBeInstanceOf(HttpException);
});
});

View File

@@ -75,6 +75,18 @@ export function prepareAgentStep(
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
// Pure, unit-testable post-processing for a model-generated title (#199): trim
// whitespace, strip a single pair of surrounding quotes the model often adds,
// drop a trailing period, and hard-cap the length to the page-title column.
export function cleanGeneratedTitle(text: string): string {
return text
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\.+$/, '')
.trim()
.slice(0, 255);
}
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
@@ -789,6 +801,27 @@ export class AiChatService implements OnModuleInit {
}
}
/**
* One-shot page-title generation from a note's content (#199). No tools, no
* streaming — mirrors generateTitle() but for an arbitrary note body supplied
* by the client, and RETURNS the title instead of writing it (the client
* applies it via the existing /pages/update route, which enforces edit
* permission). The content is truncated to keep the prompt cheap and within
* context limits. Throws AiNotConfiguredException (503) if AI is unconfigured.
*/
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 cleanGeneratedTitle(text);
}
/**
* Cheap, non-blocking title generation from the first user message. Uses
* generateText (async) and writes the result back onto the chat row. Any

View File

@@ -17,6 +17,16 @@ export class RenameChatDto {
title: string;
}
/** One-shot page-title generation from note content (#199). */
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;
}
/** Optional chat id for listing messages of a specific chat. */
export class GetChatMessagesDto {
@IsString()