Files
veridian/app/components/Settings/AIServiceProvider.vue
T

333 lines
13 KiB
Vue

<script setup lang="ts">
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls } from '~/types/model';
import { useSettings } from '~/composables/useSettings';
const triplit = useTriplitClient();
const { pageParams } = useSettings();
const { providers } = await useModels();
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('');
const providerApiUrl = computed(() => apiProxyUrl.value === '' ? providerBaseUrls[provider.value!.type] : apiProxyUrl.value);
const decryptApiKey = async () => {
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;
await triplit.update('providers', provider.value.id, {
config: {
...provider.value.config,
apiProxyUrl: value,
},
});
};
const toggleModel = async (id: string) => {
if (!provider.value) return;
await triplit.update('models', id, {
enabled: !provider.value!.models.find(m => m.id === id)!.enabled,
});
};
const fetchingModels = ref(false);
const fetchModels = async () => {
const { user } = useAuth();
fetchingModels.value = true;
try {
const [providerResponse, devDataResponse] = await Promise.all([
$fetch(`${providerApiUrl.value}/models`),
$fetch('https://models.dev/api.json')
]);
const providerType = provider.value!.type;
const modelDetails = devDataResponse[providerType]?.models || {};
const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m])
);
const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = [];
providerResponse.data.forEach((pModel: any) => {
const slug = pModel.id.toLowerCase();
const info = modelDetails[slug] || {};
console.log("INFO", info);
const capabilities = [];
if (info.reasoning) {
capabilities.push('reasoning');
}
if (info.tool_call) {
capabilities.push('tools');
}
const attributes = {
inputModalities: new Set(info.modalities?.input.filter(m => ['text', 'image'].includes(m)) || ['text']),
outputModalities: new Set(info.modalities?.output.filter(m => ['text', 'image'].includes(m)) || ['text']),
capabilities,
contextWindow: pModel.context_length || info.limit?.context || null,
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
};
const existing = existingModelsMap.get(pModel.id);
if (existing) {
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
const { id, ...existingWithoutId } = existing;
toUpdate.push({
id: existing.id,
data: {
...existingWithoutId,
name: existing.name || info.name || pModel.name || pModel.id,
attributes: attributes, // Update tech specs
releasedAt: new Date(pModel.created * 1000),
updatedAt: new Date()
}
});
} else {
// INSERT logic: This is a brand new model
toInsert.push({
userId: user.value?.id,
providerId: provider.value!.id,
externalId: pModel.id,
name: info.name || pModel.name || pModel.id,
isCustom: false,
enabled: false,
attributes: attributes,
releasedAt: new Date(pModel.created * 1000),
createdAt: new Date(),
updatedAt: new Date()
});
}
});
await Promise.all([
...toInsert.map(item => triplit.insert('models', item)),
...toUpdate.map(item => triplit.update('models', item.id, (m) => {
Object.assign(m, item.data);
}))
]);
} catch (error) {
console.error('Failed to fetch models:', error);
} finally {
fetchingModels.value = false;
}
}
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-4 mt-4">
<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(--color-highlight)] items-center gap-1 w-7/10">
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'"
id="provider-api-key" :value="apiKey"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible"
class="text-sm p-2 text-[var(--color-muted)] hover:text-[var(--color-text)]">
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-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(--color-highlight)] items-center gap-1 w-7/10">
<input :placeholder="providerBaseUrls[provider!.type]" class="w-full px-2 py-1 bg-transparent"
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiProxyUrl"
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
</div>
</div>
<div class="flex flex-row justify-center text-xs">
<p class="text-[var(--color-muted)]">
<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">
<h4 class="whitespace-nowrap m-0">
Model List
<span class="text-sm text-[var(--color-muted)] font-normal text-xs">
{{ provider?.models.length }} models available
</span>
</h4>
<div class="flex items-center gap-2">
<input v-model="modelSearch" type="text" class="px-2 py-1 bg-[var(--color-highlight)] text-xs"
placeholder="Search models..." />
<button @click="fetchModels"
class="whitespace-nowrap flex bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] 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(--color-muted)]">
No models found
</span>
</div>
<div v-else class="flex flex-col gap-1 mt-2">
<span class="text-sm text-[var(--color-muted)]">
Enabled
</span>
<div class="flex flex-col gap-1">
<div class="p-3 flex items-center justify-between"
v-for="model in provider?.models.filter(m => m.enabled === true).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
:key="model.id">
<div class="flex flex-row items-center">
<div class="flex items-center">
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
</div>
<div class="flex flex-col gap-1 ml-2">
<div
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
{{ model.name }}
<span
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
{{ model.externalId }}
</span>
</div>
<div class="text-xs text-[var(--color-muted)]">
Released on {{
model.releasedAt?.toISOString().split('T')[0] }}
</div>
</div>
</div>
<div>
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
</div>
</div>
<span class="text-sm text-[var(--color-muted)]">
Disabled
</span>
<div class="flex flex-col gap-1">
<div class="p-3 flex items-center justify-between"
v-for="model in provider?.models.filter(m => m.enabled === false).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
:key="model.id">
<div class="flex flex-row items-center">
<div class="flex items-center">
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
</div>
<div class="flex flex-col gap-1 ml-2">
<div
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
{{ model.name }}
<span
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
{{ model.externalId }}
</span>
</div>
<div class="text-xs text-[var(--color-muted)]">
Released on {{ model.releasedAt?.toISOString().split('T')[0] }}
</div>
</div>
</div>
<div>
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.animate-rotate {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>