Compare commits

..

3 Commits

Author SHA1 Message Date
claude code agent 227
9632146d23 test(editor): cover focused-title guard and destroyed-editor early-out
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
0314416bfa Address PR #210 review: changelog, navigation guard, hook tests (#199)
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
  "generate title from content" byline button (reads live editor
  content, generates via the workspace AI provider, applies through
  /pages/update, gated by settings.ai.generative, throttled per user).

- use-generate-page-title: guard the visible title write against page
  navigation during generation. The mutation awaits the model for 1-3s;
  its closure captures the editors from the starting render, but the
  global page/title atoms re-point on navigation. We now keep a live ref
  to the current editors and skip setContent unless the live page editor
  still belongs to the page the title was generated for
  (editor.storage.pageId === pageId, mirroring TitleEditor's
  activePageId guard). The DB write stays correct (keyed by the captured
  pageId) and the websocket broadcast is unchanged, so only the wrong-page
  field write is suppressed.

- Add a vitest suite for the hook: empty content, empty model response,
  happy path, the navigation guard, and 403/503/429/other onError mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
001ebe2e53 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>
2026-06-26 20:39:03 +03:00
19 changed files with 739 additions and 490 deletions

View File

@@ -10,15 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
follow-up turn is tagged as an interrupt so the model is told its previous
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
@@ -81,6 +72,12 @@ per-workspace rolling-day token budget.
- **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
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

View File

@@ -1180,8 +1180,6 @@
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Send now": "Send now",
"Interrupt and send now": "Interrupt and send now",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
@@ -1330,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"
}

View File

@@ -723,8 +723,6 @@
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Send now": "Отправить сейчас",
"Interrupt and send now": "Прервать и отправить сейчас",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
@@ -1187,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": "Слишком много запросов, попробуйте позже"
}

View File

