Skip to content

Commit 713bd11

Browse files
feat(ui, api): prompt template export (#6745)
## Summary Adds option to download all prompt templates to a CSV ## Related Issues / Discussions <!--WHEN APPLICABLE: List any related issues or discussions on github or discord. If this PR closes an issue, please use the "Closes #1234" format, so that the issue will be automatically closed when the PR merges.--> ## QA Instructions <!--WHEN APPLICABLE: Describe how you have tested the changes in this PR. Provide enough detail that a reviewer can reproduce your tests.--> ## Merge Plan <!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like DB schemas, may need some care when merging. For example, a careful rebase by the change author, timing to not interfere with a pending release, or a message to contributors on discord after merging.--> ## Checklist - [ ] _The PR has a short but descriptive title, suitable for a changelog_ - [ ] _Tests added / updated (if applicable)_ - [ ] _Documentation added / updated (if applicable)_
2 parents 29bfe49 + 182571d commit 713bd11

File tree

15 files changed

+311
-127
lines changed

15 files changed

+311
-127
lines changed

invokeai/app/api/routers/style_presets.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import csv
12
import io
23
import json
34
import traceback
45
from typing import Optional
56

67
import pydantic
7-
from fastapi import APIRouter, File, Form, HTTPException, Path, UploadFile
8+
from fastapi import APIRouter, File, Form, HTTPException, Path, Response, UploadFile
89
from fastapi.responses import FileResponse
910
from PIL import Image
1011
from pydantic import BaseModel, Field
@@ -230,6 +231,35 @@ async def get_style_preset_image(
230231
raise HTTPException(status_code=404)
231232

232233

234+
@style_presets_router.get(
235+
"/export",
236+
operation_id="export_style_presets",
237+
responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}},
238+
status_code=200,
239+
)
240+
async def export_style_presets():
241+
# Create an in-memory stream to store the CSV data
242+
output = io.StringIO()
243+
writer = csv.writer(output)
244+
245+
# Write the header
246+
writer.writerow(["name", "prompt", "negative_prompt"])
247+
248+
style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(type=PresetType.User)
249+
250+
for preset in style_presets:
251+
writer.writerow([preset.name, preset.preset_data.positive_prompt, preset.preset_data.negative_prompt])
252+
253+
csv_data = output.getvalue()
254+
output.close()
255+
256+
return Response(
257+
content=csv_data,
258+
media_type="text/csv",
259+
headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"},
260+
)
261+
262+
233263
@style_presets_router.post(
234264
"/import",
235265
operation_id="import_style_presets",

invokeai/app/services/style_preset_records/style_preset_records_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from abc import ABC, abstractmethod
22

33
from invokeai.app.services.style_preset_records.style_preset_records_common import (
4+
PresetType,
45
StylePresetChanges,
56
StylePresetRecordDTO,
67
StylePresetWithoutId,
@@ -36,6 +37,6 @@ def delete(self, style_preset_id: str) -> None:
3637
pass
3738

3839
@abstractmethod
39-
def get_many(self) -> list[StylePresetRecordDTO]:
40+
def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]:
4041
"""Gets many workflows."""
4142
pass

invokeai/app/services/style_preset_records/style_preset_records_common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId
128128
style_presets.append(style_preset)
129129
except pydantic.ValidationError as e:
130130
if file.content_type == "text/csv":
131-
msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'"
131+
msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
132132
else: # file.content_type == "application/json":
133-
msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'"
133+
msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
134134
raise InvalidPresetImportDataError(msg) from e
135135
finally:
136136
file.file.close()

invokeai/app/services/style_preset_records/style_preset_records_sqlite.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
66
from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase
77
from invokeai.app.services.style_preset_records.style_preset_records_common import (
8+
PresetType,
89
StylePresetChanges,
910
StylePresetNotFoundError,
1011
StylePresetRecordDTO,
@@ -159,19 +160,25 @@ def delete(self, style_preset_id: str) -> None:
159160
self._lock.release()
160161
return None
161162

162-
def get_many(
163-
self,
164-
) -> list[StylePresetRecordDTO]:
163+
def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]:
165164
try:
166165
self._lock.acquire()
167166
main_query = """
168167
SELECT
169168
*
170169
FROM style_presets
171-
ORDER BY LOWER(name) ASC
172170
"""
173171

174-
self._cursor.execute(main_query)
172+
if type is not None:
173+
main_query += "WHERE type = ? "
174+
175+
main_query += "ORDER BY LOWER(name) ASC"
176+
177+
if type is not None:
178+
self._cursor.execute(main_query, (type,))
179+
else:
180+
self._cursor.execute(main_query)
181+
175182
rows = self._cursor.fetchall()
176183
style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows]
177184

