Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions apps/google-docs/contentful-app-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,6 @@
"appaction.call"
]
},
{
"id": "analyzeContentTypes",
"name": "Analyze Content Types",
"description": "Analyzes content type structure and relationships using AI.",
"path": "functions/handlers/createPreview/createContentTypesAnalysisHandler.js",
"entryFile": "functions/handlers/createPreview/createContentTypesAnalysisHandler.ts",
"allowNetworks": [
"https://api.openai.com"
],
"accepts": [
"appaction.call"
]
},
{
"id": "initiateGdocOauth",
"name": "Initiate Gdoc OAuth Flow",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ import { createOpenAI } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import { ContentTypeProps } from 'contentful-management';
import { FinalEntriesResultSchema, FinalEntriesResult } from './schema';
import { fetchGoogleDocAsJson } from '../../service/googleDriveService';

/**
* Configuration for the document parser
*/
export interface DocumentParserConfig {
documentId: string;
oauthToken: string;
documentJson: unknown; // Google Doc JSON (fetched from frontend)
contentTypes: ContentTypeProps[];
openAiApiKey: string;
locale?: string;
Expand All @@ -40,25 +38,26 @@ export async function createPreviewWithAgent(
const modelVersion = 'gpt-4o';
const temperature = 0.3;

const { documentId, oauthToken, openAiApiKey, contentTypes, locale = 'en-US' } = config;
const { documentJson, openAiApiKey, contentTypes, locale = 'en-US' } = config;

const openaiClient = createOpenAI({
apiKey: openAiApiKey,
});

console.log('Document Parser Agent document content Input:', documentId);
const documentJson = await fetchGoogleDocAsJson({ documentId, oauthToken });
const prompt = buildExtractionPrompt({ contentTypes, documentJson, locale });

const aiStartTime = Date.now();
const result = await generateObject({
model: openaiClient(modelVersion),
schema: FinalEntriesResultSchema,
temperature,
system: buildSystemPrompt(),
prompt,
});
const aiDuration = Date.now() - aiStartTime;
console.log(`[Timing] AI generateObject: ${aiDuration}ms`);

const finalResult = result.object as FinalEntriesResult;
console.log('Document Parser Agent Result:', JSON.stringify(result, null, 2));

return finalResult;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ import { createPreviewWithAgent } from '../../agents/documentParserAgent/documen
import { fetchContentTypes } from '../../service/contentTypeService';
import { initContentfulManagementClient } from '../../service/initCMAClient';

export type CreatePreviewParameters = {
export type AppActionParameters = {
contentTypeIds: string[];
documentId: string;
oauthToken: string;
documentJson: unknown; // Google Doc JSON (fetched from frontend)
};

interface AppInstallationParameters {
Expand All @@ -27,34 +26,37 @@ interface AppInstallationParameters {
*/
export const handler: FunctionEventHandler<
FunctionTypeEnum.AppActionCall,
CreatePreviewParameters
AppActionParameters
> = async (
event: AppActionRequest<'Custom', CreatePreviewParameters>,
event: AppActionRequest<'Custom', AppActionParameters>,
context: FunctionEventContext
) => {
const { contentTypeIds, documentId, oauthToken } = event.body;
const { contentTypeIds, documentJson } = event.body;
const { openAiApiKey } = context.appInstallationParameters as AppInstallationParameters;
if (!documentJson) {
throw new Error('Document JSON is required');
}

if (!contentTypeIds || contentTypeIds.length === 0) {
throw new Error('At least one content type ID is required');
}

const cma = initContentfulManagementClient(context);

const contentTypes = await fetchContentTypes(cma, new Set<string>(contentTypeIds));

// Process the document and create preview entries
// Use the same AI agent to analyze the document and generate proposed entries
const aiDocumentResponse = await createPreviewWithAgent({
documentId,
oauthToken,
documentJson,
openAiApiKey,
contentTypes,
});

return {
success: true,
summary: aiDocumentResponse.summary,
totalEntriesExtracted: aiDocumentResponse.totalEntries,
entries: aiDocumentResponse.entries,
response: {
entries: aiDocumentResponse.entries,
summary: aiDocumentResponse.summary,
totalEntries: aiDocumentResponse.totalEntries,
},
};
};
22 changes: 11 additions & 11 deletions apps/google-docs/src/components/page/ContentTypePickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface ContentTypePickerModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (contentTypes: SelectedContentType[]) => void;
isSubmitting: boolean;
isGeneratingPlan: boolean;
selectedContentTypes: SelectedContentType[];
setSelectedContentTypes: (
contentTypes: SelectedContentType[] | ((prev: SelectedContentType[]) => SelectedContentType[])
Expand All @@ -34,7 +34,7 @@ export const ContentTypePickerModal = ({
isOpen,
onClose,
onSelect,
isSubmitting,
isGeneratingPlan,
selectedContentTypes,
setSelectedContentTypes,
}: ContentTypePickerModalProps) => {
Expand Down Expand Up @@ -88,7 +88,7 @@ export const ContentTypePickerModal = ({
}, [isOpen]);

const handleAddContentType = (contentTypeId: string) => {
if (!contentTypeId || isSubmitting) return;
if (!contentTypeId || isGeneratingPlan) return;

const contentType = contentTypes.find((ct) => ct.sys.id === contentTypeId);
if (contentType && !selectedContentTypes.some((ct) => ct.id === contentTypeId)) {
Expand All @@ -100,12 +100,12 @@ export const ContentTypePickerModal = ({
};

const handleRemoveContentType = (contentTypeId: string) => {
if (isSubmitting) return;
if (isGeneratingPlan) return;
setSelectedContentTypes(selectedContentTypes.filter((ct) => ct.id !== contentTypeId));
};

const handleClose = () => {
if (isSubmitting) return; // Prevent closing during submission
if (isGeneratingPlan) return; // Prevent closing during plan generation
onClose();
};

Expand Down Expand Up @@ -144,7 +144,7 @@ export const ContentTypePickerModal = ({
onChange={(e) => {
handleAddContentType(e.target.value);
}}
isDisabled={isLoading || availableContentTypes.length === 0 || isSubmitting}>
isDisabled={isLoading || availableContentTypes.length === 0 || isGeneratingPlan}>
<Select.Option value="" isDisabled>
{isLoading ? 'Loading content types...' : 'Select one or more'}
</Select.Option>
Expand Down Expand Up @@ -172,22 +172,22 @@ export const ContentTypePickerModal = ({
<Pill
key={ct.id}
label={ct.name}
onClose={isSubmitting ? undefined : () => handleRemoveContentType(ct.id)}
onClose={isGeneratingPlan ? undefined : () => handleRemoveContentType(ct.id)}
/>
))}
</Flex>
)}
</Modal.Content>
<Modal.Controls>
<Button onClick={handleClose} variant="secondary" isDisabled={isSubmitting}>
<Button onClick={handleClose} variant="secondary" isDisabled={isGeneratingPlan}>
Cancel
</Button>
<Button
onClick={handleContinue}
variant="primary"
isDisabled={isLoading || isSubmitting}
endIcon={isSubmitting ? <Spinner /> : undefined}>
Next
isDisabled={isLoading || isGeneratingPlan}
endIcon={isGeneratingPlan ? <Spinner /> : undefined}>
Continue
</Button>
</Modal.Controls>
</>
Expand Down
29 changes: 29 additions & 0 deletions apps/google-docs/src/components/page/LoadingModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Modal, Paragraph, Spinner, Flex } from '@contentful/f36-components';

interface LoadingModalProps {
isOpen: boolean;
message?: string;
}

export const LoadingModal = ({ isOpen, message = 'Processing...' }: LoadingModalProps) => {
return (
<Modal
isShown={isOpen}
onClose={() => {}}
size="medium"
shouldCloseOnOverlayClick={false}
shouldCloseOnEscapePress={false}>
{() => (
<>
<Modal.Header title="Processing" />
<Modal.Content>
<Flex alignItems="center" gap="spacingM">
<Spinner size="small" />
<Paragraph marginBottom="none">{message}</Paragraph>
</Flex>
</Modal.Content>
</>
)}
</Modal>
);
};
124 changes: 124 additions & 0 deletions apps/google-docs/src/components/page/PreviewModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Button, Modal, Paragraph, Box, Flex, Text, Card } from '@contentful/f36-components';
import tokens from '@contentful/f36-tokens';
import { EntryToCreate } from '../../../functions/agents/documentParserAgent/schema';
import { SelectedContentType } from './ContentTypePickerModal';
import { PageAppSDK } from '@contentful/app-sdk';

interface PreviewData {
summary: string;
totalEntries: number;
entries: EntryToCreate[];
}

interface PreviewModalProps {
sdk: PageAppSDK;
isOpen: boolean;
onClose: () => void;
onCreateEntries: (contentTypes: SelectedContentType[]) => void;
preview: PreviewData | null;
isCreatingEntries: boolean;
isLoading: boolean;
}

export const PreviewModal = ({
sdk,
isOpen,
isCreatingEntries,
onClose,
onCreateEntries,
preview,
isLoading,
}: PreviewModalProps) => {
const handleClose = () => {
if (!isLoading && !isCreatingEntries) {
onClose();
}
};

if (!preview) {
return null;
}

const entries = preview.entries || [];
const totalEntries = preview.totalEntries ?? entries.length;

return (
<Modal
title="Create entries"
isShown={isOpen}
onClose={handleClose}
size="large"
shouldCloseOnOverlayClick={!isLoading && !isCreatingEntries}
shouldCloseOnEscapePress={!isLoading && !isCreatingEntries}>
{() => (
<>
<Modal.Header title="Create entries" onClose={handleClose} />
<Modal.Content>
<Paragraph marginBottom="spacingM" color="gray700">
{preview.summary ||
`Based off the document, ${totalEntries} ${
totalEntries === 1 ? 'entry is' : 'entries are'
} being suggested:`}
</Paragraph>

<Box marginBottom="spacingM">
{entries.length > 0 ? (
<Box>
{entries.map((entry, index) => {
const title = entry.fields.displayField?.[sdk.locales.default] || '';
return (
<Card
key={index}
padding="default"
marginBottom="spacingS"
style={{ padding: `${tokens.spacingS} ${tokens.spacingM}` }}>
<Flex alignItems="center">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text
as="span"
fontWeight="fontWeightMedium"
fontSize="fontSizeM"
style={{ color: tokens.gray900 }}>
{title.length > 60 ? title.substring(0, 60) + '...' : title}
</Text>
<Text
as="span"
fontWeight="fontWeightNormal"
fontSize="fontSizeM"
style={{ color: tokens.gray500, marginLeft: tokens.spacingXs }}>
({entry.contentTypeId || 'unknown'})
</Text>
</Box>
</Flex>
</Card>
);
})}
</Box>
) : (
<Paragraph color="gray600">No entries found</Paragraph>
)}
</Box>
</Modal.Content>
<Modal.Controls>
<Button
onClick={handleClose}
variant="secondary"
isDisabled={isLoading || isCreatingEntries}>
Cancel
</Button>
<Button
onClick={() =>
onCreateEntries(
entries.map((entry) => ({ id: entry.contentTypeId } as SelectedContentType))
)
}
variant="primary"
isDisabled={isLoading || entries.length === 0}>
Create entries
</Button>
</Modal.Controls>
</>
)}
</Modal>
);
};
Loading