Files
veridian/app/components/Settings/AIServiceProvider.vue
T
zoeissleeping 6ee4087a29 ♻️ refactor: optimize state management, switch to @tanstack/vue-virtual, and improve performance
- Centralize `useAgents` and `useModels` state within the Nuxt app context to prevent data leaks and improve initialization.
- Migrate virtualization from `vue-virtual-scroller` to `@tanstack/vue-virtual` with new `RowVirtualizerFixed` and `RowVirtualizerDynamic` components.
- Upgrade Nuxt to v4.3.1 and remove `@vue-macros/nuxt`.
- Replace `big.js` with an optimized custom `lshDecimal` string manipulation logic for pricing calculations in the provider API.
- Implement automatic focus redirection in `ChatInput` to capture standard keyboard input.
- Refactor Sidenav and Settings components to utilize virtualization for long lists (topics, agents, models).
- Enhance theme colors and mobile experience. More work to come on both of these.
2026-02-23 15:56:10 +00:00

300 lines
12 KiB
Vue

<script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls, type Model } from '~/types/model';
import { useSettings } from '~/composables/useSettings';
import ModelItem from './ModelItem.vue';
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
const triplit = useTriplitClient();
const { pageParams } = useSettings();
const { providers } = useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null);
const provider = computed(() => {
if (pageParams.value.length === 0) return null;
return providers.value!.find(p => p.id === pageParams.value[0]);
});
watch(provider, async () => {
if (!provider.value) return;
await decryptApiKey();
});
const apiKeyVisible = ref(false);
const apiKey = ref('');
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
const modelSearch = ref('');
watch(pageParams, () => {
if (pageParams.value.length === 0) return;
console.log(scrollContainerRef.value);
apiKey.value = provider.value?.config.apiKey ?? '';
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
modelSearch.value = '';
nextTick(() => {
if (scrollContainerRef.value) {
scrollContainerRef.value.scrollTo({ top: 0, behavior: 'instant' });
}
});
})
const decryptApiKey = async () => {
if (!provider.value?.config.apiKey) {
apiKey.value = '';
return
};
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
);
apiKey.value = await decrypt(key, base64ToUint8Array(provider.value!.config.apiKey));
}
if (import.meta.client) {
await decryptApiKey();
};
const toggleProvider = async () => {
await triplit.update('providers', provider.value!.id, {
enabled: !provider.value!.enabled,
});
};
const updateApiKey = async (value: string) => {
if (!provider.value) return;
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
);
const encypted = await encryptData(key, value);
await triplit.update('providers', provider.value.id, {
config: {
...provider.value.config,
apiKey: uint8ArrayToBase64(encypted),
},
});
};
const updateProxyUrl = async (value: string) => {
if (!provider.value) return;
apiProxyUrl.value = value;
await triplit.update('providers', provider.value.id, {
config: {
...provider.value.config,
apiProxyUrl: value,
},
});
};
const fetchingModels = ref(false);
const fetchModels = async () => {
const { user } = useAuth()
fetchingModels.value = true;
try {
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST',
body: JSON.stringify({
providerApiKey: apiKey.value
})
}) as any;
const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m])
);
const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = [];
for (const model of modelsData.models) {
const existing = existingModelsMap.get(model.id);
if (existing) {
const { id, ...existingWithoutId } = model;
console.log("existingWithoutId", existingWithoutId);
toUpdate.push({
id: existing.id,
data: existingWithoutId,
});
} else {
toInsert.push({
userId: user.value?.id!,
externalId: model.id,
providerId: provider.value!.id!,
name: model.name || model.id,
cost: model.cost || {},
attributes: model.attributes,
isCustom: false,
enabled: false,
releasedAt: model.releasedAt,
});
}
}
// delete models that are not in the API response and are not custom models
const apiModelIds = new Set(modelsData.models.values().map((m: any) => m.id));
console.log("apiModelIds", apiModelIds);
const toDelete = (provider.value?.models || []).filter((m: any) =>
!m.isCustom && !apiModelIds.has(m.externalId)
);
console.log({ toUpdate, toInsert, toDelete });
await Promise.all([
...toInsert.map(item => triplit.insert('models', item)),
...toUpdate.map(item => triplit.update('models', item.id, item.data)),
...toDelete.map(item => triplit.delete('models', item.id))
]);
} catch (error) {
console.error('Failed to fetch models:', error);
} finally {
fetchingModels.value = false;
}
}
const deleteModels = async () => {
if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.delete('models', m.id)));
};
const enabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value)
.sort(sortByReleaseDate)
)
const disabledModels = computed(() =>
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value)
.sort(sortByReleaseDate)
)
defineEmits(['navigate']);
</script>
<template>
<div ref="scrollContainerRef"
class="flex flex-col gap-4 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]"
v-if="provider">
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">Enabled</label>
<Slider :checked="provider.enabled" @click.stop="toggleProvider()" />
</div>
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input class="placeholder:text-[var(--text-tertiary)] w-full p-0 pl-2 py-1 bg-transparent"
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiKey"
autocomplete="false" spellcheck="false"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible"
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
</button>
</div>
</div>
<div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input :placeholder="provider.type ? providerBaseUrls[provider.type] ?? '' : ''"
class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
id="provider-proxy-url" :value="apiProxyUrl"
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
</div>
</div>
<div class="flex flex-row justify-center text-xs">
<p class="text-[var(--text-secondary)]">
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
</p>
</div>
<div class="flex flex-col">
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center">
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
Model List
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
@click="deleteModels">
<Icon name="mynaui:x-solid" />
</button>
</span>
</h4>
<div class="flex items-center gap-2">
<div class="flex items-center justify-center px-2 py-1 bg-[var(--bg-container)] text-xs rounded-md">
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
type="text" placeholder="Search models..." />
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
<Icon name="mynaui:x" class="text-3.5 block text-[var(--text-secondary)]" />
</button>
</div>
<button @click="fetchModels"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
fetch models
</button>
</div>
</div>
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
<Icon name="mynaui:info-circle" class="text-4" />
<span class="text-sm text-[var(--text-secondary)]">
No models found
</span>
</div>
<ClientOnly v-else>
<div class="flex flex-col gap-1 mt-2">
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Enabled
</span>
<div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="enabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" />
</template>
</RowVirtualizerDynamic>
</div>
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
Disabled
</span>
<div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="disabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20">
<template v-slot="{ item: model }">
<ModelItem :model="model" />
</template>
</RowVirtualizerDynamic>
</div>
</div>
</ClientOnly>
</div>
</div>
</template>