Skip to content

Commit e40d038

Browse files
authored
feat: add ai summary to publish doc modal (#756)
1 parent ce60276 commit e40d038

File tree

6 files changed

+272
-4
lines changed

6 files changed

+272
-4
lines changed

.changeset/rude-pumpkins-fetch.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 ai summary to publish doc modal (#756)

packages/root-cms/core/ai.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,73 @@ export type RootAiModel =
2727

2828
const DEFAULT_MODEL: RootAiModel = 'vertexai/gemini-2.5-flash';
2929

30+
export interface SummarizeDiffOptions {
31+
before: Record<string, any> | null;
32+
after: Record<string, any> | null;
33+
}
34+
35+
/**
36+
* Generates a natural language summary of the differences between two JSON
37+
* payloads.
38+
*/
39+
export async function summarizeDiff(
40+
cmsClient: RootCMSClient,
41+
options: SummarizeDiffOptions
42+
): Promise<string> {
43+
const cmsPluginOptions = cmsClient.cmsPlugin.getConfig();
44+
const firebaseConfig = cmsPluginOptions.firebaseConfig;
45+
const model: RootAiModel =
46+
(typeof cmsPluginOptions.experiments?.ai === 'object'
47+
? cmsPluginOptions.experiments.ai.model
48+
: undefined) || DEFAULT_MODEL;
49+
50+
const ai = genkit({
51+
plugins: [
52+
vertexAI({
53+
projectId: firebaseConfig.projectId,
54+
location: firebaseConfig.location || 'us-central1',
55+
}),
56+
],
57+
});
58+
59+
const beforeJson = JSON.stringify(options.before ?? null, null, 2);
60+
const afterJson = JSON.stringify(options.after ?? null, null, 2);
61+
62+
const systemPrompt = [
63+
'You are an assistant that summarizes changes made to CMS documents stored as JSON.',
64+
'Provide a concise description of the most important updates using short bullet points.',
65+
'If there are no meaningful differences, respond with "No significant changes."',
66+
'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.',
67+
].join('\n');
68+
69+
const diffPrompt = [
70+
'Previous version JSON:',
71+
'```json',
72+
beforeJson,
73+
'```',
74+
'',
75+
'Updated version JSON:',
76+
'```json',
77+
afterJson,
78+
'```',
79+
'',
80+
'Summarize the differences between the two payloads.',
81+
].join('\n');
82+
83+
const res = await ai.generate({
84+
model,
85+
messages: [
86+
{
87+
role: 'system',
88+
content: [{text: systemPrompt}],
89+
},
90+
],
91+
prompt: [{text: diffPrompt}],
92+
});
93+
94+
return res.text?.trim() || '';
95+
}
96+
3097
export class Chat {
3198
chatClient: ChatClient;
3299
cmsClient: RootCMSClient;

packages/root-cms/core/api.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
AiResponse,
88
SendPromptOptions,
99
} from '../shared/ai/prompts.js';
10-
import {ChatClient, RootAiModel} from './ai.js';
10+
import {ChatClient, RootAiModel, summarizeDiff} from './ai.js';
1111
import {RootCMSClient} from './client.js';
1212
import {runCronJobs} from './cron.js';
1313
import {arrayToCsv, csvToArray} from './csv.js';
@@ -323,6 +323,43 @@ export function api(server: Server, options: ApiOptions) {
323323
}
324324
});
325325

