feat: In-app AI Provider Configuration (#7650)

This commit is contained in:
Michael Genson
2026-05-23 11:13:10 -05:00
committed by GitHub
parent f6fe92b400
commit c3f87736d0
86 changed files with 3325 additions and 297 deletions

View File

@@ -6,7 +6,7 @@
<br>
<DocLink
class="mt-2"
link="/documentation/getting-started/installation/open-ai"
link="/documentation/getting-started/installation/ai-providers"
/>
</BaseCardSectionTitle>
</v-container>
@@ -17,6 +17,36 @@
<div>
<v-card-text>
<v-container class="pa-0">
<v-row>
<v-col cols="12" md="6">
<v-select
v-if="groups"
v-model="selectedGroupId"
:items="groups"
item-title="name"
item-value="id"
:label="$t('group.group')"
density="compact"
variant="outlined"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="selectedProviderId"
:items="groupProviders"
item-title="name"
item-value="id"
:label="$t('group.ai-provider-settings.ai-provider')"
density="compact"
variant="outlined"
clearable
hide-details
:disabled="!selectedGroupId"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="auto"
@@ -61,6 +91,7 @@
<v-card-actions>
<BaseButton
type="submit"
:disabled="!selectedProviderId"
:text="$t('admin.run-test')"
:icon="$globals.icons.check"
:loading="loading"
@@ -85,7 +116,9 @@
<script setup lang="ts">
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<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
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<string | null>(null);
const groupProviders = ref<AIProviderSummary[]>([]);
const selectedProviderId = ref<string | null>(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) {

View File

@@ -33,6 +33,13 @@
v-if="group.preferences"
v-model="group.preferences"
/>
<GroupAIProviderSettingsEditor
v-if="group.aiProviderSettings"
v-model="group.aiProviderSettings"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
@@ -50,8 +57,10 @@
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
import type { VForm } from "vuetify/components";
definePageMeta({
@@ -72,7 +81,7 @@ const adminApi = useAdminApi();
const userError = ref(false);
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
const { data: group, refresh } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
if (!groupId.value) {
return null;
}
@@ -86,7 +95,7 @@ const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
if (!refGroupEditForm.value?.validate() || !group.value) {
return;
}
@@ -103,4 +112,40 @@ async function handleSubmit() {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
async function handleCreateProvider(data: AIProviderCreate) {
if (!group.value) return;
const result = await adminApi.aiProviders.createProvider(group.value.id, data);
if (result.data) {
await 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) {
if (!group.value) return;
const result = await adminApi.aiProviders.updateProvider(group.value.id, id, data);
if (result.data) {
await 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) {
if (!group.value) return;
const result = await adminApi.aiProviders.deleteProvider(group.value.id, id);
if (result.data) {
await refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
}
}
</script>

View File

@@ -45,6 +45,14 @@
:title="$t('settings.site-settings')"
/>
<v-divider />
<v-stepper-item
:value="Pages.AI_PROVIDERS"
:icon="$globals.icons.robot"
:complete="currentPage > Pages.AI_PROVIDERS"
:color="getStepperColor(currentPage, Pages.AI_PROVIDERS)"
:title="$t('group.ai-provider-settings.ai-providers')"
/>
<v-divider />
<v-stepper-item
:value="Pages.CONFIRM"
:icon="$globals.icons.chefHat"
@@ -173,6 +181,43 @@
</v-stepper-actions>
</v-stepper-window-item>
<!-- AI PROVIDERS -->
<v-stepper-window-item :value="Pages.AI_PROVIDERS">
<v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $t('group.ai-provider-settings.ai-providers') }}
</v-card-title>
<v-card-subtitle class="px-0 py-2 text-wrap">
{{ $t('group.ai-provider-settings.ai-providers-description') }}
</v-card-subtitle>
<GroupAIProviderSettingsEditor
v-if="group?.aiProviderSettings"
v-model="group.aiProviderSettings"
hide-header
class="mt-4"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<v-btn
variant="flat"
color="success"
:disabled="isSubmitting"
:loading="isSubmitting"
:text="$t('general.next')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<!-- CONFIRMATION -->
<v-stepper-window-item :value="Pages.CONFIRM">
<v-container max-width="880">
@@ -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);

View File

@@ -284,7 +284,6 @@ const appConfig = ref<CheckApp>({
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<SimpleCheck[]>(() => {
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;
});

View File

@@ -45,6 +45,7 @@
<script setup lang="ts">
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
import { useGroupSelf } from "~/composables/use-groups";
definePageMeta({
middleware: ["group-only"],
@@ -52,7 +53,8 @@ definePageMeta({
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $globals } = useNuxtApp();
const { $globals } = useNuxtApp();
const { group } = useGroupSelf();
useSeoMeta({
title: i18n.t("general.create"),
@@ -78,7 +80,7 @@ const subpages = computed<MenuItem[]>(() => [
icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-images"),
value: "image",
hide: !$appInfo.enableOpenaiImageServices,
hide: !group.value?.aiProviderSettings?.imageProviderEnabled,
},
{
icon: $globals.icons.edit,

View File

@@ -25,7 +25,7 @@
persistent-hint
/>
</v-card-text>
<v-card-text v-if="$appInfo.enableOpenai">
<v-card-text v-if="group?.aiProviderSettings?.aiEnabled">
{{ $t('recipe.recipe-debugger-use-openai-description') }}
<v-checkbox
v-model="state.useOpenAI"
@@ -69,6 +69,7 @@
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useGroupSelf } from "~/composables/use-groups";
import { validators } from "~/composables/use-validators";
import type { Recipe } from "~/lib/api/types/recipe";
@@ -80,6 +81,7 @@ const state = reactive({
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const { group } = useGroupSelf();
const recipeUrl = computed({
set(recipe_import_url: string | null) {

View File

@@ -11,7 +11,7 @@
<v-card-text>
<v-card-text class="pa-0">
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
<p v-if="$appInfo.enableOpenaiTranscriptionServices">
<p v-if="group?.aiProviderSettings?.audioProviderEnabled">
{{ $t('recipe.scrape-recipe-description-transcription') }}
</p>
</v-card-text>
@@ -145,6 +145,7 @@
<script setup lang="ts">
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { useGroupSelf } from "~/composables/use-groups";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
@@ -162,6 +163,7 @@ const auth = useMealieAuth();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { group } = useGroupSelf();
const router = useRouter();
const tags = useTagStore();

View File

@@ -17,25 +17,52 @@
</template>
{{ $t("profile.group-description") }}
</BasePageTitle>
<v-form ref="refGroupEditForm" @submit.prevent="handleSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
<div class="mb-10">
<v-form ref="refGroupPrefsEditForm" @submit.prevent="handlePrefsSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</div>
<div>
<v-form ref="refGroupAISettingsForm" @submit.prevent="handleAISettingsSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupAIProviderSettingsEditor
v-if="group.aiProviderSettings"
v-model="group.aiProviderSettings"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</div>
</v-container>
</template>
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
import { useGroupSelf } from "~/composables/use-groups";
import { useAIProviders } from "~/composables/use-ai-providers";
import { alert } from "~/composables/use-toast";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
@@ -49,10 +76,11 @@ useSeoMeta({
title: i18n.t("group.group"),
});
const refGroupEditForm = ref<VForm | null>(null);
const refGroupPrefsEditForm = ref<VForm | null>(null);
const refGroupAISettingsForm = ref<VForm | null>(null);
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || !group.value?.preferences) {
async function handlePrefsSubmit() {
if (!refGroupPrefsEditForm.value?.validate() || !group.value?.preferences) {
return;
}
@@ -64,6 +92,55 @@ async function handleSubmit() {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
async function handleAISettingsSubmit() {
if (!refGroupAISettingsForm.value?.validate() || !group.value?.aiProviderSettings) {
return;
}
const data = await groupActions.updateAIProviderSettings();
if (data) {
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
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"));
}
}
</script>
<style lang="css">

View File

@@ -45,7 +45,7 @@
sm="12"
md="12"
>
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4">
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4 pa-2">
<v-card-title class="text-h6 pb-0">
{{ $t('profile.household-statistics') }}
</v-card-title>