Add individual AI text requests, upgrade documentation

This commit is contained in:
Sam Becker 2024-03-22 15:15:00 -05:00
parent 58ebc3902f
commit 65132a0862
15 changed files with 329 additions and 102 deletions

View File

@ -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

View File

@ -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,
}} />
);
};

View File

@ -1,4 +1,4 @@
import clsx from 'clsx/lite';
import { clsx } from 'clsx/lite';
import Badge from './Badge';
export default function ExperimentalBadge({

View File

@ -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>
);
};

View File

@ -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

View File

@ -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>

View File

@ -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"',

View File

@ -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,

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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

View File

@ -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,

View File

@ -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');

View File

@ -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>

View File

@ -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;