diff --git a/__tests__/ai.test.ts b/__tests__/ai.test.ts new file mode 100644 index 00000000..b70c93a0 --- /dev/null +++ b/__tests__/ai.test.ts @@ -0,0 +1,42 @@ +/* eslint-disable quotes */ +import { parseTitleAndCaption } from "@/photo/ai"; + +describe('AI text parses', () => { + it('titles and captions', () => { + // Complex case + expect(parseTitleAndCaption( + `'Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight."'` + )).toStrictEqual({ + title: 'Ephemeral Beauty', + caption: 'Roses bask in fleeting sunlight', + }); + // Without surrounding single quotes + expect(parseTitleAndCaption( + `Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight."` + )).toStrictEqual({ + title: 'Ephemeral Beauty', + caption: 'Roses bask in fleeting sunlight', + }); + // Without trailing period + expect(parseTitleAndCaption( + `Title: "Ephemeral Beauty" Caption: "Roses bask in fleeting sunlight"` + )).toStrictEqual({ + title: 'Ephemeral Beauty', + caption: 'Roses bask in fleeting sunlight', + }); + // Without and quotes + expect(parseTitleAndCaption( + `Title: Ephemeral Beauty Caption: Roses bask in fleeting sunlight` + )).toStrictEqual({ + title: 'Ephemeral Beauty', + caption: 'Roses bask in fleeting sunlight', + }); + // With single space + expect(parseTitleAndCaption( + `Title: Ephemeral Beauty Caption: Roses bask in fleeting sunlight` + )).toStrictEqual({ + title: 'Ephemeral Beauty', + caption: 'Roses bask in fleeting sunlight', + }); + }); +}); diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index bae24bf1..353c15ff 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -48,7 +48,7 @@ export default function PhotoEditPageClient({ hasTextContent, setHasTextContent, aiContent, - } = usePhotoFormParent(photoForm); + } = usePhotoFormParent({ photoForm }); return ( { @@ -188,5 +189,5 @@ export async function streamAiImageQueryAction( query: AiImageQuery, ) { return safelyRunAdminServerAction(async () => - streamAiImageQuery(imageBase64, query)); + streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query])); } diff --git a/src/photo/ai/index.ts b/src/photo/ai/index.ts index 2e30881f..ed935deb 100644 --- a/src/photo/ai/index.ts +++ b/src/photo/ai/index.ts @@ -1,7 +1,5 @@ /* eslint-disable max-len */ -import { streamOpenAiImageQuery } from '@/services/openai'; - export type AiImageQuery = 'title' | 'caption' | @@ -23,5 +21,13 @@ export const AI_IMAGE_QUERIES: Record = { 'description-semantic': 'List up to 5 things in this image without description as a comma-separated list', }; -export const streamAiImageQuery = (imageBase64: string, query: AiImageQuery) => - streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]); +export const parseTitleAndCaption = (text: string) => { + const matches = text.includes('Title') + ? text.match(/^[`'"]*Title: ["']*(.*?)["']*[ ]*Caption: ["']*(.*?)\.*["']*[`'"]*$/) + : text.match(/^(.*?): (.*?)$/); + + return { + title: matches?.[1] ?? '', + caption: matches?.[2] ?? '', + }; +}; diff --git a/src/photo/ai/useAiImageQueries.ts b/src/photo/ai/useAiImageQueries.ts index 6346c019..2cc369ae 100644 --- a/src/photo/ai/useAiImageQueries.ts +++ b/src/photo/ai/useAiImageQueries.ts @@ -1,10 +1,12 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import useAiImageQuery from './useAiImageQuery'; import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery'; export type AiContent = ReturnType; -export default function useAiImageQueries() { +export default function useAiImageQueries( + shouldAutoGenerateText?: boolean, +) { const [imageData, setImageData] = useState(); const isReady = Boolean(imageData); @@ -42,13 +44,23 @@ export default function useAiImageQueries() { isLoadingTags || isLoadingSemantic; + const hasRunAllQueriesOnce = useRef(false); + const request = useCallback(async () => { - if (!isLoading) { - requestTitleCaption(); - requestTags(); - requestSemantic(); + console.log('RUNNING ALL AI QUERIES'); + hasRunAllQueriesOnce.current = true; + requestTitleCaption(); + requestTags(); + requestSemantic(); + }, [requestTitleCaption, requestTags, requestSemantic]); + + useEffect(() => { + if (shouldAutoGenerateText && imageData) { + if (!hasRunAllQueriesOnce.current) { + request(); + } } - }, [isLoading, requestTitleCaption, requestTags, requestSemantic]); + }, [shouldAutoGenerateText, imageData, request]); return { request, diff --git a/src/photo/ai/useTitleCaptionAiImageQuery.ts b/src/photo/ai/useTitleCaptionAiImageQuery.ts index 7f4e550a..a02a200e 100644 --- a/src/photo/ai/useTitleCaptionAiImageQuery.ts +++ b/src/photo/ai/useTitleCaptionAiImageQuery.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import useAiImageQuery from './useAiImageQuery'; +import { parseTitleAndCaption } from '.'; export default function useTitleCaptionAiImageQuery( imageBase64: string | undefined, @@ -11,16 +12,8 @@ export default function useTitleCaptionAiImageQuery( error, ] = useAiImageQuery(imageBase64, 'title-and-caption'); - const { title, caption } = useMemo(() => { - const matches = text.includes('Title') - ? text.match(/^[`']*Title: "*(.*?)"* Caption: "*(.*?)\.*"*[`']*$/) - : text.match(/^(.*?): (.*?)$/); - - return { - title: matches?.[1] ?? '', - caption: matches?.[2] ?? '', - }; - }, [text]); + const { title, caption } = useMemo(() => + parseTitleAndCaption(text), [text]); const isLoadingTitle = isLoading && !caption; const isLoadingCaption = isLoading; diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index c2fc7efb..c2813655 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -123,21 +123,33 @@ export default function PhotoForm({ } }, []); - useEffect(() => setFormData(data => - ({ ...data, title: aiContent?.title })), - [aiContent?.title]); + useEffect(() => + setFormData(data => aiContent?.hasContent + ? { ...data, title: aiContent?.title } + : data), + [aiContent?.title, aiContent?.hasContent]); - useEffect(() => setFormData(data => - ({ ...data, caption: aiContent?.caption })), - [aiContent?.caption]); + useEffect(() => + setFormData(data => aiContent?.hasContent + ? { ...data, caption: aiContent?.caption } + : data), + [aiContent?.caption, aiContent?.hasContent]); - useEffect(() => setFormData(data => - ({ ...data, tags: aiContent?.tags })), - [aiContent?.tags]); + useEffect(() => + setFormData(data => aiContent?.hasContent + ? { ...data, tags: aiContent?.tags } + : data), + [aiContent?.tags, aiContent?.hasContent]); - useEffect(() => setFormData(data => - ({ ...data, semanticDescription: aiContent?.semanticDescription })), - [aiContent?.semanticDescription]); + useEffect(() => + setFormData(data => aiContent?.hasContent + ? { ...data, semanticDescription: aiContent?.semanticDescription } + : data), + [aiContent?.semanticDescription, aiContent?.hasContent]); + + useEffect(() => { + onTextContentChange?.(formHasTextContent(formData)); + }, [onTextContentChange, formData]); const isFieldGeneratingAi = (key: keyof PhotoFormData) => { switch (key) { @@ -236,7 +248,6 @@ export default function PhotoForm({ onChange={value => { const formUpdated = { ...formData, [key]: value }; setFormData(formUpdated); - onTextContentChange?.(formHasTextContent(formUpdated)); if (validate) { setFormErrors({ ...formErrors, [key]: validate(value) }); } else if (validateStringMaxLength !== undefined) { @@ -273,7 +284,7 @@ export default function PhotoForm({ Cancel {type === 'create' ? 'Create' : 'Update'} diff --git a/src/photo/form/usePhotoFormParent.ts b/src/photo/form/usePhotoFormParent.ts index 4f2c68bb..559c7938 100644 --- a/src/photo/form/usePhotoFormParent.ts +++ b/src/photo/form/usePhotoFormParent.ts @@ -2,15 +2,19 @@ import { useState } from 'react'; import { PhotoFormData, formHasTextContent } from '.'; import useAiImageQueries from '../ai/useAiImageQueries'; -export default function usePhotoFormParent( - photoForm?: Partial -) { +export default function usePhotoFormParent({ + photoForm, + shouldAutoGenerateText, +}: { + photoForm?: Partial, + shouldAutoGenerateText?: boolean, +} = {}) { const [pending, setIsPending] = useState(false); const [updatedTitle, setUpdatedTitle] = useState(''); const [hasTextContent, setHasTextContent] = useState(photoForm ? formHasTextContent(photoForm) : false); - const aiContent = useAiImageQueries(); + const aiContent = useAiImageQueries(shouldAutoGenerateText); return { pending,