streaming, markdown, model selecting, and lots more

This commit is contained in:
Zoe
2026-02-02 23:16:06 -06:00
parent 8c28946703
commit d5a5945c03
114 changed files with 6109 additions and 2938 deletions
@@ -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>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import GeneralSettings from './GeneralSettings.vue';
import ProviderSettings from './ProviderSettings.vue';
import ProviderSidebar from './ProviderSidebar.vue';
import AIServiceProvider from './AIServiceProvider.vue';
const { providers } = await useModels();
const { currentPage, pageParams, open, setPage, close } = useSettings();
console.log(providers.value);
const PAGES_CONFIG = {
general: {
label: 'General',
icon: 'mynaui:cog-four',
component: GeneralSettings
},
providers: {
label: 'AI Providers',
icon: 'mynaui:api',
component: ProviderSettings,
sidebar: ProviderSidebar
},
} as const;
const runtimePage = computed(() => {
// 1. Get the base config (e.g., 'providers' or 'general')
const config = PAGES_CONFIG[currentPage.value as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
// 2. Determine the actual component to show
let component = config.component;
let label = config.label as string;
if (currentPage.value === 'providers' && pageParams.value.length > 0) {
component = AIServiceProvider;
const providerId = pageParams.value[0];
console.log("PROVIDERS", providers.value);
const provider = providers.value!.find(p => p.id === providerId);
label = provider ? provider.name : 'Unknown Provider';
}
return {
...config,
label,
component,
params: pageParams.value,
} as {
label: string;
icon: string;
component: Component;
sidebar?: Component;
params: string[];
};
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
watch(open, (value) => {
if (value) {
document.body.addEventListener('keydown', handleKeyDown);
} else {
document.body.removeEventListener('keydown', handleKeyDown);
}
});
onUnmounted(() => {
if (open.value) {
document.body.removeEventListener('keydown', handleKeyDown);
}
});
</script>
<template>
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="open" class="fixed inset-0 z-45 bg-black/80 backdrop-blur-md" @click.self="close">
</div>
</Transition>
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
overflow-hidden flex max-h-[90vh] p-2">
<nav class="w-64 flex flex-col gap-1 mr-2">
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="setPage" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
:class="[currentPage === id ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
<div class="flex items-center gap-2 max-w-full flex-1">
<Icon :name="config.icon" class="w-5 h-5" />
{{ config.label }}
</div>
</button>
</nav>
<!-- DYNAMIC CONTENT -->
<main class="flex-1 flex flex-col overflow-hidden">
<div
class="flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
<header class="flex items-center justify-between pl-2 pb-2 ">
<h2 class="text-lg font-semibold m-0">{{ runtimePage.label }}</h2>
<button
class="hover:bg-[var(--color-highlight)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
@click="close">
<Icon name="mynaui:x-solid" />
</button>
</header>
<!-- KeepAlive preserves state if the user clicks back/forth between tabs -->
<KeepAlive>
<component @navigate="setPage" :is="runtimePage.component" />
</KeepAlive>
</div>
</main>
</div>
</div>
</Transition>
</template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
defineEmits(['navigate']);
</script>
<template>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
const triplit = useTriplitClient();
const { providers } = await useModels();
const toggleProvider = async (id: string) => {
const provider = providers.value!.find(p => p.id === id);
if (!provider) return;
await triplit.update('providers', provider.id, {
enabled: !provider.enabled,
});
};
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold flex items-center gap-2">
Enabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
{{providers?.filter(p => p.enabled).length}}
</span>
</h2>
<div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
<div class="flex flex-col flex-grow">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
<hr class="border-t border-[var(--color-highlight)]" />
</div>
<div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div>
</button>
</div>
<h2 class="text-lg font-semibold flex items-center gap-2">
Disabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
{{providers?.filter(p => !p.enabled).length}}
</span>
</h2>
<div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
<div class="flex flex-col flex-grow">
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
<hr class="border-t border-[var(--color-highlight)]" />
</div>
<div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div>
</button>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
const { pageParams } = useSettings();
const { providers } = await useModels();
console.log("PROVIDERS", providers.value);
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-1">
<button @click="$emit('navigate', 'general')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
</button>
<button @click="$emit('navigate', 'providers')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Icon name="mynaui:envelope-open" class="text-4" /> All
</button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
<span>{{ p.name }}</span>
</button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)"
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
<span>{{ p.name }}</span>
</button>
</div>
</template>