feat(chat): add dynamic provider catalog and zen support

This commit is contained in:
2026-03-08 21:29:48 +00:00
parent 3526c963a4
commit d35feed98c
7 changed files with 5880 additions and 68 deletions

View File

@@ -566,6 +566,15 @@ export type RecommendationResponse = {
};
};
export type ChatProviderCatalogEntry = {
id: string;
label: string;
available_for_chat: boolean;
needs_api_key: boolean | null;
default_model: string | null;
api_base: string | null;
};
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection

View File

@@ -2,11 +2,7 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
type Provider = {
value: string;
label: string;
};
import type { ChatProviderCatalogEntry } from '$lib/types.js';
type Conversation = {
id: string;
@@ -29,18 +25,30 @@
let streamingContent = '';
let selectedProvider = 'openai';
const providers: Provider[] = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Google Gemini' },
{ value: 'ollama', label: 'Ollama' },
{ value: 'groq', label: 'Groq' },
{ value: 'mistral', label: 'Mistral' },
{ value: 'github_models', label: 'GitHub Models' },
{ value: 'openrouter', label: 'OpenRouter' }
];
let providerCatalog: ChatProviderCatalogEntry[] = [];
$: chatProviders = providerCatalog.filter((provider) => provider.available_for_chat);
onMount(loadConversations);
onMount(async () => {
await Promise.all([loadConversations(), loadProviderCatalog()]);
});
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
return;
}
const catalog = (await res.json()) as ChatProviderCatalogEntry[];
providerCatalog = catalog;
const availableProviders = catalog.filter((provider) => provider.available_for_chat);
if (!availableProviders.length) {
return;
}
if (!availableProviders.some((provider) => provider.id === selectedProvider)) {
selectedProvider = availableProviders[0].id;
}
}
async function loadConversations() {
const res = await fetch('/api/chat/conversations/');
@@ -86,6 +94,7 @@
async function sendMessage() {
if (!inputMessage.trim() || isStreaming) return;
if (!chatProviders.some((provider) => provider.id === selectedProvider)) return;
let conversation = activeConversation;
if (!conversation) {
@@ -258,9 +267,13 @@
</svg>
<h1 class="text-lg font-semibold">{$t('chat.title')}</h1>
<div class="ml-auto">
<select class="select select-bordered select-sm" bind:value={selectedProvider}>
{#each providers as provider}
<option value={provider.value}>{provider.label}</option>
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
@@ -325,7 +338,7 @@
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim()}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}

View File

@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { addToast } from '$lib/toasts';
import { CURRENCY_LABELS, CURRENCY_OPTIONS } from '$lib/money';
import type { ImmichIntegration, User } from '$lib/types.js';
import type { ChatProviderCatalogEntry, ImmichIntegration, User } from '$lib/types.js';
import type { PageData } from './$types';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
@@ -46,6 +46,7 @@
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'anthropic';
let providerCatalog: ChatProviderCatalogEntry[] = [];
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
@@ -53,21 +54,26 @@
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
const API_KEY_PROVIDER_OPTIONS = [
{ value: 'anthropic', labelKey: 'settings.api_key_provider_anthropic' },
{ value: 'openai', labelKey: 'settings.api_key_provider_openai' },
{ value: 'gemini', labelKey: 'settings.api_key_provider_gemini' },
{ value: 'ollama', labelKey: 'settings.api_key_provider_ollama' },
{ value: 'groq', labelKey: 'settings.api_key_provider_groq' },
{ value: 'mistral', labelKey: 'settings.api_key_provider_mistral' },
{ value: 'github_models', labelKey: 'settings.api_key_provider_github_models' },
{ value: 'openrouter', labelKey: 'settings.api_key_provider_openrouter' }
];
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
return;
}
providerCatalog = await res.json();
if (!providerCatalog.length) {
return;
}
if (!providerCatalog.some((provider) => provider.id === newApiKeyProvider)) {
newApiKeyProvider = providerCatalog[0].id;
}
}
function getApiKeyProviderLabel(provider: string): string {
const option = API_KEY_PROVIDER_OPTIONS.find((entry) => entry.value === provider);
if (option) {
return $t(option.labelKey);
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
if (catalogProvider) {
return catalogProvider.label;
}
if (provider === 'google_maps') {
@@ -127,6 +133,8 @@
];
onMount(async () => {
void loadProviderCatalog();
if (browser) {
const queryParams = new URLSearchParams($page.url.search);
const pageParam = queryParams.get('page');
@@ -1638,16 +1646,17 @@
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
>
{#each API_KEY_PROVIDER_OPTIONS as option}
<option value={option.value}>{$t(option.labelKey)}</option>
{/each}
</select>
</div>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
disabled={providerCatalog.length === 0}
>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
@@ -1665,7 +1674,11 @@
{$t('settings.api_key_write_only_hint')}
</p>
</div>
<button class="btn btn-primary" type="submit" disabled={isSavingApiKey}>
<button
class="btn btn-primary"
type="submit"
disabled={isSavingApiKey || providerCatalog.length === 0}
>
{#if isSavingApiKey}
<span class="loading loading-spinner loading-sm"></span>
{/if}