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,
|
||||
setHasTextContent,
|
||||
aiContent,
|
||||
} = usePhotoFormParent(photoForm);
|
||||
} = usePhotoFormParent({ photoForm });
|
||||
|
||||
return (
|
||||
<AdminChildPage
|
||||
|
||||
@ -27,7 +27,7 @@ export default function UploadPageClient({
|
||||
hasTextContent,
|
||||
setHasTextContent,
|
||||
aiContent,
|
||||
} = usePhotoFormParent();
|
||||
} = usePhotoFormParent({ shouldAutoGenerateText: hasAiTextGeneration });
|
||||
|
||||
return (
|
||||
<AdminChildPage
|
||||
|
||||
@ -34,7 +34,8 @@ import { extractExifDataFromBlobPath } from './server';
|
||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
||||
import { convertPhotoToPhotoDbInsert } from '.';
|
||||
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) {
|
||||
return safelyRunAdminServerAction(async () => {
|
||||
@ -188,5 +189,5 @@ export async function streamAiImageQueryAction(
|
||||
query: AiImageQuery,
|
||||
) {
|
||||
return safelyRunAdminServerAction(async () =>
|
||||
streamAiImageQuery(imageBase64, query));
|
||||
streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
|
||||
}
|
||||
|
||||
@ -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<AiImageQuery, string> = {
|
||||
'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] ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
@ -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<typeof useAiImageQueries>;
|
||||
|
||||
export default function useAiImageQueries() {
|
||||
export default function useAiImageQueries(
|
||||
shouldAutoGenerateText?: boolean,
|
||||
) {
|
||||
const [imageData, setImageData] = useState<string>();
|
||||
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
</Link>
|
||||
<SubmitButtonWithStatus
|
||||
disabled={!isFormValid(formData)}
|
||||
disabled={!isFormValid(formData) || aiContent?.isLoading}
|
||||
onFormStatusChange={onFormStatusChange}
|
||||
>
|
||||
{type === 'create' ? 'Create' : 'Update'}
|
||||
|
||||
@ -2,15 +2,19 @@ import { useState } from 'react';
|
||||
import { PhotoFormData, formHasTextContent } from '.';
|
||||
import useAiImageQueries from '../ai/useAiImageQueries';
|
||||
|
||||
export default function usePhotoFormParent(
|
||||
photoForm?: Partial<PhotoFormData>
|
||||
) {
|
||||
export default function usePhotoFormParent({
|
||||
photoForm,
|
||||
shouldAutoGenerateText,
|
||||
}: {
|
||||
photoForm?: Partial<PhotoFormData>,
|
||||
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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user