Skip to content

Commit f6f66b8

Browse files
authored
feat: add publish message support (#891)
1 parent 00373a7 commit f6f66b8

File tree

14 files changed

+519
-41
lines changed

14 files changed

+519
-41
lines changed

.changeset/gold-boxes-attack.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: add publish message support

packages/root-cms/core/ai.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,63 @@ export interface SummarizeDiffOptions {
5151
export async function summarizeDiff(
5252
cmsClient: RootCMSClient,
5353
options: SummarizeDiffOptions
54+
): Promise<string> {
55+
const cmsPluginOptions = cmsClient.cmsPlugin.getConfig();
56+
const firebaseConfig = cmsPluginOptions.firebaseConfig;
57+
// Use fastest model for diff summarization
58+
const model: RootAiModel = 'gemini-2.5-flash';
59+
60+
const ai = genkit({
61+
plugins: [
62+
vertexAI({
63+
projectId: firebaseConfig.projectId,
64+
location: firebaseConfig.location || 'us-central1',
65+
}),
66+
],
67+
});
68+
69+
const beforeJson = JSON.stringify(options.before ?? null, null, 2);
70+
const afterJson = JSON.stringify(options.after ?? null, null, 2);
71+
72+
const systemPrompt = [
73+
'Summarize CMS document changes in 2-4 bullet points.',
74+
'Focus on content changes only. Ignore metadata like timestamps.',
75+
'If no meaningful changes, say "No significant changes."',
76+
].join('\n');
77+
78+
const diffPrompt = [
79+
'Before:',
80+
beforeJson,
81+
'',
82+
'After:',
83+
afterJson,
84+
'',
85+
'What changed?',
86+
].join('\n');
87+
88+
const res = await generate(ai, model, {
89+
messages: [
90+
{
91+
role: 'system',
92+
content: [{text: systemPrompt}],
93+
},
94+
],
95+
prompt: [{text: diffPrompt}],
96+
config: {
97+
// Respond more quickly with less creativity.
98+
temperature: 0.3,
99+
},
100+
});
101+
102+
return res.text?.trim() || '';
103+
}
104+
105+
/**
106+
* Generates a concise publish message based on document changes.
107+
*/
108+
export async function generatePublishMessage(
109+
cmsClient: RootCMSClient,
110+
options: SummarizeDiffOptions
54111
): Promise<string> {
55112
const cmsPluginOptions = cmsClient.cmsPlugin.getConfig();
56113
const firebaseConfig = cmsPluginOptions.firebaseConfig;
@@ -72,10 +129,13 @@ export async function summarizeDiff(
72129
const afterJson = JSON.stringify(options.after ?? null, null, 2);
73130

74131
const systemPrompt = [
75-
'You are an assistant that summarizes changes made to CMS documents stored as JSON.',
76-
'Provide a concise description of the most important updates using short bullet points.',
77-
'If there are no meaningful differences, respond with "No significant changes."',
78-
'Focus on just the content changes, ignore insignificant changes to richtext blocks and structure, such as updates to the richtext block\'s "timestamp" and "version" fields.',
132+
'You are an assistant that generates concise commit-style messages for CMS document changes.',
133+
'Generate a single short sentence (maximum 60 characters) describing the most important change.',
134+
'Use imperative mood like "Add feature" or "Update content" or "Fix typo".',
135+
'Focus on the key content change, ignore structural metadata changes.',
136+
'Do not use punctuation at the end.',
137+
'Examples: "Add new hero image", "Update pricing details", "Fix typo in headline"',
138+
'Include ',
79139
].join('\n');
80140

81141
const diffPrompt = [
@@ -89,7 +149,7 @@ export async function summarizeDiff(
89149
afterJson,
90150
'```',
91151
'',
92-
'Summarize the differences between the two payloads.',
152+
'Generate a commit message for these changes.',
93153
].join('\n');
94154

95155
const res = await generate(ai, model, {

packages/root-cms/core/api.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,59 @@ export function api(server: Server, options: ApiOptions) {
503503
}
504504
);
505505

506+
server.use(
507+
'/cms/api/ai.publish_message',
508+
async (req: Request, res: Response) => {
509+
if (
510+
req.method !== 'POST' ||
511+
!String(req.get('content-type')).startsWith('application/json')
512+
) {
513+
res.status(400).json({success: false, error: 'BAD_REQUEST'});
514+
return;
515+
}
516+
517+
if (!req.user?.email) {
518+
res.status(401).json({success: false, error: 'UNAUTHORIZED'});
519+
return;
520+
}
521+
522+
const reqBody = req.body || {};
523+
const docId =
524+
typeof reqBody.docId === 'string' ? reqBody.docId.trim() : '';
525+
if (!docId) {
526+
res.status(400).json({
527+
success: false,
528+
error: 'MISSING_REQUIRED_FIELD',
529+
field: 'docId',
530+
});
531+
return;
532+
}
533+
534+
try {
535+
const cmsClient = new RootCMSClient(req.rootConfig!);
536+
const beforeVersion: DocVersion = 'published';
537+
const afterVersion: DocVersion = 'draft';
538+
const diffPayload = await buildDocDiffPayload(cmsClient, docId, {
539+
beforeVersion,
540+
afterVersion,
541+
});
542+
if (!diffPayload.before && !diffPayload.after) {
543+
res.status(200).json({success: true, message: 'Initial version'});
544+
return;
545+
}
546+
const {generatePublishMessage} = await import('./ai.js');
547+
const message = await generatePublishMessage(cmsClient, {
548+
before: diffPayload.before,
549+
after: diffPayload.after,
550+
});
551+
res.status(200).json({success: true, message});
552+
} catch (err: any) {
553+
console.error(err.stack || err);
554+
res.status(500).json({success: false, error: 'UNKNOWN'});
555+
}
556+
}
557+
);
558+
506559
server.use('/cms/api/ai.translate', async (req: Request, res: Response) => {
507560
if (
508561
req.method !== 'POST' ||

packages/root-cms/ui/components/PublishDocModal/PublishDocModal.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,57 @@
7777
text-align: right;
7878
}
7979

80+
.PublishDocModal__form__publishMessage {
81+
margin-top: 16px;
82+
}
83+
84+
.PublishDocModal__form__publishMessage__wrapper {
85+
position: relative;
86+
}
87+
88+
.PublishDocModal__form__publishMessage__textarea {
89+
width: 100%;
90+
border: 1px solid var(--color-border);
91+
border-radius: 4px;
92+
padding: 8px 40px 8px 12px;
93+
font-family: inherit;
94+
font-size: 12px;
95+
resize: vertical;
96+
line-height: 1.5;
97+
}
98+
99+
.PublishDocModal__form__publishMessage__textarea:focus {
100+
outline: none;
101+
border-color: #339af0;
102+
}
103+
104+
.PublishDocModal__form__publishMessage__sparkle {
105+
position: absolute;
106+
right: 6px;
107+
top: 6px;
108+
background: white;
109+
border: 1px solid var(--color-border);
110+
cursor: pointer;
111+
padding: 4px;
112+
border-radius: 4px;
113+
transition: all 0.2s ease;
114+
display: flex;
115+
align-items: center;
116+
justify-content: center;
117+
line-height: 1;
118+
color: inherit;
119+
}
120+
121+
.PublishDocModal__form__publishMessage__sparkle:hover:not(:disabled) {
122+
background: rgba(51, 154, 240, 0.05);
123+
border-color: #339af0;
124+
}
125+
126+
.PublishDocModal__form__publishMessage__sparkle:disabled {
127+
opacity: 0.5;
128+
cursor: not-allowed;
129+
}
130+
80131
.PublishDocModal__form__buttons {
81132
margin-top: 24px;
82133
display: flex;

packages/root-cms/ui/components/PublishDocModal/PublishDocModal.tsx

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import './PublishDocModal.css';
22

3-
import {Accordion, Button} from '@mantine/core';
3+
import {Accordion, Button, Loader, Tooltip} from '@mantine/core';
44
import {ContextModalProps, useModals} from '@mantine/modals';
55
import {showNotification} from '@mantine/notifications';
6-
import {IconGitCompare} from '@tabler/icons-preact';
6+
import {IconGitCompare, IconSparkles} from '@tabler/icons-preact';
77
import {useState, useRef} from 'preact/hooks';
88
import {useModalTheme} from '../../hooks/useModalTheme.js';
99
import {useProjectRoles} from '../../hooks/useProjectRoles.js';
@@ -47,6 +47,8 @@ export function PublishDocModal(
4747
const [publishType, setPublishType] = useState<PublishType>('');
4848
const [scheduledDate, setScheduledDate] = useState('');
4949
const [loading, setLoading] = useState(false);
50+
const [publishMessage, setPublishMessage] = useState('');
51+
const [generatingMessage, setGeneratingMessage] = useState(false);
5052
const dateTimeRef = useRef<HTMLInputElement>(null);
5153
const modals = useModals();
5254
const modalTheme = useModalTheme();
@@ -61,7 +63,9 @@ export function PublishDocModal(
6163
async function publish() {
6264
try {
6365
setLoading(true);
64-
await cmsPublishDoc(props.docId);
66+
await cmsPublishDoc(props.docId, {
67+
publishMessage: publishMessage.trim() || undefined,
68+
});
6569
setLoading(false);
6670
showNotification({
6771
title: 'Published!',
@@ -84,7 +88,9 @@ export function PublishDocModal(
8488
try {
8589
setLoading(true);
8690
const millis = Math.floor(new Date(scheduledDate).getTime());
87-
await cmsScheduleDoc(props.docId, millis);
91+
await cmsScheduleDoc(props.docId, millis, {
92+
publishMessage: publishMessage.trim() || undefined,
93+
});
8894
setLoading(false);
8995
showNotification({
9096
title: 'Scheduled!',
@@ -103,6 +109,38 @@ export function PublishDocModal(
103109
}
104110
}
105111

112+
async function generatePublishMessage() {
113+
try {
114+
setGeneratingMessage(true);
115+
const res = await window.fetch('/cms/api/ai.publish_message', {
116+
method: 'POST',
117+
headers: {'Content-Type': 'application/json'},
118+
body: JSON.stringify({docId: props.docId}),
119+
});
120+
const data = await res.json();
121+
if (data.success && data.message) {
122+
setPublishMessage(data.message);
123+
} else {
124+
showNotification({
125+
title: 'Generation failed',
126+
message: 'Failed to generate publish message.',
127+
color: 'red',
128+
autoClose: 5000,
129+
});
130+
}
131+
} catch (err) {
132+
console.error(err);
133+
showNotification({
134+
title: 'Generation failed',
135+
message: 'Failed to generate publish message.',
136+
color: 'red',
137+
autoClose: 5000,
138+
});
139+
} finally {
140+
setGeneratingMessage(false);
141+
}
142+
}
143+
106144
function onSubmit() {
107145
modals.openConfirmModal({
108146
...modalTheme,
@@ -226,6 +264,43 @@ export function PublishDocModal(
226264
</div>
227265
</div>
228266

267+
<div className="PublishDocModal__form__publishMessage">
268+
<div className="PublishDocModal__form__publishMessage__wrapper">
269+
<textarea
270+
className="PublishDocModal__form__publishMessage__textarea"
271+
placeholder="Optional: Add a message to describe the changes"
272+
value={publishMessage}
273+
rows={3}
274+
onInput={(e: Event) => {
275+
const target = e.target as HTMLTextAreaElement;
276+
setPublishMessage(target.value);
277+
}}
278+
/>
279+
{experiments.ai && (
280+
<button
281+
type="button"
282+
className="PublishDocModal__form__publishMessage__sparkle"
283+
onClick={generatePublishMessage}
284+
disabled={generatingMessage}
285+
>
286+
<Tooltip
287+
label="Generate message"
288+
withArrow
289+
position="left"
290+
allowPointerEvents
291+
wrapLines
292+
>
293+
{generatingMessage ? (
294+
<Loader size={16} />
295+
) : (
296+
<IconSparkles size={16} stroke={1.5} />
297+
)}
298+
</Tooltip>
299+
</button>
300+
)}
301+
</div>
302+
</div>
303+
229304
<div className="PublishDocModal__form__buttons">
230305
<Button
231306
variant="outline"

0 commit comments

Comments
 (0)