mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: Import + Translate recipe images with OpenAI (#3974)
Co-authored-by: Johan Lindell <johan@lindell.me> Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
parent
3d921cb677
commit
8a15f400e1
@ -17,9 +17,16 @@ class PathObject(BaseModel):
|
||||
http_verbs: list[HTTPRequest]
|
||||
|
||||
|
||||
def get_path_objects(app: FastAPI):
|
||||
paths = []
|
||||
def force_include_in_schema(app: FastAPI):
|
||||
# clear schema cache
|
||||
app.openapi_schema = None
|
||||
for route in app.routes:
|
||||
route.include_in_schema = True
|
||||
|
||||
|
||||
def get_path_objects(app: FastAPI):
|
||||
force_include_in_schema(app)
|
||||
paths = []
|
||||
for key, value in app.openapi().items():
|
||||
if key == "paths":
|
||||
for key, value2 in value.items():
|
||||
|
@ -106,14 +106,15 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
|
||||
|
||||
Mealie supports various integrations using OpenAI. To enable OpenAI, [you must provide your OpenAI API key](https://platform.openai.com/api-keys). You can tweak how OpenAI is used using these backend settings. Please note that while OpenAI usage is optimized to reduce API costs, you're unlikely to be able to use OpenAI features with the free tier limits.
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 10 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| Variables | Default | Description |
|
||||
| ---------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
|
||||
### Themeing
|
||||
|
||||
|
@ -1,25 +1,26 @@
|
||||
<template>
|
||||
<v-app dark>
|
||||
<TheSnackbar />
|
||||
<v-app dark>
|
||||
<TheSnackbar />
|
||||
|
||||
<AppSidebar
|
||||
v-model="sidebar"
|
||||
absolute
|
||||
:top-link="topLinks"
|
||||
:secondary-links="cookbookLinks || []"
|
||||
:bottom-links="isAdmin ? bottomLinks : []"
|
||||
>
|
||||
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
|
||||
<v-icon left large color="primary">
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
{{ $t("general.create") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense class="my-0 py-0">
|
||||
<template v-for="(item, index) in createLinks">
|
||||
<AppSidebar
|
||||
v-model="sidebar"
|
||||
absolute
|
||||
:top-link="topLinks"
|
||||
:secondary-links="cookbookLinks || []"
|
||||
:bottom-links="isAdmin ? bottomLinks : []"
|
||||
>
|
||||
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-if="isOwnGroup" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
|
||||
<v-icon left large color="primary">
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
{{ $t("general.create") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense class="my-0 py-0">
|
||||
<template v-for="(item, index) in createLinks">
|
||||
<div v-if="!item.hide" :key="item.title">
|
||||
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider>
|
||||
<v-list-item v-if="!item.restricted || isOwnGroup" :key="item.title" :to="item.to" exact>
|
||||
<v-list-item-avatar>
|
||||
@ -36,195 +37,212 @@
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<template #bottom>
|
||||
<v-list-item @click.stop="languageDialog = true">
|
||||
<v-list-item-icon>
|
||||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleDark">
|
||||
<v-list-item-icon>
|
||||
<v-icon>
|
||||
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||
</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>
|
||||
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</AppSidebar>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<template #bottom>
|
||||
<v-list-item @click.stop="languageDialog = true">
|
||||
<v-list-item-icon>
|
||||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleDark">
|
||||
<v-list-item-icon>
|
||||
<v-icon>
|
||||
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||
</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>
|
||||
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</AppSidebar>
|
||||
|
||||
<AppHeader>
|
||||
<v-btn icon @click.stop="sidebar = !sidebar">
|
||||
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
||||
</v-btn>
|
||||
</AppHeader>
|
||||
<v-main>
|
||||
<v-scroll-x-transition>
|
||||
<Nuxt />
|
||||
</v-scroll-x-transition>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
<AppHeader>
|
||||
<v-btn icon @click.stop="sidebar = !sidebar">
|
||||
<v-icon> {{ $globals.icons.menu }}</v-icon>
|
||||
</v-btn>
|
||||
</AppHeader>
|
||||
<v-main>
|
||||
<v-scroll-x-transition>
|
||||
<Nuxt />
|
||||
</v-scroll-x-transition>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
||||
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
||||
import { SidebarLinks } from "~/types/application-types";
|
||||
import LanguageDialog from "~/components/global/LanguageDialog.vue";
|
||||
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
||||
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
||||
import { SidebarLinks } from "~/types/application-types";
|
||||
import LanguageDialog from "~/components/global/LanguageDialog.vue";
|
||||
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
|
||||
export default defineComponent({
|
||||
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
|
||||
setup() {
|
||||
const { $globals, $auth, $vuetify, i18n } = useContext();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
export default defineComponent({
|
||||
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
|
||||
setup() {
|
||||
const { $globals, $auth, $vuetify, i18n } = useContext();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const isAdmin = computed(() => $auth.user?.admin);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
|
||||
const isAdmin = computed(() => $auth.user?.admin);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { cookbooks } = isOwnGroup.value ? useCookbooks() : usePublicCookbooks(groupSlug.value || "");
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
const appInfo = useAppInfo();
|
||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
||||
|
||||
const languageDialog = ref<boolean>(false);
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
const sidebar = ref<boolean | null>(null);
|
||||
const languageDialog = ref<boolean>(false);
|
||||
|
||||
onMounted(() => {
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
const sidebar = ref<boolean | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
sidebar.value = !$vuetify.breakpoint.md;
|
||||
});
|
||||
|
||||
const cookbookLinks = computed(() => {
|
||||
if (!cookbooks.value) return [];
|
||||
return cookbooks.value.map((cookbook) => {
|
||||
return {
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const cookbookLinks = computed(() => {
|
||||
if (!cookbooks.value) return [];
|
||||
return cookbooks.value.map((cookbook) => {
|
||||
return {
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
interface Link {
|
||||
insertDivider: boolean;
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
to: string;
|
||||
restricted: boolean;
|
||||
hide: boolean;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
insertDivider: boolean;
|
||||
icon: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
to: string;
|
||||
restricted: boolean;
|
||||
}
|
||||
const createLinks = computed<Link[]>(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
title: i18n.tc("general.import"),
|
||||
subtitle: i18n.tc("new-recipe.import-by-url"),
|
||||
to: `/g/${groupSlug.value}/r/create/url`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.fileImage,
|
||||
title: i18n.tc("recipe.create-from-image"),
|
||||
subtitle: i18n.tc("recipe.create-recipe-from-an-image"),
|
||||
to: `/g/${groupSlug.value}/r/create/image`,
|
||||
restricted: true,
|
||||
hide: !showImageImport.value,
|
||||
},
|
||||
{
|
||||
insertDivider: true,
|
||||
icon: $globals.icons.edit,
|
||||
title: i18n.tc("general.create"),
|
||||
subtitle: i18n.tc("new-recipe.create-manually"),
|
||||
to: `/g/${groupSlug.value}/r/create/new`,
|
||||
restricted: true,
|
||||
hide: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const createLinks = computed<Link[]>(() => [
|
||||
{
|
||||
insertDivider: false,
|
||||
icon: $globals.icons.link,
|
||||
title: i18n.tc("general.import"),
|
||||
subtitle: i18n.tc("new-recipe.import-by-url"),
|
||||
to: `/g/${groupSlug.value}/r/create/url`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
insertDivider: true,
|
||||
icon: $globals.icons.edit,
|
||||
title: i18n.tc("general.create"),
|
||||
subtitle: i18n.tc("new-recipe.create-manually"),
|
||||
to: `/g/${groupSlug.value}/r/create/new`,
|
||||
restricted: true,
|
||||
},
|
||||
]);
|
||||
const bottomLinks = computed<SidebarLinks>(() => [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.tc("general.settings"),
|
||||
to: "/admin/site-settings",
|
||||
restricted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const bottomLinks = computed<SidebarLinks>(() => [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.tc("general.settings"),
|
||||
to: "/admin/site-settings",
|
||||
restricted: true,
|
||||
},
|
||||
]);
|
||||
const topLinks = computed<SidebarLinks>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.tc("general.recipes"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
title: i18n.tc("meal-plan.meal-planner"),
|
||||
to: "/group/mealplan/planner/view",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.formatListCheck,
|
||||
title: i18n.tc("shopping-list.shopping-lists"),
|
||||
to: "/shopping-lists",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.timelineText,
|
||||
title: i18n.tc("recipe.timeline"),
|
||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.book,
|
||||
to: `/g/${groupSlug.value}/cookbooks`,
|
||||
title: i18n.tc("cookbook.cookbooks"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.organizers,
|
||||
title: i18n.tc("general.organizers"),
|
||||
restricted: true,
|
||||
children: [
|
||||
{
|
||||
icon: $globals.icons.categories,
|
||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||
title: i18n.tc("sidebar.categories"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.tags,
|
||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||
title: i18n.tc("sidebar.tags"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.potSteam,
|
||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||
title: i18n.tc("tool.tools"),
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const topLinks = computed<SidebarLinks>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
title: i18n.tc("general.recipes"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.calendarMultiselect,
|
||||
title: i18n.tc("meal-plan.meal-planner"),
|
||||
to: "/group/mealplan/planner/view",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.formatListCheck,
|
||||
title: i18n.tc("shopping-list.shopping-lists"),
|
||||
to: "/shopping-lists",
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.timelineText,
|
||||
title: i18n.tc("recipe.timeline"),
|
||||
to: `/g/${groupSlug.value}/recipes/timeline`,
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.book,
|
||||
to: `/g/${groupSlug.value}/cookbooks`,
|
||||
title: i18n.tc("cookbook.cookbooks"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.organizers,
|
||||
title: i18n.tc("general.organizers"),
|
||||
restricted: true,
|
||||
children: [
|
||||
{
|
||||
icon: $globals.icons.categories,
|
||||
to: `/g/${groupSlug.value}/recipes/categories`,
|
||||
title: i18n.tc("sidebar.categories"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.tags,
|
||||
to: `/g/${groupSlug.value}/recipes/tags`,
|
||||
title: i18n.tc("sidebar.tags"),
|
||||
restricted: true,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.potSteam,
|
||||
to: `/g/${groupSlug.value}/recipes/tools`,
|
||||
title: i18n.tc("tool.tools"),
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
groupSlug,
|
||||
cookbookLinks,
|
||||
createLinks,
|
||||
bottomLinks,
|
||||
topLinks,
|
||||
isAdmin,
|
||||
isOwnGroup,
|
||||
languageDialog,
|
||||
toggleDark,
|
||||
sidebar,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
return {
|
||||
groupSlug,
|
||||
cookbookLinks,
|
||||
createLinks,
|
||||
bottomLinks,
|
||||
topLinks,
|
||||
isAdmin,
|
||||
isOwnGroup,
|
||||
languageDialog,
|
||||
toggleDark,
|
||||
sidebar,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -1,6 +1,16 @@
|
||||
<template>
|
||||
<v-container class="pa-0">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="8" align-self="center">
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
class="cropper"
|
||||
:src="img"
|
||||
:default-size="defaultSize"
|
||||
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
|
||||
/>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col cols="2" align-self="center">
|
||||
<v-container class="pa-0 mx-0">
|
||||
<v-row v-for="(row, keyRow) in controls" :key="keyRow">
|
||||
@ -17,16 +27,6 @@
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col cols="8" align-self="center">
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
class="cropper"
|
||||
:src="img"
|
||||
:default-size="defaultSize"
|
||||
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
@ -561,7 +561,12 @@
|
||||
"create-recipe-description": "Create a new recipe from scratch.",
|
||||
"create-recipes": "Create Recipes",
|
||||
"import-with-zip": "Import with .zip",
|
||||
"create-recipe-from-an-image": "Create recipe from an image",
|
||||
"create-recipe-from-an-image": "Create Recipe from an Image",
|
||||
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
|
||||
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
|
||||
"create-from-image": "Create from Image",
|
||||
"should-translate-description": "Translate the recipe into my language",
|
||||
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
|
||||
"bulk-url-import": "Bulk URL Import",
|
||||
"debug-scraper": "Debug Scraper",
|
||||
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",
|
||||
|
@ -42,6 +42,7 @@ export interface AppInfo {
|
||||
oidcRedirect: boolean;
|
||||
oidcProviderName: string;
|
||||
enableOpenai: boolean;
|
||||
enableOpenaiImageServices: boolean;
|
||||
}
|
||||
export interface AppStartupInfo {
|
||||
isFirstLogin: boolean;
|
||||
|
@ -35,6 +35,7 @@ const routes = {
|
||||
recipesCreateUrl: `${prefix}/recipes/create-url`,
|
||||
recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`,
|
||||
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
|
||||
recipesCreateFromImage: `${prefix}/recipes/create-from-image`,
|
||||
recipesCategory: `${prefix}/recipes/category`,
|
||||
recipesParseIngredient: `${prefix}/parser/ingredient`,
|
||||
recipesParseIngredients: `${prefix}/parser/ingredients`,
|
||||
@ -140,6 +141,19 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
||||
return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload);
|
||||
}
|
||||
|
||||
async createOneFromImage(fileObject: Blob | File, fileName: string, translateLanguage: string | null = null) {
|
||||
const formData = new FormData();
|
||||
formData.append("images", fileObject);
|
||||
formData.append("extension", fileName.split(".").pop() ?? "");
|
||||
|
||||
let apiRoute = routes.recipesCreateFromImage
|
||||
if (translateLanguage) {
|
||||
apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`
|
||||
}
|
||||
|
||||
return await this.requests.post<string>(apiRoute, formData);
|
||||
}
|
||||
|
||||
async parseIngredients(parser: Parser, ingredients: Array<string>) {
|
||||
parser = parser || "nlp";
|
||||
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
|
||||
|
@ -28,6 +28,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
|
||||
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
|
||||
|
||||
@ -37,7 +38,10 @@ export default defineComponent({
|
||||
setup() {
|
||||
const { $auth, $globals, i18n } = useContext();
|
||||
|
||||
const subpages: MenuItem[] = [
|
||||
const appInfo = useAppInfo();
|
||||
const enableOpenAIImages = computed(() => appInfo.value?.enableOpenaiImageServices);
|
||||
|
||||
const subpages = computed<MenuItem[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.link,
|
||||
text: i18n.tc("recipe.import-with-url"),
|
||||
@ -48,6 +52,12 @@ export default defineComponent({
|
||||
text: i18n.tc("recipe.bulk-url-import"),
|
||||
value: "bulk",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.fileImage,
|
||||
text: i18n.tc("recipe.create-from-image"),
|
||||
value: "image",
|
||||
hide: !enableOpenAIImages.value,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.edit,
|
||||
text: i18n.tc("recipe.create-recipe"),
|
||||
@ -63,7 +73,7 @@ export default defineComponent({
|
||||
text: i18n.tc("recipe.debug-scraper"),
|
||||
value: "debug",
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
161
frontend/pages/g/_groupSlug/r/create/image.vue
Normal file
161
frontend/pages/g/_groupSlug/r/create/image.vue
Normal file
@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
|
||||
<div>
|
||||
<v-card-title class="headline"> {{ $t('recipe.create-recipe-from-an-image') }} </v-card-title>
|
||||
<v-card-text>
|
||||
<p>{{ $t('recipe.create-recipe-from-an-image-description') }}</p>
|
||||
<v-container class="pa-0">
|
||||
<v-row>
|
||||
<v-col cols="auto" align-self="center">
|
||||
<AppButtonUpload
|
||||
v-if="!uploadedImage"
|
||||
class="ml-auto"
|
||||
url="none"
|
||||
file-name="image"
|
||||
accept="image/*"
|
||||
:text="$i18n.tc('recipe.upload-image')"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@uploaded="uploadImage"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!!uploadedImage"
|
||||
color="error"
|
||||
@click="clearImage"
|
||||
>
|
||||
<v-icon left>{{ $globals.icons.close }}</v-icon>
|
||||
{{ $i18n.tc('recipe.remove-image') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
</v-row>
|
||||
|
||||
<div v-if="uploadedImage && uploadedImagePreviewUrl" class="mt-3">
|
||||
<v-row>
|
||||
<v-col cols="12" class="pb-0">
|
||||
<v-card-text class="pa-0">
|
||||
<p class="mb-0">
|
||||
{{ $t('recipe.crop-and-rotate-the-image') }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row style="max-width: 600px;">
|
||||
<v-spacer />
|
||||
<v-col cols="12">
|
||||
<ImageCropper
|
||||
:img="uploadedImagePreviewUrl"
|
||||
cropper-height="50vh"
|
||||
cropper-width="100%"
|
||||
@save="updateUploadedImage"
|
||||
/>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
</v-row>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions v-if="uploadedImage">
|
||||
<div>
|
||||
<p style="width: 250px">
|
||||
<BaseButton rounded block type="submit" :loading="loading" />
|
||||
</p>
|
||||
<p>
|
||||
<v-checkbox
|
||||
v-model="shouldTranslate"
|
||||
hide-details
|
||||
:label="$t('recipe.should-translate-description')"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</p>
|
||||
<p v-if="loading" class="mb-0">
|
||||
{{ $t('recipe.please-wait-image-procesing') }}
|
||||
</p>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</v-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useContext,
|
||||
useRoute,
|
||||
useRouter,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { i18n } = useContext();
|
||||
const api = useUserApi();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || "");
|
||||
|
||||
const domUrlForm = ref<VForm | null>(null);
|
||||
const uploadedImage = ref<Blob | File>();
|
||||
const uploadedImageName = ref<string>("");
|
||||
const uploadedImagePreviewUrl = ref<string>();
|
||||
const shouldTranslate = ref(true);
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
uploadedImage.value = fileObject;
|
||||
uploadedImageName.value = fileObject.name;
|
||||
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function updateUploadedImage(fileObject: Blob) {
|
||||
uploadedImage.value = fileObject;
|
||||
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
uploadedImage.value = undefined;
|
||||
uploadedImageName.value = "";
|
||||
uploadedImagePreviewUrl.value = undefined;
|
||||
}
|
||||
|
||||
async function createRecipe() {
|
||||
if (!uploadedImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
|
||||
const { data, error } = await api.recipes.createOneFromImage(uploadedImage.value, uploadedImageName.value, translateLanguage);
|
||||
if (error || !data) {
|
||||
alert.error(i18n.tc("events.something-went-wrong"));
|
||||
state.loading = false;
|
||||
} else {
|
||||
router.push(`/g/${groupSlug.value}/r/${data}`);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
domUrlForm,
|
||||
uploadedImage,
|
||||
uploadedImagePreviewUrl,
|
||||
shouldTranslate,
|
||||
uploadImage,
|
||||
updateUploadedImage,
|
||||
clearImage,
|
||||
createRecipe,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -304,6 +304,8 @@ class AppSettings(AppLoggingSettings):
|
||||
"""Your OpenAI API key. Required to enable OpenAI features"""
|
||||
OPENAI_MODEL: str = "gpt-4o"
|
||||
"""Which OpenAI model to send requests to. Leave this unset for most usecases"""
|
||||
OPENAI_ENABLE_IMAGE_SERVICES: bool = True
|
||||
"""Whether to enable image-related features in OpenAI"""
|
||||
OPENAI_WORKERS: int = 2
|
||||
"""
|
||||
Number of OpenAI workers per request. Higher values may increase
|
||||
@ -314,8 +316,7 @@ class AppSettings(AppLoggingSettings):
|
||||
Sending database data may increase accuracy in certain requests,
|
||||
but will incur additional API costs
|
||||
"""
|
||||
|
||||
OPENAI_REQUEST_TIMEOUT: int = 10
|
||||
OPENAI_REQUEST_TIMEOUT: int = 60
|
||||
"""
|
||||
The number of seconds to wait for an OpenAI request to complete before cancelling the request
|
||||
"""
|
||||
|
@ -34,6 +34,7 @@ class AdminAboutController(BaseAdminController):
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
enable_openai=settings.OPENAI_ENABLED,
|
||||
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
|
||||
)
|
||||
|
||||
@router.get("/statistics", response_model=AppStatistics)
|
||||
|
@ -33,6 +33,7 @@ def get_app_info(session: Session = Depends(generate_session)):
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
enable_openai=settings.OPENAI_ENABLED,
|
||||
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
|
||||
)
|
||||
|
||||
|
||||
|
@ -254,6 +254,9 @@ class RecipeController(BaseRecipeController):
|
||||
|
||||
return "recipe_scrapers was unable to scrape this URL"
|
||||
|
||||
# ==================================================================================================================
|
||||
# Other Create Operations
|
||||
|
||||
@router.post("/create-from-zip", status_code=201)
|
||||
def create_recipe_from_zip(self, archive: UploadFile = File(...)):
|
||||
"""Create recipe from archive"""
|
||||
@ -266,6 +269,31 @@ class RecipeController(BaseRecipeController):
|
||||
|
||||
return recipe.slug
|
||||
|
||||
@router.post("/create-from-image", status_code=201)
|
||||
async def create_recipe_from_image(
|
||||
self,
|
||||
images: list[UploadFile] = File(...),
|
||||
translate_language: str | None = Query(None, alias="translateLanguage"),
|
||||
):
|
||||
"""
|
||||
Create a recipe from an image using OpenAI.
|
||||
Optionally specify a language for it to translate the recipe to.
|
||||
"""
|
||||
|
||||
if not (self.settings.OPENAI_ENABLED and self.settings.OPENAI_ENABLE_IMAGE_SERVICES):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=ErrorResponse.respond("OpenAI image services are not enabled"),
|
||||
)
|
||||
|
||||
recipe = await self.service.create_from_images(images, translate_language)
|
||||
self.publish_event(
|
||||
event_type=EventTypes.recipe_created,
|
||||
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug),
|
||||
)
|
||||
|
||||
return recipe.slug
|
||||
|
||||
# ==================================================================================================================
|
||||
# CRUD Operations
|
||||
|
||||
|
@ -19,6 +19,7 @@ class AppInfo(MealieModel):
|
||||
oidc_redirect: bool
|
||||
oidc_provider_name: str
|
||||
enable_openai: bool
|
||||
enable_openai_image_services: bool
|
||||
|
||||
|
||||
class AppTheme(MealieModel):
|
||||
|
@ -1,5 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class OpenAIBase(BaseModel):
|
||||
"""
|
||||
@ -8,3 +12,16 @@ class OpenAIBase(BaseModel):
|
||||
"""
|
||||
|
||||
__doc__ = "" # we don't want to include the docstring in the JSON schema
|
||||
|
||||
@classmethod
|
||||
def parse_openai_response(cls, response: str | None):
|
||||
"""
|
||||
This method should be implemented in the child class. It should
|
||||
parse the JSON response from OpenAI and return a dictionary.
|
||||
"""
|
||||
|
||||
try:
|
||||
return cls.model_validate_json(response or "")
|
||||
except Exception:
|
||||
logger.debug(f"Failed to parse OpenAI response as {cls}. Response: {response}")
|
||||
raise
|
||||
|
205
mealie/schema/openai/recipe.py
Normal file
205
mealie/schema/openai/recipe.py
Normal file
@ -0,0 +1,205 @@
|
||||
from textwrap import dedent
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ._base import OpenAIBase
|
||||
|
||||
|
||||
class OpenAIRecipeIngredient(OpenAIBase):
|
||||
title: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The title of the section of the recipe that the ingredient is found in. Recipes may not specify
|
||||
ingredient sections, in which case this should be left blank.
|
||||
Only the first item in the section should have this set,
|
||||
whereas subsuquent items should have their titles left blank (unless they start a new section).
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
text: str = Field(
|
||||
...,
|
||||
description=dedent(
|
||||
"""
|
||||
The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
|
||||
"2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
|
||||
since this field is required.
|
||||
|
||||
If the ingredient has no text, but has a title, include the title on the
|
||||
next ingredient instead.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OpenAIRecipeInstruction(OpenAIBase):
|
||||
title: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The title of the section of the recipe that the instruction is found in. Recipes may not specify
|
||||
instruction sections, in which case this should be left blank.
|
||||
Only the first instruction in the section should have this set,
|
||||
whereas subsuquent instructions should have their titles left blank (unless they start a new section).
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
text: str = Field(
|
||||
...,
|
||||
description=dedent(
|
||||
"""
|
||||
The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
|
||||
or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
|
||||
salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
|
||||
|
||||
Sometimes, but not always, recipes will include their number in front of the text, such as
|
||||
"1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
|
||||
("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
|
||||
the text. However, if they use words ("First", "Second", etc.), then those should be included.
|
||||
|
||||
If the instruction is completely blank, skip it and do not add the instruction, since this field is
|
||||
required. If the ingredient has no text, but has a title, include the title on the next
|
||||
instruction instead.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OpenAIRecipeNotes(OpenAIBase):
|
||||
title: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The title of the note. Notes may not specify a title, and just have a body of text. In this case,
|
||||
title should be left blank, and all content should go in the note text. If the note title is just
|
||||
"note" or "info", you should ignore it and leave the title blank.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
text: str = Field(
|
||||
...,
|
||||
description=dedent(
|
||||
"""
|
||||
The text of the note. This should represent the entire note, such as "This recipe is great for
|
||||
a summer picnic" or "This recipe is a family favorite". They may also include additional prep
|
||||
instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
|
||||
the dough the night before and refrigerate it until ready to bake".
|
||||
|
||||
If the note is completely blank, skip it and do not add the note, since this field is required.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OpenAIRecipe(OpenAIBase):
|
||||
name: str = Field(
|
||||
...,
|
||||
description=dedent(
|
||||
"""
|
||||
The name or title of the recipe. If you're unable to determine the name of the recipe, you should
|
||||
make your best guess based upon the ingredients and instructions provided.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
description: str | None = Field(
|
||||
...,
|
||||
description=dedent(
|
||||
"""
|
||||
A long description of the recipe. This should be a string that describes the recipe in a few words
|
||||
or sentences. If the recipe doesn't have a description, you should return None.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
recipe_yield: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
|
||||
If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
|
||||
by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
|
||||
isn't specified, you should return None.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
total_time: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The total time it takes to make the recipe. This should be a string that describes a duration of time,
|
||||
such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
|
||||
the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
|
||||
perform time but not a total time, you should return None. Do not duplicate times between total time, prep
|
||||
time and perform time.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
prep_time: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The time it takes to prepare the recipe. This should be a string that describes a duration of time,
|
||||
such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
|
||||
less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
|
||||
supplies only one time, it should be the total time. Do not duplicate times between total time, prep
|
||||
time and coperformok time.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
perform_time: str | None = Field(
|
||||
None,
|
||||
description=dedent(
|
||||
"""
|
||||
The time it takes to cook the recipe. This should be a string that describes a duration of time,
|
||||
such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
|
||||
less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
|
||||
recipe specifies a cook time, active time, or other time besides total or prep, you should use that
|
||||
time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
|
||||
perform time. Do not duplicate times between total time, prep time and perform time.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
ingredients: list[OpenAIRecipeIngredient] = Field(
|
||||
[],
|
||||
description=dedent(
|
||||
"""
|
||||
A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
|
||||
Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
|
||||
separate ingredients.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
instructions: list[OpenAIRecipeInstruction] = Field(
|
||||
[],
|
||||
description=dedent(
|
||||
"""
|
||||
A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
|
||||
Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
|
||||
Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
|
||||
"1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
notes: list[OpenAIRecipeNotes] = Field(
|
||||
[],
|
||||
description=dedent(
|
||||
"""
|
||||
A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
|
||||
They may appear anywhere on the recipe, though they are typically found under the instructions.
|
||||
"""
|
||||
),
|
||||
)
|
@ -1,6 +1,8 @@
|
||||
from .openai import OpenAIDataInjection, OpenAIService
|
||||
from .openai import OpenAIDataInjection, OpenAIImageExternal, OpenAILocalImage, OpenAIService
|
||||
|
||||
__all__ = [
|
||||
"OpenAIDataInjection",
|
||||
"OpenAIImageExternal",
|
||||
"OpenAILocalImage",
|
||||
"OpenAIService",
|
||||
]
|
||||
|
@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
@ -9,6 +11,7 @@ from openai.resources.chat.completions import ChatCompletion
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.pkgs import img
|
||||
|
||||
from .._base_service import BaseService
|
||||
|
||||
@ -39,6 +42,37 @@ class OpenAIDataInjection(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class OpenAIImageBase(BaseModel, ABC):
|
||||
@abstractmethod
|
||||
def get_image_url(self) -> str: ...
|
||||
|
||||
def build_message(self) -> dict:
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.get_image_url()},
|
||||
}
|
||||
|
||||
|
||||
class OpenAIImageExternal(OpenAIImageBase):
|
||||
url: str
|
||||
|
||||
def get_image_url(self) -> str:
|
||||
return self.url
|
||||
|
||||
|
||||
class OpenAILocalImage(OpenAIImageBase):
|
||||
filename: str
|
||||
path: Path
|
||||
|
||||
def get_image_url(self) -> str:
|
||||
image = img.PillowMinifier.to_webp(
|
||||
self.path, dest=self.path.parent.joinpath(f"{self.filename}-min-original.webp")
|
||||
)
|
||||
with open(image, "rb") as f:
|
||||
b64content = base64.b64encode(f.read()).decode("utf-8")
|
||||
return f"data:image/webp;base64,{b64content}"
|
||||
|
||||
|
||||
class OpenAIService(BaseService):
|
||||
PROMPTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) / "prompts"
|
||||
|
||||
@ -50,6 +84,7 @@ class OpenAIService(BaseService):
|
||||
self.model = settings.OPENAI_MODEL
|
||||
self.workers = settings.OPENAI_WORKERS
|
||||
self.send_db_data = settings.OPENAI_SEND_DATABASE_DATA
|
||||
self.enable_image_services = settings.OPENAI_ENABLE_IMAGE_SERVICES
|
||||
|
||||
self.get_client = lambda: AsyncOpenAI(
|
||||
base_url=settings.OPENAI_BASE_URL,
|
||||
@ -99,7 +134,7 @@ class OpenAIService(BaseService):
|
||||
return "\n".join(content_parts)
|
||||
|
||||
async def _get_raw_response(
|
||||
self, prompt: str, message: str, temperature=0.2, force_json_response=True
|
||||
self, prompt: str, content: list[dict], temperature=0.2, force_json_response=True
|
||||
) -> ChatCompletion:
|
||||
client = self.get_client()
|
||||
return await client.chat.completions.create(
|
||||
@ -110,7 +145,7 @@ class OpenAIService(BaseService):
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": message,
|
||||
"content": content,
|
||||
},
|
||||
],
|
||||
model=self.model,
|
||||
@ -118,10 +153,26 @@ class OpenAIService(BaseService):
|
||||
response_format={"type": "json_object"} if force_json_response else NOT_GIVEN,
|
||||
)
|
||||
|
||||
async def get_response(self, prompt: str, message: str, temperature=0.2, force_json_response=True) -> str | None:
|
||||
async def get_response(
|
||||
self,
|
||||
prompt: str,
|
||||
message: str,
|
||||
*,
|
||||
images: list[OpenAIImageBase] | None = None,
|
||||
temperature=0.2,
|
||||
force_json_response=True,
|
||||
) -> str | None:
|
||||
"""Send data to OpenAI and return the response message content"""
|
||||
if images and not self.enable_image_services:
|
||||
self.logger.warning("OpenAI image services are disabled, ignoring images")
|
||||
images = None
|
||||
|
||||
try:
|
||||
response = await self._get_raw_response(prompt, message, temperature, force_json_response)
|
||||
user_messages = [{"type": "text", "text": message}]
|
||||
for image in images or []:
|
||||
user_messages.append(image.build_message())
|
||||
|
||||
response = await self._get_raw_response(prompt, user_messages, temperature, force_json_response)
|
||||
if not response.choices:
|
||||
return None
|
||||
return response.choices[0].message.content
|
||||
|
@ -0,0 +1,11 @@
|
||||
You are a bot that reads an image, or a set of images, and parses it into recipe JSON. You will receive an image from the user and you need to extract the recipe data and return its JSON in valid schema. The recipe schema will be included at the bottom of this message.
|
||||
|
||||
It is imperative that you do not create any data or otherwise make up any information. Failure to adhere to this rule is illegal and will result in harsh punishment. If you are unable to extract data due to insufficient input, you may reply with a completely empty JSON object (represented by two brackets: {}).
|
||||
|
||||
Do not under any circumstances insert data not found directly in the image. Ingredients, instructions, and notes should come directly from the image and not be generated or otherwise made up. It is illegal for you to create information not found directly in the image.
|
||||
|
||||
Your response must be in valid JSON in the provided Recipe definition below. You must respond in this JSON schema; failure to do so is illegal. It is imperative that you follow the schema precisely to avoid punishment. You must follow the JSON schema.
|
||||
|
||||
The user message that you receive will be one or more images. Assume all images provided belong to a single recipe, not multiple recipes. The recipe may consist of printed text or handwritten text. It may be rotated or not properly cropped. It is your job to figure out which part of the image is the important content and extract it.
|
||||
|
||||
The text you receive in the provided image or images may not be in English. The user may provide a language for you to translate the recipe into. If the user doesn't ask for a translation, you should preserve the text as-is without translating or otherwise modifying it. Otherwise, you should translate all text (recipe name, ingredients, instructions, etc.) to the requested language.
|
@ -82,7 +82,7 @@ class OpenAIParser(ABCIngredientParser):
|
||||
# re-combine chunks into one response
|
||||
responses_json = await asyncio.gather(*tasks)
|
||||
responses = [
|
||||
OpenAIIngredients.model_validate_json(response_json) for response_json in responses_json if responses_json
|
||||
OpenAIIngredients.parse_openai_response(response_json) for response_json in responses_json if responses_json
|
||||
]
|
||||
if not responses:
|
||||
raise Exception("No response from OpenAI")
|
||||
|
@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@ -11,24 +12,29 @@ from fastapi import UploadFile
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.core import exceptions
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.dependencies.dependencies import get_temporary_path
|
||||
from mealie.lang.providers import Translator
|
||||
from mealie.pkgs import cache
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.repos.repository_generic import RepositoryGeneric
|
||||
from mealie.schema.openai.recipe import OpenAIRecipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_notes import RecipeNote
|
||||
from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
from mealie.schema.recipe.recipe_step import RecipeStep
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||
from mealie.schema.recipe.request_helpers import RecipeDuplicate
|
||||
from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
from .template_service import TemplateService
|
||||
|
||||
|
||||
class RecipeService(BaseService):
|
||||
class RecipeServiceBase(BaseService):
|
||||
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB, translator: Translator):
|
||||
self.repos = repos
|
||||
self.user = user
|
||||
@ -39,6 +45,8 @@ class RecipeService(BaseService):
|
||||
|
||||
super().__init__()
|
||||
|
||||
|
||||
class RecipeService(RecipeServiceBase):
|
||||
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
|
||||
recipe = self.repos.recipes.by_group(self.group.id).get_one(data, key)
|
||||
if recipe is None:
|
||||
@ -250,6 +258,26 @@ class RecipeService(BaseService):
|
||||
|
||||
return recipe
|
||||
|
||||
async def create_from_images(self, images: list[UploadFile], translate_language: str | None = None) -> Recipe:
|
||||
openai_recipe_service = OpenAIRecipeService(self.repos, self.user, self.group, self.translator)
|
||||
with get_temporary_path() as temp_path:
|
||||
local_images: list[Path] = []
|
||||
for image in images:
|
||||
with temp_path.joinpath(image.filename).open("wb") as buffer:
|
||||
shutil.copyfileobj(image.file, buffer)
|
||||
local_images.append(temp_path.joinpath(image.filename))
|
||||
|
||||
recipe_data = await openai_recipe_service.build_recipe_from_images(
|
||||
local_images, translate_language=translate_language
|
||||
)
|
||||
|
||||
recipe = self.create_one(recipe_data)
|
||||
data_service = RecipeDataService(recipe.id)
|
||||
|
||||
with open(local_images[0], "rb") as f:
|
||||
data_service.write_image(f.read(), "webp")
|
||||
return recipe
|
||||
|
||||
def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
|
||||
"""Duplicates a recipe and returns the new recipe."""
|
||||
|
||||
@ -379,3 +407,67 @@ class RecipeService(BaseService):
|
||||
def render_template(self, recipe: Recipe, temp_dir: Path, template: str) -> Path:
|
||||
t_service = TemplateService(temp_dir)
|
||||
return t_service.render(recipe, template)
|
||||
|
||||
|
||||
class OpenAIRecipeService(RecipeServiceBase):
|
||||
def _convert_recipe(self, openai_recipe: OpenAIRecipe) -> Recipe:
|
||||
return Recipe(
|
||||
user_id=self.user.id,
|
||||
group_id=self.user.group_id,
|
||||
name=openai_recipe.name,
|
||||
slug=slugify(openai_recipe.name),
|
||||
description=openai_recipe.description,
|
||||
recipe_yield=openai_recipe.recipe_yield,
|
||||
total_time=openai_recipe.total_time,
|
||||
prep_time=openai_recipe.prep_time,
|
||||
perform_time=openai_recipe.perform_time,
|
||||
recipe_ingredient=[
|
||||
RecipeIngredient(title=ingredient.title, note=ingredient.text)
|
||||
for ingredient in openai_recipe.ingredients
|
||||
if ingredient.text
|
||||
],
|
||||
recipe_instructions=[
|
||||
RecipeStep(title=instruction.title, text=instruction.text)
|
||||
for instruction in openai_recipe.instructions
|
||||
if instruction.text
|
||||
],
|
||||
notes=[RecipeNote(title=note.title or "", text=note.text) for note in openai_recipe.notes if note.text],
|
||||
)
|
||||
|
||||
async def build_recipe_from_images(self, images: list[Path], translate_language: str | None) -> Recipe:
|
||||
settings = get_app_settings()
|
||||
if not (settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES):
|
||||
raise ValueError("OpenAI image services are not available")
|
||||
|
||||
openai_service = OpenAIService()
|
||||
prompt = openai_service.get_prompt(
|
||||
"recipes.parse-recipe-image",
|
||||
data_injections=[
|
||||
OpenAIDataInjection(
|
||||
description=(
|
||||
"This is the JSON response schema. You must respond in valid JSON that follows this schema. "
|
||||
"Your payload should be as compact as possible, eliminating unncessesary whitespace. "
|
||||
"Any fields with default values which you do not populate should not be in the payload."
|
||||
),
|
||||
value=OpenAIRecipe,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
openai_images = [OpenAILocalImage(filename=os.path.basename(image), path=image) for image in images]
|
||||
message = (
|
||||
f"Please extract the recipe from the {'images' if len(openai_images) > 1 else 'image'} provided."
|
||||
"There should be exactly one recipe."
|
||||
)
|
||||
|
||||
if translate_language:
|
||||
message += f" Please translate the recipe to {translate_language}."
|
||||
|
||||
response = await openai_service.get_response(prompt, message, images=openai_images, force_json_response=True)
|
||||
try:
|
||||
openai_recipe = OpenAIRecipe.parse_openai_response(response)
|
||||
recipe = self._convert_recipe(openai_recipe)
|
||||
except Exception as e:
|
||||
raise ValueError("Unable to parse recipe from image") from e
|
||||
|
||||
return recipe
|
||||
|
@ -0,0 +1,58 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.schema.openai.recipe import (
|
||||
OpenAIRecipe,
|
||||
OpenAIRecipeIngredient,
|
||||
OpenAIRecipeInstruction,
|
||||
OpenAIRecipeNotes,
|
||||
)
|
||||
from mealie.services.openai import OpenAIService
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_int, random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
def test_openai_create_recipe_from_image(
|
||||
api_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
unique_user: TestUser,
|
||||
test_image_jpg: str,
|
||||
):
|
||||
async def mock_get_response(self, prompt: str, message: str, *args, **kwargs) -> str | None:
|
||||
data = OpenAIRecipe(
|
||||
name=random_string(),
|
||||
description=random_string(),
|
||||
recipe_yield=random_string(),
|
||||
total_time=random_string(),
|
||||
prep_time=random_string(),
|
||||
perform_time=random_string(),
|
||||
ingredients=[OpenAIRecipeIngredient(text=random_string()) for _ in range(random_int(5, 10))],
|
||||
instructions=[OpenAIRecipeInstruction(text=random_string()) for _ in range(1, random_int(5, 10))],
|
||||
notes=[OpenAIRecipeNotes(text=random_string()) for _ in range(random_int(2, 5))],
|
||||
)
|
||||
return data.model_dump_json()
|
||||
|
||||
monkeypatch.setattr(OpenAIService, "get_response", mock_get_response)
|
||||
with open(test_image_jpg, "rb") as f:
|
||||
r = api_client.post(
|
||||
api_routes.recipes_create_from_image,
|
||||
files={"images": ("test_image_jpg.jpg", f, "image/jpeg")},
|
||||
data={"extension": "jpg"},
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
# since OpenAI is mocked, we don't need to validate the data, we just need to make sure a recipe is created,
|
||||
# and that it has an image
|
||||
slug: str = json.loads(r.text)
|
||||
r = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
|
||||
assert r.status_code == 200
|
||||
recipe_id = r.json()["id"]
|
||||
|
||||
r = api_client.get(
|
||||
api_routes.media_recipes_recipe_id_images_file_name(recipe_id, "original.webp"), headers=unique_user.token
|
||||
)
|
||||
assert r.status_code == 200
|
@ -17,20 +17,14 @@ admin_email = "/api/admin/email"
|
||||
"""`/api/admin/email`"""
|
||||
admin_groups = "/api/admin/groups"
|
||||
"""`/api/admin/groups`"""
|
||||
admin_logs = "/api/admin/logs"
|
||||
"""`/api/admin/logs`"""
|
||||
admin_maintenance = "/api/admin/maintenance"
|
||||
"""`/api/admin/maintenance`"""
|
||||
admin_maintenance_clean_images = "/api/admin/maintenance/clean/images"
|
||||
"""`/api/admin/maintenance/clean/images`"""
|
||||
admin_maintenance_clean_logs = "/api/admin/maintenance/clean/logs"
|
||||
"""`/api/admin/maintenance/clean/logs`"""
|
||||
admin_maintenance_clean_recipe_folders = "/api/admin/maintenance/clean/recipe-folders"
|
||||
"""`/api/admin/maintenance/clean/recipe-folders`"""
|
||||
admin_maintenance_clean_temp = "/api/admin/maintenance/clean/temp"
|
||||
"""`/api/admin/maintenance/clean/temp`"""
|
||||
admin_maintenance_logs = "/api/admin/maintenance/logs"
|
||||
"""`/api/admin/maintenance/logs`"""
|
||||
admin_maintenance_storage = "/api/admin/maintenance/storage"
|
||||
"""`/api/admin/maintenance/storage`"""
|
||||
admin_users = "/api/admin/users"
|
||||
@ -47,6 +41,8 @@ app_about_startup_info = "/api/app/about/startup-info"
|
||||
"""`/api/app/about/startup-info`"""
|
||||
app_about_theme = "/api/app/about/theme"
|
||||
"""`/api/app/about/theme`"""
|
||||
auth_logout = "/api/auth/logout"
|
||||
"""`/api/auth/logout`"""
|
||||
auth_refresh = "/api/auth/refresh"
|
||||
"""`/api/auth/refresh`"""
|
||||
auth_token = "/api/auth/token"
|
||||
@ -143,6 +139,8 @@ recipes_bulk_actions_settings = "/api/recipes/bulk-actions/settings"
|
||||
"""`/api/recipes/bulk-actions/settings`"""
|
||||
recipes_bulk_actions_tag = "/api/recipes/bulk-actions/tag"
|
||||
"""`/api/recipes/bulk-actions/tag`"""
|
||||
recipes_create_from_image = "/api/recipes/create-from-image"
|
||||
"""`/api/recipes/create-from-image`"""
|
||||
recipes_create_from_zip = "/api/recipes/create-from-zip"
|
||||
"""`/api/recipes/create-from-zip`"""
|
||||
recipes_create_url = "/api/recipes/create-url"
|
||||
@ -212,11 +210,6 @@ def admin_groups_item_id(item_id):
|
||||
return f"{prefix}/admin/groups/{item_id}"
|
||||
|
||||
|
||||
def admin_logs_num(num):
|
||||
"""`/api/admin/logs/{num}`"""
|
||||
return f"{prefix}/admin/logs/{num}"
|
||||
|
||||
|
||||
def admin_users_item_id(item_id):
|
||||
"""`/api/admin/users/{item_id}`"""
|
||||
return f"{prefix}/admin/users/{item_id}"
|
||||
@ -362,6 +355,11 @@ def groups_webhooks_item_id(item_id):
|
||||
return f"{prefix}/groups/webhooks/{item_id}"
|
||||
|
||||
|
||||
def groups_webhooks_item_id_test(item_id):
|
||||
"""`/api/groups/webhooks/{item_id}/test`"""
|
||||
return f"{prefix}/groups/webhooks/{item_id}/test"
|
||||
|
||||
|
||||
def media_recipes_recipe_id_assets_file_name(recipe_id, file_name):
|
||||
"""`/api/media/recipes/{recipe_id}/assets/{file_name}`"""
|
||||
return f"{prefix}/media/recipes/{recipe_id}/assets/{file_name}"
|
||||
|
Loading…
x
Reference in New Issue
Block a user