diff --git a/docker/entry.sh b/docker/entry.sh index cccc2ba9d..91217e864 100644 --- a/docker/entry.sh +++ b/docker/entry.sh @@ -58,9 +58,6 @@ load_secrets() { "OIDC_CONFIGURATION_URL" "OIDC_CLIENT_ID" "OIDC_CLIENT_SECRET" - - "OPENAI_BASE_URL" - "OPENAI_API_KEY" ) # If any secrets are set, prefer them over base environment variables. diff --git a/docs/docs/documentation/community-guide/ios-shortcut.md b/docs/docs/documentation/community-guide/ios-shortcut.md index 62e52063f..3f963ce6d 100644 --- a/docs/docs/documentation/community-guide/ios-shortcut.md +++ b/docs/docs/documentation/community-guide/ios-shortcut.md @@ -1,10 +1,10 @@ !!! info This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed! -An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s). +An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s). !!! note - If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/) + If adding via images make sure to enable [Mealie's AI Integration](https://docs.mealie.io/documentation/getting-started/installation/ai-providers) ## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible. @@ -23,7 +23,7 @@ An API key is needed to authenticate with mealie. To create an api key for a use The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com". ## Using the Shortcut -Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**. +Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**. !!! note - Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition. \ No newline at end of file + Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition. diff --git a/docs/docs/documentation/getting-started/faq.md b/docs/docs/documentation/getting-started/faq.md index 6f6728b90..c03bcb367 100644 --- a/docs/docs/documentation/getting-started/faq.md +++ b/docs/docs/documentation/getting-started/faq.md @@ -33,7 +33,7 @@ 6. Click the Edit button/icon again 7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe. 8. Click `Parse` and you will be taken to the ingredient parsing page. - 9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai). + 9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled AI support](./installation/ai-providers.md). 10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above. 11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database 12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out. diff --git a/docs/docs/documentation/getting-started/features.md b/docs/docs/documentation/getting-started/features.md index 57beb87c0..a25db8f82 100644 --- a/docs/docs/documentation/getting-started/features.md +++ b/docs/docs/documentation/getting-started/features.md @@ -11,7 +11,7 @@ Mealie offers several ways to create recipes: - **Recipe HTML or JSON:** Copy/paste structured HTML or JSON and Mealie can import it. - **Manual Editor:** Create recipes from scratch using the integrated editor. -Mealie's [AI integration](./installation/open-ai.md) greatly expands the ways you can create recipes: +Mealie's [AI integration](./installation/ai-providers.md) greatly expands the ways you can create recipes: - **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR and AI to import it. - **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and turn it into a recipe. diff --git a/docs/docs/documentation/getting-started/installation/ai-providers.md b/docs/docs/documentation/getting-started/installation/ai-providers.md new file mode 100644 index 000000000..e3aebf047 --- /dev/null +++ b/docs/docs/documentation/getting-started/installation/ai-providers.md @@ -0,0 +1,29 @@ +# AI Integration + +:octicons-tag-24: v1.7.0 + +Mealie's AI integration enables several features and enhancements throughout the application. To enable AI features, you must have access to an AI provider (such as OpenAI). Mealie works with any OpenAI-compatible API. + +## Configuration + +To set up AI providers, visit your group settings. + +[Group Settings Demo](https://demo.mealie.io/group){ .md-button .md-button--primary } + +- To enable AI features at all, you *must* set a default provider (e.g. `gpt-5`) +- To enable image recognition features, such as creating a recipe from an image, configure a provider capable of image recognition (e.g. `gpt-5`) +- To enable audio transcription features, such as importing a recipe from a video, configure a provider capable of audio transcriptions (e.g. `whisper-1`) + +For most users, choosing an OpenAI model (such as `gpt-5`) and supplying the OpenAI API key is all you need to do. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative. + +If you have another provider you'd like to use, such as Azure, you can configure Mealie to use that instead as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, set your `base_url` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama. + +Note that some models are capable of handling multiple features (e.g. `gpt-5` can handle both normal chat requests and image recognition requests). You may configure one provider for multiple provider features. + +While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./installation/ai-providers.md). + +## AI Features +- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0) +- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0) +- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0) +- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed AI, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0) diff --git a/docs/docs/documentation/getting-started/installation/backend-config.md b/docs/docs/documentation/getting-started/installation/backend-config.md index 4e7ef368c..27dae3885 100644 --- a/docs/docs/documentation/getting-started/installation/backend-config.md +++ b/docs/docs/documentation/getting-started/installation/backend-config.md @@ -120,22 +120,10 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md) :octicons-tag-24: v1.7.0 -Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md). -For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`) +Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./ai-providers.md). | Variables | Default | Description | |-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| OPENAI_BASE_URL[†][secrets] | 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[†][secrets] | 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_AUDIO_MODEL
:octicons-tag-24: v3.13.0 | whisper-1 | Which OpenAI model to use for audio transcriptions, if enabled. If you're not sure, leave this empty | -| OPENAI_CUSTOM_HEADERS
:octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them | -| OPENAI_CUSTOM_PARAMS
:octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them | -| OPENAI_ENABLE_IMAGE_SERVICES
:octicons-tag-24: v1.12.0 | 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_ENABLE_TRANSCRIPTION_SERVICES
:octicons-tag-24: v3.13.0 | True | Whether to enable OpenAI transcription services, such as creating recipes via video URL. 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 | 300 | 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 | | OPENAI_CUSTOM_PROMPT_DIR
:octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. | ### Theming @@ -315,7 +303,6 @@ at least these sensitive environment variables when working within shared enviro - `POSTGRES_PASSWORD` - `SMTP_PASSWORD` - `LDAP_QUERY_PASSWORD` -- `OPENAI_API_KEY` [docker-secrets]: https://docs.docker.com/compose/use-secrets/ [secrets]: #docker-secrets diff --git a/docs/docs/documentation/getting-started/installation/open-ai.md b/docs/docs/documentation/getting-started/installation/open-ai.md deleted file mode 100644 index bbaae51d7..000000000 --- a/docs/docs/documentation/getting-started/installation/open-ai.md +++ /dev/null @@ -1,24 +0,0 @@ -# OpenAI Integration - -:octicons-tag-24: v1.7.0 - -Mealie's OpenAI integration enables several features and enhancements throughout the application. To enable OpenAI features, you must have an account with OpenAI and configure Mealie to use the OpenAI API key (for more information, check out the [backend configuration](./backend-config.md#openai)). - -## Configuration - -For most users, supplying the OpenAI API key is all you need to do; you will use the regular OpenAI service with the default language model. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative. - -Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama. - -If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`. -If you wish to disable transcription features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_TRANSCRIPTION_SERVICES` to `False`. - -For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai). - - - -## OpenAI Features -- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0) -- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0) -- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0) -- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed using OpenAI's Whisper model, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 52ca05cc0..16042494f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -76,7 +76,7 @@ nav: - Backend Configuration: "documentation/getting-started/installation/backend-config.md" - Security: "documentation/getting-started/installation/security.md" - Logs: "documentation/getting-started/installation/logs.md" - - OpenAI: "documentation/getting-started/installation/open-ai.md" + - AI Providers: "documentation/getting-started/installation/ai-providers.md" - Usage: - Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md" - Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md" diff --git a/frontend/app/components/Domain/Announcement/Announcements/2026-05-21_1_ai-providers.vue b/frontend/app/components/Domain/Announcement/Announcements/2026-05-21_1_ai-providers.vue new file mode 100644 index 000000000..c0d746eec --- /dev/null +++ b/frontend/app/components/Domain/Announcement/Announcements/2026-05-21_1_ai-providers.vue @@ -0,0 +1,63 @@ + + + + + + + diff --git a/frontend/app/components/Domain/Group/GroupAIProviderDialog.vue b/frontend/app/components/Domain/Group/GroupAIProviderDialog.vue new file mode 100644 index 000000000..53797dd18 --- /dev/null +++ b/frontend/app/components/Domain/Group/GroupAIProviderDialog.vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/app/components/Domain/Group/GroupAIProviderSettingsEditor.vue b/frontend/app/components/Domain/Group/GroupAIProviderSettingsEditor.vue new file mode 100644 index 000000000..1b679aa6c --- /dev/null +++ b/frontend/app/components/Domain/Group/GroupAIProviderSettingsEditor.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/app/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue b/frontend/app/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue index b5cf74dfa..20fe48c02 100644 --- a/frontend/app/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue +++ b/frontend/app/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue @@ -200,6 +200,7 @@ import { useUserApi } from "~/composables/api"; import { useIngredientTextParser } from "~/composables/recipes"; import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store"; import { useGlobalI18n } from "~/composables/use-global-i18n"; +import { useGroupSelf } from "~/composables/use-groups"; import { alert } from "~/composables/use-toast"; import { useParsingPreferences } from "~/composables/use-users/preferences"; @@ -215,7 +216,7 @@ const emit = defineEmits<{ (e: "save", value: NoUndefinedField): void; }>(); -const { $appInfo } = useNuxtApp(); +const { group } = useGroupSelf(); const i18n = useGlobalI18n(); const api = useUserApi(); const drag = ref(false); @@ -240,7 +241,7 @@ const availableParsers = computed(() => { { text: i18n.t("recipe.parser.openai-parser"), value: "openai", - hide: !$appInfo.enableOpenai, + hide: !group.value?.aiProviderSettings?.aiEnabled, }, ]; }); diff --git a/frontend/app/components/Domain/User/UserProfileLinkCard.vue b/frontend/app/components/Domain/User/UserProfileLinkCard.vue index 01d97529e..ffdbe5851 100644 --- a/frontend/app/components/Domain/User/UserProfileLinkCard.vue +++ b/frontend/app/components/Domain/User/UserProfileLinkCard.vue @@ -4,7 +4,7 @@ style="border-color: lightgrey;" :to="link.to" height="100%" - class="d-flex flex-column mt-4" + class="d-flex flex-column mt-4 pa-2" >
import { useLoggedInState } from "~/composables/use-logged-in-state"; import type { SideBarLink } from "~/types/application-types"; +import { useGroupSelf } from "~/composables/use-groups"; import { useCookbookPreferences } from "~/composables/use-users/preferences"; import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store"; import type { ReadCookBook } from "~/lib/api/types/cookbook"; const i18n = useI18n(); -const { $appInfo, $globals } = useNuxtApp(); +const { $globals } = useNuxtApp(); const display = useDisplay(); const auth = useMealieAuth(); const { isOwnGroup } = useLoggedInState(); +const { group } = useGroupSelf(); const route = useRoute(); const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || ""); @@ -131,7 +133,7 @@ const cookbooks = computed(() => { return []; }); -const showImageImport = computed(() => $appInfo.enableOpenaiImageServices); +const showImageImport = computed(() => group.value?.aiProviderSettings?.imageProviderEnabled); const sidebar = ref(false); onMounted(() => { diff --git a/frontend/app/components/global/BaseCardSectionTitle.vue b/frontend/app/components/global/BaseCardSectionTitle.vue index 520922ed5..4743e561d 100644 --- a/frontend/app/components/global/BaseCardSectionTitle.vue +++ b/frontend/app/components/global/BaseCardSectionTitle.vue @@ -7,7 +7,8 @@ 'mt-8': section, }" > - + + {{ title }} + diff --git a/frontend/app/composables/use-ai-providers.ts b/frontend/app/composables/use-ai-providers.ts new file mode 100644 index 000000000..4eaabc896 --- /dev/null +++ b/frontend/app/composables/use-ai-providers.ts @@ -0,0 +1,55 @@ +import { useUserApi } from "~/composables/api"; +import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group"; + +export function useAIProviders() { + const api = useUserApi(); + const loading = ref(false); + + async function getOne(id: string) { + loading.value = true; + try { + return await api.aiProviders.getOne(id); + } + finally { + loading.value = false; + } + } + + async function createOne(payload: AIProviderCreate) { + loading.value = true; + try { + return await api.aiProviders.createOne(payload); + } + finally { + loading.value = false; + } + } + + async function updateOne(id: string, payload: AIProviderUpdate) { + loading.value = true; + try { + return await api.aiProviders.updateOne(id, payload); + } + finally { + loading.value = false; + } + } + + async function deleteOne(id: string) { + loading.value = true; + try { + return await api.aiProviders.deleteOne(id); + } + finally { + loading.value = false; + } + } + + return { + loading: readonly(loading), + getOne, + createOne, + updateOne, + deleteOne, + }; +} diff --git a/frontend/app/composables/use-announcements.ts b/frontend/app/composables/use-announcements.ts index 92faa219f..696e1b127 100644 --- a/frontend/app/composables/use-announcements.ts +++ b/frontend/app/composables/use-announcements.ts @@ -22,7 +22,10 @@ const allAnnouncements: Announcement[] = Object.entries(_announcementsUnsorted) .map(([path, mod]) => { const key = path.split("/").at(-1)!.replace(".vue", ""); - const parsed = new Date(key.split("_", 1)[0]!); + const dateParts = key.split("_", 1)[0]!.split("-").map(Number); + const parsed = dateParts.length === 3 + ? new Date(dateParts[0]!, dateParts[1]! - 1, dateParts[2]!) + : new Date(NaN); const date = isNaN(parsed.getTime()) ? undefined : parsed; return { diff --git a/frontend/app/composables/use-groups.ts b/frontend/app/composables/use-groups.ts index 3904abcb8..9b9326255 100644 --- a/frontend/app/composables/use-groups.ts +++ b/frontend/app/composables/use-groups.ts @@ -42,6 +42,25 @@ export const useGroupSelf = function () { return data || undefined; }, + async updateAIProviderSettings() { + if (!groupSelfRef.value) { + await refreshGroupSelf(); + } + if (!groupSelfRef.value?.aiProviderSettings) { + return; + } + + const { data } = await api.groups.setAIProviderSettings(groupSelfRef.value.aiProviderSettings); + + if (data) { + groupSelfRef.value.aiProviderSettings = data; + } + + return data || undefined; + }, + async refresh() { + await refreshGroupSelf(); + }, }; const group = actions.get(); diff --git a/frontend/app/lang/messages/en-US.json b/frontend/app/lang/messages/en-US.json index 039d8b51c..380488373 100644 --- a/frontend/app/lang/messages/en-US.json +++ b/frontend/app/lang/messages/en-US.json @@ -223,7 +223,9 @@ "show-advanced": "Show Advanced", "add-field": "Add Field", "date-created": "Date Created", - "date-updated": "Date Updated" + "date-updated": "Date Updated", + "key": "Key", + "value": "Value" }, "group": { "are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete {groupName}?", @@ -283,7 +285,40 @@ "admin-group-management-text": "Changes to this group will be reflected immediately.", "group-id-value": "Group Id: {0}", "total-households": "Total Households", - "you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household" + "you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household", + "ai-provider-settings": { + "ai-provider-settings": "AI Provider Settings", + "ai-provider": "AI Provider", + "ai-providers": "AI Providers", + "ai-provider-settings-description": "Configure AI providers to enable AI-powered features, such as enhanced ingredient parsing, creating recipes from videos, and more!", + "providers": "Providers", + "create-provider": "Create Provider", + "edit-provider": "Edit Provider", + "default-provider": "Default Provider", + "default-provider-description": "Required to enable AI features", + "audio-provider": "Audio Provider", + "audio-provider-description": "Enables audio transcription features, such as creating recipes from videos", + "image-provider": "Image Provider", + "image-provider-description": "Enables image recognition features, such as creating recipes from images", + "provider-name": "Provider Name", + "api-key": "API Key", + "api-key-description-create": "Your provider's API key for authentication. If your service (e.g. Ollama) doesn't use an API key, you still have to put something here.", + "api-key-description-edit": "Leave this blank unless you want to change it.", + "base-url": "Base URL", + "base-url-description": "If you're using OpenAI leave this blank. Must be an OpenAI-compatible endpoint (e.g. \"http://localhost:11434/v1\").", + "model": "Model", + "model-description": "Which model your AI provider should use (e.g. \"gpt-5\").", + "request-timeout-seconds": "Request Timeout (seconds)", + "provider-created": "Provider created", + "provider-updated": "Provider updated", + "provider-deleted": "Provider deleted", + "provider-create-failed": "Failed to create provider", + "provider-update-failed": "Failed to update provider", + "provider-delete-failed": "Failed to delete provider", + "request-headers": "Request Headers", + "request-params": "Request Parameters", + "no-default-provider-warning": "You have not set a default provider, so AI features are disabled" + } }, "household": { "household": "Household", @@ -1362,6 +1397,8 @@ "already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage", "common-settings-for-new-sites": "Here are some common settings for new sites", "setup-complete": "Setup Complete!", + + "ai-providers-description": "Optionally configure AI providers for your group. AI providers enable features like creating recipes from images, importing recipes from videos, and enhanced ingredient parsing. You can always configure this later from your group settings.", "here-are-a-few-things-to-help-you-get-started": "Here are a few things to help you get started with Mealie", "restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.", "manage-profile-or-get-invite-link": "Manage your own profile, or grab an invite link to share with others." diff --git a/frontend/app/lib/api/admin/admin-ai-providers.ts b/frontend/app/lib/api/admin/admin-ai-providers.ts new file mode 100644 index 000000000..7cf7102ff --- /dev/null +++ b/frontend/app/lib/api/admin/admin-ai-providers.ts @@ -0,0 +1,27 @@ +import { BaseAPI } from "../base/base-clients"; +import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group"; + +const prefix = "/api/admin"; + +const routes = { + providers: (groupId: string) => `${prefix}/groups/${groupId}/ai-providers/providers`, + providersId: (groupId: string, providerId: string) => `${prefix}/groups/${groupId}/ai-providers/providers/${providerId}`, +}; + +export class AdminAIProvidersApi extends BaseAPI { + async createProvider(groupId: string, payload: AIProviderCreate) { + return await this.requests.post(routes.providers(groupId), payload); + } + + async getProvider(groupId: string, providerId: string) { + return await this.requests.get(routes.providersId(groupId, providerId)); + } + + async updateProvider(groupId: string, providerId: string, payload: AIProviderUpdate) { + return await this.requests.put(routes.providersId(groupId, providerId), payload); + } + + async deleteProvider(groupId: string, providerId: string) { + return await this.requests.delete(routes.providersId(groupId, providerId)); + } +} diff --git a/frontend/app/lib/api/admin/admin-debug.ts b/frontend/app/lib/api/admin/admin-debug.ts index af6babfab..038f59fcf 100644 --- a/frontend/app/lib/api/admin/admin-debug.ts +++ b/frontend/app/lib/api/admin/admin-debug.ts @@ -4,11 +4,11 @@ import type { DebugResponse } from "~/lib/api/types/admin"; const prefix = "/api"; const routes = { - openai: `${prefix}/admin/debug/openai`, + openai: providerId => `${prefix}/admin/debug/openai/${providerId}`, }; export class AdminDebugAPI extends BaseAPI { - async debugOpenAI(fileObject: Blob | File | undefined = undefined, fileName = "") { + async debugOpenAI(providerId: string, fileObject: Blob | File | undefined = undefined, fileName = "") { let formData: FormData | null = null; if (fileObject) { formData = new FormData(); @@ -16,6 +16,6 @@ export class AdminDebugAPI extends BaseAPI { formData.append("extension", fileName.split(".").pop() ?? ""); } - return await this.requests.post(routes.openai, formData); + return await this.requests.post(routes.openai(providerId), formData); } } diff --git a/frontend/app/lib/api/client-admin.ts b/frontend/app/lib/api/client-admin.ts index 1352edba9..5d45e8324 100644 --- a/frontend/app/lib/api/client-admin.ts +++ b/frontend/app/lib/api/client-admin.ts @@ -6,6 +6,7 @@ import { AdminBackupsApi } from "./admin/admin-backups"; import { AdminMaintenanceApi } from "./admin/admin-maintenance"; import { AdminAnalyticsApi } from "./admin/admin-analytics"; import { AdminDebugAPI } from "./admin/admin-debug"; +import { AdminAIProvidersApi } from "./admin/admin-ai-providers"; import type { ApiRequestInstance } from "~/lib/api/types/non-generated"; export class AdminAPI { @@ -17,6 +18,7 @@ export class AdminAPI { public maintenance: AdminMaintenanceApi; public analytics: AdminAnalyticsApi; public debug: AdminDebugAPI; + public aiProviders: AdminAIProvidersApi; constructor(requests: ApiRequestInstance) { this.about = new AdminAboutAPI(requests); @@ -27,6 +29,7 @@ export class AdminAPI { this.maintenance = new AdminMaintenanceApi(requests); this.analytics = new AdminAnalyticsApi(requests); this.debug = new AdminDebugAPI(requests); + this.aiProviders = new AdminAIProvidersApi(requests); Object.freeze(this); } diff --git a/frontend/app/lib/api/client-user.ts b/frontend/app/lib/api/client-user.ts index 7d786f6f2..74ba694d6 100644 --- a/frontend/app/lib/api/client-user.ts +++ b/frontend/app/lib/api/client-user.ts @@ -24,6 +24,7 @@ import { MultiPurposeLabelsApi } from "./user/group-multiple-purpose-labels"; import { GroupEventNotifierApi } from "./user/group-event-notifier"; import { MealPlanRulesApi } from "./user/group-mealplan-rules"; import { GroupDataSeederApi } from "./user/group-seeder"; +import { AIProvidersAPI } from "./user/group-ai-providers"; import type { ApiRequestInstance } from "~/lib/api/types/non-generated"; export class UserApiClient { @@ -53,6 +54,7 @@ export class UserApiClient { public groupEventNotifier: GroupEventNotifierApi; public upload: UploadFile; public seeders: GroupDataSeederApi; + public aiProviders: AIProvidersAPI; constructor(requests: ApiRequestInstance) { // Recipes @@ -80,6 +82,7 @@ export class UserApiClient { this.shopping = new ShoppingApi(requests); this.multiPurposeLabels = new MultiPurposeLabelsApi(requests); this.seeders = new GroupDataSeederApi(requests); + this.aiProviders = new AIProvidersAPI(requests); // Admin this.backups = new BackupAPI(requests); diff --git a/frontend/app/lib/api/types/admin.ts b/frontend/app/lib/api/types/admin.ts index a5658d56f..173fa8a74 100644 --- a/frontend/app/lib/api/types/admin.ts +++ b/frontend/app/lib/api/types/admin.ts @@ -16,9 +16,6 @@ export interface AdminAboutInfo { enableOidc: boolean; oidcRedirect: boolean; oidcProviderName: string; - enableOpenai: boolean; - enableOpenaiImageServices: boolean; - enableOpenaiTranscriptionServices: boolean; tokenTime: number; versionLatest: string; apiPort: number; @@ -50,9 +47,6 @@ export interface AppInfo { enableOidc: boolean; oidcRedirect: boolean; oidcProviderName: string; - enableOpenai: boolean; - enableOpenaiImageServices: boolean; - enableOpenaiTranscriptionServices: boolean; tokenTime: number; } export interface AppStartupInfo { @@ -95,7 +89,6 @@ export interface CheckAppConfig { emailReady: boolean; ldapReady: boolean; oidcReady: boolean; - enableOpenai: boolean; baseUrlSet: boolean; isUpToDate: boolean; } diff --git a/frontend/app/lib/api/types/group.ts b/frontend/app/lib/api/types/group.ts index 498ea009e..9a45d0394 100644 --- a/frontend/app/lib/api/types/group.ts +++ b/frontend/app/lib/api/types/group.ts @@ -17,6 +17,77 @@ export type SupportedMigrations = | "recipekeeper" | "cookn"; +export interface AIProviderCreate { + name: string; + baseUrl?: string | null; + model: string; + timeout?: number; + requestHeaders?: { + [k: string]: string; + }; + requestParams?: { + [k: string]: string; + }; +} +export interface AIProviderOut { + name: string; + baseUrl?: string | null; + model: string; + timeout?: number; + requestHeaders?: { + [k: string]: string; + }; + requestParams?: { + [k: string]: string; + }; + id: string; +} +export interface AIProviderSave { + name: string; + baseUrl?: string | null; + model: string; + timeout?: number; + requestHeaders?: { + [k: string]: string; + }; + requestParams?: { + [k: string]: string; + }; + settingsId: string; +} +export interface AIProviderSettingsCreate { + groupId: string; +} +export interface AIProviderSettingsOut { + defaultProviderId: string | null; + audioProviderId: string | null; + imageProviderId: string | null; + providers: AIProviderSummary[]; + aiEnabled: boolean; + audioProviderEnabled: boolean; + imageProviderEnabled: boolean; +} +export interface AIProviderSummary { + id: string; + name: string; +} +export interface AIProviderSettingsUpdate { + defaultProviderId: string | null; + audioProviderId: string | null; + imageProviderId: string | null; +} +export interface AIProviderUpdate { + name: string; + baseUrl?: string | null; + model: string; + timeout?: number; + requestHeaders?: { + [k: string]: string; + }; + requestParams?: { + [k: string]: string; + }; +} export interface CreateGroupPreferences { privateGroup?: boolean; showAnnouncements?: boolean; @@ -29,6 +100,7 @@ export interface GroupAdminUpdate { id: string; name: string; preferences?: UpdateGroupPreferences | null; + aiProviderSettings?: AIProviderSettingsUpdate | null; } export interface UpdateGroupPreferences { privateGroup?: boolean; diff --git a/frontend/app/lib/api/types/user.ts b/frontend/app/lib/api/types/user.ts index a5c3adaa1..c1dc7a706 100644 --- a/frontend/app/lib/api/types/user.ts +++ b/frontend/app/lib/api/types/user.ts @@ -59,6 +59,7 @@ export interface GroupInDB { households?: GroupHouseholdSummary[] | null; users?: UserSummary[] | null; preferences?: ReadGroupPreferences | null; + aiProviderSettings?: AIProviderSettingsOut | null; } export interface CategoryBase { name: string; @@ -89,11 +90,25 @@ export interface ReadGroupPreferences { groupId: string; id: string; } +export interface AIProviderSettingsOut { + defaultProviderId: string | null; + audioProviderId: string | null; + imageProviderId: string | null; + providers: AIProviderSummary[]; + aiEnabled: boolean; + audioProviderEnabled: boolean; + imageProviderEnabled: boolean; +} +export interface AIProviderSummary { + id: string; + name: string; +} export interface GroupSummary { name: string; id: string; slug: string; preferences?: ReadGroupPreferences | null; + aiProviderSettings?: AIProviderSettingsOut | null; } export interface LongLiveTokenCreateResponse { name: string; diff --git a/frontend/app/lib/api/user/group-ai-providers.ts b/frontend/app/lib/api/user/group-ai-providers.ts new file mode 100644 index 000000000..4fe85621e --- /dev/null +++ b/frontend/app/lib/api/user/group-ai-providers.ts @@ -0,0 +1,27 @@ +import { BaseAPI } from "../base/base-clients"; +import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group"; + +const prefix = "/api/groups/ai-providers"; + +const routes = { + providers: `${prefix}/providers`, + providersId: (id: string) => `${prefix}/providers/${id}`, +}; + +export class AIProvidersAPI extends BaseAPI { + async getOne(id: string) { + return await this.requests.get(routes.providersId(id)); + } + + async createOne(payload: AIProviderCreate) { + return await this.requests.post(routes.providers, payload); + } + + async updateOne(id: string, payload: AIProviderUpdate) { + return await this.requests.put(routes.providersId(id), payload); + } + + async deleteOne(id: string) { + return await this.requests.delete(routes.providersId(id)); + } +} diff --git a/frontend/app/lib/api/user/groups.ts b/frontend/app/lib/api/user/groups.ts index bd3611d9c..c1a75ddbf 100644 --- a/frontend/app/lib/api/user/groups.ts +++ b/frontend/app/lib/api/user/groups.ts @@ -3,6 +3,8 @@ import type { PaginationData } from "../types/non-generated"; import type { QueryValue } from "../base/route"; import type { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user"; import type { + AIProviderSettingsUpdate, + AIProviderSettingsOut, GroupAdminUpdate, GroupStorage, ReadGroupPreferences, @@ -15,6 +17,7 @@ const routes = { groups: `${prefix}/admin/groups`, groupsSelf: `${prefix}/groups/self`, preferences: `${prefix}/groups/preferences`, + aiProviderSettings: `${prefix}/groups/ai-providers/settings`, storage: `${prefix}/groups/storage`, members: `${prefix}/groups/members`, groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`, @@ -29,15 +32,15 @@ export class GroupAPI extends BaseCRUDAPI(routes.groupsSelf); } - async getPreferences() { - return await this.requests.get(routes.preferences); - } - async setPreferences(payload: UpdateGroupPreferences) { // TODO: This should probably be a patch request, which isn't offered by the API currently return await this.requests.put(routes.preferences, payload); } + async setAIProviderSettings(payload: AIProviderSettingsUpdate) { + return await this.requests.put(routes.aiProviderSettings, payload); + } + async fetchMembers(page = 1, perPage = -1, params = {} as Record) { return await this.requests.get>(routes.members, { page, perPage, ...params }); } diff --git a/frontend/app/lib/api/user/households.ts b/frontend/app/lib/api/user/households.ts index 363af940d..62b6ba8c0 100644 --- a/frontend/app/lib/api/user/households.ts +++ b/frontend/app/lib/api/user/households.ts @@ -43,10 +43,6 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly { return await this.requests.get(routes.householdsSelfRecipesSlug(recipeSlug)); } - async getPreferences() { - return await this.requests.get(routes.preferences); - } - async setPreferences(payload: UpdateHouseholdPreferences) { // TODO: This should probably be a patch request, which isn't offered by the API currently return await this.requests.put(routes.preferences, payload); diff --git a/frontend/app/pages/admin/debug/openai.vue b/frontend/app/pages/admin/debug/openai.vue index 3b1388df3..1d69f09ba 100644 --- a/frontend/app/pages/admin/debug/openai.vue +++ b/frontend/app/pages/admin/debug/openai.vue @@ -6,7 +6,7 @@
@@ -17,6 +17,36 @@
+ + + + + + + + import { useAdminApi } from "~/composables/api"; +import { useGroups } from "~/composables/use-groups"; import { alert } from "~/composables/use-toast"; +import type { AIProviderSummary } from "~/lib/api/types/group"; definePageMeta({ layout: "admin", @@ -106,10 +139,24 @@ const uploadedImage = ref(); const uploadedImageName = ref(""); const uploadedImagePreviewUrl = ref(); -function uploadImage(fileObject: File) { - uploadedImage.value = fileObject; - uploadedImageName.value = fileObject.name; - uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject); +// Group + provider selection +const { groups } = useGroups(); +const selectedGroupId = ref(null); +const groupProviders = ref([]); +const selectedProviderId = ref(null); + +watch(selectedGroupId, (id) => { + groupProviders.value = []; + selectedProviderId.value = null; + if (!id) return; + const group = groups.value?.find(g => g.id === id); + groupProviders.value = group?.aiProviderSettings?.providers ?? []; +}); + +function uploadImage(fileObject: unknown) { + uploadedImage.value = fileObject as File; + uploadedImageName.value = (fileObject as File).name; + uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject as File); } function clearImage() { @@ -119,10 +166,15 @@ function clearImage() { } async function testOpenAI() { + if (!selectedProviderId.value) { + alert.error("Please select a provider"); + return; + } + response.value = ""; loading.value = true; - const { data } = await api.debug.debugOpenAI(uploadedImage.value); + const { data } = await api.debug.debugOpenAI(selectedProviderId.value, uploadedImage.value); loading.value = false; if (!data) { diff --git a/frontend/app/pages/admin/manage/groups/[id].vue b/frontend/app/pages/admin/manage/groups/[id].vue index 781f8c7d0..61b7a7643 100644 --- a/frontend/app/pages/admin/manage/groups/[id].vue +++ b/frontend/app/pages/admin/manage/groups/[id].vue @@ -33,6 +33,13 @@ v-if="group.preferences" v-model="group.preferences" /> +
@@ -50,8 +57,10 @@ diff --git a/frontend/app/pages/admin/setup.vue b/frontend/app/pages/admin/setup.vue index 795795ecb..87bf27336 100644 --- a/frontend/app/pages/admin/setup.vue +++ b/frontend/app/pages/admin/setup.vue @@ -45,6 +45,14 @@ :title="$t('settings.site-settings')" /> + + + + + + + {{ $t('group.ai-provider-settings.ai-providers') }} + + + {{ $t('group.ai-provider-settings.ai-providers-description') }} + + + + + + + + @@ -252,7 +297,11 @@ import { useLocales } from "~/composables/use-locales"; import { alert } from "~/composables/use-toast"; import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form"; import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form"; +import { useGroupSelf } from "~/composables/use-groups"; +import { useAIProviders } from "~/composables/use-ai-providers"; import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue"; +import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue"; +import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group"; definePageMeta({ layout: "blank", @@ -267,6 +316,42 @@ const userApi = useUserApi(); const adminApi = useAdminApi(); const groupSlug = computed(() => auth.user.value?.groupSlug); + +const { group, actions: groupActions } = useGroupSelf(); +const { createOne, updateOne, deleteOne } = useAIProviders(); + +async function handleCreateProvider(data: AIProviderCreate) { + const result = await createOne(data); + if (result.data) { + await groupActions.refresh(); + alert.success(i18n.t("group.ai-provider-settings.provider-created")); + } + else { + alert.error(i18n.t("group.ai-provider-settings.provider-create-failed")); + } +} + +async function handleUpdateProvider(id: string, data: AIProviderUpdate) { + const result = await updateOne(id, data); + if (result.data) { + await groupActions.refresh(); + alert.success(i18n.t("group.ai-provider-settings.provider-updated")); + } + else { + alert.error(i18n.t("group.ai-provider-settings.provider-update-failed")); + } +} + +async function handleDeleteProvider(id: string) { + const result = await deleteOne(id); + if (result.data) { + await groupActions.refresh(); + alert.success(i18n.t("group.ai-provider-settings.provider-deleted")); + } + else { + alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed")); + } +} const { locale } = useLocales(); const router = useRouter(); const isSubmitting = ref(false); @@ -281,8 +366,9 @@ enum Pages { LANDING = 1, USER_INFO = 2, PAGE_2 = 3, - CONFIRM = 4, - END = 5, + AI_PROVIDERS = 4, + CONFIRM = 5, + END = 6, } function getStepperColor(currentPage: Pages, page: Pages) { @@ -475,6 +561,7 @@ async function submitAll() { const tasks = [ submitRegistration(), submitCommonSettings(), + groupActions.updateAIProviderSettings(), ]; await Promise.all(tasks); diff --git a/frontend/app/pages/admin/site-settings.vue b/frontend/app/pages/admin/site-settings.vue index 791e6838e..db4425893 100644 --- a/frontend/app/pages/admin/site-settings.vue +++ b/frontend/app/pages/admin/site-settings.vue @@ -284,7 +284,6 @@ const appConfig = ref({ isUpToDate: false, ldapReady: false, oidcReady: false, - enableOpenai: false, }); function isLocalHostOrHttps() { return window.location.hostname === "localhost" || window.location.protocol === "https:"; @@ -351,15 +350,6 @@ const simpleChecks = computed(() => { color: appConfig.value.oidcReady ? goodColor : warningColor, icon: appConfig.value.oidcReady ? goodIcon : warningIcon, }, - { - id: "openai-ready", - text: appConfig.value.enableOpenai ? i18n.t("settings.openai-ready") : i18n.t("settings.openai-not-ready"), - status: appConfig.value.enableOpenai, - errorText: i18n.t("settings.openai-ready-error-text"), - successText: i18n.t("settings.openai-ready-success-text"), - color: appConfig.value.enableOpenai ? goodColor : warningColor, - icon: appConfig.value.enableOpenai ? goodIcon : warningIcon, - }, ]; return data; }); diff --git a/frontend/app/pages/g/[groupSlug]/r/create.vue b/frontend/app/pages/g/[groupSlug]/r/create.vue index 04feb0c49..ab9e8b83c 100644 --- a/frontend/app/pages/g/[groupSlug]/r/create.vue +++ b/frontend/app/pages/g/[groupSlug]/r/create.vue @@ -45,6 +45,7 @@