mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-25 19:20: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"
|
||||
|
||||
@@ -96,15 +96,17 @@
|
||||
<script setup lang="ts">
|
||||
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<boolean>(false);
|
||||
onMounted(() => {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
'mt-8': section,
|
||||
}"
|
||||
>
|
||||
<v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;">
|
||||
<v-card-title :class="`text-title-${size} pl-0 py-0 d-flex align-center`" style="font-weight: normal;">
|
||||
<slot name="prepend-title" />
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
size="small"
|
||||
@@ -16,6 +17,7 @@
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
{{ title }}
|
||||
<slot name="append-title" />
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="$slots.default"
|
||||
@@ -30,11 +32,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Size = "large" | "medium" | "small";
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: String as () => Size,
|
||||
default: "large",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
|
||||
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal file
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(value, key) in (modelValue ?? {})"
|
||||
:key="key"
|
||||
class="d-flex align-center mb-2 gap-2"
|
||||
>
|
||||
<v-text-field
|
||||
:model-value="key"
|
||||
:label="resolvedKeyLabel"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
readonly
|
||||
class="me-3 flex-grow-1"
|
||||
/>
|
||||
<v-text-field
|
||||
:model-value="value"
|
||||
:label="resolvedValueLabel"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="ms-3 flex-grow-1"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click="removeEntry(key)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center mt-2 gap-2" @focusout="onNewEntryFocusOut">
|
||||
<v-text-field
|
||||
v-model="newKey"
|
||||
:label="resolvedKeyLabel"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="me-3 flex-grow-1"
|
||||
@keydown.enter.prevent="addEntry"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="newValue"
|
||||
:label="resolvedValueLabel"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="ms-3 flex-grow-1"
|
||||
@keydown.enter.prevent="addEntry"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
:disabled="!newKey?.trim()"
|
||||
@click="addEntry"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.createAlt }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
|
||||
const i18n = useGlobalI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Record<string, string> | null;
|
||||
keyLabel?: string;
|
||||
valueLabel?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: Record<string, string>): void;
|
||||
}>();
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const resolvedKeyLabel = computed(() => props.keyLabel ?? i18n.t("general.key"));
|
||||
const resolvedValueLabel = computed(() => props.valueLabel ?? i18n.t("general.value"));
|
||||
|
||||
const newKey = ref("");
|
||||
const newValue = ref("");
|
||||
|
||||
function current(): Record<string, string> {
|
||||
return { ...(props.modelValue ?? {}) };
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
const key = newKey.value?.trim();
|
||||
if (!key) return;
|
||||
const updated = current();
|
||||
updated[key] = newValue.value;
|
||||
emit("update:modelValue", updated);
|
||||
newKey.value = "";
|
||||
newValue.value = "";
|
||||
}
|
||||
|
||||
function onNewEntryFocusOut(e: FocusEvent) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||
const currentTarget = e.currentTarget as HTMLElement;
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
addEntry();
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(key: string, value: string) {
|
||||
const updated = current();
|
||||
updated[key] = value;
|
||||
emit("update:modelValue", updated);
|
||||
}
|
||||
|
||||
function removeEntry(key: string) {
|
||||
const updated = current();
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete updated[key];
|
||||
emit("update:modelValue", updated);
|
||||
}
|
||||
</script>
|
||||
55
frontend/app/composables/use-ai-providers.ts
Normal file
55
frontend/app/composables/use-ai-providers.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <b>{groupName}<b/>?",
|
||||
@@ -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."
|
||||
|
||||
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal file
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal file
@@ -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<AIProviderOut>(routes.providers(groupId), payload);
|
||||
}
|
||||
|
||||
async getProvider(groupId: string, providerId: string) {
|
||||
return await this.requests.get<AIProviderOut>(routes.providersId(groupId, providerId));
|
||||
}
|
||||
|
||||
async updateProvider(groupId: string, providerId: string, payload: AIProviderUpdate) {
|
||||
return await this.requests.put<AIProviderOut>(routes.providersId(groupId, providerId), payload);
|
||||
}
|
||||
|
||||
async deleteProvider(groupId: string, providerId: string) {
|
||||
return await this.requests.delete<AIProviderOut>(routes.providersId(groupId, providerId));
|
||||
}
|
||||
}
|
||||
@@ -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<DebugResponse>(routes.openai, formData);
|
||||
return await this.requests.post<DebugResponse>(routes.openai(providerId), formData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal file
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal file
@@ -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<AIProviderOut>(routes.providersId(id));
|
||||
}
|
||||
|
||||
async createOne(payload: AIProviderCreate) {
|
||||
return await this.requests.post<AIProviderOut>(routes.providers, payload);
|
||||
}
|
||||
|
||||
async updateOne(id: string, payload: AIProviderUpdate) {
|
||||
return await this.requests.put<AIProviderOut, AIProviderUpdate>(routes.providersId(id), payload);
|
||||
}
|
||||
|
||||
async deleteOne(id: string) {
|
||||
return await this.requests.delete<AIProviderOut>(routes.providersId(id));
|
||||
}
|
||||
}
|
||||
@@ -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<GroupBase, GroupInDB, GroupAdminUpdate
|
||||
return await this.requests.get<GroupSummary>(routes.groupsSelf);
|
||||
}
|
||||
|
||||
async getPreferences() {
|
||||
return await this.requests.get<ReadGroupPreferences>(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<ReadGroupPreferences, UpdateGroupPreferences>(routes.preferences, payload);
|
||||
}
|
||||
|
||||
async setAIProviderSettings(payload: AIProviderSettingsUpdate) {
|
||||
return await this.requests.put<AIProviderSettingsOut, AIProviderSettingsUpdate>(routes.aiProviderSettings, payload);
|
||||
}
|
||||
|
||||
async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
return await this.requests.get<PaginationData<UserSummary>>(routes.members, { page, perPage, ...params });
|
||||
}
|
||||
|
||||
@@ -43,10 +43,6 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
|
||||
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
|
||||
}
|
||||
|
||||
async getPreferences() {
|
||||
return await this.requests.get<ReadHouseholdPreferences>(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<ReadHouseholdPreferences, UpdateHouseholdPreferences>(routes.preferences, payload);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
frontend/app/plugins/no-autofill.client.ts
Normal file
65
frontend/app/plugins/no-autofill.client.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* v-no-autofill directive
|
||||
*
|
||||
* Vuetify 3 places data-* attributes on its wrapper div, not the underlying
|
||||
* <input> element, so password managers still offer to autofill. This directive
|
||||
* uses a MutationObserver to find and patch every <input> inside the host
|
||||
* element, even ones rendered asynchronously (dialogs, conditional blocks).
|
||||
*
|
||||
* From: https://github.com/vuetifyjs/vuetify/issues/18202
|
||||
*
|
||||
* Usage:
|
||||
* <v-text-field v-no-autofill ... />
|
||||
* <v-form v-no-autofill>...</v-form>
|
||||
* <div v-no-autofill>...</div>
|
||||
*/
|
||||
|
||||
import type { Directive, DirectiveBinding } from "vue";
|
||||
|
||||
interface ObservedElement extends HTMLElement {
|
||||
_noAutofillObserver?: MutationObserver;
|
||||
}
|
||||
|
||||
function patchInput(input: HTMLInputElement) {
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.setAttribute("data-1p-ignore", "true");
|
||||
input.setAttribute("data-lpignore", "true");
|
||||
input.setAttribute("data-protonpass-ignore", "true");
|
||||
input.setAttribute("data-bwignore", "true");
|
||||
input.setAttribute("data-form-type", "other");
|
||||
}
|
||||
|
||||
function patchAll(el: HTMLElement) {
|
||||
if (el.tagName === "INPUT") {
|
||||
patchInput(el as HTMLInputElement);
|
||||
}
|
||||
el.querySelectorAll<HTMLInputElement>("input").forEach(patchInput);
|
||||
}
|
||||
|
||||
const noAutofill: Directive<ObservedElement> = {
|
||||
mounted(el: ObservedElement, _binding: DirectiveBinding) {
|
||||
patchAll(el);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
patchAll(node as HTMLElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el, { childList: true, subtree: true });
|
||||
el._noAutofillObserver = observer;
|
||||
},
|
||||
|
||||
unmounted(el: ObservedElement) {
|
||||
el._noAutofillObserver?.disconnect();
|
||||
delete el._noAutofillObserver;
|
||||
},
|
||||
};
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive("no-autofill", noAutofill);
|
||||
});
|
||||
2
frontend/app/types/components.d.ts
vendored
2
frontend/app/types/components.d.ts
vendored
@@ -14,6 +14,7 @@ import type BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.
|
||||
import type BaseDialog from "@/components/global/BaseDialog.vue";
|
||||
import type BaseDivider from "@/components/global/BaseDivider.vue";
|
||||
import type BaseExpansionPanels from "@/components/global/BaseExpansionPanels.vue";
|
||||
import type BaseKeyValueEditor from "@/components/global/BaseKeyValueEditor.vue";
|
||||
import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
||||
import type BasePageTitle from "@/components/global/BasePageTitle.vue";
|
||||
import type ButtonLink from "@/components/global/ButtonLink.vue";
|
||||
@@ -54,6 +55,7 @@ declare module "vue" {
|
||||
BaseDialog: typeof BaseDialog;
|
||||
BaseDivider: typeof BaseDivider;
|
||||
BaseExpansionPanels: typeof BaseExpansionPanels;
|
||||
BaseKeyValueEditor: typeof BaseKeyValueEditor;
|
||||
BaseOverflowButton: typeof BaseOverflowButton;
|
||||
BasePageTitle: typeof BasePageTitle;
|
||||
ButtonLink: typeof ButtonLink;
|
||||
|
||||
Reference in New Issue
Block a user