invokeai/frontend/web/public/locales/en.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,14 +1701,21 @@
17011701
"deleteImage": "Delete Image",
17021702
"deleteTemplate": "Delete Template",
17031703
"deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.",
1704+
"exportPromptTemplates": "Export My Prompt Templates (CSV)",
17041705
"editTemplate": "Edit Template",
1706+
"exportDownloaded": "Export Downloaded",
1707+
"exportFailed": "Unable to generate and download CSV",
17051708
"flatten": "Flatten selected template into current prompt",
1706-
"importTemplates": "Import Prompt Templates",
1707-
"importTemplatesDesc": "Format must be either a CSV with columns: 'name', 'prompt' or 'positive_prompt', and 'negative_prompt' included, or a JSON file with keys 'name', 'prompt' or 'positive_prompt', and 'negative_prompt",
1709+
"importTemplates": "Import Prompt Templates (CSV/JSON)",
1710+
"acceptedColumnsKeys": "Accepted columns/keys:",
1711+
"nameColumn": "'name'",
1712+
"positivePromptColumn": "'prompt' or 'positive_prompt'",
1713+
"negativePromptColumn": "'negative_prompt'",
17081714
"insertPlaceholder": "Insert placeholder",
17091715
"myTemplates": "My Templates",
17101716
"name": "Name",
17111717
"negativePrompt": "Negative Prompt",
1718+
"noTemplates": "No templates",
17121719
"noMatchingTemplates": "No matching templates",
17131720
"promptTemplatesDesc1": "Prompt templates add text to the prompts you write in the prompt box.",
17141721
"promptTemplatesDesc2": "Use the placeholder string <Pre>{{placeholder}}</Pre> to specify where your prompt should be included in the template.",
@@ -1719,6 +1726,7 @@
17191726
"searchByName": "Search by name",
17201727
"shared": "Shared",
17211728
"sharedTemplates": "Shared Templates",
1729+
"templateActions": "Template Actions",
17221730
"templateDeleted": "Prompt template deleted",
17231731
"toggleViewMode": "Toggle View Mode",
17241732
"type": "Type",

invokeai/frontend/web/src/common/components/IAIImageFallback.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => {
4747
userSelect: 'none',
4848
opacity: 0.7,
4949
color: 'base.500',
50+
fontSize: 'md',
5051
...sx,
5152
}),
5253
[sx]
@@ -55,11 +56,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => {
5556
return (
5657
<Flex sx={styles} {...rest}>
5758
{icon && <Icon as={icon} boxSize={boxSize} opacity={0.7} />}
58-
{props.label && (
59-
<Text textAlign="center" fontSize="md">
60-
{props.label}
61-
</Text>
62-
)}
59+
{props.label && <Text textAlign="center">{props.label}</Text>}
6360
</Flex>
6461
);
6562
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { IconButton } from '@invoke-ai/ui-library';
2+
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
3+
import { useCallback } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
import { PiPlusBold } from 'react-icons/pi';
6+
7+
export const StylePresetCreateButton = () => {
8+
const handleClickAddNew = useCallback(() => {
9+
$stylePresetModalState.set({
10+
prefilledFormData: null,
11+
updatingStylePresetId: null,
12+
isModalOpen: true,
13+
});
14+
}, []);
15+
16+
const { t } = useTranslation();
17+
18+
return (
19+
<IconButton
20+
icon={<PiPlusBold />}
21+
tooltip={t('stylePresets.createPromptTemplate')}
22+
aria-label={t('stylePresets.createPromptTemplate')}
23+
onClick={handleClickAddNew}
24+
size="md"
25+
variant="ghost"
26+
w={8}
27+
h={8}
28+
/>
29+
);
30+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import { IconButton, spinAnimation } from '@invoke-ai/ui-library';
3+
import { EMPTY_ARRAY } from 'app/store/constants';
4+
import { toast } from 'features/toast/toast';
5+
import { useCallback } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi';
8+
import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
9+
10+
const loadingStyles: SystemStyleObject = {
11+
svg: { animation: spinAnimation },
12+
};
13+
14+
export const StylePresetExportButton = () => {
15+
const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery();
16+
const { t } = useTranslation();
17+
const { presetCount } = useListStylePresetsQuery(undefined, {
18+
selectFromResult: ({ data }) => {
19+
const userPresets = data?.filter((preset) => preset.type === 'user') ?? EMPTY_ARRAY;
20+
return {
21+
presetCount: userPresets.length,
22+
};
23+
},
24+
});
25+
const handleClickDownloadCsv = useCallback(async () => {
26+
let blob;
27+
try {
28+
const response = await exportStylePresets().unwrap();
29+
blob = new Blob([response], { type: 'text/csv' });
30+
} catch (error) {
31+
toast({
32+
status: 'error',
33+
title: t('stylePresets.exportFailed'),
34+
});
35+
return;
36+
}
37+
38+
if (blob) {
39+
const url = window.URL.createObjectURL(blob);
40+
const a = document.createElement('a');
41+
a.href = url;
42+
a.download = 'data.csv';
43+
document.body.appendChild(a);
44+
a.click();
45+
document.body.removeChild(a);
46+
window.URL.revokeObjectURL(url);
47+
toast({
48+
status: 'success',
49+
title: t('stylePresets.exportDownloaded'),
50+
});
51+
}
52+
}, [exportStylePresets, t]);
53+
54+
return (
55+
<IconButton
56+
onClick={handleClickDownloadCsv}
57+
icon={!isLoading ? <PiDownloadSimpleBold /> : <PiSpinner />}
58+
tooltip={t('stylePresets.exportPromptTemplates')}
59+
aria-label={t('stylePresets.exportPromptTemplates')}
60+
size="md"
61+
variant="link"
62+
w={8}
63+
h={8}
64+
sx={isLoading ? loadingStyles : undefined}
65+
isDisabled={isLoading || presetCount === 0}
66+
/>
67+
);
68+
};

invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetPromptField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const StylePresetPromptField = (props: Props) => {
3939
} else {
4040
field.onChange(value + PRESET_PLACEHOLDER);
4141
}
42+
43+
textareaRef.current?.focus();
4244
}, [value, field, textareaRef]);
4345

4446
const isPromptPresent = useMemo(() => value?.includes(PRESET_PLACEHOLDER), [value]);

invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx

Lines changed: 0 additions & 69 deletions
This file was deleted.

0 commit comments

Comments
 (0)