-
+
{title}
+ {experimental &&
+ }
{children}
diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx
index 105b1049..d0588f29 100644
--- a/src/components/CommandKClient.tsx
+++ b/src/components/CommandKClient.tsx
@@ -27,6 +27,7 @@ export type CommandKSection = {
accessory?: ReactNode
items: {
label: string
+ keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
@@ -157,8 +158,13 @@ export default function CommandKClient({
open={isOpen}
onOpenChange={setIsOpen}
label="Global Command Menu"
- filter={(value, search) =>
- value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
+ filter={(value, search, keywords) => {
+ const searchFormatted = search.trim().toLocaleLowerCase();
+ return (
+ value.toLocaleLowerCase().includes(searchFormatted) ||
+ keywords?.includes(searchFormatted)
+ ) ? 1 : 0 ;
+ }}
loop
>
{items.map(({
- accessory,
label,
+ keywords,
annotation,
annotationAria,
+ accessory,
path,
action,
}) =>
+ Experimental
+
+ );
+}
diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx
index 19e7ab01..3c7bdd7f 100644
--- a/src/components/FieldSetWithStatus.tsx
+++ b/src/components/FieldSetWithStatus.tsx
@@ -24,6 +24,7 @@ export default function FieldSetWithStatus({
capitalize,
type = 'text',
inputRef,
+ accessory,
}: {
id: string
label: string
@@ -41,6 +42,7 @@ export default function FieldSetWithStatus({
capitalize?: boolean
type?: FieldSetType
inputRef?: LegacyRef
+ accessory?: React.ReactNode
}) {
const { pending } = useFormStatus();
@@ -68,58 +70,76 @@ export default function FieldSetWithStatus({
}
- {selectOptions
- ?
- : tagOptions
- ?
+ {selectOptions
+ ?
- : onChange?.(type === 'checkbox'
- ? e.target.value === 'true' ? 'false' : 'true'
- : e.target.value)}
- type={type}
- autoComplete="off"
- autoCapitalize={!capitalize ? 'off' : undefined}
- readOnly={readOnly || pending}
+ onChange={e => onChange?.(e.target.value)}
className={clsx(
- type === 'text' && 'w-full',
- Boolean(error) && 'error',
+ 'w-full',
+ clsx(Boolean(error) && 'error'),
+ // Use special class because `select` can't be readonly
+ readOnly || pending && 'disabled-select',
)}
- />}
+ >
+ {selectOptionsDefaultLabel &&
+ }
+ {selectOptions.map(({ value: optionValue, label: optionLabel }) =>
+ )}
+
+ : tagOptions
+ ?
+ : type === 'textarea'
+ ?
);
};
diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx
index 6be696f5..84a1d6ca 100644
--- a/src/photo/PhotoEditPageClient.tsx
+++ b/src/photo/PhotoEditPageClient.tsx
@@ -4,21 +4,27 @@ import AdminChildPage from '@/components/AdminChildPage';
import { Photo } from '.';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
-import { PhotoFormData, convertPhotoToFormData } from './form';
+import {
+ PhotoFormData,
+ convertPhotoToFormData,
+} from './form';
import PhotoForm from './form/PhotoForm';
import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions';
import { Tags } from '@/tag';
-import { useState } from 'react';
+import AiButton from './ai/AiButton';
+import usePhotoFormParent from './form/usePhotoFormParent';
export default function PhotoEditPageClient({
photo,
uniqueTags,
+ hasAiTextGeneration,
}: {
photo: Photo
- uniqueTags?: Tags
+ uniqueTags: Tags
+ hasAiTextGeneration: boolean
}) {
const seedExifData = { url: photo.url };
@@ -27,14 +33,23 @@ export default function PhotoEditPageClient({
seedExifData,
);
- const [pending, setIsPending] = useState(false);
- const [updatedTitle, setUpdatedTitle] = useState('');
-
const hasExifDataBeenFound = !areSimpleObjectsEqual(
updatedExifData,
seedExifData,
);
+ const photoForm = convertPhotoToFormData(photo);
+
+ const {
+ pending,
+ setIsPending,
+ updatedTitle,
+ setUpdatedTitle,
+ hasTextContent,
+ setHasTextContent,
+ aiContent,
+ } = usePhotoFormParent({ photoForm });
+
return (
-
- }
- >
- EXIF
-
- }
+
+ {hasAiTextGeneration &&
+
}
+
+
}
isLoading={pending}
>
diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx
index 80adf8d5..c54b0088 100644
--- a/src/photo/PhotoLarge.tsx
+++ b/src/photo/PhotoLarge.tsx
@@ -1,5 +1,6 @@
import {
Photo,
+ altTextForPhoto,
shouldShowCameraDataForPhoto,
shouldShowExifDataForPhoto,
titleForPhoto,
@@ -54,7 +55,7 @@ export default function PhotoLarge({
contentMain={
);
diff --git a/src/photo/PhotoTiny.tsx b/src/photo/PhotoTiny.tsx
index 98eed075..f7c54af5 100644
--- a/src/photo/PhotoTiny.tsx
+++ b/src/photo/PhotoTiny.tsx
@@ -1,4 +1,4 @@
-import { Photo, titleForPhoto } from '.';
+import { Photo, altTextForPhoto } from '.';
import ImageTiny from '@/components/ImageTiny';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
@@ -31,7 +31,7 @@ export default function PhotoTiny({
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
- alt={titleForPhoto(photo)}
+ alt={altTextForPhoto(photo)}
/>
);
diff --git a/src/photo/UploadPageClient.tsx b/src/photo/UploadPageClient.tsx
index b37a4d39..8f00719e 100644
--- a/src/photo/UploadPageClient.tsx
+++ b/src/photo/UploadPageClient.tsx
@@ -5,19 +5,32 @@ import { PATH_ADMIN_UPLOADS } from '@/site/paths';
import { PhotoFormData } from './form';
import { Tags } from '@/tag';
import PhotoForm from './form/PhotoForm';
-import { useState } from 'react';
+import usePhotoFormParent from './form/usePhotoFormParent';
+import AiButton from './ai/AiButton';
+import { AiAutoGeneratedField } from './ai';
export default function UploadPageClient({
blobId,
photoFormExif,
uniqueTags,
+ hasAiTextGeneration,
+ textFieldsToAutoGenerate,
}: {
blobId?: string
photoFormExif: Partial
uniqueTags: Tags
+ hasAiTextGeneration?: boolean
+ textFieldsToAutoGenerate?: AiAutoGeneratedField[],
}) {
- const [pending, setIsPending] = useState(false);
- const [updatedTitle, setUpdatedTitle] = useState('');
+ const {
+ pending,
+ setIsPending,
+ updatedTitle,
+ setUpdatedTitle,
+ hasTextContent,
+ setHasTextContent,
+ aiContent,
+ } = usePhotoFormParent({ textFieldsToAutoGenerate });
return (
}
isLoading={pending}
>
diff --git a/src/photo/actions.ts b/src/photo/actions.ts
index 7b2033e7..9cd072d6 100644
--- a/src/photo/actions.ts
+++ b/src/photo/actions.ts
@@ -33,10 +33,12 @@ import {
import { extractExifDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert } from '.';
-import { safelyRunServerAdminAction } from '@/auth';
+import { safelyRunAdminServerAction } from '@/auth';
+import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
+import { streamOpenAiImageQuery } from '@/services/openai';
export async function createPhotoAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData, true);
const updatedUrl = await convertUploadToPhoto(photo.url, photo.id);
@@ -52,7 +54,7 @@ export async function createPhotoAction(formData: FormData) {
}
export async function updatePhotoAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData);
await sqlUpdatePhoto(photo);
@@ -67,7 +69,7 @@ export async function toggleFavoritePhotoAction(
photoId: string,
shouldRedirect?: boolean,
) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const photo = await getPhoto(photoId);
if (photo) {
const { tags } = photo;
@@ -88,7 +90,7 @@ export async function deletePhotoAction(
photoUrl: string,
shouldRedirect?: boolean,
) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
await sqlDeletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
@@ -98,7 +100,7 @@ export async function deletePhotoAction(
};
export async function deletePhotoFormAction(formData: FormData) {
- return safelyRunServerAdminAction(async () =>
+ return safelyRunAdminServerAction(async () =>
deletePhotoAction(
formData.get('id') as string,
formData.get('url') as string,
@@ -107,7 +109,7 @@ export async function deletePhotoFormAction(formData: FormData) {
};
export async function deletePhotoTagGloballyAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
await sqlDeletePhotoTagGlobally(tag);
@@ -118,7 +120,7 @@ export async function deletePhotoTagGloballyAction(formData: FormData) {
}
export async function renamePhotoTagGloballyAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const tag = formData.get('tag') as string;
const updatedTag = formData.get('updatedTag') as string;
@@ -132,7 +134,7 @@ export async function renamePhotoTagGloballyAction(formData: FormData) {
}
export async function deleteBlobPhotoAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
await deleteStorageUrl(formData.get('url') as string);
revalidateAdminPaths();
@@ -146,7 +148,7 @@ export async function deleteBlobPhotoAction(formData: FormData) {
export async function getExifDataAction(
photoFormPrevious: Partial,
): Promise> {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const { url } = photoFormPrevious;
if (url) {
const { photoFormExif } = await extractExifDataFromBlobPath(url);
@@ -159,7 +161,7 @@ export async function getExifDataAction(
}
export async function syncPhotoExifDataAction(formData: FormData) {
- return safelyRunServerAdminAction(async () => {
+ return safelyRunAdminServerAction(async () => {
const photoId = formData.get('id') as string;
if (photoId) {
const photo = await getPhoto(photoId);
@@ -179,5 +181,13 @@ export async function syncPhotoExifDataAction(formData: FormData) {
}
export async function syncCacheAction() {
- return safelyRunServerAdminAction(revalidateAllKeysAndPaths);
+ return safelyRunAdminServerAction(revalidateAllKeysAndPaths);
+}
+
+export async function streamAiImageQueryAction(
+ imageBase64: string,
+ query: AiImageQuery,
+) {
+ return safelyRunAdminServerAction(async () =>
+ streamOpenAiImageQuery(imageBase64, AI_IMAGE_QUERIES[query]));
}
diff --git a/src/photo/ai/AiButton.tsx b/src/photo/ai/AiButton.tsx
new file mode 100644
index 00000000..b7892312
--- /dev/null
+++ b/src/photo/ai/AiButton.tsx
@@ -0,0 +1,63 @@
+import Spinner from '@/components/Spinner';
+import { AiContent } from './useAiImageQueries';
+import { HiSparkles } from 'react-icons/hi';
+import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
+import { useMemo } from 'react';
+import { clsx } from 'clsx/lite';
+
+export default function AiButton({
+ aiContent,
+ requestFields = ALL_AI_AUTO_GENERATED_FIELDS,
+ shouldConfirm,
+ className,
+}: {
+ aiContent: AiContent
+ requestFields?: AiAutoGeneratedField[]
+ shouldConfirm?: boolean
+ className?: string
+}) {
+ const isLoading = useMemo(() =>
+ (requestFields ?? []).map(field => {
+ switch (field) {
+ case 'title':
+ return aiContent.isLoadingTitle;
+ case 'caption':
+ return aiContent.isLoadingCaption;
+ case 'tags':
+ return aiContent.isLoadingTags;
+ case 'semantic':
+ return aiContent.isLoadingSemantic;
+ default:
+ return false;
+ }
+ }).some(Boolean)
+ , [
+ requestFields,
+ aiContent.isLoadingCaption,
+ aiContent.isLoadingSemantic,
+ aiContent.isLoadingTags,
+ aiContent.isLoadingTitle,
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/photo/ai/index.ts b/src/photo/ai/index.ts
new file mode 100644
index 00000000..4fbb228e
--- /dev/null
+++ b/src/photo/ai/index.ts
@@ -0,0 +1,65 @@
+/* eslint-disable max-len */
+
+export type AiAutoGeneratedField =
+ 'title' |
+ 'caption' |
+ 'tags' |
+ 'semantic'
+
+export const ALL_AI_AUTO_GENERATED_FIELDS: AiAutoGeneratedField[] = [
+ 'title',
+ 'caption',
+ 'tags',
+ 'semantic',
+];
+
+export const parseAiAutoGeneratedFieldsText = (
+ text = 'all',
+): AiAutoGeneratedField[] => {
+ const textFormatted = text.trim().toLocaleLowerCase();
+ if (textFormatted === 'none') {
+ return [];
+ } else if (textFormatted === 'all') {
+ return ALL_AI_AUTO_GENERATED_FIELDS;
+ } else {
+ const fields = textFormatted
+ .toLocaleLowerCase()
+ .split(',')
+ .map(field => field.trim())
+ .filter(field => ALL_AI_AUTO_GENERATED_FIELDS
+ .includes(field as AiAutoGeneratedField));
+ return fields as AiAutoGeneratedField[];
+ }
+};
+
+export type AiImageQuery =
+ 'title' |
+ 'caption' |
+ 'title-and-caption' |
+ 'tags' |
+ 'description-small' |
+ 'description' |
+ 'description-large' |
+ 'description-semantic';
+
+export const AI_IMAGE_QUERIES: Record = {
+ 'title': 'Write a short title for this image in 3 words or less',
+ 'caption': 'Write a pithy caption for this image in 6 words or less and no punctuation',
+ '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"',
+ 'tags': 'Describe this image three or less comma-separated keywords with no adjective or adverbs',
+ 'description-small': 'Describe this image succinctly without the initial text "This image shows" or "This is a picture of"',
+ 'description': 'Describe this image',
+ 'description-large': 'Describe this image in detail',
+ 'description-semantic': 'List up to 5 things in this image without description as a comma-separated list',
+};
+
+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
new file mode 100644
index 00000000..63adb5b0
--- /dev/null
+++ b/src/photo/ai/useAiImageQueries.ts
@@ -0,0 +1,134 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import useAiImageQuery from './useAiImageQuery';
+import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery';
+import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.';
+
+export type AiContent = ReturnType;
+
+export default function useAiImageQueries(
+ textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
+) {
+ const [imageData, setImageData] = useState();
+
+ const isReady = Boolean(imageData);
+
+ const [
+ requestTitleCaption,
+ _title,
+ _caption,
+ _isLoadingTitle,
+ _isLoadingCaption,
+ resetTitle,
+ resetCaption,
+ ] = useTitleCaptionAiImageQuery(imageData);
+
+ const [
+ requestTitle,
+ titleSolo,
+ isLoadingTitleSolo,
+ resetTitleSolo,
+ ] = useAiImageQuery(imageData, 'title');
+
+ const [
+ requestCaption,
+ captionSolo,
+ isLoadingCaptionSolo,
+ resetCaptionSolo,
+ ] = useAiImageQuery(imageData, 'caption');
+
+ const [
+ requestTags,
+ tags,
+ isLoadingTags,
+ ] = useAiImageQuery(imageData, 'tags');
+
+ const [
+ requestSemantic,
+ semanticDescription,
+ isLoadingSemantic,
+ ] = useAiImageQuery(imageData, 'description-small');
+
+ const title = _title || titleSolo;
+ const caption = _caption || captionSolo;
+ const isLoadingTitle = _isLoadingTitle || isLoadingTitleSolo;
+ const isLoadingCaption = _isLoadingCaption || isLoadingCaptionSolo;
+
+ const hasContent = Boolean(
+ title ||
+ caption ||
+ tags ||
+ semanticDescription
+ );
+
+ const isLoading =
+ isLoadingTitle ||
+ isLoadingCaption ||
+ isLoadingTags ||
+ isLoadingSemantic;
+
+ const hasRunAllQueriesOnce = useRef(false);
+
+ const request = useCallback(async (
+ fields = ALL_AI_AUTO_GENERATED_FIELDS,
+ ) => {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('RUNNING AI QUERIES', fields);
+ }
+ hasRunAllQueriesOnce.current = true;
+ if (fields.includes('title') && fields.includes('caption')) {
+ // Unmask individual title + caption
+ resetTitleSolo();
+ resetCaptionSolo();
+ requestTitleCaption();
+ } else {
+ if (fields.includes('title')) {
+ // Unmask combined title
+ resetTitle();
+ resetTitleSolo();
+ requestTitle();
+ }
+ if (fields.includes('caption')) {
+ // Unmask combined caption
+ resetCaption();
+ resetCaptionSolo();
+ requestCaption();
+ }
+ }
+ if (fields.includes('tags')) { requestTags(); }
+ if (fields.includes('semantic')) { requestSemantic(); }
+ }, [
+ requestTitleCaption,
+ requestTitle,
+ requestCaption,
+ requestTags,
+ requestSemantic,
+ resetTitle,
+ resetTitleSolo,
+ resetCaption,
+ resetCaptionSolo,
+ ]);
+
+ useEffect(() => {
+ if (imageData && !hasRunAllQueriesOnce.current) {
+ if (textFieldsToAutoGenerate.length > 0) {
+ request(textFieldsToAutoGenerate);
+ }
+ }
+ }, [textFieldsToAutoGenerate, imageData, request]);
+
+ return {
+ request,
+ title,
+ caption,
+ tags,
+ semanticDescription,
+ isReady,
+ hasContent,
+ isLoading,
+ isLoadingTitle,
+ isLoadingCaption,
+ isLoadingTags,
+ isLoadingSemantic,
+ setImageData,
+ };
+}
diff --git a/src/photo/ai/useAiImageQuery.ts b/src/photo/ai/useAiImageQuery.ts
new file mode 100644
index 00000000..9e28e3ff
--- /dev/null
+++ b/src/photo/ai/useAiImageQuery.ts
@@ -0,0 +1,53 @@
+import { useCallback, useState } from 'react';
+import { streamAiImageQueryAction } from '../actions';
+import { readStreamableValue } from 'ai/rsc';
+import { AiImageQuery } from '.';
+
+export default function useAiImageQuery(
+ imageBase64: string | undefined,
+ query: AiImageQuery,
+) {
+ const [text, setText] = useState('');
+ const [error, setError] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const request = useCallback(async () => {
+ if (imageBase64) {
+ setIsLoading(true);
+ setText('');
+ try {
+ const textStream = await streamAiImageQueryAction(
+ imageBase64,
+ query,
+ );
+ for await (const text of readStreamableValue(textStream)) {
+ setText((text ?? '')
+ .replaceAll('\n', ' ')
+ .replaceAll('"', '')
+ .replace(/\.$/, ''));
+ }
+ setIsLoading(false);
+ } catch (e) {
+ setError(e);
+ setIsLoading(false);
+ }
+ }
+ }, [imageBase64, query]);
+
+ const reset = useCallback(() => {
+ setText('');
+ setError(undefined);
+ setIsLoading(false);
+ }, []);
+
+ // Withhold streaming text if it's a null response
+ const isTextError = text.toLocaleLowerCase().startsWith('sorry');
+
+ return [
+ request,
+ isTextError ? '' : text,
+ isLoading,
+ reset,
+ error,
+ ] as const;
+};
diff --git a/src/photo/ai/useTitleCaptionAiImageQuery.ts b/src/photo/ai/useTitleCaptionAiImageQuery.ts
new file mode 100644
index 00000000..6768decd
--- /dev/null
+++ b/src/photo/ai/useTitleCaptionAiImageQuery.ts
@@ -0,0 +1,40 @@
+import { useCallback, useEffect, useState } from 'react';
+import useAiImageQuery from './useAiImageQuery';
+import { parseTitleAndCaption } from '.';
+
+export default function useTitleCaptionAiImageQuery(
+ imageBase64: string | undefined,
+) {
+ const [
+ request,
+ text,
+ isLoading,
+ _reset,
+ error,
+ ] = useAiImageQuery(imageBase64, 'title-and-caption');
+
+ const [title, setTitle] = useState('');
+ const [caption, setCaption] = useState('');
+ useEffect(() => {
+ const { title, caption } = parseTitleAndCaption(text);
+ setTitle(title);
+ setCaption(caption);
+ }, [text]);
+
+ const resetTitle = useCallback(() => setTitle(''), []);
+ const resetCaption = useCallback(() => setCaption(''), []);
+
+ const isLoadingTitle = isLoading && !caption;
+ const isLoadingCaption = isLoading;
+
+ return [
+ request,
+ title,
+ caption,
+ isLoadingTitle,
+ isLoadingCaption,
+ resetTitle,
+ resetCaption,
+ error,
+ ] as const;
+}
diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx
index 6f4e4b76..a0100576 100644
--- a/src/photo/form/PhotoForm.tsx
+++ b/src/photo/form/PhotoForm.tsx
@@ -5,6 +5,7 @@ import {
FORM_METADATA_ENTRIES,
PhotoFormData,
convertFormKeysToLabels,
+ formHasTextContent,
getFormErrors,
isFormValid,
} from '.';
@@ -25,6 +26,8 @@ import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config';
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
+import { AiContent } from '../ai/useAiImageQueries';
+import AiButton from '../ai/AiButton';
const THUMBNAIL_SIZE = 300;
@@ -33,22 +36,29 @@ export default function PhotoForm({
updatedExifData,
type = 'create',
uniqueTags,
+ aiContent,
debugBlur,
onTitleChange,
+ onTextContentChange,
onFormStatusChange,
}: {
initialPhotoForm: Partial
updatedExifData?: Partial
type?: 'create' | 'edit'
uniqueTags?: Tags
+ aiContent?: AiContent
+ setImageData?: (imageData: string) => void
debugBlur?: boolean
onTitleChange?: (updatedTitle: string) => void
+ onTextContentChange?: (hasContent: boolean) => void,
onFormStatusChange?: (pending: boolean) => void
}) {
const [formData, setFormData] =
useState>(initialPhotoForm);
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
+ const [blurError, setBlurError] =
+ useState();
// Update form when EXIF data
// is refreshed by parent
@@ -114,8 +124,89 @@ export default function PhotoForm({
}
}, []);
+ useEffect(() =>
+ setFormData(data => aiContent?.hasContent
+ ? { ...data, title: aiContent?.title }
+ : data),
+ [aiContent?.title, aiContent?.hasContent]);
+
+ useEffect(() =>
+ setFormData(data => aiContent?.hasContent
+ ? { ...data, caption: aiContent?.caption }
+ : data),
+ [aiContent?.caption, aiContent?.hasContent]);
+
+ useEffect(() =>
+ setFormData(data => aiContent?.hasContent
+ ? { ...data, tags: aiContent?.tags }
+ : data),
+ [aiContent?.tags, aiContent?.hasContent]);
+
+ 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) {
+ case 'title':
+ return aiContent?.isLoadingTitle;
+ case 'caption':
+ return aiContent?.isLoadingCaption;
+ case 'tags':
+ return aiContent?.isLoadingTags;
+ case 'semanticDescription':
+ return aiContent?.isLoadingSemantic;
+ default:
+ return false;
+ }
+ };
+
+ const aiButtonForField = (key: keyof PhotoFormData) => {
+ if (aiContent) {
+ switch (key) {
+ case 'title':
+ return ;
+ case 'caption':
+ return ;
+ case 'tags':
+ return ;
+ case 'semanticDescription':
+ return ;
+ }
+ }
+ };
+
return (
+ {debugBlur && blurError &&
+
+ {blurError}
+
}
{debugBlur && formData.blurData &&
![]()
{
- setFormData({ ...formData, [key]: value });
+ const formUpdated = { ...formData, [key]: value };
+ setFormData(formUpdated);
if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) {
@@ -211,8 +306,11 @@ export default function PhotoForm({
placeholder={loadingMessage && !formData[key]
? loadingMessage
: undefined}
- loading={loadingMessage && !formData[key] ? true : false}
+ loading={
+ (loadingMessage && !formData[key] ? true : false) ||
+ isFieldGeneratingAi(key)}
type={type}
+ accessory={aiButtonForField(key)}
/>)}
{type === 'create' ? 'Create' : 'Update'}
diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts
index 078e36c6..63ab8698 100644
--- a/src/photo/form/index.ts
+++ b/src/photo/form/index.ts
@@ -13,7 +13,10 @@ import {
MAKE_FUJIFILM,
} from '@/vendors/fujifilm';
import { FilmSimulation } from '@/simulation';
-import { BLUR_ENABLED, GEO_PRIVACY_ENABLED } from '@/site/config';
+import {
+ BLUR_ENABLED,
+ GEO_PRIVACY_ENABLED,
+} from '@/site/config';
import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag';
type VirtualFields = 'favorite';
@@ -24,7 +27,8 @@ export type FieldSetType =
'text' |
'email' |
'password' |
- 'checkbox';
+ 'checkbox' |
+ 'textarea';
export type AnnotatedTag = {
value: string,
@@ -55,7 +59,8 @@ const STRING_MAX_LENGTH_SHORT = 255;
const STRING_MAX_LENGTH_LONG = 1000;
const FORM_METADATA = (
- tagOptions?: AnnotatedTag[]
+ tagOptions?: AnnotatedTag[],
+ aiTextGeneration?: boolean,
): Record => ({
title: {
label: 'title',
@@ -66,13 +71,8 @@ const FORM_METADATA = (
label: 'caption',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
- shouldHide: ({ title, caption }) => !title && !caption,
- },
- semanticDescription: {
- label: 'semantic description',
- capitalize: true,
- validateStringMaxLength: STRING_MAX_LENGTH_LONG,
- hide: true,
+ shouldHide: ({ title, caption }) =>
+ !aiTextGeneration && (!title && !caption),
},
tags: {
label: 'tags',
@@ -81,6 +81,13 @@ const FORM_METADATA = (
? `'${TAG_FAVS}' is a reserved tag`
: undefined,
},
+ semanticDescription: {
+ type: 'textarea',
+ label: 'semantic description (not visible)',
+ capitalize: true,
+ validateStringMaxLength: STRING_MAX_LENGTH_LONG,
+ hide: !aiTextGeneration,
+ },
id: { label: 'id', readOnly: true, hideIfEmpty: true },
blurData: {
label: 'blur data',
@@ -144,6 +151,14 @@ export const isFormValid = (formData: Partial) =>
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
);
+export const formHasTextContent = ({
+ title,
+ caption,
+ tags,
+ semanticDescription,
+}: Partial) =>
+ Boolean(title || caption || tags || semanticDescription);
+
// CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (
diff --git a/src/photo/form/usePhotoFormParent.ts b/src/photo/form/usePhotoFormParent.ts
new file mode 100644
index 00000000..bf3fe83a
--- /dev/null
+++ b/src/photo/form/usePhotoFormParent.ts
@@ -0,0 +1,29 @@
+import { useState } from 'react';
+import { PhotoFormData, formHasTextContent } from '.';
+import useAiImageQueries from '../ai/useAiImageQueries';
+import { AiAutoGeneratedField } from '../ai';
+
+export default function usePhotoFormParent({
+ photoForm,
+ textFieldsToAutoGenerate,
+}: {
+ photoForm?: Partial,
+ textFieldsToAutoGenerate?: AiAutoGeneratedField[],
+} = {}) {
+ const [pending, setIsPending] = useState(false);
+ const [updatedTitle, setUpdatedTitle] = useState('');
+ const [hasTextContent, setHasTextContent] =
+ useState(photoForm ? formHasTextContent(photoForm) : false);
+
+ const aiContent = useAiImageQueries(textFieldsToAutoGenerate);
+
+ return {
+ pending,
+ setIsPending,
+ updatedTitle,
+ setUpdatedTitle,
+ hasTextContent,
+ setHasTextContent,
+ aiContent,
+ };
+}
diff --git a/src/photo/index.ts b/src/photo/index.ts
index 89094cff..88059b4f 100644
--- a/src/photo/index.ts
+++ b/src/photo/index.ts
@@ -168,6 +168,9 @@ export const translatePhotoId = (id: string) =>
export const titleForPhoto = (photo: Photo) =>
photo.title || 'Untitled';
+export const altTextForPhoto = (photo: Photo) =>
+ photo.semanticDescription || titleForPhoto(photo);
+
export const photoLabelForCount = (count: number) =>
count === 1 ? 'Photo' : 'Photos';
@@ -247,3 +250,9 @@ export const shouldShowCameraDataForPhoto = (photo: Photo) =>
export const shouldShowExifDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA && photoHasExifData(photo);
+
+export const getKeywordsForPhoto = (photo: Photo) =>
+ (photo.caption ?? '').split(' ')
+ .concat((photo.semanticDescription ?? '').split(' '))
+ .filter(Boolean)
+ .map(keyword => keyword.toLocaleLowerCase());
diff --git a/src/services/openai.ts b/src/services/openai.ts
new file mode 100644
index 00000000..63502c8e
--- /dev/null
+++ b/src/services/openai.ts
@@ -0,0 +1,76 @@
+'use server';
+
+import OpenAI from 'openai';
+import { createStreamableValue, render } from 'ai/rsc';
+import { kv } from '@vercel/kv';
+import { Ratelimit } from '@upstash/ratelimit';
+import { AI_TEXT_GENERATION_ENABLED, HAS_VERCEL_KV } from '@/site/config';
+import { safelyRunAdminServerAction } from '@/auth';
+
+const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
+const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100;
+
+const provider = AI_TEXT_GENERATION_ENABLED
+ ? new OpenAI({ apiKey: process.env.OPENAI_SECRET_KEY })
+ : undefined;
+
+// Allows 100 requests per hour
+const ratelimit = HAS_VERCEL_KV
+ ? new Ratelimit({
+ redis: kv,
+ limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'),
+ })
+ : undefined;
+
+export const streamOpenAiImageQuery = async (
+ imageBase64: string,
+ query: string,
+) => {
+ return safelyRunAdminServerAction(async () => {
+ if (ratelimit) {
+ let success = false;
+ try {
+ success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success;
+ } catch (e: any) {
+ console.error('Failed to rate limit OpenAI', e);
+ throw new Error('Failed to rate limit OpenAI');
+ }
+ if (!success) {
+ console.error('OpenAI rate limit exceeded');
+ throw new Error('OpenAI rate limit exceeded');
+ }
+ }
+
+ const stream = createStreamableValue('');
+
+ if (provider) {
+ render({
+ provider,
+ model: 'gpt-4-vision-preview',
+ messages: [{
+ 'role': 'user',
+ 'content': [
+ {
+ 'type': 'text',
+ 'text': query,
+ }, {
+ 'type': 'image_url',
+ 'image_url': {
+ 'url': imageBase64,
+ },
+ },
+ ],
+ }],
+ text: ({ content, done }): any => {
+ if (done) {
+ stream.done(content);
+ } else {
+ stream.update(content);
+ }
+ },
+ });
+ }
+
+ return stream.value;
+ });
+};
diff --git a/src/services/vercel-postgres.ts b/src/services/vercel-postgres.ts
index 8516c62e..a0b67f6b 100644
--- a/src/services/vercel-postgres.ts
+++ b/src/services/vercel-postgres.ts
@@ -294,7 +294,7 @@ export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
limit?: number
offset?: number
- title?: string
+ query?: string
tag?: string
camera?: Camera
simulation?: FilmSimulation
@@ -344,7 +344,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
- title,
+ query,
tag,
camera,
simulation,
@@ -370,9 +370,10 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
wheres.push(`taken_at <= $${valueIndex++}`);
values.push(takenAfterInclusive.toISOString());
}
- if (title) {
- wheres.push(`LOWER(title) LIKE $${valueIndex++}`);
- values.push(`%${title.toLowerCase()}%`);
+ if (query) {
+ // eslint-disable-next-line max-len
+ wheres.push(`CONCAT(title, ' ', caption, ' ', semantic_description) ILIKE $${valueIndex++}`);
+ values.push(`%${query.toLocaleLowerCase()}%`);
}
if (tag) {
wheres.push(`$${valueIndex++}=ANY(tags)`);
diff --git a/src/site/CommandK.tsx b/src/site/CommandK.tsx
index ced26023..523207bf 100644
--- a/src/site/CommandK.tsx
+++ b/src/site/CommandK.tsx
@@ -19,7 +19,7 @@ import {
import { formatCameraText } from '@/camera';
import { authCached } from '@/auth/cache';
import { getPhotos } from '@/services/vercel-postgres';
-import { photoQuantityText, titleForPhoto } from '@/photo';
+import { getKeywordsForPhoto, photoQuantityText, titleForPhoto } from '@/photo';
import PhotoTiny from '@/photo/PhotoTiny';
import { formatDate } from '@/utility/date';
import { formatCount, formatCountDescriptive } from '@/utility/string';
@@ -139,15 +139,14 @@ export default async function CommandK() {
]}
onQueryChange={async (query) => {
'use server';
- const photos = (await getPhotos({ title: query, limit: 10 }))
- .filter(({ title }) => Boolean(title));
+ const photos = (await getPhotos({ query, limit: 10 }));
return photos.length > 0
? [{
heading: 'Photos',
accessory: ,
items: photos.map(photo => ({
- accessory: ,
label: titleForPhoto(photo),
+ keywords: getKeywordsForPhoto(photo),
annotation: <>
{formatDate(photo.takenAt)}
@@ -156,6 +155,7 @@ export default async function CommandK() {
{formatDate(photo.takenAt, true)}
>,
+ accessory: ,
path: pathForPhoto(photo),
})),
}]
diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx
index 7b08db87..5d4d52c7 100644
--- a/src/site/SiteChecklistClient.tsx
+++ b/src/site/SiteChecklistClient.tsx
@@ -20,10 +20,12 @@ import { toastSuccess } from '@/toast';
import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
+import { HiSparkles } from 'react-icons/hi';
export default function SiteChecklistClient({
- hasPostgres,
- hasStorage,
+ hasVercelPostgres,
+ hasVercelKV,
+ hasStorageProvider,
hasVercelBlobStorage,
hasCloudflareR2Storage,
hasAwsS3Storage,
@@ -40,9 +42,13 @@ export default function SiteChecklistClient({
isBlurEnabled,
isGeoPrivacyEnabled,
isPriorityOrderEnabled,
+ isAiTextGenerationEnabled,
+ aiTextAutoGeneratedFields,
+ hasAiTextAutoGeneratedFields,
isPublicApiEnabled,
isOgTextBottomAligned,
gridAspectRatio,
+ hasGridAspectRatio,
showRefreshButton,
secret,
}: ConfigChecklistStatus & {
@@ -92,10 +98,16 @@ export default function SiteChecklistClient({
}}
/>;
- const renderEnvVar = (variable: string) =>
+ const renderEnvVar = (
+ variable: string,
+ minimal?: boolean,
+ ) =>
`{variable}`
- {renderCopyButton(variable, variable, true)}
+ {!minimal && renderCopyButton(variable, variable, true)}
;
const renderEnvVars = (variables: string[]) =>
- {variables.map(renderEnvVar)}
+ {variables.map(envVar => renderEnvVar(envVar))}
;
const renderSubStatus = (
@@ -133,7 +145,7 @@ export default function SiteChecklistClient({
>
{renderLink(
@@ -145,13 +157,13 @@ export default function SiteChecklistClient({
and connect to project
{renderSubStatus(
@@ -259,13 +271,57 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
+ }
+ experimental
+ optional
+ >
+
+ Store your OpenAI secret key in order to add experimental support
+ for AI-generated text descriptions and enable an invisible field
+ called {'"Semantic Description"'} used to support CMD-K search
+ {renderEnvVars(['OPENAI_SECRET_KEY'])}
+
+
+ {renderLink(
+ // eslint-disable-next-line max-len
+ 'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
+ 'Create Vercel KV store',
+ )}
+ {' '}
+ and connect to project in order to enable rate limiting
+
+
+ Comma-separated fields to auto-generate when
+ uploading photos. Accepted values: title, caption,
+ tags, description, all, or none (default is {'"all"'}).
+ {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
+
+
}
optional
>
Set environment variable to any number to enforce aspect ratio
{' '}
- (defaults to {'"1"'}, i.e., square)—set to {'"0"'} to disable:
+ (default is {'"1"'}, i.e., square)—set to {'"0"'} to disable:
{renderEnvVars(['NEXT_PUBLIC_GRID_ASPECT_RATIO'])}
Set environment variable to {'"BOTTOM"'} to
- keep OG image text bottom aligned (default is top):
+ keep OG image text bottom aligned (default is {'"top"'}):
{renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])}
diff --git a/src/site/config.ts b/src/site/config.ts
index c4997ce2..4059cf05 100644
--- a/src/site/config.ts
+++ b/src/site/config.ts
@@ -1,3 +1,4 @@
+import { parseAiAutoGeneratedFieldsText } from '@/photo/ai';
import type { StorageType } from '@/services/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
@@ -37,6 +38,14 @@ export const SITE_DESCRIPTION =
process.env.NEXT_PUBLIC_SITE_DESCRIPTION ||
SITE_DOMAIN;
+// STORAGE: VERCEL POSTGRES
+export const HAS_VERCEL_POSTGRES =
+ (process.env.POSTGRES_HOST ?? '').length > 0;
+
+// STORAGE: VERCEL KV
+export const HAS_VERCEL_KV =
+ (process.env.KV_URL ?? '').length > 0;
+
// STORAGE: VERCEL BLOB
export const HAS_VERCEL_BLOB_STORAGE =
(process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0;
@@ -84,6 +93,10 @@ export const CURRENT_STORAGE: StorageType =
export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1';
export const BLUR_ENABLED = process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
export const GEO_PRIVACY_ENABLED = process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
+export const AI_TEXT_GENERATION_ENABLED =
+ Boolean(process.env.OPENAI_SECRET_KEY);
+export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
+ process.env.AI_TEXT_AUTO_GENERATED_FIELDS);
export const PRIORITY_ORDER_ENABLED =
process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1';
export const PUBLIC_API_ENABLED = process.env.NEXT_PUBLIC_PUBLIC_API === '1';
@@ -100,11 +113,12 @@ export const OG_TEXT_BOTTOM_ALIGNMENT =
export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;
export const CONFIG_CHECKLIST_STATUS = {
- hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
+ hasVercelPostgres: HAS_VERCEL_POSTGRES,
+ hasVercelKV: HAS_VERCEL_KV,
hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE,
hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE,
hasAwsS3Storage: HAS_AWS_S3_STORAGE,
- hasStorage:
+ hasStorageProvider:
HAS_VERCEL_BLOB_STORAGE ||
HAS_CLOUDFLARE_R2_STORAGE ||
HAS_AWS_S3_STORAGE,
@@ -123,16 +137,25 @@ export const CONFIG_CHECKLIST_STATUS = {
isProModeEnabled: PRO_MODE_ENABLED,
isBlurEnabled: BLUR_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
+ isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
+ aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
+ ? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
+ ? ['none']
+ : AI_TEXT_AUTO_GENERATED_FIELDS
+ : ['all'],
+ hasAiTextAutoGeneratedFields:
+ Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS),
isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,
isPublicApiEnabled: PUBLIC_API_ENABLED,
isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT,
gridAspectRatio: GRID_ASPECT_RATIO,
+ hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
};
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
export const IS_SITE_READY =
- CONFIG_CHECKLIST_STATUS.hasPostgres &&
- CONFIG_CHECKLIST_STATUS.hasStorage &&
+ CONFIG_CHECKLIST_STATUS.hasVercelPostgres &&
+ CONFIG_CHECKLIST_STATUS.hasStorageProvider &&
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
CONFIG_CHECKLIST_STATUS.hasAdminUser;
diff --git a/src/site/globals.css b/src/site/globals.css
index bf2ff669..141af746 100644
--- a/src/site/globals.css
+++ b/src/site/globals.css
@@ -19,21 +19,24 @@
}
.control,
button, .button,
- input[type=text], input[type=email], input[type=password], select {
+ input[type=text], input[type=email], input[type=password], select, textarea {
@apply
px-2.5 py-2
border rounded-md
bg-main
border-gray-200 dark:border-gray-700
- font-mono text-base leading-tight
- min-h-[2.4rem]
+ font-mono text-base leading-tight
}
- input[type=text], input[type=email], input[type=password], select {
+ input[type=text], input[type=email], input[type=password], select, textarea {
@apply
text-[1rem] /* Prevent iOS auto-zoom behavior */
min-w-[20rem] read-only:cursor-default
}
- input[type=text], input[type=email], input[type=password] {
+ input[type=text], input[type=email], input[type=password], select {
+ @apply
+ min-h-[2.4rem]
+ }
+ input[type=text], input[type=email], input[type=password], textarea {
@apply
read-only:bg-gray-100
dark:read-only:bg-gray-900 dark:read-only:text-gray-400