diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 57018246..bdb263e1 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1328,5 +1328,13 @@ "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?", "The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?", "Failed to set custom address": "Failed to set custom address", - "Failed to remove custom address": "Failed to remove custom address" + "Failed to remove custom address": "Failed to remove custom address", + "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" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 1ce29237..de994244 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1185,5 +1185,13 @@ "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", "The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?", "Failed to set custom address": "Не удалось задать пользовательский адрес", - "Failed to remove custom address": "Не удалось удалить пользовательский адрес" + "Failed to remove custom address": "Не удалось удалить пользовательский адрес", + "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": "Слишком много запросов, попробуйте позже" } diff --git a/apps/client/src/features/ai-chat/services/ai-chat-service.ts b/apps/client/src/features/ai-chat/services/ai-chat-service.ts index cc8e6b5a..0d64bbe3 100644 --- a/apps/client/src/features/ai-chat/services/ai-chat-service.ts +++ b/apps/client/src/features/ai-chat/services/ai-chat-service.ts @@ -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 { + 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 diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx new file mode 100644 index 00000000..b191c426 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx @@ -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 = ({ + pageId, + color = "gray", + iconSize = 20, +}) => { + const { t } = useTranslation(); + const gen = useGeneratePageTitle(pageId); + + return ( + + gen.mutate()} + > + + + + ); +}; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index e9dcff4b..93b3b8ed 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -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} /> c.id !== creator?.id, @@ -238,6 +251,11 @@ function PageByline({ {showDictation && editor && ( )} + {/* 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 && ( + + )} ); diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.ts b/apps/client/src/features/editor/hooks/use-generate-page-title.ts new file mode 100644 index 00000000..e2ae88a3 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-generate-page-title.ts @@ -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({ + 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" }); + }, + }); +} diff --git a/apps/server/src/core/ai-chat/ai-chat.controller.ts b/apps/server/src/core/ai-chat/ai-chat.controller.ts index 0f243dec..da09c340 100644 --- a/apps/server/src/core/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/core/ai-chat/ai-chat.controller.ts @@ -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. diff --git a/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts b/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts new file mode 100644 index 00000000..08d5bbc9 --- /dev/null +++ b/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 492ce9f6..de4b90db 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -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 @@ -793,6 +805,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 { + 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 diff --git a/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts index a48f2b84..1fb13304 100644 --- a/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts @@ -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()