247 lines
11 KiB
Vue
247 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import type { Model } from '~/composables/useModels';
|
|
import { encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
|
|
import { providerIcons } from '~/utils/model-mapping';
|
|
import { sortByReleaseDate } from '~/utils/sort';
|
|
|
|
// the providers offered up front, the rest sit behind "More…"
|
|
const FEATURED_PROVIDERS = ['openrouter', 'anthropic', 'openai', 'google', 'ollama'];
|
|
// these run on your own machine and are reached by address, not by key
|
|
const LOCAL_PROVIDERS = ['ollama', 'vllm'];
|
|
// A provider can list hundreds of models, so only the newest few are turned on
|
|
// to start with. The rest are a click away in the provider's settings.
|
|
const MODELS_ENABLED_AT_START = 8;
|
|
|
|
const PROVIDER_LABELS: Record<string, string> = {
|
|
openrouter: 'OpenRouter',
|
|
anthropic: 'Anthropic',
|
|
openai: 'OpenAI',
|
|
google: 'Google',
|
|
ollama: 'Ollama',
|
|
};
|
|
|
|
const KEY_PLACEHOLDERS: Record<string, string> = {
|
|
openrouter: 'sk-or-…',
|
|
anthropic: 'sk-ant-…',
|
|
openai: 'sk-…',
|
|
};
|
|
|
|
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
|
|
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
|
|
const SELECTED_STYLE = {
|
|
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
|
|
};
|
|
|
|
defineProps<{
|
|
submitLabel: string;
|
|
// shown next to skip, for flows that have a step before this one
|
|
showBack?: boolean;
|
|
}>();
|
|
|
|
// done: a provider was connected or the step was skipped
|
|
const emit = defineEmits(['done', 'back']);
|
|
|
|
const { providers } = await useModels();
|
|
|
|
const showAll = ref(false);
|
|
const selectedType = ref(FEATURED_PROVIDERS.find(type => providers.value.some(p => p.type === type))
|
|
?? providers.value[0]?.type ?? '');
|
|
|
|
const featured = computed(() => FEATURED_PROVIDERS
|
|
.map(type => providers.value.find(p => p.type === type))
|
|
.filter(p => p !== undefined));
|
|
|
|
const others = computed(() => providers.value.filter(p => !FEATURED_PROVIDERS.includes(p.type)));
|
|
|
|
const shownProviders = computed(() => showAll.value ? [...featured.value, ...others.value] : featured.value);
|
|
|
|
const selected = computed(() => providers.value.find(p => p.type === selectedType.value));
|
|
const isLocal = computed(() => LOCAL_PROVIDERS.includes(selectedType.value));
|
|
|
|
const apiKey = ref('');
|
|
const apiKeyVisible = ref(false);
|
|
const baseUrl = ref('');
|
|
|
|
const busy = ref(false);
|
|
const error = ref('');
|
|
|
|
const ready = computed(() => isLocal.value ? baseUrl.value.trim() !== '' : apiKey.value.trim() !== '');
|
|
|
|
const selectProvider = (type: string) => {
|
|
selectedType.value = type;
|
|
error.value = '';
|
|
};
|
|
|
|
const submit = async () => {
|
|
const provider = selected.value;
|
|
if (busy.value || !ready.value || !provider) return;
|
|
|
|
busy.value = true;
|
|
error.value = '';
|
|
|
|
const storedKey = localStorage.getItem('encryptionKey');
|
|
if (storedKey === null) {
|
|
error.value = 'This browser has no encryption key for your account. Log out and back in, then add the provider under Settings.';
|
|
busy.value = false;
|
|
return;
|
|
}
|
|
|
|
const key = apiKey.value.trim();
|
|
const config = { ...provider.config };
|
|
|
|
try {
|
|
if (isLocal.value) {
|
|
config.apiProxyUrl = baseUrl.value.trim();
|
|
} else {
|
|
const cryptoKey = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
|
|
}
|
|
|
|
const saved = await $fetch(`/api/provider/${provider.id}`, {
|
|
method: 'PATCH',
|
|
body: { config, enabled: true, expectedUpdatedAt: new Date(provider.updatedAt).toISOString() },
|
|
});
|
|
providers.value = providers.value.map(p => p.id === provider.id
|
|
? { ...p, config, enabled: true, updatedAt: new Date(saved.updatedAt) }
|
|
: p);
|
|
} catch (err) {
|
|
console.error('Failed to save provider:', err);
|
|
error.value = 'The provider could not be saved. Try again.';
|
|
busy.value = false;
|
|
return;
|
|
}
|
|
|
|
let models: Model[];
|
|
try {
|
|
const response = await $fetch<{ models: Model[] }>(`/api/provider/${provider.id}/models`, {
|
|
method: 'POST',
|
|
body: { providerApiKey: isLocal.value ? undefined : key },
|
|
});
|
|
models = response.models;
|
|
} catch (err) {
|
|
console.error('Failed to fetch models:', err);
|
|
error.value = isLocal.value
|
|
? 'No models could be fetched from that address. Check that it is right and reachable from the server.'
|
|
: 'No models could be fetched with that key. Check that it is right and try again.';
|
|
busy.value = false;
|
|
return;
|
|
}
|
|
|
|
const toEnable = models
|
|
.filter(m => m.outputModalities.includes('text'))
|
|
.sort(sortByReleaseDate)
|
|
.slice(0, MODELS_ENABLED_AT_START);
|
|
|
|
if (toEnable.length === 0) {
|
|
error.value = 'The provider answered, but has no chat models to offer. Try another provider.';
|
|
busy.value = false;
|
|
return;
|
|
}
|
|
|
|
// a model that fails to turn on here can still be turned on in settings
|
|
const results = await Promise.allSettled(toEnable.map(m => $fetch(`/api/model/${m.id}`, {
|
|
method: 'PATCH',
|
|
body: { enabled: true },
|
|
})));
|
|
const enabledIds = new Set(toEnable.filter((_, i) => results[i]!.status === 'fulfilled').map(m => m.id));
|
|
|
|
providers.value = providers.value.map(p => p.id === provider.id
|
|
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
|
|
: p);
|
|
|
|
if (!models.some(model => model.outputModalities.includes("text") && (model.enabled || enabledIds.has(model.id)))) {
|
|
error.value = "The models were fetched, but none could be enabled. Try again.";
|
|
busy.value = false;
|
|
return;
|
|
}
|
|
|
|
emit('done');
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
|
<fieldset class="flex flex-col">
|
|
<legend
|
|
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
|
Provider
|
|
</legend>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button v-for="p in shownProviders" :key="p.id" type="button"
|
|
:aria-pressed="selectedType === p.type" :disabled="busy" @click="selectProvider(p.type)"
|
|
class="flex items-center justify-center gap-2 h-11 px-2 min-w-0 text-sm rounded-lg border disabled:cursor-not-allowed transition-colors duration-200"
|
|
:style="selectedType === p.type ? SELECTED_STYLE : undefined" :class="selectedType === p.type
|
|
? 'border-[var(--color-accent)] font-medium'
|
|
: 'border-[var(--color-border)] enabled:@hover:bg-[var(--color-hover)]'">
|
|
<component v-if="providerIcons[p.type]" :is="providerIcons[p.type]" :color="true"
|
|
class="w-5.5 h-5.5 flex-shrink-0 text-[var(--text-primary)]" />
|
|
<span class="case-capital overflow-hidden text-ellipsis whitespace-nowrap">
|
|
{{ PROVIDER_LABELS[p.type] ?? p.name }}
|
|
</span>
|
|
</button>
|
|
<button v-if="!showAll && others.length > 0" type="button" :disabled="busy"
|
|
@click="showAll = true"
|
|
class="flex items-center justify-center h-11 text-sm rounded-lg border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:cursor-not-allowed transition-colors duration-200">
|
|
More…
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div v-if="isLocal" class="flex flex-col gap-1.5">
|
|
<label for="baseurl"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
|
Server address
|
|
</label>
|
|
<input id="baseurl" v-model="baseUrl" type="url" :disabled="busy" autocomplete="off"
|
|
spellcheck="false" :placeholder="selected?.defaultBaseUrl || 'http://localhost:11434'"
|
|
class="text-sm border border-[var(--color-border)]" />
|
|
</div>
|
|
<div v-else class="flex flex-col gap-1.5">
|
|
<label for="apikey"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
|
API key
|
|
</label>
|
|
<div
|
|
class="flex items-center rounded-md bg-[var(--bg-container)] border border-[var(--color-border)]">
|
|
<input id="apikey" v-model="apiKey" :type="apiKeyVisible ? 'text' : 'password'"
|
|
:disabled="busy" autocomplete="off" spellcheck="false"
|
|
:placeholder="KEY_PLACEHOLDERS[selectedType] ?? 'API key'"
|
|
class="flex-1 min-w-0 text-sm !bg-transparent" />
|
|
<button type="button" :aria-label="apiKeyVisible ? 'Hide API key' : 'Show API key'"
|
|
@click="apiKeyVisible = !apiKeyVisible"
|
|
class="flex items-center justify-center w-10 h-10 flex-shrink-0 text-[var(--text-secondary)]">
|
|
<span class="text-4.5" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="!isLocal" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
|
|
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
|
<span>
|
|
Encrypted in this browser with AES-GCM before it is stored. Nobody else on this instance,
|
|
including admins, can read it.
|
|
</span>
|
|
</p>
|
|
|
|
<slot name="note"></slot>
|
|
|
|
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
|
|
|
<div class="flex gap-2">
|
|
<button v-if="showBack" type="button" :disabled="busy" @click="emit('back')"
|
|
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
|
Back
|
|
</button>
|
|
<button type="button" :disabled="busy" @click="emit('done')"
|
|
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
|
Skip for now
|
|
</button>
|
|
<button type="submit" :disabled="busy || !ready"
|
|
class="accent flex-1 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
|
|
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
|
<span v-else>{{ submitLabel }}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</template>
|