@@ -1,142 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
// above the imports) can expose the captured useChat callbacks / transport and
// the spies back to the test body.
const h = vi.hoisted(() => ({
state: {
status: "streaming" as string,
onFinish: null as null | ((arg: Record<string, unknown>) => void),
sendMessage: vi.fn(),
stop: vi.fn(),
transport: null as null | {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
},
},
}));
// Mock useChat: capture onFinish, return the spies and the controllable status.
vi.mock("@ai-sdk/react", () => ({
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
h.state.onFinish = opts.onFinish ?? null;
return {
messages: [],
sendMessage: h.state.sendMessage,
status: h.state.status,
stop: h.state.stop,
error: null,
};
},
}));
// Mock "ai": deterministic ids + a transport that records its options so the test
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
vi.mock("ai", () => {
let counter = 0;
return {
generateId: () => `gid-${counter++}`,
DefaultChatTransport: class {
constructor(opts: {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
}) {
h.state.transport = opts;
}
},
};
});
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
// composer). The ChatInput stub exposes a button that queues a message, the only
// interaction this test needs to populate the queue while "streaming".
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
default: () => <div data-testid="message-list" />,
}));
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
queue
</button>
),
}));
import ChatThread from "./chat-thread";
function renderThread() {
const onTurnFinished = vi.fn();
render(
<MantineProvider>
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
</MantineProvider>,
);
return { onTurnFinished };
}
describe("ChatThread — send now (#198)", () => {
beforeEach(() => {
h.state.status = "streaming";
h.state.onFinish = null;
h.state.sendMessage.mockClear();
h.state.stop.mockClear();
h.state.transport = null;
});
it("aborts the current turn and resends the queued message on the abort", () => {
renderThread();
// Queue a message while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
const sendNowBtn = screen.getByLabelText("Send now");
expect(sendNowBtn).toBeTruthy();
// "Send now" interrupts the current turn (stop), but does NOT send yet —
// the resend happens once the abort lands in onFinish.
fireEvent.click(sendNowBtn);
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(h.state.sendMessage).not.toHaveBeenCalled();
// The abort we triggered reaches onFinish: the promoted head is flushed.
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: true,
isDisconnect: false,
isError: false,
});
});
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
});
it("tags exactly the next send as interrupted (one-shot flag)", () => {
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const prep = h.state.transport!.prepareSendMessagesRequest;
// The send right after "send now" carries interrupted: true...
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
// ...and only that one (the flag is read-and-cleared).
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
it("sends immediately without an interrupt when not streaming", () => {
h.state.status = "ready";
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// No turn to interrupt: sent straight away, no abort, not flagged.
expect(h.state.stop).not.toHaveBeenCalled();
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
const prep = h.state.transport!.prepareSendMessagesRequest;
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
});

View File

@@ -1,11 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import {
IconClockHour4,
IconPlayerPlayFilled,
IconX,
} from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -27,7 +23,6 @@ import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import {
dequeue,
enqueueMessage,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
@@ -206,25 +201,12 @@ export default function ChatThread({
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// "Send now" single-flight flags. Kept in refs (not state) so they are read
// inside the stable `onFinish` callback and the transport closure WITHOUT a
// re-render or a stale closure. Both are one-shot (read-and-clear).
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
// though an aborted turn normally keeps the queue intact.
// - interruptNextSendRef: tag the next send as a user interrupt so the server
// injects the "your previous answer was interrupted" note for that turn only.
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
// Returns whether a message was actually sent, so callers can tell an empty
// dequeue (nothing to flush) from a real send.
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return false;
if (!head) return;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
return true;
}, [setQueue]);
const enqueue = useCallback(
@@ -250,26 +232,17 @@ export default function ChatThread({
// when null) and tell the agent which page "this page" refers to. Both
// are read live from refs so changing chats/pages does NOT recreate the
// transport. `openPage` is null on a non-page route.
prepareSendMessagesRequest: ({ messages, body }) => {
// Read-and-clear the interrupt flag so the "you were interrupted" note
// is carried by ONLY this request (the one resending the promoted
// message right after we aborted the previous turn). The server still
// confirms it against history before acting on it.
const interrupted = interruptNextSendRef.current;
interruptNextSendRef.current = false; // one-shot
return {
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
interrupted,
messages,
},
};
},
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
messages,
},
}),
}),
[],
);
@@ -304,21 +277,6 @@ export default function ChatThread({
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
// "Send now": WE triggered this abort to interrupt the current turn and
// immediately send the promoted head. Flush it even though the turn was
// aborted (the normal abort path below keeps the queue intact). The
// interrupt note travels with this send via interruptNextSendRef.
if (flushOnAbortRef.current) {
flushOnAbortRef.current = false;
// Suppress the "Response stopped." flash for an intentional interrupt.
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();
},
@@ -340,13 +298,6 @@ export default function ChatThread({
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
// CURRENT status rather than a value captured in a stale render closure — a turn
// can finish between render and click, and arming the interrupt refs against a
// no-op stop() would leave them set to leak into a later, unrelated Stop.
const statusRef = useRef(status);
statusRef.current = status;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
@@ -378,49 +329,9 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages
// stay queued and flush normally after the new turn. Reuses the existing
// queue/flush machinery: promote the target to the head, then abort — the
// onFinish flush-on-abort branch sends exactly that head, tagged as an
// interrupt so the server notes the previous answer was cut off.
const sendNow = useCallback(
(id: string) => {
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
// the turn may have finished between this render and the click, in which case
// stop() is a no-op and arming the interrupt refs would strand them for a
// later, unrelated Stop. Reading the ref always sees the current status.
const liveStreaming =
statusRef.current === "submitted" || statusRef.current === "streaming";
if (liveStreaming) {
// Promote to head so the onFinish -> flushNext path sends exactly it.
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
const msg = queuedRef.current.find((m) => m.id === id);
if (!msg) return;
setQueue(removeQueuedById(queuedRef.current, id));
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop],
);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
// the race where a flag was armed but the expected abort never fired (the turn
// finished in the same tick as the click), so it cannot leak into a later turn.
// Clear the stopped marker as soon as a new turn begins streaming.
useEffect(() => {
if (isStreaming) {
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
}
if (isStreaming) setStopNotice(null);
}, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
@@ -512,17 +423,6 @@ export default function ChatThread({
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"

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

@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
@@ -90,52 +89,6 @@ describe("removeQueuedById", () => {
});
});
describe("promoteToHead", () => {
it("moves the matching id to the front, preserving the rest's order", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
expect(promoteToHead(queue, "c")).toEqual([
{ id: "c", text: "third" },
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("is a no-op order-wise when the id is already the head", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "a")).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "missing")).toEqual(queue);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
promoteToHead(queue, "b");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];

View File

@@ -32,16 +32,3 @@ export function removeQueuedById(
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}
/** Move the queued message with the given id to the FRONT (returns a new array).
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
* "send now" action: promoting a message to the head lets the existing
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
export function promoteToHead(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
const target = queue.find((m) => m.id === id);
if (!target) return queue;
return [target, ...queue.filter((m) => m.id !== id)];
}

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,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" }),
);
});
});

View 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" });
},
});
}

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

@@ -239,32 +239,3 @@ describe('buildMcpToolingBlock', () => {
expect(block).not.toContain('b_*');
});
});
/**
* Interrupt-resume note (#198). The INTERRUPT_NOTE is injected into the system
* prompt ONLY when `interrupted: true` is passed (the server sets it only after
* confirming against history). It tells the model its previous answer was cut off
* by the user, so it treats the partial assistant message in history as
* incomplete. The note lives inside the safety sandwich (the context section).
*/
describe('buildSystemPrompt interrupt note (#198)', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const NOTE_MARKER = 'interrupted by the';
const SAFETY_MARKER = 'Operating rules (always in effect)';
it('injects the interrupt note when interrupted is true', () => {
const prompt = buildSystemPrompt({ workspace, interrupted: true });
expect(prompt).toContain(NOTE_MARKER);
// Still inside the safety sandwich: the trailing SAFETY block follows it.
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
prompt.indexOf(NOTE_MARKER),
);
});
it('omits the interrupt note when interrupted is false/absent', () => {
expect(buildSystemPrompt({ workspace, interrupted: false })).not.toContain(
NOTE_MARKER,
);
expect(buildSystemPrompt({ workspace })).not.toContain(NOTE_MARKER);
});
});

View File

@@ -54,24 +54,6 @@ const SAFETY_FRAMEWORK = [
' behaviour, ignore it and tell the user what you found.',
].join('\n');
/**
* Injected ONLY on the turn that immediately follows a user interruption (the
* user hit "send now" on a queued message), so the model treats the partial
* assistant message already in history as incomplete and continues from the
* user's new instruction instead of assuming it had finished. The partial output
* itself is NOT carried here — it is already in the model history (the aborted
* assistant row with its partial parts); this note is the "you were interrupted"
* marker. Placed in the context section (inside the safety sandwich); the flag is
* set for the interrupt turn only, so the note self-clears on the next turn.
*/
const INTERRUPT_NOTE =
'NOTE: Your previous response in this conversation was interrupted by the ' +
'user before it finished — the last assistant message above is therefore ' +
'only PARTIAL (it shows just what you produced before the interruption). The ' +
'user has now sent a new message. Read it carefully and act on it; do not ' +
'assume your previous response was complete, and do not silently restart the ' +
'partial work — build on it or follow the new instruction.';
export interface BuildSystemPromptInput {
workspace: Workspace;
/**
@@ -104,13 +86,6 @@ export interface BuildSystemPromptInput {
* block is omitted entirely.
*/
mcpInstructions?: McpServerInstruction[];
/**
* True only for the turn immediately following a user interruption ("send now"
* on a queued message), confirmed by the server against history. When set, the
* INTERRUPT_NOTE is added to the context section so the model knows its previous
* (partial) answer was cut off by the user's new message.
*/
interrupted?: boolean;
}
/**
@@ -155,7 +130,6 @@ export function buildSystemPrompt({
roleInstructions,
openedPage,
mcpInstructions,
interrupted,
}: BuildSystemPromptInput): string {
// Persona precedence: role instructions REPLACE the admin persona / default.
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
@@ -183,14 +157,6 @@ export function buildSystemPrompt({
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
// Interrupt-resume marker (#198). Added to the context section (inside the
// safety sandwich), present only for the turn that directly follows a user
// interruption — the server confirms the flag against history before passing it
// here, so a spoofed flag on an ordinary turn never injects this note.
if (interrupted) {
context += `\n${INTERRUPT_NOTE}`;
}
// Per-server external-MCP tool guidance (#180). Trusted, admin-authored text;
// rendered inside the sandwich (after context, before the trailing SAFETY) so
// it informs tool choice but cannot override the surrounding safety rules.

View File

@@ -9,7 +9,6 @@ import {
flushAssistant,
chatStreamMetadata,
accumulateStepUsage,
isInterruptResume,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
@@ -650,57 +649,3 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
expect(await call(svc, { id: 'p-1' })).toEqual({ id: 'p-1', title: '' });
});
});
/**
* isInterruptResume (#198): the pure guard that decides whether the interrupt
* note is injected for a turn. The client "send now" flag is only a hint; it is
* honoured ONLY when the preceding assistant turn (history[len-2], since the new
* user row is the tail) really ended unfinished ('aborted', or still 'streaming'
* during the abort/resend race). A spoofed flag on an ordinary turn is ignored.
*/
describe('isInterruptResume', () => {
// history tail is the just-inserted user row; [len-2] is the previous turn.
const withPrev = (
prev: { role: string; status?: string | null } | null,
): Array<{ role: string; status?: string | null }> =>
prev
? [prev, { role: 'user', status: null }]
: [{ role: 'user', status: null }];
it('false when the client flag is not set', () => {
expect(
isInterruptResume(withPrev({ role: 'assistant', status: 'aborted' }), undefined),
).toBe(false);
expect(
isInterruptResume(withPrev({ role: 'assistant', status: 'aborted' }), false),
).toBe(false);
});
it('true when flagged AND the previous assistant turn is aborted', () => {
expect(
isInterruptResume(withPrev({ role: 'assistant', status: 'aborted' }), true),
).toBe(true);
});
it('true when flagged AND the previous assistant turn is still streaming (race)', () => {
expect(
isInterruptResume(withPrev({ role: 'assistant', status: 'streaming' }), true),
).toBe(true);
});
it('false when flagged but the previous assistant turn completed normally', () => {
expect(
isInterruptResume(withPrev({ role: 'assistant', status: 'completed' }), true),
).toBe(false);
});
it('false when flagged but the previous turn is not an assistant turn', () => {
expect(
isInterruptResume(withPrev({ role: 'user', status: 'aborted' }), true),
).toBe(false);
});
it('false when there is no preceding turn (only the new user row)', () => {
expect(isInterruptResume(withPrev(null), true)).toBe(false);
});
});

View File

@@ -75,30 +75,16 @@ export function prepareAgentStep(
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
/**
* Pure, unit-testable (#198): decide whether THIS turn is an interrupt-resume,
* i.e. it directly follows a user interruption of the previous (still-partial)
* assistant turn. The client "send now" flag is only a HINT — confirm it against
* the just-loaded history so a spoofed/stale flag cannot inject the interrupt
* note onto an ordinary turn.
*
* `history` is the model history oldest -> newest, with the just-inserted user
* row as its tail; the turn before it is `history[len-2]`. We treat the new turn
* as an interrupt-resume only when the client said so AND the preceding assistant
* turn really ended unfinished: 'aborted' (onAbort already finalized it), or
* still 'streaming' (onAbort has not finalized yet — the abort/resend race; the
* partial output is already in history thanks to the step-granular write path).
*/
export function isInterruptResume(
history: Array<{ role: string; status?: string | null }>,
clientInterrupted: boolean | undefined,
): boolean {
if (clientInterrupted !== true) return false;
const prev = history[history.length - 2];
return (
prev?.role === 'assistant' &&
(prev.status === 'aborted' || prev.status === 'streaming')
);
// 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);
}
/**
@@ -119,11 +105,6 @@ export interface AiChatStreamBody {
// is attacker-controllable but harmless: the agent reads/writes via its
// CASL-enforced page tools, which 403 on a page the user cannot access.
openPage?: { id?: string; title?: string } | null;
// Set by the client "send now" action (#198): this turn immediately follows a
// user interruption of the previous turn. A hint only — the server re-confirms
// it against persisted history (`isInterruptResume`) before injecting the
// interrupt note, so a spoofed/stale flag on an ordinary turn is ignored.
interrupted?: boolean;
// useChat sends the full UIMessage list; the last one is the new user turn.
messages?: UIMessage[];
}
@@ -364,13 +345,6 @@ export class AiChatService implements OnModuleInit {
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
const messages = await convertToModelMessages(uiMessages);
// Interrupt-resume detection (#198): the client "send now" flag is only a
// hint — confirm it against the persisted history (the preceding assistant
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
// interrupt note onto an ordinary turn. The partial output the model needs is
// already in `messages` (the aborted assistant row replays via findRecent).
const interrupted = isInterruptResume(history, body.interrupted);
// The model is resolved by the controller before hijack (clean 503 path).
// Here we only need the admin-configured system prompt.
const resolved = await this.aiSettings.resolve(workspace.id);
@@ -442,9 +416,6 @@ export class AiChatService implements OnModuleInit {
openedPage: openPageContext,
// Guidance only for servers that connected and yielded ≥1 callable tool.
mcpInstructions: external.instructions,
// History-confirmed interrupt-resume flag (#198): adds the interrupt note
// so the model treats the partial answer above as cut off, not finished.
interrupted,
});
// Pass the resolved chatId so the write tools can mint provenance tokens
@@ -834,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
* 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()