Compare commits
3 Commits
feat/198-i
...
feat/199-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 |
@@ -72,6 +72,12 @@ per-workspace rolling-day token budget.
|
|||||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||||
|
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||||
|
byline reads the live editor content (including unsaved edits), generates a
|
||||||
|
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||||
|
applies it through the existing `/pages/update` route — reflecting it in the
|
||||||
|
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||||
|
flag and throttled per user. (#199)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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}}\" 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?",
|
"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 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1185,5 +1185,13 @@
|
|||||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
"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}}» уже используется. Переместить его на эту страницу?",
|
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
"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": "Слишком много запросов, попробуйте позже"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export async function exportAiChat(
|
|||||||
return req.data.markdown;
|
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
|
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
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 MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
@@ -74,6 +75,9 @@ export function FullEditor({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
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 fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user.settings?.preferences?.editorToolbar ?? false;
|
||||||
@@ -111,11 +115,13 @@ export function FullEditor({
|
|||||||
editable={editable}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<PageByline
|
<PageByline
|
||||||
|
pageId={pageId}
|
||||||
creator={creator}
|
creator={creator}
|
||||||
contributors={contributors}
|
contributors={contributors}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
isDictationEnabled={isDictationEnabled}
|
isDictationEnabled={isDictationEnabled}
|
||||||
|
isTitleGenEnabled={isTitleGenEnabled}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor
|
<MemoizedPageEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
@@ -128,19 +134,23 @@ export function FullEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PageBylineProps = {
|
type PageBylineProps = {
|
||||||
|
pageId: string;
|
||||||
creator?: PageUser;
|
creator?: PageUser;
|
||||||
contributors?: IContributor[];
|
contributors?: IContributor[];
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
isDictationEnabled?: boolean;
|
isDictationEnabled?: boolean;
|
||||||
|
isTitleGenEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PageByline({
|
function PageByline({
|
||||||
|
pageId,
|
||||||
creator,
|
creator,
|
||||||
contributors,
|
contributors,
|
||||||
editable,
|
editable,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
isDictationEnabled,
|
isDictationEnabled,
|
||||||
|
isTitleGenEnabled,
|
||||||
}: PageBylineProps) {
|
}: PageBylineProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||||
@@ -148,6 +158,9 @@ function PageByline({
|
|||||||
const showDictation = Boolean(
|
const showDictation = Boolean(
|
||||||
isDictationEnabled && editable && isEditMode && editor,
|
isDictationEnabled && editable && isEditMode && editor,
|
||||||
);
|
);
|
||||||
|
const showTitleGen = Boolean(
|
||||||
|
isTitleGenEnabled && editable && isEditMode && editor,
|
||||||
|
);
|
||||||
|
|
||||||
const otherContributors = (contributors ?? []).filter(
|
const otherContributors = (contributors ?? []).filter(
|
||||||
(c) => c.id !== creator?.id,
|
(c) => c.id !== creator?.id,
|
||||||
@@ -238,6 +251,11 @@ function PageByline({
|
|||||||
{showDictation && editor && (
|
{showDictation && editor && (
|
||||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
<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>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
pageEditorAtom,
|
||||||
|
titleEditorAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
|
|
||||||
|
// --- Mocks for the hook's collaborators ---------------------------------------
|
||||||
|
|
||||||
|
const generatePageTitleMock = vi.fn();
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateTitleMock = vi.fn();
|
||||||
|
const updatePageDataMock = vi.fn();
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
||||||
|
updatePageData: (page: unknown) => updatePageDataMock(page),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const emitMock = vi.fn();
|
||||||
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||||
|
useQueryEmit: () => emitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const localEmitMock = vi.fn();
|
||||||
|
vi.mock("@/lib/local-emitter.ts", () => ({
|
||||||
|
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||||
|
// purely via the fake page editor's getHTML().
|
||||||
|
vi.mock("@docmost/editor-ext", () => ({
|
||||||
|
htmlToMarkdown: (html: string) => html,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notificationsShowMock = vi.fn();
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are registered.
|
||||||
|
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||||
|
|
||||||
|
// --- Test helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||||
|
return {
|
||||||
|
isDestroyed: false,
|
||||||
|
getHTML: () => html,
|
||||||
|
storage: { pageId },
|
||||||
|
} as unknown as Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTitleEditor(): Editor & {
|
||||||
|
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
isDestroyed: false,
|
||||||
|
isFocused: false,
|
||||||
|
commands: { setContent: vi.fn() },
|
||||||
|
} as unknown as Editor & {
|
||||||
|
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(pageId: string, store = createStore()) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
return { result, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_A = {
|
||||||
|
id: "pageA",
|
||||||
|
title: "Generated Title",
|
||||||
|
spaceId: "space1",
|
||||||
|
slugId: "slugA",
|
||||||
|
parentPageId: null,
|
||||||
|
icon: null,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useGeneratePageTitle", () => {
|
||||||
|
it("shows a notice and bails when the editor content is empty", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
||||||
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
||||||
|
);
|
||||||
|
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the title untouched when the model returns nothing usable", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||||
|
generatePageTitleMock.mockResolvedValue(" ");
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Could not generate a title",
|
||||||
|
color: "yellow",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
const titleEditor = makeTitleEditor();
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
|
store.set(titleEditorAtom as never, titleEditor);
|
||||||
|
generatePageTitleMock.mockResolvedValue("Generated Title");
|
||||||
|
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||||
|
pageId: "pageA",
|
||||||
|
title: "Generated Title",
|
||||||
|
});
|
||||||
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||||
|
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||||
|
"Generated Title",
|
||||||
|
);
|
||||||
|
expect(localEmitMock).toHaveBeenCalled();
|
||||||
|
expect(emitMock).toHaveBeenCalled();
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: "Title generated" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
|
store.set(titleEditorAtom as never, titleEditor);
|
||||||
|
|
||||||
|
// Control when generation resolves so we can navigate mid-flight.
|
||||||
|
let resolveTitle!: (t: string) => void;
|
||||||
|
generatePageTitleMock.mockReturnValue(
|
||||||
|
new Promise<string>((res) => {
|
||||||
|
resolveTitle = res;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
let pending!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
pending = result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// User navigates to page B: the live page editor now belongs to pageB.
|
||||||
|
act(() => {
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveTitle("Generated Title");
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB write is still correct (keyed by the captured pageId)...
|
||||||
|
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||||
|
pageId: "pageA",
|
||||||
|
title: "Generated Title",
|
||||||
|
});
|
||||||
|
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||||
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
|
// The change is still broadcast to other clients.
|
||||||
|
expect(emitMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
const titleEditor = makeTitleEditor();
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
|
store.set(titleEditorAtom as never, titleEditor);
|
||||||
|
|
||||||
|
// Resolve generation under our control so we can mark the live title editor
|
||||||
|
// as focused before the post-generation write runs.
|
||||||
|
let resolveTitle!: (t: string) => void;
|
||||||
|
generatePageTitleMock.mockReturnValue(
|
||||||
|
new Promise<string>((res) => {
|
||||||
|
resolveTitle = res;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
let pending!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
pending = result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The user clicked into the title field while the model ran — overwriting it
|
||||||
|
// now would clobber what they are actively typing.
|
||||||
|
act(() => {
|
||||||
|
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveTitle("Generated Title");
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The DB write still persists the value...
|
||||||
|
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||||
|
pageId: "pageA",
|
||||||
|
title: "Generated Title",
|
||||||
|
});
|
||||||
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||||
|
// ...but the visible field is left alone while it is focused.
|
||||||
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
|
// The change is still broadcast to other clients.
|
||||||
|
expect(localEmitMock).toHaveBeenCalled();
|
||||||
|
expect(emitMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bails before calling the model when the page editor is destroyed", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
const pageEditor = makePageEditor("pageA");
|
||||||
|
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
||||||
|
store.set(pageEditorAtom as never, pageEditor);
|
||||||
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[403, "AI title generation is disabled"],
|
||||||
|
[503, "AI is not configured"],
|
||||||
|
[429, "Too many requests, please try again later"],
|
||||||
|
[500, "Failed to generate title"],
|
||||||
|
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
|
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||||
|
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
||||||
|
const { result } = setup("pageA", store);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message, color: "red" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
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();
|
||||||
|
|
||||||
|
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||||
|
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||||
|
// its closure captures the editors from the render that started it. Keep a live
|
||||||
|
// reference so the post-generation write targets whatever page is on screen
|
||||||
|
// *now*, not the page the generation was started from.
|
||||||
|
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||||
|
editorsRef.current = { pageEditor, titleEditor };
|
||||||
|
|
||||||
|
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).
|
||||||
|
//
|
||||||
|
// Guard against navigation during generation: if the user switched pages
|
||||||
|
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||||
|
// page, so writing here would drop page A's title into page B's visible
|
||||||
|
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||||
|
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||||
|
// pageId` guard — bail the visible write unless that live editor still
|
||||||
|
// belongs to the page this title was generated for. The DB write above is
|
||||||
|
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||||
|
// still propagates page A's change to other clients.
|
||||||
|
const livePageEditor = editorsRef.current.pageEditor;
|
||||||
|
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||||
|
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||||
|
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||||
|
?.pageId;
|
||||||
|
const stillOnPage = livePageId === pageId;
|
||||||
|
if (
|
||||||
|
stillOnPage &&
|
||||||
|
liveTitleEditor &&
|
||||||
|
!liveTitleEditor.isDestroyed &&
|
||||||
|
!liveTitleEditor.isFocused
|
||||||
|
) {
|
||||||
|
liveTitleEditor.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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
|
|||||||
import {
|
import {
|
||||||
ChatIdDto,
|
ChatIdDto,
|
||||||
ExportChatDto,
|
ExportChatDto,
|
||||||
|
GeneratePageTitleDto,
|
||||||
GetChatMessagesDto,
|
GetChatMessagesDto,
|
||||||
RenameChatDto,
|
RenameChatDto,
|
||||||
} from './dto/ai-chat.dto';
|
} from './dto/ai-chat.dto';
|
||||||
@@ -316,6 +317,43 @@ export class AiChatController {
|
|||||||
return { text };
|
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
|
* Ensure the chat exists, belongs to this workspace, AND was created by the
|
||||||
* requesting user (per-user isolation). Throws ForbiddenException otherwise.
|
* requesting user (per-user isolation). Throws ForbiddenException otherwise.
|
||||||
|
|||||||
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal file
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,18 @@ export function prepareAgentStep(
|
|||||||
|
|
||||||
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
|
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
|
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
||||||
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
* 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<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
|
* Cheap, non-blocking title generation from the first user message. Uses
|
||||||
* generateText (async) and writes the result back onto the chat row. Any
|
* generateText (async) and writes the result back onto the chat row. Any
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export class RenameChatDto {
|
|||||||
title: string;
|
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. */
|
/** Optional chat id for listing messages of a specific chat. */
|
||||||
export class GetChatMessagesDto {
|
export class GetChatMessagesDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user