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

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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,
},
];
});

View File

@@ -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"

View File

@@ -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(() => {

View File

@@ -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: "",

View 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>

View 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,
};
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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."

View 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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View 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));
}
}

View File

@@ -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 });
}

View File

@@ -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);

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>

View 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);
});

View File

@@ -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;