Refine ai text generation form behavior

This commit is contained in:
Sam Becker 2024-03-21 16:05:13 -05:00
parent 5a0e372e39
commit 28f6310fe1
9 changed files with 112 additions and 43 deletions

42
__tests__/ai.test.ts Normal file
View File

@ -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',
});
});
});

View File

@ -48,7 +48,7 @@ export default function PhotoEditPageClient({
hasTextContent, hasTextContent,
setHasTextContent, setHasTextContent,
aiContent, aiContent,
} = usePhotoFormParent(photoForm); } = usePhotoFormParent({ photoForm });
return ( return (
<AdminChildPage <AdminChildPage

View File

@ -27,7 +27,7 @@ export default function UploadPageClient({
hasTextContent, hasTextContent,
setHasTextContent, setHasTextContent,
aiContent, aiContent,
} = usePhotoFormParent(); } = usePhotoFormParent({ shouldAutoGenerateText: hasAiTextGeneration });
return ( return (
<AdminChildPage <AdminChildPage

View File

@ -34,7 +34,8 @@ import { extractExifDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag'; import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert } from '.'; import { convertPhotoToPhotoDbInsert } from '.';
import { safelyRunAdminServerAction } from '@/auth'; import { safelyRunAdminServerAction } from '@/auth';
import { AiImageQuery, streamAiImageQuery } from './ai'; import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai';
export async function createPhotoAction(formData: FormData) { export async function createPhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => { return safelyRunAdminServerAction(async () => {
@ -188,5 +189,5 @@ export async function streamAiImageQueryAction(
query: AiImageQuery, query: AiImageQuery,
) { ) {
return safelyRunAdminServerAction(async () => return safelyRunAdminServerAction(async () =>
streamAiImageQuery(imageBase64, query)); streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
} }

View File

@ -1,7 +1,5 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import { streamOpenAiImageQuery } from '@/services/openai';
export type AiImageQuery = export type AiImageQuery =
'title' | 'title' |
'caption' | 'caption' |
@ -23,5 +21,13 @@ export const AI_IMAGE_QUERIES: Record<AiImageQuery, string> = {
'description-semantic': 'List up to 5 things in this image without description as a comma-separated list', 'description-semantic': 'List up to 5 things in this image without description as a comma-separated list',
}; };
export const streamAiImageQuery = (imageBase64: string, query: AiImageQuery) => export const parseTitleAndCaption = (text: string) => {
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]); const matches = text.includes('Title')
? text.match(/^[`'"]*Title: ["']*(.*?)["']*[ ]*Caption: ["']*(.*?)\.*["']*[`'"]*$/)
: text.match(/^(.*?): (.*?)$/);
return {
title: matches?.[1] ?? '',
caption: matches?.[2] ?? '',
};
};

View File

@ -1,10 +1,12 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import useAiImageQuery from './useAiImageQuery'; import useAiImageQuery from './useAiImageQuery';
import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery'; import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery';
export type AiContent = ReturnType<typeof useAiImageQueries>; export type AiContent = ReturnType<typeof useAiImageQueries>;
export default function useAiImageQueries() { export default function useAiImageQueries(
shouldAutoGenerateText?: boolean,
) {
const [imageData, setImageData] = useState<string>(); const [imageData, setImageData] = useState<string>();
const isReady = Boolean(imageData); const isReady = Boolean(imageData);
@ -42,13 +44,23 @@ export default function useAiImageQueries() {
isLoadingTags || isLoadingTags ||
isLoadingSemantic; isLoadingSemantic;
const hasRunAllQueriesOnce = useRef(false);
const request = useCallback(async () => { const request = useCallback(async () => {
if (!isLoading) { console.log('RUNNING ALL AI QUERIES');
requestTitleCaption(); hasRunAllQueriesOnce.current = true;
requestTags(); requestTitleCaption();
requestSemantic(); requestTags();
requestSemantic();
}, [requestTitleCaption, requestTags, requestSemantic]);
useEffect(() => {
if (shouldAutoGenerateText && imageData) {
if (!hasRunAllQueriesOnce.current) {
request();
}
} }
}, [isLoading, requestTitleCaption, requestTags, requestSemantic]); }, [shouldAutoGenerateText, imageData, request]);
return { return {
request, request,

View File

@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import useAiImageQuery from './useAiImageQuery'; import useAiImageQuery from './useAiImageQuery';
import { parseTitleAndCaption } from '.';
export default function useTitleCaptionAiImageQuery( export default function useTitleCaptionAiImageQuery(
imageBase64: string | undefined, imageBase64: string | undefined,
@ -11,16 +12,8 @@ export default function useTitleCaptionAiImageQuery(
error, error,
] = useAiImageQuery(imageBase64, 'title-and-caption'); ] = useAiImageQuery(imageBase64, 'title-and-caption');
const { title, caption } = useMemo(() => { const { title, caption } = useMemo(() =>
const matches = text.includes('Title') parseTitleAndCaption(text), [text]);
? text.match(/^[`']*Title: "*(.*?)"* Caption: "*(.*?)\.*"*[`']*$/)
: text.match(/^(.*?): (.*?)$/);
return {
title: matches?.[1] ?? '',
caption: matches?.[2] ?? '',
};
}, [text]);
const isLoadingTitle = isLoading && !caption; const isLoadingTitle = isLoading && !caption;
const isLoadingCaption = isLoading; const isLoadingCaption = isLoading;

View File

@ -123,21 +123,33 @@ export default function PhotoForm({
} }
}, []); }, []);
useEffect(() => setFormData(data => useEffect(() =>
({ ...data, title: aiContent?.title })), setFormData(data => aiContent?.hasContent
[aiContent?.title]); ? { ...data, title: aiContent?.title }
: data),
[aiContent?.title, aiContent?.hasContent]);
useEffect(() => setFormData(data => useEffect(() =>
({ ...data, caption: aiContent?.caption })), setFormData(data => aiContent?.hasContent
[aiContent?.caption]); ? { ...data, caption: aiContent?.caption }
: data),
[aiContent?.caption, aiContent?.hasContent]);
useEffect(() => setFormData(data => useEffect(() =>
({ ...data, tags: aiContent?.tags })), setFormData(data => aiContent?.hasContent
[aiContent?.tags]); ? { ...data, tags: aiContent?.tags }
: data),
[aiContent?.tags, aiContent?.hasContent]);
useEffect(() => setFormData(data => useEffect(() =>
({ ...data, semanticDescription: aiContent?.semanticDescription })), setFormData(data => aiContent?.hasContent
[aiContent?.semanticDescription]); ? { ...data, semanticDescription: aiContent?.semanticDescription }
: data),
[aiContent?.semanticDescription, aiContent?.hasContent]);
useEffect(() => {
onTextContentChange?.(formHasTextContent(formData));
}, [onTextContentChange, formData]);
const isFieldGeneratingAi = (key: keyof PhotoFormData) => { const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
switch (key) { switch (key) {
@ -236,7 +248,6 @@ export default function PhotoForm({
onChange={value => { onChange={value => {
const formUpdated = { ...formData, [key]: value }; const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated); setFormData(formUpdated);
onTextContentChange?.(formHasTextContent(formUpdated));
if (validate) { if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) }); setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) { } else if (validateStringMaxLength !== undefined) {
@ -273,7 +284,7 @@ export default function PhotoForm({
Cancel Cancel
</Link> </Link>
<SubmitButtonWithStatus <SubmitButtonWithStatus
disabled={!isFormValid(formData)} disabled={!isFormValid(formData) || aiContent?.isLoading}
onFormStatusChange={onFormStatusChange} onFormStatusChange={onFormStatusChange}
> >
{type === 'create' ? 'Create' : 'Update'} {type === 'create' ? 'Create' : 'Update'}

View File

@ -2,15 +2,19 @@ import { useState } from 'react';
import { PhotoFormData, formHasTextContent } from '.'; import { PhotoFormData, formHasTextContent } from '.';
import useAiImageQueries from '../ai/useAiImageQueries'; import useAiImageQueries from '../ai/useAiImageQueries';
export default function usePhotoFormParent( export default function usePhotoFormParent({
photoForm?: Partial<PhotoFormData> photoForm,
) { shouldAutoGenerateText,
}: {
photoForm?: Partial<PhotoFormData>,
shouldAutoGenerateText?: boolean,
} = {}) {
const [pending, setIsPending] = useState(false); const [pending, setIsPending] = useState(false);
const [updatedTitle, setUpdatedTitle] = useState(''); const [updatedTitle, setUpdatedTitle] = useState('');
const [hasTextContent, setHasTextContent] = const [hasTextContent, setHasTextContent] =
useState(photoForm ? formHasTextContent(photoForm) : false); useState(photoForm ? formHasTextContent(photoForm) : false);
const aiContent = useAiImageQueries(); const aiContent = useAiImageQueries(shouldAutoGenerateText);
return { return {
pending, pending,