streaming, markdown, model selecting, and lots more
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user