326+
server.use('/cms/api/ai.diff', async (req: Request, res: Response) => {
327+
if (
328+
req.method !== 'POST' ||
329+
!String(req.get('content-type')).startsWith('application/json')
330+
) {
331+
res.status(400).json({success: false, error: 'BAD_REQUEST'});
332+
return;
333+
}
334+
335+
if (!req.user?.email) {
336+
res.status(401).json({success: false, error: 'UNAUTHORIZED'});
337+
return;
338+
}
339+
340+
const reqBody = req.body || {};
341+
if (!Object.prototype.hasOwnProperty.call(reqBody, 'after')) {
342+
res.status(400).json({
343+
success: false,
344+
error: 'MISSING_REQUIRED_FIELD',
345+
field: 'after',
346+
});
347+
return;
348+
}
349+
350+
try {
351+
const cmsClient = new RootCMSClient(req.rootConfig!);
352+
const summary = await summarizeDiff(cmsClient, {
353+
before: reqBody.before ?? null,
354+
after: reqBody.after ?? null,
355+
});
356+
res.status(200).json({success: true, summary});
357+
} catch (err: any) {
358+
console.error(err.stack || err);
359+
res.status(500).json({success: false, error: 'UNKNOWN'});
360+
}
361+
});
362+
326363
server.use('/cms/api/ai.list_chats', async (req: Request, res: Response) => {
327364
if (
328365
req.method !== 'POST' ||

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@
8888
margin-top: 24px;
8989
}
9090

91+
.PublishDocModal__ShowChanges + .PublishDocModal__ShowChanges {
92+
margin-top: 12px;
93+
}
94+
95+
.PublishDocModal__ShowChanges__loading {
96+
padding: 12px 16px 24px;
97+
}
98+
9199
.PublishDocModal__ShowChanges .mantine-Accordion-control[aria-expanded="true"] {
92100
background: #fff;
93101
}
@@ -100,3 +108,10 @@
100108
.PublishDocModal__ShowChanges .mantine-Accordion-contentInner {
101109
padding: 0;
102110
}
111+
112+
.PublishDocModal__ShowChanges__aiSummary {
113+
padding: 12px 16px 24px;
114+
white-space: pre-wrap;
115+
font-family: var(--font-family-mono);
116+
color: black;
117+
}

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

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {showNotification} from '@mantine/notifications';
44
import {useState, useRef} from 'preact/hooks';
55
import {useModalTheme} from '../../hooks/useModalTheme.js';
66
import {joinClassNames} from '../../utils/classes.js';
7-
import {cmsPublishDoc, cmsScheduleDoc} from '../../utils/doc.js';
7+
import {
8+
cmsGetDocDiffSummary,
9+
cmsPublishDoc,
10+
cmsScheduleDoc,
11+
} from '../../utils/doc.js';
812
import {getLocalISOString} from '../../utils/time.js';
913
import {DocDiffViewer} from '../DocDiffViewer/DocDiffViewer.js';
1014
import {Text} from '../Text/Text.js';
@@ -45,6 +49,7 @@ export function PublishDocModal(
4549
const dateTimeRef = useRef<HTMLInputElement>(null);
4650
const modals = useModals();
4751
const modalTheme = useModalTheme();
52+
const experiments = window.__ROOT_CTX.experiments || {};
4853

4954
const buttonLabel = publishType === 'scheduled' ? 'Schedule' : 'Publish';
5055

@@ -228,12 +233,98 @@ export function PublishDocModal(
228233
</div>
229234
</form>
230235

236+
{experiments.ai && <AiSummary docId={props.docId} />}
231237
<ShowChanges docId={props.docId} />
232238
</div>
233239
</div>
234240
);
235241
}
236242

243+
function AiSummary(props: {docId: string}) {
244+
const docId = props.docId;
245+
const [status, setStatus] = useState<
246+
'idle' | 'loading' | 'success' | 'error'
247+
>('idle');
248+
const [summary, setSummary] = useState('');
249+
const [error, setError] = useState('');
250+
const hasRequestedRef = useRef(false);
251+
252+
async function loadSummary() {
253+
setStatus('loading');
254+
try {
255+
const res = await cmsGetDocDiffSummary(docId);
256+
setSummary(res);
257+
setStatus('success');
258+
} catch (err) {
259+
console.error(err);
260+
setError(err instanceof Error ? err.message : 'Unknown error');
261+
setStatus('error');
262+
}
263+
}
264+
265+
function handleToggle() {
266+
if (!hasRequestedRef.current) {
267+
hasRequestedRef.current = true;
268+
loadSummary();
269+
}
270+
}
271+
272+
let content = null;
273+
if (status === 'idle' || status === 'loading') {
274+
content = (
275+
<div className="PublishDocModal__ShowChanges__loading">
276+
<Loader size="md" color="gray" />
277+
</div>
278+
);
279+
} else if (status === 'error') {
280+
content = (
281+
<Text
282+
className="PublishDocModal__ShowChanges__aiSummary"
283+
size="body-sm"
284+
color="gray"
285+
>
286+
Failed to load AI summary.
287+
{error && (
288+
<>
289+
<br />
290+
{error}
291+
</>
292+
)}
293+
</Text>
294+
);
295+
} else if (!summary) {
296+
content = (
297+
<Text
298+
className="PublishDocModal__ShowChanges__aiSummary"
299+
size="body-sm"
300+
color="gray"
301+
>
302+
No AI summary available for this draft yet.
303+
</Text>
304+
);
305+
} else {
306+
content = (
307+
<Text
308+
className="PublishDocModal__ShowChanges__aiSummary"
309+
as="pre"
310+
size="body-sm"
311+
>
312+
{summary}
313+
</Text>
314+
);
315+
}
316+
317+
return (
318+
<div className="PublishDocModal__ShowChanges">
319+
<Accordion iconPosition="right" onChange={() => handleToggle()}>
320+
<Accordion.Item label="Summarize changes (AI)">
321+
{content}
322+
</Accordion.Item>
323+
</Accordion>
324+
</div>
325+
);
326+
}
327+
237328
function ShowChanges(props: {docId: string}) {
238329
const docId = props.docId;
239330
const [toggled, setToggled] = useState(false);
@@ -245,15 +336,17 @@ function ShowChanges(props: {docId: string}) {
245336
return (
246337
<div className="PublishDocModal__ShowChanges">
247338
<Accordion iconPosition="right" onChange={() => toggle()}>
248-
<Accordion.Item label="Show changes">
339+
<Accordion.Item label="Show changes (JSON)">
249340
{toggled ? (
250341
<DocDiffViewer
251342
left={{docId, versionId: 'published'}}
252343
right={{docId, versionId: 'draft'}}
253344
showExpandButton={true}
254345
/>
255346
) : (
256-
<Loader />
347+
<div className="PublishDocModal__ShowChanges__loading">
348+
<Loader size="md" color="gray" />
349+
</div>
257350
)}
258351
</Accordion.Item>
259352
</Accordion>

packages/root-cms/ui/utils/doc.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,57 @@ export function unmarshalArray(arrObject: ArrayObject): any[] {
904904
return arr;
905905
}
906906

907+
export async function cmsGetDocDiffSummary(docId: string): Promise<string> {
908+
const [publishedDoc, draftDoc] = await Promise.all([
909+
cmsReadDocVersion(docId, 'published'),
910+
cmsReadDocVersion(docId, 'draft'),
911+
]);
912+
913+
if (!draftDoc && !publishedDoc) {
914+
return '';
915+
}
916+
917+
const payload = {
918+
before: publishedDoc
919+
? unmarshalData(publishedDoc.fields || {}, {removeArrayKey: true})
920+
: null,
921+
after: draftDoc
922+
? unmarshalData(draftDoc.fields || {}, {removeArrayKey: true})
923+
: null,
924+
};
925+
926+
const res = await window.fetch('/cms/api/ai.diff', {
927+
method: 'POST',
928+
headers: {'content-type': 'application/json'},
929+
body: JSON.stringify(payload),
930+
});
931+
932+
const responseText = await res.text();
933+
let resData: any = null;
934+
try {
935+
resData = JSON.parse(responseText);
936+
} catch (err) {
937+
// Ignore JSON parsing errors and fall back to the response text below.
938+
}
939+
940+
if (!res.ok || resData?.success === false) {
941+
const errorMessage =
942+
(resData && (resData.error || resData.message)) || responseText;
943+
throw new Error(errorMessage || 'Failed to fetch AI summary');
944+
}
945+
946+
if (typeof resData?.summary === 'string') {
947+
return resData.summary;
948+
}
949+
if (typeof resData?.data?.summary === 'string') {
950+
return resData.data.summary;
951+
}
952+
if (!resData && responseText) {
953+
return responseText;
954+
}
955+
return '';
956+
}
957+
907958
function isObject(data: any): boolean {
908959
return typeof data === 'object' && !Array.isArray(data) && data !== null;
909960
}

0 commit comments

Comments
 (0)