Refine ai text generation form behavior
This commit is contained in:
parent
5a0e372e39
commit
28f6310fe1
42
__tests__/ai.test.ts
Normal file
42
__tests__/ai.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -48,7 +48,7 @@ export default function PhotoEditPageClient({
|
|||||||
hasTextContent,
|
hasTextContent,
|
||||||
setHasTextContent,
|
setHasTextContent,
|
||||||
aiContent,
|
aiContent,
|
||||||
} = usePhotoFormParent(photoForm);
|
} = usePhotoFormParent({ photoForm });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminChildPage
|
<AdminChildPage
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export default function UploadPageClient({
|
|||||||
hasTextContent,
|
hasTextContent,
|
||||||
setHasTextContent,
|
setHasTextContent,
|
||||||
aiContent,
|
aiContent,
|
||||||
} = usePhotoFormParent();
|
} = usePhotoFormParent({ shouldAutoGenerateText: hasAiTextGeneration });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminChildPage
|
<AdminChildPage
|
||||||
|
|||||||
@ -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]));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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] ?? '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user