Add individual AI text requests, upgrade documentation
This commit is contained in:
parent
58ebc3902f
commit
65132a0862
@ -80,6 +80,9 @@ _⚠️ READ BEFORE PROCEEDING_
|
||||
- Setup usage limits to avoid unexpected charges (_recommended_)
|
||||
2. Add rate limiting (_recommended_)
|
||||
- As an additional precaution, create a [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) store and link it to your project in order to enable rate limiting
|
||||
3. Configure auto-generated fields (optional)
|
||||
- Set which text fields should auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title, semantic`
|
||||
- Accepted values: title, caption, tags, description, all, or none (default is "all")
|
||||
|
||||
### 8. Optional configuration
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@ import { extractExifDataFromBlobPath } from '@/photo/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUniqueTagsCached } from '@/photo/cache';
|
||||
import UploadPageClient from '@/photo/UploadPageClient';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/site/config';
|
||||
import {
|
||||
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||
AI_TEXT_GENERATION_ENABLED,
|
||||
} from '@/site/config';
|
||||
|
||||
interface Params {
|
||||
params: { uploadPath: string }
|
||||
@ -21,12 +24,15 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
|
||||
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
|
||||
|
||||
const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
|
||||
|
||||
return (
|
||||
<UploadPageClient {...{
|
||||
blobId,
|
||||
photoFormExif,
|
||||
uniqueTags,
|
||||
hasAiTextGeneration,
|
||||
textFieldsToAutoGenerate,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import clsx from 'clsx/lite';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import Badge from './Badge';
|
||||
|
||||
export default function ExperimentalBadge({
|
||||
|
||||
@ -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<HTMLInputElement>
|
||||
accessory?: React.ReactNode
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
@ -68,71 +70,76 @@ export default function FieldSetWithStatus({
|
||||
<Spinner />
|
||||
</span>}
|
||||
</label>
|
||||
{selectOptions
|
||||
? <select
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
clsx(Boolean(error) && 'error'),
|
||||
// Use special class because `select` can't be readonly
|
||||
readOnly || pending && 'disabled-select',
|
||||
)}
|
||||
>
|
||||
{selectOptionsDefaultLabel &&
|
||||
<option value="">{selectOptionsDefaultLabel}</option>}
|
||||
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
|
||||
<option
|
||||
key={optionValue}
|
||||
value={optionValue}
|
||||
>
|
||||
{optionLabel}
|
||||
</option>)}
|
||||
</select>
|
||||
: tagOptions
|
||||
? <TagInput
|
||||
<div className="flex gap-2">
|
||||
{selectOptions
|
||||
? <select
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
options={tagOptions}
|
||||
onChange={onChange}
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending || loading}
|
||||
/>
|
||||
: type === 'textarea'
|
||||
? <textarea
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
clsx(Boolean(error) && 'error'),
|
||||
// Use special class because `select` can't be readonly
|
||||
readOnly || pending && 'disabled-select',
|
||||
)}
|
||||
>
|
||||
{selectOptionsDefaultLabel &&
|
||||
<option value="">{selectOptionsDefaultLabel}</option>}
|
||||
{selectOptions.map(({ value: optionValue, label: optionLabel }) =>
|
||||
<option
|
||||
key={optionValue}
|
||||
value={optionValue}
|
||||
>
|
||||
{optionLabel}
|
||||
</option>)}
|
||||
</select>
|
||||
: tagOptions
|
||||
? <TagInput
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
options={tagOptions}
|
||||
onChange={onChange}
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
'w-full h-24 resize-none',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
: type === 'textarea'
|
||||
? <textarea
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
'w-full h-24 resize-none',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>
|
||||
: <input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
value={value}
|
||||
checked={type === 'checkbox' ? value === 'true' : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange?.(type === 'checkbox'
|
||||
? e.target.value === 'true' ? 'false' : 'true'
|
||||
: e.target.value)}
|
||||
type={type}
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending || loading}
|
||||
className={clsx(
|
||||
type === 'text' && 'w-full',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
<div>
|
||||
{accessory}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,17 +7,20 @@ import { Tags } from '@/tag';
|
||||
import PhotoForm from './form/PhotoForm';
|
||||
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<PhotoFormData>
|
||||
uniqueTags: Tags
|
||||
hasAiTextGeneration: boolean
|
||||
hasAiTextGeneration?: boolean
|
||||
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
|
||||
}) {
|
||||
const {
|
||||
pending,
|
||||
@ -27,7 +30,7 @@ export default function UploadPageClient({
|
||||
hasTextContent,
|
||||
setHasTextContent,
|
||||
aiContent,
|
||||
} = usePhotoFormParent({ shouldAutoGenerateText: hasAiTextGeneration });
|
||||
} = usePhotoFormParent({ textFieldsToAutoGenerate });
|
||||
|
||||
return (
|
||||
<AdminChildPage
|
||||
|
||||
@ -1,26 +1,61 @@
|
||||
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: { request, isReady, isLoading },
|
||||
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 (
|
||||
<button
|
||||
className="flex min-w-[3.25rem] min-h-9 justify-center"
|
||||
onClick={() => {
|
||||
className={clsx(
|
||||
'flex min-w-[3.25rem] min-h-9 justify-center',
|
||||
className,
|
||||
)}
|
||||
onClick={e => {
|
||||
if (
|
||||
!shouldConfirm ||
|
||||
confirm('Are you sure you want to overwrite existing content?')
|
||||
) {
|
||||
request();
|
||||
aiContent.request(requestFields);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
disabled={!isReady || isLoading}
|
||||
disabled={!aiContent.isReady || isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
|
||||
</button>
|
||||
|
||||
@ -1,5 +1,37 @@
|
||||
/* 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' |
|
||||
@ -11,8 +43,8 @@ export type AiImageQuery =
|
||||
'description-semantic';
|
||||
|
||||
export const AI_IMAGE_QUERIES: Record<AiImageQuery, string> = {
|
||||
'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?',
|
||||
'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"',
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
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<typeof useAiImageQueries>;
|
||||
|
||||
export default function useAiImageQueries(
|
||||
shouldAutoGenerateText?: boolean,
|
||||
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
||||
) {
|
||||
const [imageData, setImageData] = useState<string>();
|
||||
|
||||
@ -13,12 +14,28 @@ export default function useAiImageQueries(
|
||||
|
||||
const [
|
||||
requestTitleCaption,
|
||||
title,
|
||||
caption,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
_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,
|
||||
@ -31,6 +48,11 @@ export default function useAiImageQueries(
|
||||
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 ||
|
||||
@ -46,23 +68,53 @@ export default function useAiImageQueries(
|
||||
|
||||
const hasRunAllQueriesOnce = useRef(false);
|
||||
|
||||
const request = useCallback(async () => {
|
||||
const request = useCallback(async (
|
||||
fields = ALL_AI_AUTO_GENERATED_FIELDS,
|
||||
) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('RUNNING ALL AI QUERIES');
|
||||
console.log('RUNNING AI QUERIES', fields);
|
||||
}
|
||||
hasRunAllQueriesOnce.current = true;
|
||||
requestTitleCaption();
|
||||
requestTags();
|
||||
requestSemantic();
|
||||
}, [requestTitleCaption, requestTags, requestSemantic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoGenerateText && imageData) {
|
||||
if (!hasRunAllQueriesOnce.current) {
|
||||
request();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}, [shouldAutoGenerateText, imageData, request]);
|
||||
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,
|
||||
|
||||
@ -14,6 +14,7 @@ export default function useAiImageQuery(
|
||||
const request = useCallback(async () => {
|
||||
if (imageBase64) {
|
||||
setIsLoading(true);
|
||||
setText('');
|
||||
try {
|
||||
const textStream = await streamAiImageQueryAction(
|
||||
imageBase64,
|
||||
@ -22,6 +23,7 @@ export default function useAiImageQuery(
|
||||
for await (const text of readStreamableValue(textStream)) {
|
||||
setText((text ?? '')
|
||||
.replaceAll('\n', ' ')
|
||||
.replaceAll('"', '')
|
||||
.replace(/\.$/, ''));
|
||||
}
|
||||
setIsLoading(false);
|
||||
@ -32,6 +34,12 @@ export default function useAiImageQuery(
|
||||
}
|
||||
}, [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');
|
||||
|
||||
@ -39,6 +47,7 @@ export default function useAiImageQuery(
|
||||
request,
|
||||
isTextError ? '' : text,
|
||||
isLoading,
|
||||
reset,
|
||||
error,
|
||||
] as const;
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import useAiImageQuery from './useAiImageQuery';
|
||||
import { parseTitleAndCaption } from '.';
|
||||
|
||||
@ -9,11 +9,20 @@ export default function useTitleCaptionAiImageQuery(
|
||||
request,
|
||||
text,
|
||||
isLoading,
|
||||
_reset,
|
||||
error,
|
||||
] = useAiImageQuery(imageBase64, 'title-and-caption');
|
||||
|
||||
const { title, caption } = useMemo(() =>
|
||||
parseTitleAndCaption(text), [text]);
|
||||
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;
|
||||
@ -24,6 +33,8 @@ export default function useTitleCaptionAiImageQuery(
|
||||
caption,
|
||||
isLoadingTitle,
|
||||
isLoadingCaption,
|
||||
resetTitle,
|
||||
resetCaption,
|
||||
error,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ 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;
|
||||
|
||||
@ -166,6 +167,40 @@ export default function PhotoForm({
|
||||
}
|
||||
};
|
||||
|
||||
const aiButtonForField = (key: keyof PhotoFormData) => {
|
||||
if (aiContent) {
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return <AiButton
|
||||
aiContent={aiContent}
|
||||
requestFields={['title']}
|
||||
shouldConfirm={Boolean(formData.title)}
|
||||
className="h-full"
|
||||
/>;
|
||||
case 'caption':
|
||||
return <AiButton
|
||||
aiContent={aiContent}
|
||||
requestFields={['caption']}
|
||||
shouldConfirm={Boolean(formData.caption)}
|
||||
className="h-full"
|
||||
/>;
|
||||
case 'tags':
|
||||
return <AiButton
|
||||
aiContent={aiContent}
|
||||
requestFields={['tags']}
|
||||
shouldConfirm={Boolean(formData.tags)}
|
||||
className="h-full"
|
||||
/>;
|
||||
case 'semanticDescription':
|
||||
return <AiButton
|
||||
aiContent={aiContent}
|
||||
requestFields={['semantic']}
|
||||
shouldConfirm={Boolean(formData.semanticDescription)}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-[38rem]">
|
||||
{debugBlur && blurError &&
|
||||
@ -275,6 +310,7 @@ export default function PhotoForm({
|
||||
(loadingMessage && !formData[key] ? true : false) ||
|
||||
isFieldGeneratingAi(key)}
|
||||
type={type}
|
||||
accessory={aiButtonForField(key)}
|
||||
/>)}
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { PhotoFormData, formHasTextContent } from '.';
|
||||
import useAiImageQueries from '../ai/useAiImageQueries';
|
||||
import { AiAutoGeneratedField } from '../ai';
|
||||
|
||||
export default function usePhotoFormParent({
|
||||
photoForm,
|
||||
shouldAutoGenerateText,
|
||||
textFieldsToAutoGenerate,
|
||||
}: {
|
||||
photoForm?: Partial<PhotoFormData>,
|
||||
shouldAutoGenerateText?: boolean,
|
||||
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
|
||||
} = {}) {
|
||||
const [pending, setIsPending] = useState(false);
|
||||
const [updatedTitle, setUpdatedTitle] = useState('');
|
||||
const [hasTextContent, setHasTextContent] =
|
||||
useState(photoForm ? formHasTextContent(photoForm) : false);
|
||||
|
||||
const aiContent = useAiImageQueries(shouldAutoGenerateText);
|
||||
const aiContent = useAiImageQueries(textFieldsToAutoGenerate);
|
||||
|
||||
return {
|
||||
pending,
|
||||
|
||||
@ -28,7 +28,13 @@ export const streamOpenAiImageQuery = async (
|
||||
) => {
|
||||
return safelyRunAdminServerAction(async () => {
|
||||
if (ratelimit) {
|
||||
const { success } = await ratelimit.limit(RATE_LIMIT_IDENTIFIER);
|
||||
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');
|
||||
|
||||
@ -43,9 +43,12 @@ export default function SiteChecklistClient({
|
||||
isGeoPrivacyEnabled,
|
||||
isPriorityOrderEnabled,
|
||||
isAiTextGenerationEnabled,
|
||||
aiTextAutoGeneratedFields,
|
||||
hasAiTextAutoGeneratedFields,
|
||||
isPublicApiEnabled,
|
||||
isOgTextBottomAligned,
|
||||
gridAspectRatio,
|
||||
hasGridAspectRatio,
|
||||
showRefreshButton,
|
||||
secret,
|
||||
}: ConfigChecklistStatus & {
|
||||
@ -299,6 +302,18 @@ export default function SiteChecklistClient({
|
||||
{' '}
|
||||
and connect to project in order to enable rate limiting
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
// eslint-disable-next-line max-len
|
||||
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(', ')}`}
|
||||
status={hasAiTextAutoGeneratedFields}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</Checklist>
|
||||
<Checklist
|
||||
title="Settings"
|
||||
@ -306,7 +321,7 @@ export default function SiteChecklistClient({
|
||||
optional
|
||||
>
|
||||
<ChecklistRow
|
||||
title="Pro Mode"
|
||||
title="Pro mode"
|
||||
status={isProModeEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -316,7 +331,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Image Blur"
|
||||
title="Image blur"
|
||||
status={isBlurEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -326,7 +341,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_BLUR_DISABLED'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Geo Privacy"
|
||||
title="Geo privacy"
|
||||
status={isGeoPrivacyEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -336,7 +351,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Priority Order"
|
||||
title="Priority order"
|
||||
status={isPriorityOrderEnabled}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -356,7 +371,7 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show Repo Link"
|
||||
title="Show repo link"
|
||||
status={showRepoLink}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
@ -384,24 +399,24 @@ export default function SiteChecklistClient({
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title={`Grid Aspect Ratio: ${gridAspectRatio}`}
|
||||
status={gridAspectRatio !== 0}
|
||||
title={`Grid aspect ratio: ${gridAspectRatio}`}
|
||||
status={hasGridAspectRatio}
|
||||
isPending={isPendingPage}
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Legacy OG Text Alignment"
|
||||
title="Legacy OG text alignment"
|
||||
status={isOgTextBottomAligned}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
>
|
||||
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'])}
|
||||
</ChecklistRow>
|
||||
</Checklist>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { parseAiAutoGeneratedFieldsText } from '@/photo/ai';
|
||||
import type { StorageType } from '@/services/storage';
|
||||
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
|
||||
|
||||
@ -94,6 +95,8 @@ 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';
|
||||
@ -135,10 +138,18 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user