Wire up page-level AI streaming

This commit is contained in:
Sam Becker 2024-03-20 23:05:21 -05:00
parent 1371a8dcc4
commit e2e8c8edda
8 changed files with 157 additions and 207 deletions

View File

@ -12,6 +12,9 @@ import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions'; import { getExifDataAction } from './actions';
import { Tags } from '@/tag'; import { Tags } from '@/tag';
import { useState } from 'react'; import { useState } from 'react';
import useImageQueries from './ai/useImageQueries';
import { HiSparkles } from 'react-icons/hi';
import Spinner from '@/components/Spinner';
export default function PhotoEditPageClient({ export default function PhotoEditPageClient({
photo, photo,
@ -37,6 +40,8 @@ export default function PhotoEditPageClient({
seedExifData, seedExifData,
); );
const aiContent = useImageQueries();
return ( return (
<AdminChildPage <AdminChildPage
backPath={PATH_ADMIN_PHOTOS} backPath={PATH_ADMIN_PHOTOS}
@ -45,16 +50,27 @@ export default function PhotoEditPageClient({
? updatedTitle ? updatedTitle
: photo.title || photo.id} : photo.title || photo.id}
accessory={ accessory={
<form action={action}> <div className="flex gap-2">
<input name="photoUrl" value={photo.url} hidden readOnly /> <button
<SubmitButtonWithStatus className="min-w-[3.25rem] flex justify-center"
icon={<IconGrSync onClick={aiContent.request}
className="translate-y-[-1px] sm:mr-[4px]" disabled={!aiContent.isReady || aiContent.isLoading}
/>}
> >
EXIF {aiContent.isLoading
</SubmitButtonWithStatus> ? <Spinner />
</form>} : <HiSparkles size={16} />}
</button>
<form action={action}>
<input name="photoUrl" value={photo.url} hidden readOnly />
<SubmitButtonWithStatus
icon={<IconGrSync
className="translate-y-[-1px] sm:mr-[4px]"
/>}
>
EXIF
</SubmitButtonWithStatus>
</form>
</div>}
isLoading={pending} isLoading={pending}
> >
<PhotoForm <PhotoForm
@ -64,7 +80,7 @@ export default function PhotoEditPageClient({
? updatedExifData ? updatedExifData
: undefined} : undefined}
uniqueTags={uniqueTags} uniqueTags={uniqueTags}
aiTextGeneration={aiTextGeneration} aiContent={aiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle} onTitleChange={setUpdatedTitle}
onFormStatusChange={setIsPending} onFormStatusChange={setIsPending}
/> />

View File

@ -34,7 +34,7 @@ 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 { ImageQuery, streamImageQuery } from './ai'; import { AiImageQuery, streamAiImageQuery } from './ai';
export async function createPhotoAction(formData: FormData) { export async function createPhotoAction(formData: FormData) {
return safelyRunAdminServerAction(async () => { return safelyRunAdminServerAction(async () => {
@ -183,10 +183,10 @@ export async function syncCacheAction() {
return safelyRunAdminServerAction(revalidateAllKeysAndPaths); return safelyRunAdminServerAction(revalidateAllKeysAndPaths);
} }
export async function streamImageQueryAction( export async function streamAiImageQueryAction(
imageBase64: string, imageBase64: string,
query: ImageQuery, query: AiImageQuery,
) { ) {
return safelyRunAdminServerAction(async () => return safelyRunAdminServerAction(async () =>
streamImageQuery(imageBase64, query)); streamAiImageQuery(imageBase64, query));
} }

View File

@ -1,29 +1,27 @@
/* eslint-disable max-len */
import { streamOpenAiImageQuery } from '@/services/openai'; import { streamOpenAiImageQuery } from '@/services/openai';
export type ImageQuery = export type AiImageQuery =
'title' | 'title' |
'caption' | 'caption' |
'title-and-caption' |
'tags' | 'tags' |
'descriptionSmall' | 'description-small' |
'descriptionMedium' | 'description' |
'descriptionLarge' | 'description-large' |
'rich' |
'semantic'; 'semantic';
export const IMAGE_QUERIES: Record<ImageQuery, string> = { export const AI_IMAGE_QUERIES: Record<AiImageQuery, string> = {
// title: 'Provide a short title for this image', 'title': 'Provide a short title for this image in 3 words or less',
title: 'Provide a short title for this image in 3 words or less', 'caption': 'What is a pithy caption for this image in 8 words or less?',
caption: 'What is a pithy caption for this image in 8 words or less?', 'title-and-caption': 'Write a short title and pithy caption of 8 words or less for this image, using the format Title: "title" Caption: "caption"',
// eslint-disable-next-line max-len 'tags': 'Describe this image three or less comma-separated keywords with no adjective or adverbs',
tags: 'Describe this image three or less comma-separated keywords with no adjective or adverbs', 'description-small': 'Describe this image succinctly',
descriptionSmall: 'Describe this image succinctly', 'description': 'Describe this image',
descriptionMedium: 'Describe this image', 'description-large': 'Describe this image in detail',
descriptionLarge: 'Describe this image in detail', 'semantic': 'List up to 5 things in this image without description as a comma-separated list',
// eslint-disable-next-line max-len
rich: 'What is a short title and pithy caption of 8 words or less for this image?',
// eslint-disable-next-line max-len
semantic: 'List up to 5 things in this image without description as a comma-separated list',
}; };
export const streamImageQuery = (imageBase64: string, query: ImageQuery) => export const streamAiImageQuery = (imageBase64: string, query: AiImageQuery) =>
streamOpenAiImageQuery(imageBase64, IMAGE_QUERIES[query]); streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]);

View File

@ -0,0 +1,55 @@
import { useCallback, useState } from 'react';
import useImageQuery from './useImageQuery';
import useTitleCaptionImageQuery from './useTitleCaptionImageQuery';
export type AiContent = ReturnType<typeof useImageQueries>;
export default function useImageQueries() {
const [imageData, setImageData] = useState<string>();
const isReady = Boolean(imageData);
const [
requestTitleCaption,
title,
caption,
isLoadingTitleCaption,
] = useTitleCaptionImageQuery(imageData);
const [
requestTags,
tags,
isLoadingTags,
] = useImageQuery(imageData, 'tags');
const [
requestSemantic,
semantic,
isLoadingSemantic,
] = useImageQuery(imageData, 'semantic');
const isLoading = isLoadingTitleCaption || isLoadingTags || isLoadingSemantic;
const request = useCallback(async () => {
if (!isLoading) {
console.log('REQUESTING ALL IMAGE QUERIES');
requestTitleCaption();
requestTags();
requestSemantic();
}
}, [isLoading, requestTitleCaption, requestTags, requestSemantic]);
return {
request,
title,
caption,
tags,
semantic,
isReady,
isLoading,
isLoadingTitleCaption,
isLoadingTags,
isLoadingSemantic,
setImageData,
};
}

View File

@ -1,11 +1,11 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { streamImageQueryAction } from '../actions'; import { streamAiImageQueryAction } from '../actions';
import { readStreamableValue } from 'ai/rsc'; import { readStreamableValue } from 'ai/rsc';
import { ImageQuery } from '.'; import { AiImageQuery } from '.';
export default function useImageQuery( export default function useImageQuery(
imageBase64: string | undefined, imageBase64: string | undefined,
query: ImageQuery, query: AiImageQuery,
) { ) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [error, setError] = useState<any>(); const [error, setError] = useState<any>();
@ -15,12 +15,12 @@ export default function useImageQuery(
if (imageBase64) { if (imageBase64) {
setIsLoading(true); setIsLoading(true);
try { try {
const textStream = await streamImageQueryAction( const textStream = await streamAiImageQueryAction(
imageBase64, imageBase64,
query, query,
); );
for await (const text of readStreamableValue(textStream)) { for await (const text of readStreamableValue(textStream)) {
setText(text ?? ''); setText((text ?? '').replaceAll('\n', ' '));
} }
setIsLoading(false); setIsLoading(false);
} catch (e) { } catch (e) {

View File

@ -0,0 +1,32 @@
import { useMemo } from 'react';
import useImageQuery from './useImageQuery';
export default function useTitleCaptionImageQuery(
imageBase64: string | undefined,
) {
const [
request,
text,
isLoading,
error,
] = useImageQuery(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]);
return [
request,
title,
caption,
isLoading,
error,
] as const;
}

View File

@ -25,8 +25,7 @@ import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config'; import { BLUR_ENABLED } from '@/site/config';
import { Tags, sortTagsObjectWithoutFavs } from '@/tag'; import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string'; import { formatCount, formatCountDescriptive } from '@/utility/string';
import Spinner from '@/components/Spinner'; import { AiContent } from '../ai/useImageQueries';
import useImageQuery from '../ai/useImageQuery';
const THUMBNAIL_SIZE = 300; const THUMBNAIL_SIZE = 300;
@ -35,7 +34,7 @@ export default function PhotoForm({
updatedExifData, updatedExifData,
type = 'create', type = 'create',
uniqueTags, uniqueTags,
aiTextGeneration, aiContent,
debugBlur, debugBlur,
onTitleChange, onTitleChange,
onFormStatusChange, onFormStatusChange,
@ -44,7 +43,8 @@ export default function PhotoForm({
updatedExifData?: Partial<PhotoFormData> updatedExifData?: Partial<PhotoFormData>
type?: 'create' | 'edit' type?: 'create' | 'edit'
uniqueTags?: Tags uniqueTags?: Tags
aiTextGeneration?: boolean aiContent?: AiContent
setImageData?: (imageData: string) => void
debugBlur?: boolean debugBlur?: boolean
onTitleChange?: (updatedTitle: string) => void onTitleChange?: (updatedTitle: string) => void
onFormStatusChange?: (pending: boolean) => void onFormStatusChange?: (pending: boolean) => void
@ -55,8 +55,6 @@ export default function PhotoForm({
useState(getFormErrors(initialPhotoForm)); useState(getFormErrors(initialPhotoForm));
const [blurError, setBlurError] = const [blurError, setBlurError] =
useState<string>(); useState<string>();
const [imageData, setImageData] =
useState<string>();
// Update form when EXIF data // Update form when EXIF data
// is refreshed by parent // is refreshed by parent
@ -122,123 +120,12 @@ export default function PhotoForm({
} }
}, []); }, []);
// const [
// requestTitle,
// title,
// isLoadingTitle,
// errorTitle,
// ] = useImageQuery(imageData, 'title');
// const [
// requestCaption,
// caption,
// isLoadingCaption,
// errorCaption,
// ] = useImageQuery(imageData, 'caption');
const [
requestTags,
tags,
isLoadingTags,
errorTags,
] = useImageQuery(imageData, 'tags');
const [
requestRich,
rich,
isLoadingRich,
errorRich,
] = useImageQuery(imageData, 'rich');
// const [
// requestDescriptionSmall,
// descriptionSmall,
// isLoadingDescriptionSmall,
// errorDescriptionSmall,
// ] = useImageQuery(imageData, 'descriptionSmall');
const [
requestSemantic,
semantic,
isLoadingSemantic,
errorSemantic,
] = useImageQuery(imageData, 'semantic');
const renderAiButton = (
label: string,
onClick: () => void,
isLoading: boolean,
error?: any,
) =>
<button
onClick={onClick}
disabled={!imageData || isLoading}
className={clsx(
'flex gap-2 items-center justify-center',
'disabled:opacity-50 text-sm px-2.5 min-h-0 py-1.5',
Boolean(error) && 'error text-error',
)}
>
<span>
{label}
</span>
<span className="min-w-4">
{isLoading
? <Spinner className="translate-y-[1.5px]" />
: <></>}
</span>
</button>;
return ( return (
<div className="space-y-8 max-w-[38rem]"> <div className="space-y-8 max-w-[38rem]">
{blurError && {debugBlur && blurError &&
<div className="border error text-error rounded-md px-2 py-1"> <div className="border error text-error rounded-md px-2 py-1">
{blurError} {blurError}
</div>} </div>}
<div className="flex gap-2 flex-wrap">
{/* {renderAiButton(
'Title',
requestTitle,
isLoadingTitle,
errorTitle,
)}
{renderAiButton(
'Caption',
requestCaption,
isLoadingCaption,
errorCaption,
)}
{renderAiButton(
'Tags',
requestTags,
isLoadingTags,
errorTags,
)} */}
{renderAiButton(
'Rich',
requestRich,
isLoadingRich,
errorRich,
)}
{renderAiButton(
'Tags',
requestTags,
isLoadingTags,
errorTags,
)}
{renderAiButton(
'Semantic',
requestSemantic,
isLoadingSemantic,
errorSemantic,
)}
{/* {renderAiButton(
'Description',
requestDescriptionSmall,
isLoadingDescriptionSmall,
errorDescriptionSmall,
)} */}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<ImageBlurFallback <ImageBlurFallback
alt="Upload" alt="Upload"
@ -255,7 +142,7 @@ export default function PhotoForm({
imageUrl={url} imageUrl={url}
width={width} width={width}
height={height} height={height}
onLoad={setImageData} onLoad={aiContent?.setImageData}
onCapture={updateBlurData} onCapture={updateBlurData}
onError={setBlurError} onError={setBlurError}
/> />
@ -271,48 +158,10 @@ export default function PhotoForm({
height={height} height={height}
/>} />}
</div> </div>
{/* <p> <div>Title: {aiContent?.title}</div>
TITLE: {title} {isLoadingTitle && <> <div>Caption: {aiContent?.caption}</div>
<span className="inline-flex translate-y-[1.5px]"> <div>Tags: {aiContent?.tags}</div>
<Spinner /> <div>Semantic: {aiContent?.semantic}</div>
</span>
</>}
</p>
<p>
CAPTION: {caption} {isLoadingCaption && <>
<span className="inline-flex translate-y-[1.5px]">
<Spinner />
</span>
</>}
</p> */}
<p>
RICH: {rich} {isLoadingRich && <>
<span className="inline-flex translate-y-[1.5px]">
<Spinner />
</span>
</>}
</p>
<p>
TAGS: {tags} {isLoadingTags && <>
<span className="inline-flex translate-y-[1.5px]">
<Spinner />
</span>
</>}
</p>
<p>
SEMANTIC: {semantic} {isLoadingSemantic && <>
<span className="inline-flex translate-y-[1.5px]">
<Spinner />
</span>
</>}
</p>
{/* <p>
DESCRIPTION: {descriptionSmall} {isLoadingDescriptionSmall && <>
<span className="inline-flex translate-y-[1.5px]">
<Spinner />
</span>
</>}
</p> */}
<form <form
action={type === 'create' ? createPhotoAction : updatePhotoAction} action={type === 'create' ? createPhotoAction : updatePhotoAction}
onSubmit={() => blur()} onSubmit={() => blur()}
@ -325,7 +174,7 @@ export default function PhotoForm({
annotation: formatCount(count), annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'), annotationAria: formatCountDescriptive(count, 'tagged'),
})), })),
aiTextGeneration, aiContent !== undefined,
) )
.map(([key, { .map(([key, {
label, label,

View File

@ -72,12 +72,6 @@ const FORM_METADATA = (
validateStringMaxLength: STRING_MAX_LENGTH_LONG, validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: ({ title, caption }) => !title && !caption, shouldHide: ({ title, caption }) => !title && !caption,
}, },
semanticDescription: {
label: 'semantic description',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: !aiTextGeneration,
},
tags: { tags: {
label: 'tags', label: 'tags',
tagOptions, tagOptions,
@ -85,6 +79,12 @@ const FORM_METADATA = (
? `'${TAG_FAVS}' is a reserved tag` ? `'${TAG_FAVS}' is a reserved tag`
: undefined, : undefined,
}, },
semanticDescription: {
label: 'semantic description',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: !aiTextGeneration,
},
id: { label: 'id', readOnly: true, hideIfEmpty: true }, id: { label: 'id', readOnly: true, hideIfEmpty: true },
blurData: { blurData: {
label: 'blur data', label: 'blur data',