Compare commits
15 Commits
feat/198-i
...
fix/ai-cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6ed76d9a | ||
|
|
406921ac6a | ||
| 719bccd80d | |||
| 83e64bad1a | |||
| ee78a96803 | |||
| d971d02346 | |||
|
|
53cbec9354 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
e99c00a9ee | ||
|
|
1f459d8d26 | ||
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 | ||
|
|
eb5b696431 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -41,6 +41,16 @@ per-workspace rolling-day token budget.
|
|||||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||||
alias any workspace member can reclaim. (#205)
|
alias any workspace member can reclaim. (#205)
|
||||||
|
|
||||||
|
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||||
|
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||||
|
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||||
|
permanent first. The deadline is frozen at creation time, so later changes to
|
||||||
|
the workspace setting never reschedule existing notes; an hourly background
|
||||||
|
sweep trashes notes past their deadline (children ride along). An open
|
||||||
|
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||||
|
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||||
|
Operators configure the lifetime per workspace. (#201)
|
||||||
|
|
||||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||||
An assistant turn is now persisted to the database step by step: the row is
|
An assistant turn is now persisted to the database step by step: the row is
|
||||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||||
@@ -81,9 +91,24 @@ 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
|
||||||
|
|
||||||
|
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||||
|
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||||
|
rows, which silently dropped the beginning of any longer chat. It is now
|
||||||
|
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||||
|
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||||
|
safety net far above any realistic chat, not a conversational limit). On a
|
||||||
|
very long chat this can eventually reach the model's context window; the
|
||||||
|
client already surfaces that as "start a new chat". (#202)
|
||||||
|
|
||||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||||
|
|||||||
@@ -598,6 +598,17 @@
|
|||||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||||
"Move to trash": "Move to trash",
|
"Move to trash": "Move to trash",
|
||||||
|
"Make temporary": "Make temporary",
|
||||||
|
"Make permanent": "Make permanent",
|
||||||
|
"New temporary note": "New temporary note",
|
||||||
|
"Temporary note": "Temporary note",
|
||||||
|
"Temporary notes": "Temporary notes",
|
||||||
|
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||||
|
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||||
|
"Note is now permanent": "Note is now permanent",
|
||||||
|
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||||
|
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||||
|
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||||
"Move this page to trash?": "Move this page to trash?",
|
"Move this page to trash?": "Move this page to trash?",
|
||||||
"Restore page": "Restore page",
|
"Restore page": "Restore page",
|
||||||
"Permanently delete": "Permanently delete",
|
"Permanently delete": "Permanently delete",
|
||||||
@@ -1330,5 +1341,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -607,6 +607,17 @@
|
|||||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||||
"Move to trash": "Переместить в корзину",
|
"Move to trash": "Переместить в корзину",
|
||||||
|
"Make temporary": "Сделать временной",
|
||||||
|
"Make permanent": "Сделать постоянной",
|
||||||
|
"New temporary note": "Новая временная заметка",
|
||||||
|
"Temporary note": "Временная заметка",
|
||||||
|
"Temporary notes": "Временные заметки",
|
||||||
|
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||||
|
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||||
|
"Note is now permanent": "Заметка теперь постоянная",
|
||||||
|
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||||
|
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||||
|
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||||
"Restore page": "Восстановить страницу",
|
"Restore page": "Восстановить страницу",
|
||||||
"Permanently delete": "Удалить навсегда",
|
"Permanently delete": "Удалить навсегда",
|
||||||
@@ -1187,5 +1198,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": "Слишком много запросов, попробуйте позже"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import MessageItem from "./message-item";
|
import MessageItem from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||||
|
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||||
|
// live (mutable) message — see message-item.tsx.
|
||||||
const renderRow = (message: UIMessage) =>
|
const renderRow = (message: UIMessage) =>
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MessageItem message={message} />
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
]);
|
]);
|
||||||
rerender(
|
rerender(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MessageItem message={next} />
|
<MessageItem message={next} signature={messageSignature(next)} />
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
|
|||||||
expect(callsFor("beta")).toBe(1);
|
expect(callsFor("beta")).toBe(1);
|
||||||
expect(callsFor("gamm")).toBe(1);
|
expect(callsFor("gamm")).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||||
|
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||||
|
// (reasoning-first providers render nothing at first) must still stream its text
|
||||||
|
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||||
|
// memo recomputed the signature from the (mutated) message — identical on both
|
||||||
|
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||||
|
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||||
|
renderChatMarkdownSpy.mockClear();
|
||||||
|
// Reuse ONE message object across renders (as the SDK does).
|
||||||
|
const message = msg([{ type: "text", text: "" }]);
|
||||||
|
const { rerender, queryByText } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
// Empty text part: nothing visible rendered yet.
|
||||||
|
expect(queryByText("streamed answer")).toBeNull();
|
||||||
|
|
||||||
|
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||||
|
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={message} signature={messageSignature(message)} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||||
|
expect(callsFor("streamed answer")).toBe(1);
|
||||||
|
expect(queryByText("streamed answer")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { arePropsEqual } from "./message-item";
|
import { arePropsEqual } from "./message-item";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||||
* return false on any visible prop/content change (so the row re-renders) and
|
* return false on any visible prop/content change (so the row re-renders) and
|
||||||
* true when nothing visible changed (so a finalized row is skipped). A FIXED
|
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||||
* message id is used so a content-identical clone yields an equal signature.
|
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||||
|
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||||
|
* content-identical clone yields an equal signature.
|
||||||
*/
|
*/
|
||||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||||
|
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||||
|
// the signature must never be recomputed inside arePropsEqual).
|
||||||
const props = (
|
const props = (
|
||||||
message: UIMessage,
|
message: UIMessage,
|
||||||
over: Record<string, unknown> = {},
|
over: Record<string, unknown> = {},
|
||||||
) => ({
|
) => ({
|
||||||
message,
|
message,
|
||||||
|
signature: messageSignature(message),
|
||||||
showCitations: true,
|
showCitations: true,
|
||||||
neutralizeInternalLinks: false,
|
neutralizeInternalLinks: false,
|
||||||
assistantName: "AI",
|
assistantName: "AI",
|
||||||
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true on the identity fast path (same message object, equal props)", () => {
|
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||||
const m = msg([{ type: "text", text: "answer" }]);
|
const m = msg([{ type: "text", text: "answer" }]);
|
||||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
|
|||||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||||
|
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||||
|
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||||
|
// recomputing `messageSignature(message)` inside the comparator would read
|
||||||
|
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||||
|
// every streamed update and the assistant row freezes at its initial empty
|
||||||
|
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||||
|
// the parent captured at each render. This fails against the old implementation
|
||||||
|
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||||
|
// live objects).
|
||||||
|
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||||
|
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||||
|
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||||
|
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||||
|
(message.parts[0] as { text: string }).text = "hello world";
|
||||||
|
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||||
|
expect(prevSig).not.toBe(nextSig);
|
||||||
|
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||||
|
const base = {
|
||||||
|
message,
|
||||||
|
showCitations: true,
|
||||||
|
neutralizeInternalLinks: false,
|
||||||
|
assistantName: "AI",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
arePropsEqual(
|
||||||
|
{ ...base, signature: prevSig },
|
||||||
|
{ ...base, signature: nextSig },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
|||||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
|
/**
|
||||||
|
* Immutable content signature for `message`, computed by the PARENT
|
||||||
|
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||||
|
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||||
|
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||||
|
*
|
||||||
|
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||||
|
* array/objects in place and handing back a message wrapper that SHARES those
|
||||||
|
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||||
|
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||||
|
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||||
|
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||||
|
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||||
|
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||||
|
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||||
|
* value from the previous render (old content) and `next.signature` the new
|
||||||
|
* content, so they differ as the turn streams in and the row re-renders.
|
||||||
|
*/
|
||||||
|
signature: string;
|
||||||
/**
|
/**
|
||||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||||
* Defaults to true (internal chat). The public share passes false.
|
* Defaults to true (internal chat). The public share passes false.
|
||||||
@@ -88,6 +106,8 @@ function MessageItem({
|
|||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageItemProps) {
|
}: MessageItemProps) {
|
||||||
|
// `signature` is intentionally not read in the body — it exists solely as the
|
||||||
|
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
@@ -203,24 +223,30 @@ function MessageItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||||
* TAIL message gets a fresh object whose signature changes each delta, so it
|
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||||
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||||
* per-token whole-transcript re-render into a tail-only one. */
|
* the same signature and is skipped, turning a per-token whole-transcript
|
||||||
|
* re-render into a tail-only one.
|
||||||
|
*
|
||||||
|
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||||
|
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||||
|
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||||
|
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||||
|
* here — recomputing the signature from them yields equal strings every time and
|
||||||
|
* freezes the row at its initial empty render (the bug this guards against). See
|
||||||
|
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||||
|
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||||
|
* signature changed. */
|
||||||
export function arePropsEqual(
|
export function arePropsEqual(
|
||||||
prev: MessageItemProps,
|
prev: MessageItemProps,
|
||||||
next: MessageItemProps,
|
next: MessageItemProps,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (
|
return (
|
||||||
prev.showCitations !== next.showCitations ||
|
prev.signature === next.signature &&
|
||||||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
prev.showCitations === next.showCitations &&
|
||||||
prev.assistantName !== next.assistantName
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
) {
|
prev.assistantName === next.assistantName
|
||||||
return false;
|
);
|
||||||
}
|
|
||||||
// Fast path: identical message object (finalized rows keep their identity
|
|
||||||
// across deltas) — skip without building signatures.
|
|
||||||
if (prev.message === next.message) return true;
|
|
||||||
return messageSignature(prev.message) === messageSignature(next.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MessageItem, arePropsEqual);
|
export default memo(MessageItem, arePropsEqual);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
|||||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
@@ -196,9 +197,16 @@ export default function MessageList({
|
|||||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||||
<Stack gap={0} pr="xs">
|
<Stack gap={0} pr="xs">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
|
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||||
|
// string and handed to MessageItem as its memo key. It must NOT be
|
||||||
|
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||||
|
// shared `parts` in place, so prev/next message objects both read the
|
||||||
|
// latest content there and the memo would skip every streamed update
|
||||||
|
// (freezing the row at its empty render). See message-item.tsx.
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,17 +26,20 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
|
|||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||||
|
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
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);
|
||||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||||
|
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
|
||||||
|
|
||||||
type PageUser = {
|
type PageUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,6 +77,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;
|
||||||
@@ -103,6 +109,7 @@ export function FullEditor({
|
|||||||
<MemoizedFixedToolbar />
|
<MemoizedFixedToolbar />
|
||||||
)}
|
)}
|
||||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||||
|
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||||
<MemoizedTitleEditor
|
<MemoizedTitleEditor
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
slugId={slugId}
|
slugId={slugId}
|
||||||
@@ -111,11 +118,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 +137,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 +161,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 +254,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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,40 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
import {
|
||||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
toggleTemplate,
|
||||||
|
toggleTemporary,
|
||||||
|
} from "@/features/page-embed/services/page-embed-api";
|
||||||
|
import type {
|
||||||
|
ToggleTemplateResponse,
|
||||||
|
ToggleTemporaryResponse,
|
||||||
|
} from "@/features/page-embed/types/page-embed.types";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After toggling a note's temporary state, mirror the new deadline into the
|
||||||
|
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||||
|
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||||
|
* Centralised here so the header menu and the banner can't drift apart on the
|
||||||
|
* cache-key plumbing.
|
||||||
|
*/
|
||||||
|
export function syncTemporaryExpiresInCache(
|
||||||
|
page: { id: string; slugId: string },
|
||||||
|
temporaryExpiresAt: string | null,
|
||||||
|
) {
|
||||||
|
for (const key of [page.slugId, page.id]) {
|
||||||
|
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||||
|
if (cached) {
|
||||||
|
queryClient.setQueryData(["pages", key], {
|
||||||
|
...cached,
|
||||||
|
temporaryExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (item) =>
|
||||||
|
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useToggleTemplateMutation() {
|
export function useToggleTemplateMutation() {
|
||||||
return useMutation<
|
return useMutation<
|
||||||
@@ -18,3 +51,20 @@ export function useToggleTemplateMutation() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useToggleTemporaryMutation() {
|
||||||
|
return useMutation<
|
||||||
|
ToggleTemporaryResponse,
|
||||||
|
Error,
|
||||||
|
{ pageId: string; temporary?: boolean }
|
||||||
|
>({
|
||||||
|
mutationFn: (data) => toggleTemporary(data),
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message || "Failed to update temporary note",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
|||||||
import type {
|
import type {
|
||||||
PageTemplateLookup,
|
PageTemplateLookup,
|
||||||
ToggleTemplateResponse,
|
ToggleTemplateResponse,
|
||||||
|
ToggleTemporaryResponse,
|
||||||
} from "../types/page-embed.types";
|
} from "../types/page-embed.types";
|
||||||
|
|
||||||
export async function lookupTemplate(params: {
|
export async function lookupTemplate(params: {
|
||||||
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
|
|||||||
const r = await api.post("/pages/toggle-template", params);
|
const r = await api.post("/pages/toggle-template", params);
|
||||||
return r.data;
|
return r.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleTemporary(params: {
|
||||||
|
pageId: string;
|
||||||
|
temporary?: boolean;
|
||||||
|
}): Promise<ToggleTemporaryResponse> {
|
||||||
|
const r = await api.post("/pages/toggle-temporary", params);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToggleTemporaryResponse = {
|
||||||
|
pageId: string;
|
||||||
|
// null => the note was made permanent; ISO string => armed deadline.
|
||||||
|
temporaryExpiresAt: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
|||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
|
IconClockHour4,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
@@ -24,6 +25,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
|||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import {
|
||||||
|
useToggleTemporaryMutation,
|
||||||
|
syncTemporaryExpiresInCache,
|
||||||
|
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
@@ -160,6 +165,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||||
const watchPage = useWatchPageMutation();
|
const watchPage = useWatchPageMutation();
|
||||||
const unwatchPage = useUnwatchPageMutation();
|
const unwatchPage = useUnwatchPageMutation();
|
||||||
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
|
const isTemporary = !!page?.temporaryExpiresAt;
|
||||||
|
|
||||||
|
const handleToggleTemporary = async () => {
|
||||||
|
if (!page?.id) return;
|
||||||
|
const next = !isTemporary;
|
||||||
|
try {
|
||||||
|
const res = await toggleTemporary.mutateAsync({
|
||||||
|
pageId: page.id,
|
||||||
|
temporary: next,
|
||||||
|
});
|
||||||
|
// Reflect the new deadline in the page cache so the menu label flips and
|
||||||
|
// any banner updates. The sidebar icon refreshes via its own query.
|
||||||
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||||
|
notifications.show({
|
||||||
|
message: next
|
||||||
|
? t("Note will move to trash unless made permanent")
|
||||||
|
: t("Note is now permanent"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// mutation surfaces the error via notifications
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@@ -309,6 +337,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconClockHour4 size={16} />}
|
||||||
|
onClick={handleToggleTemporary}
|
||||||
|
>
|
||||||
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color={"red"}
|
color={"red"}
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||||
|
import { IconClockHour4 } from "@tabler/icons-react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import {
|
||||||
|
useToggleTemporaryMutation,
|
||||||
|
syncTemporaryExpiresInCache,
|
||||||
|
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
|
type TemporaryNoteBannerProps = {
|
||||||
|
slugId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner shown on an open temporary note ("structure or die"). Mirrors
|
||||||
|
* DeletedPageBanner: it reads the page from the shared query cache and offers
|
||||||
|
* the explicit rescue action — "Make permanent". Children ride along to trash
|
||||||
|
* with the note, which is noted in the copy.
|
||||||
|
*/
|
||||||
|
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: page } = usePageQuery({ pageId: slugId });
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||||
|
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||||
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
|
|
||||||
|
// Don't show on a note that is already in trash; the deleted-page banner
|
||||||
|
// owns that state.
|
||||||
|
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
|
||||||
|
|
||||||
|
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||||
|
|
||||||
|
const handleMakePermanent = async () => {
|
||||||
|
try {
|
||||||
|
const res = await toggleTemporary.mutateAsync({
|
||||||
|
pageId: page.id,
|
||||||
|
temporary: false,
|
||||||
|
});
|
||||||
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||||
|
} catch {
|
||||||
|
// mutation surfaces the error via notifications
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||||
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<IconClockHour4
|
||||||
|
size={18}
|
||||||
|
stroke={1.5}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: "var(--mantine-color-orange-7)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="sm">
|
||||||
|
<Trans
|
||||||
|
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
||||||
|
values={{ time: expiresTimeAgo }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconClockHour4 size={16} />}
|
||||||
|
onClick={handleMakePermanent}
|
||||||
|
loading={toggleTemporary.isPending}
|
||||||
|
>
|
||||||
|
{t("Make permanent")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
|
IconClockHour4,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@@ -30,7 +31,10 @@ import {
|
|||||||
useRemoveFavoriteMutation,
|
useRemoveFavoriteMutation,
|
||||||
} from "@/features/favorite/queries/favorite-query";
|
} from "@/features/favorite/queries/favorite-query";
|
||||||
|
|
||||||
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
|
import {
|
||||||
|
useToggleTemplateMutation,
|
||||||
|
useToggleTemporaryMutation,
|
||||||
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
@@ -65,6 +69,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const isFavorited = favoriteIds.has(node.id);
|
const isFavorited = favoriteIds.has(node.id);
|
||||||
const toggleTemplate = useToggleTemplateMutation();
|
const toggleTemplate = useToggleTemplateMutation();
|
||||||
const isTemplate = !!node.isTemplate;
|
const isTemplate = !!node.isTemplate;
|
||||||
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
|
const isTemporary = !!node.temporaryExpiresAt;
|
||||||
|
|
||||||
const handleToggleTemplate = async () => {
|
const handleToggleTemplate = async () => {
|
||||||
const next = !isTemplate;
|
const next = !isTemplate;
|
||||||
@@ -84,6 +90,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleTemporary = async () => {
|
||||||
|
const next = !isTemporary;
|
||||||
|
try {
|
||||||
|
const res = await toggleTemporary.mutateAsync({
|
||||||
|
pageId: node.id,
|
||||||
|
temporary: next,
|
||||||
|
});
|
||||||
|
// Reflect the new deadline locally so the icon/menu update immediately.
|
||||||
|
setData((prev) =>
|
||||||
|
treeModel.update(prev, node.id, {
|
||||||
|
temporaryExpiresAt: res.temporaryExpiresAt,
|
||||||
|
} as any),
|
||||||
|
);
|
||||||
|
notifications.show({
|
||||||
|
message: next
|
||||||
|
? t("Note will move to trash unless made permanent")
|
||||||
|
: t("Note is now permanent"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// mutation surfaces the error via notifications
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
||||||
@@ -248,6 +277,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconClockHour4 size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleTemporary();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconClockHour4,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconPointFilled,
|
IconPointFilled,
|
||||||
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{node.temporaryExpiresAt && (
|
||||||
|
<Tooltip
|
||||||
|
// Children ride along to trash with the note (recursive removePage).
|
||||||
|
label={t("Temporary note — moves to trash unless made permanent")}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<IconClockHour4
|
||||||
|
size={14}
|
||||||
|
stroke={1.5}
|
||||||
|
// Same visual-only indicator pattern as the template icon, but
|
||||||
|
// orange to flag the impending death timer.
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: rem(4),
|
||||||
|
color: "var(--mantine-color-orange-6)",
|
||||||
|
}}
|
||||||
|
aria-label={t("Temporary note")}
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} canEdit={canEdit} />
|
<NodeMenu node={node} canEdit={canEdit} />
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
|||||||
|
|
||||||
export type UseTreeMutation = {
|
export type UseTreeMutation = {
|
||||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||||
handleCreate: (parentId: string | null) => Promise<void>;
|
handleCreate: (
|
||||||
|
parentId: string | null,
|
||||||
|
opts?: { temporary?: boolean },
|
||||||
|
) => Promise<void>;
|
||||||
handleRename: (id: string, name: string) => Promise<void>;
|
handleRename: (id: string, name: string) => Promise<void>;
|
||||||
handleDelete: (id: string) => Promise<void>;
|
handleDelete: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -119,9 +122,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
async (parentId: string | null) => {
|
async (parentId: string | null, opts?: { temporary?: boolean }) => {
|
||||||
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
|
const payload: {
|
||||||
|
spaceId: string;
|
||||||
|
parentPageId?: string;
|
||||||
|
temporary?: boolean;
|
||||||
|
} = { spaceId };
|
||||||
if (parentId) payload.parentPageId = parentId;
|
if (parentId) payload.parentPageId = parentId;
|
||||||
|
// Ask the server to arm the death timer for a "temporary note".
|
||||||
|
if (opts?.temporary) payload.temporary = true;
|
||||||
|
|
||||||
let createdPage: IPage;
|
let createdPage: IPage;
|
||||||
try {
|
try {
|
||||||
@@ -138,6 +147,8 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
spaceId: createdPage.spaceId,
|
spaceId: createdPage.spaceId,
|
||||||
parentPageId: createdPage.parentPageId,
|
parentPageId: createdPage.parentPageId,
|
||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
|
// Show the temporary-note icon immediately on optimistic insert.
|
||||||
|
temporaryExpiresAt: createdPage.temporaryExpiresAt,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
|
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||||
|
temporaryExpiresAt?: string | null;
|
||||||
children: SpaceTreeNode[];
|
children: SpaceTreeNode[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
|||||||
parentPageId: page.parentPageId,
|
parentPageId: page.parentPageId,
|
||||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||||
isTemplate: page.isTemplate,
|
isTemplate: page.isTemplate,
|
||||||
|
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface IPage {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
|
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
|
||||||
|
temporaryExpiresAt?: string | null;
|
||||||
|
// Create-only input flag: ask the server to arm the timer on a new page.
|
||||||
|
temporary?: boolean;
|
||||||
lastUpdatedById: string;
|
lastUpdatedById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconExternalLink } from "@tabler/icons-react";
|
import { IconExternalLink } from "@tabler/icons-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CopyTextButton from "@/components/common/copy.tsx";
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
@@ -122,12 +123,25 @@ export default function ShareAliasSection({
|
|||||||
const showTaken =
|
const showTaken =
|
||||||
isValid && !unchanged && availability && !availability.available;
|
isValid && !unchanged && availability && !availability.available;
|
||||||
|
|
||||||
|
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||||
|
const prefixLabel = aliasPrefixLabel();
|
||||||
|
const prefixRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [prefixWidth, setPrefixWidth] = useState(0);
|
||||||
|
|
||||||
|
// Measure the real rendered width of the prefix so the slug input sits flush
|
||||||
|
// next to it, instead of after an over-estimated character-counted gap.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (prefixRef.current) {
|
||||||
|
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
|
||||||
|
}
|
||||||
|
}, [prefixLabel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" fw={500} mt="md">
|
<Text size="sm" fw={500} mt="md">
|
||||||
{t("Custom address")}
|
{t("Custom address")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
<Text size="xs" c="dimmed" mb={6}>
|
||||||
{t("A short, memorable link you can point at any shared page.")}
|
{t("A short, memorable link you can point at any shared page.")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -159,11 +173,27 @@ export default function ShareAliasSection({
|
|||||||
// visibly to what gets stored.
|
// visibly to what gets stored.
|
||||||
onBlur={() => setValue(normalized)}
|
onBlur={() => setValue(normalized)}
|
||||||
leftSection={
|
leftSection={
|
||||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
<Box
|
||||||
{aliasPrefixLabel()}
|
ref={prefixRef}
|
||||||
</Text>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
paddingInline: "var(--mantine-spacing-xs)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "var(--mantine-font-size-xs)",
|
||||||
|
color: "var(--mantine-color-dimmed)",
|
||||||
|
backgroundColor: "var(--mantine-color-default-hover)",
|
||||||
|
borderRight: "1px solid var(--mantine-color-default-border)",
|
||||||
|
borderTopLeftRadius: "var(--input-radius)",
|
||||||
|
borderBottomLeftRadius: "var(--input-radius)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefixLabel}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
leftSectionWidth={prefixWidth || undefined}
|
||||||
placeholder={t("my-page")}
|
placeholder={t("my-page")}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
error={
|
error={
|
||||||
@@ -175,7 +205,7 @@ export default function ShareAliasSection({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group mt="xs" gap="xs">
|
<Group mt="sm" gap="xs">
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={() => handleSave(false)}
|
onClick={() => handleSave(false)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
|
IconHourglass,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconStar,
|
IconStar,
|
||||||
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
|
|||||||
handleCreate(null);
|
handleCreate(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateTemporaryPage() {
|
||||||
|
handleCreate(null, { temporary: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
|||||||
SpaceCaslAction.Manage,
|
SpaceCaslAction.Manage,
|
||||||
SpaceCaslSubject.Page,
|
SpaceCaslSubject.Page,
|
||||||
) && (
|
) && (
|
||||||
<Tooltip label={t("Create page")} withArrow position="right">
|
<>
|
||||||
<ActionIcon
|
<Tooltip
|
||||||
variant="default"
|
label={t("Create page")}
|
||||||
size={18}
|
withArrow
|
||||||
onClick={handleCreatePage}
|
position="right"
|
||||||
aria-label={t("Create page")}
|
|
||||||
>
|
>
|
||||||
<IconPlus />
|
<ActionIcon
|
||||||
</ActionIcon>
|
variant="default"
|
||||||
</Tooltip>
|
size={18}
|
||||||
|
onClick={handleCreatePage}
|
||||||
|
aria-label={t("Create page")}
|
||||||
|
>
|
||||||
|
<IconPlus />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Standalone second button: a "temporary note" auto-moves to
|
||||||
|
trash after the workspace lifetime unless made permanent. */}
|
||||||
|
<Tooltip
|
||||||
|
label={t("New temporary note")}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size={18}
|
||||||
|
onClick={handleCreateTemporaryPage}
|
||||||
|
aria-label={t("New temporary note")}
|
||||||
|
>
|
||||||
|
<IconHourglass />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
|
||||||
|
// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
|
||||||
|
// has no explicit value configured yet.
|
||||||
|
const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
|
||||||
|
* is frozen per-note at creation, so changing this only affects notes created
|
||||||
|
* afterwards. `temporaryNoteHours` is a top-level workspace column (like
|
||||||
|
* trashRetentionDays), not a nested setting.
|
||||||
|
*/
|
||||||
|
export default function TemporaryNoteSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [value, setValue] = useState<number>(
|
||||||
|
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!value || value < 1) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateWorkspace({
|
||||||
|
temporaryNoteHours: value,
|
||||||
|
} as Partial<IWorkspace>);
|
||||||
|
setWorkspace({ ...updated, temporaryNoteHours: value });
|
||||||
|
notifications.show({ message: t("Updated successfully") });
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
(err as any)?.response?.data?.message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack mt="sm">
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{t("Temporary notes")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
|
{t(
|
||||||
|
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<NumberInput
|
||||||
|
label={t("Temporary note lifetime (hours)")}
|
||||||
|
min={1}
|
||||||
|
allowDecimal={false}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
|
||||||
|
disabled={!isAdmin || isLoading}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export interface IWorkspace {
|
|||||||
aiDictationStreaming?: boolean;
|
aiDictationStreaming?: boolean;
|
||||||
aiPublicShareAssistant?: boolean;
|
aiPublicShareAssistant?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
|
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||||
|
temporaryNoteHours?: number;
|
||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
isScimEnabled?: boolean;
|
isScimEnabled?: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
|
|||||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||||
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
|
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
|
||||||
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
|
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
|
||||||
|
import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
|
|||||||
<WorkspaceNameForm />
|
<WorkspaceNameForm />
|
||||||
<HtmlEmbedSettings />
|
<HtmlEmbedSettings />
|
||||||
<TrackerSettings />
|
<TrackerSettings />
|
||||||
|
<TemporaryNoteSettings />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -241,7 +241,7 @@ describe('prepareAgentStep', () => {
|
|||||||
* write path. It runs identically for the upfront insert (empty steps,
|
* write path. It runs identically for the upfront insert (empty steps,
|
||||||
* 'streaming'), every per-step update, and the terminal finalize — so a future
|
* 'streaming'), every per-step update, and the terminal finalize — so a future
|
||||||
* background worker can call the same function. These tests pin the four status
|
* background worker can call the same function. These tests pin the four status
|
||||||
* shapes and the `metadata.parts` shape that rowToUiMessage/findRecent depend on
|
* shapes and the `metadata.parts` shape that rowToUiMessage/findAllByChat depend on
|
||||||
* (per-step text + tool parts via assistantParts, in-progress text appended).
|
* (per-step text + tool parts via assistantParts, in-progress text appended).
|
||||||
*/
|
*/
|
||||||
describe('flushAssistant', () => {
|
describe('flushAssistant', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure, unit-testable (#198): decide whether THIS turn is an interrupt-resume,
|
* 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)
|
* i.e. it directly follows a user interruption of the previous (still-partial)
|
||||||
@@ -353,12 +365,14 @@ export class AiChatService implements OnModuleInit {
|
|||||||
|
|
||||||
// Rebuild the conversation from persisted history (not the client payload),
|
// Rebuild the conversation from persisted history (not the client payload),
|
||||||
// so the model always sees the authoritative server-side transcript. Load
|
// so the model always sees the authoritative server-side transcript. Load
|
||||||
// the most RECENT tail (oldest -> newest) so chats longer than one page do
|
// the FULL history in chronological order (oldest -> newest, incl. the user
|
||||||
// not drop recent turns (incl. the user message just inserted above).
|
// message just inserted above) so NO turns are dropped — there is no
|
||||||
const history = await this.aiChatMessageRepo.findRecent(
|
// recent-tail window anymore. `findAllByChat` keeps a 5000-row memory-safety
|
||||||
|
// backstop (on overflow it keeps the NEWEST rows and logs a warning); that
|
||||||
|
// is a safety net far above any realistic chat, not a conversational limit.
|
||||||
|
const history = await this.aiChatMessageRepo.findAllByChat(
|
||||||
chatId,
|
chatId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
50,
|
|
||||||
);
|
);
|
||||||
const uiMessages = history.map(rowToUiMessage);
|
const uiMessages = history.map(rowToUiMessage);
|
||||||
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
|
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
|
||||||
@@ -834,6 +848,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
|
||||||
@@ -1256,7 +1291,7 @@ export async function applyFinalize(
|
|||||||
*
|
*
|
||||||
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
||||||
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
||||||
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
|
* findAllByChat keep replaying the turn unchanged. `metadata.finishReason`,
|
||||||
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
|
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
|
||||||
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
|
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
|
||||||
* the pre-#183 onFinish/onError records.
|
* the pre-#183 onFinish/onError records.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Default lifetime for a temporary note, in HOURS, used when the workspace has
|
||||||
|
// no `temporaryNoteHours` configured (NULL). Mirrors the trash-cleanup
|
||||||
|
// DEFAULT_RETENTION_DAYS fallback. After this many hours a temporary note is
|
||||||
|
// auto-moved to trash unless it was made permanent first.
|
||||||
|
export const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -32,4 +33,10 @@ export class CreatePageDto {
|
|||||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||||
@IsIn(['json', 'markdown', 'html'])
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
format?: ContentFormat;
|
format?: ContentFormat;
|
||||||
|
|
||||||
|
// When true, create the page as a temporary note: arm its death timer
|
||||||
|
// (now + workspace temporaryNoteHours) at creation.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
temporary?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
|
|||||||
import { PageController } from './page.controller';
|
import { PageController } from './page.controller';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||||
|
import { TemporaryNoteCleanupService } from './services/temporary-note-cleanup.service';
|
||||||
import { BacklinkService } from './services/backlink.service';
|
import { BacklinkService } from './services/backlink.service';
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
@@ -16,6 +17,7 @@ import { LabelModule } from '../label/label.module';
|
|||||||
PageService,
|
PageService,
|
||||||
PageHistoryService,
|
PageHistoryService,
|
||||||
TrashCleanupService,
|
TrashCleanupService,
|
||||||
|
TemporaryNoteCleanupService,
|
||||||
BacklinkService,
|
BacklinkService,
|
||||||
],
|
],
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
|||||||
import { PageService } from './page.service';
|
import { PageService } from './page.service';
|
||||||
import { MovePageDto } from '../dto/move-page.dto';
|
import { MovePageDto } from '../dto/move-page.dto';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||||
|
|
||||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||||
@@ -420,4 +421,79 @@ describe('PageService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create() temporary deadline (#201)', () => {
|
||||||
|
// db stub for the workspaces.temporaryNoteHours lookup:
|
||||||
|
// selectFrom('workspaces').select(['temporaryNoteHours']).where(...).executeTakeFirst()
|
||||||
|
const makeDb = (workspaceRow: any) => {
|
||||||
|
const builder: any = {
|
||||||
|
selectFrom: jest.fn(() => builder),
|
||||||
|
select: jest.fn(() => builder),
|
||||||
|
where: jest.fn(() => builder),
|
||||||
|
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeGeneralQueue = () =>
|
||||||
|
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
|
||||||
|
|
||||||
|
const run = async (dto: any, workspaceRow: any) => {
|
||||||
|
const pageRepo = {
|
||||||
|
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
|
||||||
|
};
|
||||||
|
const db = makeDb(workspaceRow);
|
||||||
|
const svc = new PageService(
|
||||||
|
pageRepo as any, // pageRepo
|
||||||
|
{} as any, // pagePermissionRepo
|
||||||
|
{} as any, // attachmentRepo
|
||||||
|
db as any, // db
|
||||||
|
{} as any, // storageService
|
||||||
|
{} as any, // attachmentQueue
|
||||||
|
{} as any, // aiQueue
|
||||||
|
makeGeneralQueue(), // generalQueue
|
||||||
|
{} as any, // eventEmitter
|
||||||
|
{} as any, // collaborationGateway
|
||||||
|
{} as any, // watcherService
|
||||||
|
{} as any, // transclusionService
|
||||||
|
);
|
||||||
|
// nextPagePosition runs a real db query; stub it out.
|
||||||
|
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
|
||||||
|
await svc.create('u1', 'w1', dto, undefined);
|
||||||
|
return { payload: pageRepo.insertPage.mock.calls[0][0], db };
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('freezes temporaryExpiresAt at now + workspace hours when temporary', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||||
|
const { payload } = await run(
|
||||||
|
{ title: 't', spaceId: 's1', temporary: true },
|
||||||
|
{ temporaryNoteHours: 5 },
|
||||||
|
);
|
||||||
|
expect(payload.temporaryExpiresAt).toEqual(
|
||||||
|
new Date(Date.now() + 5 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to DEFAULT_TEMPORARY_NOTE_HOURS when the workspace hours are null', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||||
|
const { payload } = await run(
|
||||||
|
{ title: 't', spaceId: 's1', temporary: true },
|
||||||
|
{ temporaryNoteHours: null },
|
||||||
|
);
|
||||||
|
expect(payload.temporaryExpiresAt).toEqual(
|
||||||
|
new Date(Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves temporaryExpiresAt undefined and skips the workspace lookup for a non-temporary page', async () => {
|
||||||
|
const { payload, db } = await run(
|
||||||
|
{ title: 't', spaceId: 's1' },
|
||||||
|
{ temporaryNoteHours: 5 },
|
||||||
|
);
|
||||||
|
expect(payload.temporaryExpiresAt).toBeUndefined();
|
||||||
|
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
AuthProvenanceData,
|
AuthProvenanceData,
|
||||||
agentSourceFields,
|
agentSourceFields,
|
||||||
} from '../../../common/decorators/auth-provenance.decorator';
|
} from '../../../common/decorators/auth-provenance.decorator';
|
||||||
|
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||||
|
|
||||||
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
|
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
|
||||||
// descendant traversals) may walk. Real page trees are only a handful of levels
|
// descendant traversals) may walk. Real page trees are only a handful of levels
|
||||||
@@ -140,6 +141,20 @@ export class PageService {
|
|||||||
parentPageId = parentPage.id;
|
parentPageId = parentPage.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Freeze the death timer here so later changes to the workspace setting
|
||||||
|
// never reschedule existing temporary notes. NULL => permanent page.
|
||||||
|
let temporaryExpiresAt: Date | undefined;
|
||||||
|
if (createPageDto.temporary) {
|
||||||
|
const workspace = await this.db
|
||||||
|
.selectFrom('workspaces')
|
||||||
|
.select(['temporaryNoteHours'])
|
||||||
|
.where('id', '=', workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const hours =
|
||||||
|
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||||
|
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
let content = undefined;
|
let content = undefined;
|
||||||
let textContent = undefined;
|
let textContent = undefined;
|
||||||
let ydoc = undefined;
|
let ydoc = undefined;
|
||||||
@@ -172,6 +187,7 @@ export class PageService {
|
|||||||
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
||||||
// user request leaves the column default ('user').
|
// user request leaves the column default ('user').
|
||||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||||
|
temporaryExpiresAt,
|
||||||
content,
|
content,
|
||||||
textContent,
|
textContent,
|
||||||
ydoc,
|
ydoc,
|
||||||
@@ -356,6 +372,7 @@ export class PageService {
|
|||||||
'spaceId',
|
'spaceId',
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'isTemplate',
|
'isTemplate',
|
||||||
|
'temporaryExpiresAt',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { TemporaryNoteCleanupService } from '../temporary-note-cleanup.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chainable Kysely stub that records every `.where(...)` call so the test can
|
||||||
|
* assert the sweep only selects armed, expired, not-yet-trashed notes. The
|
||||||
|
* terminal `.execute()` resolves the configured expired rows (the batch SELECT);
|
||||||
|
* `.executeTakeFirst()` resolves the per-row deadline re-read done just before
|
||||||
|
* each `removePage`. By default the re-read reports the note as still armed and
|
||||||
|
* still expired (epoch deadline < now), so the sweep proceeds to delete it;
|
||||||
|
* tests override `reReadFirst` to simulate a concurrent "Make permanent".
|
||||||
|
*/
|
||||||
|
function makeDbStub(expiredRows: any[]) {
|
||||||
|
const whereCalls: any[][] = [];
|
||||||
|
const reReadFirst = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ temporaryExpiresAt: new Date(0), deletedAt: null });
|
||||||
|
const builder: any = {
|
||||||
|
selectFrom: jest.fn(() => builder),
|
||||||
|
select: jest.fn(() => builder),
|
||||||
|
where: jest.fn((...args: any[]) => {
|
||||||
|
whereCalls.push(args);
|
||||||
|
return builder;
|
||||||
|
}),
|
||||||
|
limit: jest.fn(() => builder),
|
||||||
|
execute: jest.fn().mockResolvedValue(expiredRows),
|
||||||
|
executeTakeFirst: reReadFirst,
|
||||||
|
};
|
||||||
|
return { builder, whereCalls, reReadFirst };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
|
||||||
|
it('selects only armed, expired, not-yet-trashed notes', async () => {
|
||||||
|
const { builder, whereCalls } = makeDbStub([]);
|
||||||
|
const pageRepo = { removePage: jest.fn() } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
|
||||||
|
// temporaryExpiresAt IS NOT NULL, temporaryExpiresAt < now, deletedAt IS NULL
|
||||||
|
const cols = whereCalls.map((c) => c[0]);
|
||||||
|
const ops = whereCalls.map((c) => c[1]);
|
||||||
|
expect(cols).toEqual([
|
||||||
|
'temporaryExpiresAt',
|
||||||
|
'temporaryExpiresAt',
|
||||||
|
'deletedAt',
|
||||||
|
]);
|
||||||
|
expect(ops).toEqual(['is not', '<', 'is']);
|
||||||
|
// last operand is the trash filter -> null
|
||||||
|
expect(whereCalls[2][2]).toBeNull();
|
||||||
|
// The batch SELECT is capped so a large backlog is not pulled at once.
|
||||||
|
expect(builder.limit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(builder.limit.mock.calls[0][0]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-deletes each expired note via removePage, attributed to its creator', async () => {
|
||||||
|
const expired = [
|
||||||
|
{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' },
|
||||||
|
{ id: 'p2', creatorId: 'u2', workspaceId: 'w1' },
|
||||||
|
];
|
||||||
|
const { builder } = makeDbStub(expired);
|
||||||
|
const pageRepo = { removePage: jest.fn().mockResolvedValue(undefined) } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
|
||||||
|
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(pageRepo.removePage).toHaveBeenNthCalledWith(1, 'p1', 'u1', 'w1');
|
||||||
|
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'p2', 'u2', 'w1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues past a failing note (one bad removePage does not abort the sweep)', async () => {
|
||||||
|
const expired = [
|
||||||
|
{ id: 'bad', creatorId: 'u1', workspaceId: 'w1' },
|
||||||
|
{ id: 'good', creatorId: 'u2', workspaceId: 'w1' },
|
||||||
|
];
|
||||||
|
const { builder } = makeDbStub(expired);
|
||||||
|
const pageRepo = {
|
||||||
|
removePage: jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
.mockResolvedValueOnce(undefined),
|
||||||
|
} as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sweepExpiredTemporaryNotes(),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'good', 'u2', 'w1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trash a note made permanent in the race window', async () => {
|
||||||
|
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||||
|
// the user clicked "Make permanent" (temporary_expires_at -> null). The
|
||||||
|
// deadline re-read must catch this and skip the delete so the keep wins.
|
||||||
|
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||||
|
const { builder, reReadFirst } = makeDbStub(expired);
|
||||||
|
reReadFirst.mockResolvedValueOnce({
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
const pageRepo = { removePage: jest.fn() } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
|
||||||
|
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a note already trashed since the batch SELECT', async () => {
|
||||||
|
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||||
|
const { builder, reReadFirst } = makeDbStub(expired);
|
||||||
|
reReadFirst.mockResolvedValueOnce({
|
||||||
|
temporaryExpiresAt: new Date(0),
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
const pageRepo = { removePage: jest.fn() } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
|
||||||
|
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trash a note re-armed to a future deadline in the race window', async () => {
|
||||||
|
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||||
|
// the user disarmed it and re-armed it to a fresh, still-future deadline
|
||||||
|
// (temporary_expires_at -> now + 1h). The deadline re-read must catch that
|
||||||
|
// the note is no longer expired and skip the delete so the keep wins.
|
||||||
|
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||||
|
const { builder, reReadFirst } = makeDbStub(expired);
|
||||||
|
reReadFirst.mockResolvedValueOnce({
|
||||||
|
temporaryExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
const pageRepo = { removePage: jest.fn() } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
|
||||||
|
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no notes are expired', async () => {
|
||||||
|
const { builder } = makeDbStub([]);
|
||||||
|
const pageRepo = { removePage: jest.fn() } as any;
|
||||||
|
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||||
|
|
||||||
|
await service.sweepExpiredTemporaryNotes();
|
||||||
|
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background sweeper for temporary notes ("structure or die"). A note whose
|
||||||
|
* frozen deadline (`pages.temporary_expires_at`) has passed is auto-moved to
|
||||||
|
* trash via the exact same soft-delete path as a manual delete. Modelled on
|
||||||
|
* TrashCleanupService; `@nestjs/schedule` is already enabled globally.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TemporaryNoteCleanupService {
|
||||||
|
private readonly logger = new Logger(TemporaryNoteCleanupService.name);
|
||||||
|
|
||||||
|
// Cap a single sweep so a large backlog (e.g. many notes created during
|
||||||
|
// downtime under a short lifetime) is not loaded into memory at once. The
|
||||||
|
// remainder is drained on the next hourly run; sub-hour overshoot is fine.
|
||||||
|
private static readonly SWEEP_BATCH_LIMIT = 500;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Hourly granularity: lifetimes are configured in hours, so a sub-hour
|
||||||
|
// overshoot past the deadline is acceptable.
|
||||||
|
@Interval('temporary-note-cleanup', 60 * 60 * 1000)
|
||||||
|
async sweepExpiredTemporaryNotes() {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const expired = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'creatorId', 'workspaceId'])
|
||||||
|
.where('temporaryExpiresAt', 'is not', null)
|
||||||
|
.where('temporaryExpiresAt', '<', now)
|
||||||
|
.where('deletedAt', 'is', null) // not already in trash
|
||||||
|
.limit(TemporaryNoteCleanupService.SWEEP_BATCH_LIMIT)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
let trashed = 0;
|
||||||
|
for (const page of expired) {
|
||||||
|
try {
|
||||||
|
// Re-check the deadline at deletion time. The SELECT above is not
|
||||||
|
// transactional, so a user may click "Make permanent"
|
||||||
|
// (toggleTemporary sets temporary_expires_at = null) in the window
|
||||||
|
// between the SELECT and this per-row removePage. removePage deletes
|
||||||
|
// by id with only a `deletedAt IS NULL` filter and never re-reads the
|
||||||
|
// deadline, so without this guard a concurrently-kept note would
|
||||||
|
// still be trashed. Re-read the row and skip it unless it is still
|
||||||
|
// armed AND still expired, so a concurrent make-permanent wins.
|
||||||
|
const current = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['temporaryExpiresAt', 'deletedAt'])
|
||||||
|
.where('id', '=', page.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.deletedAt !== null ||
|
||||||
|
current.temporaryExpiresAt === null ||
|
||||||
|
new Date(current.temporaryExpiresAt) >= now
|
||||||
|
) {
|
||||||
|
// Made permanent, already trashed, or no longer expired since the
|
||||||
|
// SELECT — leave it alone.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the exact soft-delete path: recursive over children, removes
|
||||||
|
// shares in a transaction, and emits PAGE_SOFT_DELETED (tree
|
||||||
|
// invalidation + watcher notifications). Attribute the automatic
|
||||||
|
// deletion to the note's creator (no schema change). Both the SELECT
|
||||||
|
// above and removePage filter `deletedAt IS NULL`, so a double sweep
|
||||||
|
// is idempotent.
|
||||||
|
await this.pageRepo.removePage(
|
||||||
|
page.id,
|
||||||
|
// creatorId is set on every created page; a temporary note always
|
||||||
|
// has one. Cast to satisfy the non-null deletedById parameter.
|
||||||
|
page.creatorId as string,
|
||||||
|
page.workspaceId,
|
||||||
|
);
|
||||||
|
trashed++;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to trash expired temporary note ${page.id}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trashed > 0) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Temporary-note cleanup completed: ${trashed} notes trashed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'Temporary-note cleanup job failed',
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ToggleTemporaryDto {
|
||||||
|
@IsUUID()
|
||||||
|
pageId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When omitted, the temporary state is toggled relative to its current value.
|
||||||
|
* true -> arm the timer (now + workspace temporaryNoteHours);
|
||||||
|
* false -> clear it (make permanent — "structure and survive").
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
temporary?: boolean;
|
||||||
|
}
|
||||||
@@ -16,8 +16,12 @@ import { TemplateLookupDto } from './dto/template-lookup.dto';
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PageAccessService } from '../page-access/page-access.service';
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
import { ToggleTemplateDto } from './dto/toggle-template.dto';
|
import { ToggleTemplateDto } from './dto/toggle-template.dto';
|
||||||
|
import { ToggleTemporaryDto } from './dto/toggle-temporary.dto';
|
||||||
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
|
||||||
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
|
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -26,6 +30,7 @@ export class PageTemplateController {
|
|||||||
private readonly transclusionService: TransclusionService,
|
private readonly transclusionService: TransclusionService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageAccessService: PageAccessService,
|
private readonly pageAccessService: PageAccessService,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,4 +87,54 @@ export class PageTemplateController {
|
|||||||
|
|
||||||
return { pageId: page.id, isTemplate };
|
return { pageId: page.id, isTemplate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm or disarm the "death timer" on a page (`pages.temporary_expires_at`).
|
||||||
|
* Mirror of toggle-template: requires Edit on the page/space (CASL enforced in
|
||||||
|
* `validateCanEdit`). Arming freezes the deadline at now + the workspace's
|
||||||
|
* temporaryNoteHours; disarming ("Make permanent") clears it. Same workspace
|
||||||
|
* defense-in-depth as toggle-template (NotFound, never Forbidden, on mismatch).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||||
|
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('toggle-temporary')
|
||||||
|
async toggleTemporary(
|
||||||
|
@Body() dto: ToggleTemporaryDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page || page.deletedAt) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.workspaceId !== user.workspaceId) {
|
||||||
|
// Defense-in-depth: never act on a page outside the caller's workspace.
|
||||||
|
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
|
const makeTemporary =
|
||||||
|
typeof dto.temporary === 'boolean'
|
||||||
|
? dto.temporary
|
||||||
|
: page.temporaryExpiresAt == null;
|
||||||
|
|
||||||
|
let temporaryExpiresAt: Date | null = null;
|
||||||
|
if (makeTemporary) {
|
||||||
|
const workspace = await this.db
|
||||||
|
.selectFrom('workspaces')
|
||||||
|
.select(['temporaryNoteHours'])
|
||||||
|
.where('id', '=', user.workspaceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const hours =
|
||||||
|
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||||
|
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id);
|
||||||
|
|
||||||
|
return { pageId: page.id, temporaryExpiresAt };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { PageAccessService } from '../../page-access/page-access.service';
|
import { PageAccessService } from '../../page-access/page-access.service';
|
||||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||||
|
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||||
|
|
||||||
describe('PageTemplateController.toggleTemplate', () => {
|
describe('PageTemplateController.toggleTemplate', () => {
|
||||||
let controller: PageTemplateController;
|
let controller: PageTemplateController;
|
||||||
@@ -40,6 +41,8 @@ describe('PageTemplateController.toggleTemplate', () => {
|
|||||||
{ provide: TransclusionService, useValue: transclusionService },
|
{ provide: TransclusionService, useValue: transclusionService },
|
||||||
{ provide: PageRepo, useValue: pageRepo },
|
{ provide: PageRepo, useValue: pageRepo },
|
||||||
{ provide: PageAccessService, useValue: pageAccessService },
|
{ provide: PageAccessService, useValue: pageAccessService },
|
||||||
|
// toggleTemporary reads the workspace lifetime; toggleTemplate ignores it.
|
||||||
|
{ provide: KYSELY_MODULE_CONNECTION_TOKEN(), useValue: {} },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(JwtAuthGuard)
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||||
|
import { PageTemplateController } from '../page-template.controller';
|
||||||
|
import { TransclusionService } from '../transclusion.service';
|
||||||
|
import { ToggleTemporaryDto } from '../dto/toggle-temporary.dto';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { PageAccessService } from '../../page-access/page-access.service';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||||
|
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../../constants/temporary-note.constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal chainable Kysely stub: every builder method returns `this`, and the
|
||||||
|
* terminal `executeTakeFirst` resolves the configured workspace row.
|
||||||
|
*/
|
||||||
|
function makeDbStub(workspaceRow: { temporaryNoteHours: number | null } | undefined) {
|
||||||
|
const builder: any = {
|
||||||
|
selectFrom: () => builder,
|
||||||
|
select: () => builder,
|
||||||
|
where: () => builder,
|
||||||
|
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageTemplateController.toggleTemporary', () => {
|
||||||
|
let controller: PageTemplateController;
|
||||||
|
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
|
||||||
|
let pageAccessService: { validateCanEdit: jest.Mock };
|
||||||
|
|
||||||
|
const user = { id: 'u1', workspaceId: 'w1' } as any;
|
||||||
|
|
||||||
|
async function buildController(
|
||||||
|
page: any,
|
||||||
|
workspaceRow: { temporaryNoteHours: number | null } | undefined = {
|
||||||
|
temporaryNoteHours: null,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
pageRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(page),
|
||||||
|
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
pageAccessService = {
|
||||||
|
validateCanEdit: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [PageTemplateController],
|
||||||
|
providers: [
|
||||||
|
{ provide: TransclusionService, useValue: { lookupTemplate: jest.fn() } },
|
||||||
|
{ provide: PageRepo, useValue: pageRepo },
|
||||||
|
{ provide: PageAccessService, useValue: pageAccessService },
|
||||||
|
{
|
||||||
|
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
|
||||||
|
useValue: makeDbStub(workspaceRow),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(JwtAuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(UserThrottlerGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get(PageTemplateController);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFound and does not touch the page when missing', async () => {
|
||||||
|
await buildController(null);
|
||||||
|
await expect(
|
||||||
|
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFound (not Forbidden) for a cross-workspace page', async () => {
|
||||||
|
await buildController({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'OTHER',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces CASL edit: when validateCanEdit throws, the timer is NOT changed', async () => {
|
||||||
|
await buildController({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'w1',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
});
|
||||||
|
pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException());
|
||||||
|
await expect(
|
||||||
|
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arms the timer (toggle) using the default hours when the page is permanent', async () => {
|
||||||
|
await buildController({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'w1',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
});
|
||||||
|
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||||
|
|
||||||
|
const expected = new Date(
|
||||||
|
Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||||
|
{ temporaryExpiresAt: expected },
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the workspace temporaryNoteHours override when set', async () => {
|
||||||
|
await buildController(
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'w1',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
},
|
||||||
|
{ temporaryNoteHours: 3 },
|
||||||
|
);
|
||||||
|
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||||
|
const expected = new Date(Date.now() + 3 * 60 * 60 * 1000);
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||||
|
{ temporaryExpiresAt: expected },
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
expect(out.temporaryExpiresAt).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the timer (make permanent) when toggling an armed note', async () => {
|
||||||
|
await buildController({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'w1',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: new Date('2026-06-27T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||||
|
{ temporaryExpiresAt: null },
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects an explicit temporary:false instead of toggling', async () => {
|
||||||
|
await buildController({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'w1',
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null, // already permanent, but explicit false
|
||||||
|
});
|
||||||
|
const out = await controller.toggleTemporary(
|
||||||
|
{ pageId: 'p1', temporary: false } as any,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||||
|
{ temporaryExpiresAt: null },
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
expect(out.temporaryExpiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToggleTemporaryDto validation (class-validator)', () => {
|
||||||
|
const uuid = '00000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
it('accepts a valid UUID with no flag (toggle)', async () => {
|
||||||
|
const dto = plainToInstance(ToggleTemporaryDto, { pageId: uuid });
|
||||||
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an explicit boolean temporary', async () => {
|
||||||
|
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||||
|
pageId: uuid,
|
||||||
|
temporary: true,
|
||||||
|
});
|
||||||
|
expect(await validate(dto)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-UUID pageId', async () => {
|
||||||
|
const dto = plainToInstance(ToggleTemporaryDto, { pageId: 'nope' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isUuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-boolean temporary', async () => {
|
||||||
|
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||||
|
pageId: uuid,
|
||||||
|
temporary: 'yes',
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isBoolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,13 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
trashRetentionDays: number;
|
trashRetentionDays: number;
|
||||||
|
|
||||||
|
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
|
||||||
|
// creation, so changing this never reschedules existing notes.
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
temporaryNoteHours: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
allowMemberTemplates: boolean;
|
allowMemberTemplates: boolean;
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export class WorkspaceService {
|
|||||||
if (
|
if (
|
||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||||
@@ -337,7 +338,13 @@ export class WorkspaceService {
|
|||||||
) {
|
) {
|
||||||
const ws = await this.db
|
const ws = await this.db
|
||||||
.selectFrom('workspaces')
|
.selectFrom('workspaces')
|
||||||
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
|
.select([
|
||||||
|
'id',
|
||||||
|
'licenseKey',
|
||||||
|
'plan',
|
||||||
|
'trashRetentionDays',
|
||||||
|
'temporaryNoteHours',
|
||||||
|
])
|
||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -378,6 +385,14 @@ export class WorkspaceService {
|
|||||||
before.trashRetentionDays = ws.trashRetentionDays;
|
before.trashRetentionDays = ws.trashRetentionDays;
|
||||||
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' &&
|
||||||
|
updateWorkspaceDto.temporaryNoteHours !== ws.temporaryNoteHours
|
||||||
|
) {
|
||||||
|
before.temporaryNoteHours = ws.temporaryNoteHours;
|
||||||
|
after.temporaryNoteHours = updateWorkspaceDto.temporaryNoteHours;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateWorkspaceDto.aiSearch) {
|
if (updateWorkspaceDto.aiSearch) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// "Death timer" column. NULL = permanent page; non-NULL = temporary note,
|
||||||
|
// value is the exact moment the note auto-moves to trash. The deadline is
|
||||||
|
// frozen at creation, so changing the workspace setting never reschedules
|
||||||
|
// existing notes.
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.addColumn('temporary_expires_at', 'timestamptz', (col) => col)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Partial index backing the cleanup sweep: only armed, not-yet-trashed notes.
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX pages_temporary_expires_at_idx
|
||||||
|
ON pages (temporary_expires_at)
|
||||||
|
WHERE temporary_expires_at IS NOT NULL AND deleted_at IS NULL
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
|
||||||
|
// creation. NULL falls back to the in-code DEFAULT_TEMPORARY_NOTE_HOURS.
|
||||||
|
await db.schema
|
||||||
|
.alterTable('workspaces')
|
||||||
|
.addColumn('temporary_note_hours', 'int8', (col) => col)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('workspaces')
|
||||||
|
.dropColumn('temporary_note_hours')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema.dropIndex('pages_temporary_expires_at_idx').execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.dropColumn('temporary_expires_at')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
|||||||
// (multi-instance deploy).
|
// (multi-instance deploy).
|
||||||
const SWEEP_STREAMING_STALE_MS = 10 * 60 * 1000; // 10 minutes
|
const SWEEP_STREAMING_STALE_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
// Hard upper bound on the rows materialized by `findAllByChat` (export path).
|
// Hard upper bound on the rows materialized by `findAllByChat`, which now feeds
|
||||||
|
// BOTH the Markdown export and the per-turn model history.
|
||||||
// A generous cap so a pathologically huge chat cannot load an unbounded result
|
// A generous cap so a pathologically huge chat cannot load an unbounded result
|
||||||
// into memory; far above any realistic transcript length.
|
// into memory; far above any realistic transcript length.
|
||||||
const FIND_ALL_BY_CHAT_LIMIT = 5000;
|
const FIND_ALL_BY_CHAT_LIMIT = 5000;
|
||||||
@@ -78,14 +79,17 @@ export class AiChatMessageRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load ALL (non-deleted) messages of a chat in ascending chronological order
|
// Load ALL (non-deleted) messages of a chat in ascending chronological order
|
||||||
// (oldest -> newest), unpaginated. Used by the server-side Markdown export
|
// (oldest -> newest), unpaginated. Two callers, both treating the DB as the
|
||||||
// (#183), where the DB is the single source of truth and the whole transcript
|
// single source of truth and needing the whole transcript in one pass
|
||||||
// must be rendered in one pass (findByChat is cursor-paginated and would only
|
// (findByChat is cursor-paginated and would only return the first page):
|
||||||
// return the first page).
|
// - the server-side Markdown export (#183);
|
||||||
|
// - the per-turn model history, rebuilt fresh on every turn so the model
|
||||||
|
// sees the full authoritative transcript.
|
||||||
//
|
//
|
||||||
// Hard-capped at FIND_ALL_BY_CHAT_LIMIT rows (a generous bound, far above any
|
// Hard-capped at FIND_ALL_BY_CHAT_LIMIT rows (a generous bound, far above any
|
||||||
// realistic transcript) so exporting a pathologically huge chat cannot
|
// realistic transcript) — a shared memory-safety backstop for BOTH paths so a
|
||||||
// materialize an unbounded result set in memory.
|
// pathologically huge chat cannot materialize an unbounded result set in
|
||||||
|
// memory. On overflow the NEWEST rows are kept and a warning is logged.
|
||||||
async findAllByChat(
|
async findAllByChat(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -93,9 +97,9 @@ export class AiChatMessageRepo {
|
|||||||
limit: number = FIND_ALL_BY_CHAT_LIMIT,
|
limit: number = FIND_ALL_BY_CHAT_LIMIT,
|
||||||
): Promise<AiChatMessage[]> {
|
): Promise<AiChatMessage[]> {
|
||||||
// Fetch newest-first (+1 to DETECT truncation), so on overflow we keep the
|
// Fetch newest-first (+1 to DETECT truncation), so on overflow we keep the
|
||||||
// NEWEST `limit` messages — the recent conversation matters most for an
|
// NEWEST `limit` messages — the recent conversation matters most — rather
|
||||||
// export — rather than silently dropping the tail (#183 review). Reverse back
|
// than silently dropping the tail (#183 review). Then reverse back to
|
||||||
// to chronological for rendering, like findRecent.
|
// chronological order (oldest -> newest) for rendering / model replay.
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.selectFrom('aiChatMessages')
|
.selectFrom('aiChatMessages')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
@@ -110,38 +114,13 @@ export class AiChatMessageRepo {
|
|||||||
if (rows.length > limit) {
|
if (rows.length > limit) {
|
||||||
rows.length = limit; // keep the newest `limit` (rows are newest-first here)
|
rows.length = limit; // keep the newest `limit` (rows are newest-first here)
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Chat ${chatId} export truncated to the newest ${limit} messages ` +
|
`Chat ${chatId} truncated to the newest ${limit} messages ` +
|
||||||
`(older messages omitted).`,
|
`(older messages omitted).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rows.reverse();
|
return rows.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the most RECENT `limit` messages for a chat and return them in
|
|
||||||
// ascending chronological order (oldest -> newest), as the model expects.
|
|
||||||
// `findByChat` returns the FIRST page ASC (the OLDEST messages), which loses
|
|
||||||
// recent turns once a chat grows beyond a page; this rebuilds the model
|
|
||||||
// history from the tail instead. Plain query (no cursor pagination).
|
|
||||||
async findRecent(
|
|
||||||
chatId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
limit: number,
|
|
||||||
): Promise<AiChatMessage[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.selectFrom('aiChatMessages')
|
|
||||||
.select(this.baseFields)
|
|
||||||
.where('chatId', '=', chatId)
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('createdAt', 'desc')
|
|
||||||
.orderBy('id', 'desc')
|
|
||||||
.limit(limit)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Selected newest-first for the limit; reverse to oldest-first for the model.
|
|
||||||
return rows.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
async insert(
|
||||||
insertable: InsertableAiChatMessage,
|
insertable: InsertableAiChatMessage,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { PageRepo } from './page.repo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard for #201: restorePage must disarm the temporary-note death
|
||||||
|
* timer by setting `temporaryExpiresAt = null` alongside the un-delete fields.
|
||||||
|
* Otherwise a restored note whose frozen deadline already passed would be
|
||||||
|
* re-trashed by the very next cleanup sweep. There is no real DB here — a
|
||||||
|
* chainable Kysely proxy records every `.set(...)` payload so we can assert the
|
||||||
|
* single restore UPDATE clears the deadline.
|
||||||
|
*/
|
||||||
|
function makeRestoreDbStub(opts: {
|
||||||
|
pageToRestore: any;
|
||||||
|
descendants: any[];
|
||||||
|
}) {
|
||||||
|
const setCalls: any[] = [];
|
||||||
|
const proxy: any = new Proxy(function () {}, {
|
||||||
|
get(_t, prop) {
|
||||||
|
if (prop === 'then') return undefined;
|
||||||
|
if (prop === 'set')
|
||||||
|
return (payload: any) => {
|
||||||
|
setCalls.push(payload);
|
||||||
|
return proxy;
|
||||||
|
};
|
||||||
|
if (prop === 'executeTakeFirst')
|
||||||
|
return () => Promise.resolve(opts.pageToRestore);
|
||||||
|
if (prop === 'execute') return () => Promise.resolve(opts.descendants);
|
||||||
|
if (prop === 'withRecursive')
|
||||||
|
return (_name: string, cb: any) => {
|
||||||
|
// Exercise the recursive CTE builder against the proxy without a DB.
|
||||||
|
try {
|
||||||
|
cb(proxy);
|
||||||
|
} catch {
|
||||||
|
// builder shape only; ignore
|
||||||
|
}
|
||||||
|
return proxy;
|
||||||
|
};
|
||||||
|
return () => proxy;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { proxy, setCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageRepo.restorePage temporary-timer disarm (#201)', () => {
|
||||||
|
it('clears temporaryExpiresAt together with the un-delete fields', async () => {
|
||||||
|
const { proxy, setCalls } = makeRestoreDbStub({
|
||||||
|
// No parent => the deleted-parent lookup and detach branch are skipped, so
|
||||||
|
// the only UPDATE is the bulk restore we assert on.
|
||||||
|
pageToRestore: { id: 'p1', parentPageId: null, spaceId: 's1' },
|
||||||
|
descendants: [{ id: 'p1' }],
|
||||||
|
});
|
||||||
|
const eventEmitter = { emit: jest.fn() } as any;
|
||||||
|
|
||||||
|
const repo = new PageRepo(proxy, {} as any, eventEmitter);
|
||||||
|
|
||||||
|
await repo.restorePage('p1', 'w1');
|
||||||
|
|
||||||
|
expect(setCalls).toHaveLength(1);
|
||||||
|
expect(setCalls[0]).toEqual({
|
||||||
|
deletedById: null,
|
||||||
|
deletedAt: null,
|
||||||
|
temporaryExpiresAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ export class PageRepo {
|
|||||||
'workspaceId',
|
'workspaceId',
|
||||||
'isLocked',
|
'isLocked',
|
||||||
'isTemplate',
|
'isTemplate',
|
||||||
|
'temporaryExpiresAt',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
@@ -425,7 +426,10 @@ export class PageRepo {
|
|||||||
// Restore all pages, but only detach the root page if its parent is deleted
|
// Restore all pages, but only detach the root page if its parent is deleted
|
||||||
await this.db
|
await this.db
|
||||||
.updateTable('pages')
|
.updateTable('pages')
|
||||||
.set({ deletedById: null, deletedAt: null })
|
// On restore, disarm the death timer: pulling a note out of trash means
|
||||||
|
// "keep it". Otherwise a deadline now in the past would re-trash it on the
|
||||||
|
// next cleanup sweep.
|
||||||
|
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null })
|
||||||
.where('id', 'in', pageIds)
|
.where('id', 'in', pageIds)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export class WorkspaceRepo {
|
|||||||
'plan',
|
'plan',
|
||||||
'enforceMfa',
|
'enforceMfa',
|
||||||
'trashRetentionDays',
|
'trashRetentionDays',
|
||||||
|
'temporaryNoteHours',
|
||||||
'isScimEnabled',
|
'isScimEnabled',
|
||||||
];
|
];
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|||||||
2
apps/server/src/database/types/db.d.ts
vendored
2
apps/server/src/database/types/db.d.ts
vendored
@@ -297,6 +297,7 @@ export interface Pages {
|
|||||||
position: string | null;
|
position: string | null;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
temporaryExpiresAt: Timestamp | null;
|
||||||
textContent: string | null;
|
textContent: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
tsv: string | null;
|
tsv: string | null;
|
||||||
@@ -419,6 +420,7 @@ export interface WorkspaceInvitations {
|
|||||||
export interface Workspaces {
|
export interface Workspaces {
|
||||||
auditRetentionDays: Generated<number>;
|
auditRetentionDays: Generated<number>;
|
||||||
trashRetentionDays: Generated<number>;
|
trashRetentionDays: Generated<number>;
|
||||||
|
temporaryNoteHours: Generated<number>;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
customDomain: string | null;
|
customDomain: string | null;
|
||||||
|
|||||||
@@ -267,4 +267,36 @@ describe('AiChatMessageRepo.update + sweepStreaming [integration]', () => {
|
|||||||
const all = await repo.findAllByChat(cappedChat, workspaceId, 100);
|
const all = await repo.findAllByChat(cappedChat, workspaceId, 100);
|
||||||
expect(all.map((r) => r.content)).toEqual(['m1-oldest', 'm2', 'm3-newest']);
|
expect(all.map((r) => r.content)).toEqual(['m1-oldest', 'm2', 'm3-newest']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('default findAllByChat returns the FULL transcript past 50 rows — no recent-tail window (#202)', async () => {
|
||||||
|
// PR #202 swapped the model-history rebuild in AiChatService.handle from
|
||||||
|
// findRecent(chatId, ws, 50) to findAllByChat(chatId, ws) WITHOUT a limit
|
||||||
|
// arg. This pins the behavioral guarantee that switch relies on: a chat
|
||||||
|
// longer than the old 50-msg window comes back in FULL (oldest -> newest),
|
||||||
|
// so no early turns are silently dropped from what the model sees. The old
|
||||||
|
// 50-cap would have returned only the last 50 of these 60 rows.
|
||||||
|
const longChat = (
|
||||||
|
await createChat(db, { workspaceId, creatorId: userId })
|
||||||
|
).id;
|
||||||
|
const base = Date.now();
|
||||||
|
const total = 60;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
await createMessage(db, {
|
||||||
|
workspaceId,
|
||||||
|
chatId: longChat,
|
||||||
|
content: `msg-${i}`,
|
||||||
|
// Strictly increasing timestamps so ordering is deterministic.
|
||||||
|
createdAt: new Date(base + i * 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default args == exactly how handle() calls it now.
|
||||||
|
const history = await repo.findAllByChat(longChat, workspaceId);
|
||||||
|
expect(history).toHaveLength(total);
|
||||||
|
expect(history.map((r) => r.content)).toEqual(
|
||||||
|
Array.from({ length: total }, (_, i) => `msg-${i}`),
|
||||||
|
);
|
||||||
|
// The very first turn (which the old 50-window would have dropped) is present.
|
||||||
|
expect(history[0]!.content).toBe('msg-0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user