mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-26 03:30:26 -04:00
feat: In-app AI Provider Configuration (#7650)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>AI providers can now be configured directly in Mealie, without managing environment variables or secrets.</p>
|
||||
<div class="mb-2">
|
||||
AI providers enable features such as:
|
||||
<ul class="ml-6">
|
||||
<li>Creating recipes from images</li>
|
||||
<li>Importing recipes from videos (YouTube, TikTok, etc.)</li>
|
||||
<li>Enhanced ingredient parsing</li>
|
||||
<li>And more!</li>
|
||||
</ul>
|
||||
</div>
|
||||
<hr class="mt-2 mb-4">
|
||||
<p>
|
||||
<span v-if="group?.aiProviderSettings?.aiEnabled">
|
||||
Your group already has AI providers configured.
|
||||
</span>
|
||||
<span v-else>
|
||||
Your group does not currently have any AI providers configured.
|
||||
</span>
|
||||
<span v-if="user?.canManage">
|
||||
You can manage them here:
|
||||
<br>
|
||||
<v-btn class="mt-2" color="primary" to="/group">
|
||||
{{ $t("profile.group-settings") }}
|
||||
</v-btn>
|
||||
</span>
|
||||
<span v-else-if="!group?.aiProviderSettings?.aiEnabled">
|
||||
Contact a group manager or server admin to set up AI providers for your group.
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="user?.admin">
|
||||
<br>
|
||||
<p>
|
||||
As an admin, you can configure AI providers for any group. Unlike the old environment variable approach, providers are configured per-group:
|
||||
<br>
|
||||
<v-btn class="mt-2" color="primary" to="/admin/manage/groups">
|
||||
{{ $t("group.admin-group-management") }}
|
||||
</v-btn>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGroupSelf } from "~/composables/use-groups";
|
||||
import type { AnnouncementMeta } from "~/composables/use-announcements";
|
||||
|
||||
const { user } = useMealieAuth();
|
||||
const { group } = useGroupSelf();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const meta: AnnouncementMeta = {
|
||||
title: "Improved AI Provider Configuration",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
p {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="isEdit ? $t('group.ai-provider-settings.edit-provider') : $t('group.ai-provider-settings.create-provider')"
|
||||
:icon="$globals.icons.robot"
|
||||
:loading="loading"
|
||||
can-submit
|
||||
:submit-icon="isEdit ? $globals.icons.save : $globals.icons.createAlt"
|
||||
:submit-text="isEdit ? $t('general.update') : $t('general.create')"
|
||||
:submit-disabled="submitDisabled"
|
||||
@submit="handleSubmit"
|
||||
@close="resetForm"
|
||||
>
|
||||
<v-card-text v-if="init" style="max-height: 70vh; overflow-y: auto;">
|
||||
<v-form ref="form" v-no-autofill>
|
||||
<v-text-field
|
||||
v-model="formData.name"
|
||||
:label="$t('group.ai-provider-settings.provider-name')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.model"
|
||||
:label="$t('group.ai-provider-settings.model')"
|
||||
:hint="$t('group.ai-provider-settings.model-description')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.apiKey"
|
||||
:label="$t('group.ai-provider-settings.api-key')"
|
||||
:hint="$t(
|
||||
isEdit
|
||||
? 'group.ai-provider-settings.api-key-description-edit'
|
||||
: 'group.ai-provider-settings.api-key-description-create',
|
||||
)"
|
||||
:persistent-hint="isEdit"
|
||||
:rules="isEdit ? [] : [validators.required]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="formData.baseUrl"
|
||||
:label="$t('group.ai-provider-settings.base-url')"
|
||||
:hint="$t('group.ai-provider-settings.base-url-description')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-number-input
|
||||
v-model.number="formData.timeout"
|
||||
:label="$t('group.ai-provider-settings.request-timeout-seconds')"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-details
|
||||
control-variant="stacked"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-expansion-panels v-model="advancedPanel" variant="accordion">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="text-subtitle-2" expand-icon="$expand" collapse-icon="$expand">
|
||||
{{ $t('search.advanced') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text class="px-0">
|
||||
<div class="mb-2 text-subtitle-2">
|
||||
{{ $t('group.ai-provider-settings.request-headers') }}
|
||||
</div>
|
||||
<BaseKeyValueEditor
|
||||
v-model="formData.requestHeaders"
|
||||
class="mb-4"
|
||||
/>
|
||||
<v-divider class="mb-4" />
|
||||
<div class="mb-2 text-subtitle-2">
|
||||
{{ $t('group.ai-provider-settings.request-params') }}
|
||||
</div>
|
||||
<BaseKeyValueEditor
|
||||
v-model="formData.requestParams"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<AppLoader v-else waiting-text="" />
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIProviders } from "~/composables/use-ai-providers";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
providerId?: string;
|
||||
}>(), {
|
||||
providerId: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", data: AIProviderCreate): void;
|
||||
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||
}>();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
const { loading, getOne } = useAIProviders();
|
||||
const init = ref(false);
|
||||
|
||||
const form = ref();
|
||||
const advancedPanel = ref<number | undefined>(undefined);
|
||||
|
||||
const isEdit = computed(() => !!props.providerId);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: "",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
timeout: 300,
|
||||
requestHeaders: {} as Record<string, string>,
|
||||
requestParams: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
const formData = reactive(defaultForm());
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
return !formData.name?.trim() || !formData.model?.trim() || (!isEdit.value && !formData.apiKey?.trim());
|
||||
});
|
||||
|
||||
// Fetch existing provider when editing; reset form for create mode
|
||||
watch(
|
||||
() => [dialog.value, props.providerId] as const,
|
||||
async ([open, id]) => {
|
||||
if (!open) return;
|
||||
if (!id) {
|
||||
// Create mode — just show the empty form
|
||||
resetForm();
|
||||
init.value = true;
|
||||
return;
|
||||
}
|
||||
init.value = false;
|
||||
const { data } = await getOne(id);
|
||||
init.value = true;
|
||||
if (data) {
|
||||
formData.name = data.name;
|
||||
formData.model = data.model;
|
||||
formData.apiKey = "";
|
||||
formData.baseUrl = data.baseUrl ?? "";
|
||||
formData.timeout = data.timeout ?? 300;
|
||||
formData.requestHeaders = { ...(data.requestHeaders ?? {}) };
|
||||
formData.requestParams = { ...(data.requestParams ?? {}) };
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
// Required field guard (button is also disabled, but keep as a safeguard)
|
||||
if (!formData.name?.trim() || !formData.model?.trim()) return;
|
||||
if (!isEdit.value && !formData.apiKey?.trim()) return;
|
||||
|
||||
if (isEdit.value && props.providerId) {
|
||||
const payload: AIProviderUpdate & { apiKey?: string } = {
|
||||
name: formData.name,
|
||||
model: formData.model,
|
||||
baseUrl: formData.baseUrl || null,
|
||||
timeout: formData.timeout,
|
||||
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||
};
|
||||
if (formData.apiKey) {
|
||||
payload.apiKey = formData.apiKey;
|
||||
}
|
||||
emit("update", props.providerId, payload);
|
||||
}
|
||||
else {
|
||||
const createPayload = {
|
||||
name: formData.name,
|
||||
model: formData.model,
|
||||
apiKey: formData.apiKey,
|
||||
baseUrl: formData.baseUrl || null,
|
||||
timeout: formData.timeout,
|
||||
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
|
||||
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
|
||||
};
|
||||
emit("create", createPayload as AIProviderCreate);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, defaultForm());
|
||||
form.value?.reset();
|
||||
advancedPanel.value = undefined;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div v-if="providerSettings">
|
||||
<BaseCardSectionTitle v-if="!hideHeader" :title="$t('group.ai-provider-settings.ai-provider-settings')">
|
||||
<template v-if="noDefaultProviderWarning" #append-title>
|
||||
<v-tooltip location="bottom" color="warning">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" size="small" color="warning" class="ms-2">
|
||||
{{ $globals.icons.alert }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ $t('group.ai-provider-settings.no-default-provider-warning') }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</BaseCardSectionTitle>
|
||||
<v-card-text v-if="!hideHeader" class="pt-0 pb-10 px-0">
|
||||
{{ $t("group.ai-provider-settings.ai-provider-settings-description") }}
|
||||
</v-card-text>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.defaultProviderId"
|
||||
:label="$t('group.ai-provider-settings.default-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.default-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.audioProviderId"
|
||||
:label="$t('group.ai-provider-settings.audio-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.audio-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="local.imageProviderId"
|
||||
:label="$t('group.ai-provider-settings.image-provider')"
|
||||
:items="local.providers"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-card-subtitle class="mt-1">
|
||||
{{ $t("group.ai-provider-settings.image-provider-description") }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<GroupAIProviderDialog
|
||||
v-model="dialogOpen"
|
||||
:provider-id="editingProviderId ?? undefined"
|
||||
@create="(data) => $emit('create', data)"
|
||||
@update="(id, data) => $emit('update', id, data)"
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle
|
||||
:title="$t('group.ai-provider-settings.providers')"
|
||||
size="medium"
|
||||
class="pt-2"
|
||||
>
|
||||
<template #append-title>
|
||||
<BaseButton
|
||||
:text="$t('group.ai-provider-settings.create-provider')"
|
||||
class="ms-auto my-2"
|
||||
create
|
||||
small
|
||||
@click="openCreate"
|
||||
/>
|
||||
</template>
|
||||
</BaseCardSectionTitle>
|
||||
|
||||
<v-card
|
||||
v-for="provider in local.providers"
|
||||
:key="provider.id"
|
||||
variant="tonal"
|
||||
class="pa-0 mb-4"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<v-col :cols="10">
|
||||
<v-card-text>
|
||||
{{ provider.name }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
|
||||
<v-col :cols="2">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.edit,
|
||||
text: $t('general.edit'),
|
||||
event: 'edit',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]"
|
||||
@edit="openEdit(provider.id)"
|
||||
@delete="$emit('delete', provider.id)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
|
||||
import type { AIProviderSettingsOut } from "~/lib/api/types/user";
|
||||
|
||||
const providerSettings = defineModel<AIProviderSettingsOut>({ required: true });
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideHeader?: boolean;
|
||||
}>(), {
|
||||
hideHeader: false,
|
||||
});
|
||||
|
||||
const { hideHeader } = toRefs(props);
|
||||
|
||||
const local = reactive({ ...providerSettings.value });
|
||||
watch(local, (newVal) => { providerSettings.value = { ...newVal }; });
|
||||
// Sync back when the parent refreshes after create/update/delete
|
||||
watch(providerSettings, (newVal) => { if (newVal) Object.assign(local, newVal); });
|
||||
|
||||
const noDefaultProviderWarning = computed(
|
||||
() => local.providers.length > 0 && !local.defaultProviderId,
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
(e: "create", data: AIProviderCreate): void;
|
||||
(e: "update", id: string, data: AIProviderUpdate): void;
|
||||
(e: "delete", id: string): void;
|
||||
}>();
|
||||
|
||||
const dialogOpen = ref(false);
|
||||
const editingProviderId = ref<string | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
editingProviderId.value = null;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
editingProviderId.value = id;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -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<RecipeIngredient[]>): 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div
|
||||
v-if="$vuetify.display.smAndDown"
|
||||
|
||||
Reference in New Issue
Block a user