fix(chat): add saved AI defaults and harden suggestions

This commit is contained in:
2026-03-09 20:32:13 +00:00
parent 21954df3ee
commit bb54503235
38 changed files with 3949 additions and 105 deletions

View File

@@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { ImmichIntegration, User } from '$lib/types';
import type { ImmichIntegration, User, UserAISettings } from '$lib/types';
import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@@ -95,6 +95,7 @@ export const load: PageServerLoad = async (event) => {
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
let aiSettings: UserAISettings | null = null;
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
@@ -108,6 +109,17 @@ export const load: PageServerLoad = async (event) => {
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
}
let aiSettingsFetch = await fetch(`${endpoint}/api/integrations/ai-settings/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (aiSettingsFetch.ok) {
const aiSettingsResponse = (await aiSettingsFetch.json()) as UserAISettings[];
aiSettings = aiSettingsResponse[0] ?? null;
}
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
if (!publicUrlFetch.ok) {
@@ -131,6 +143,7 @@ export const load: PageServerLoad = async (event) => {
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
aiSettings,
wandererEnabled,
wandererExpired
}

View File

@@ -47,6 +47,11 @@
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'anthropic';
let providerCatalog: ChatProviderCatalogEntry[] = [];
let defaultAiProvider = (data.props.aiSettings?.preferred_provider ?? '').trim().toLowerCase();
let defaultAiModel = (data.props.aiSettings?.preferred_model ?? '').trim();
let defaultAiModels: string[] = [];
let defaultAiModelsLoading = false;
let isSavingDefaultAiSettings = false;
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
@@ -70,6 +75,104 @@
}
}
function getConfiguredChatProviders() {
return providerCatalog.filter(
(provider) =>
provider.available_for_chat && (provider.user_configured || provider.instance_configured)
);
}
async function loadDefaultAiModels(providerId: string) {
if (!providerId) {
defaultAiModels = [];
return;
}
defaultAiModelsLoading = true;
try {
const res = await fetch(`/api/chat/providers/${providerId}/models/`);
if (!res.ok) {
defaultAiModels = [];
return;
}
const payload = await res.json();
defaultAiModels = Array.isArray(payload.models) ? (payload.models as string[]) : [];
} catch {
defaultAiModels = [];
} finally {
defaultAiModelsLoading = false;
}
}
async function initializeDefaultAiSettings() {
const configuredProviders = getConfiguredChatProviders();
if (!configuredProviders.length) {
defaultAiProvider = '';
defaultAiModel = '';
defaultAiModels = [];
return;
}
if (
!defaultAiProvider ||
!configuredProviders.some((provider) => provider.id === defaultAiProvider)
) {
defaultAiProvider = configuredProviders[0].id;
defaultAiModel = '';
}
await loadDefaultAiModels(defaultAiProvider);
if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
defaultAiModel = '';
}
}
async function onDefaultAiProviderChange() {
defaultAiModel = '';
await loadDefaultAiModels(defaultAiProvider);
}
async function saveDefaultAiSettings(event: SubmitEvent) {
event.preventDefault();
if (!defaultAiProvider) {
addToast('error', $t('settings.default_ai_provider_required'));
return;
}
isSavingDefaultAiSettings = true;
try {
const res = await fetch('/api/integrations/ai-settings/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
preferred_provider: defaultAiProvider,
preferred_model: defaultAiModel || null
})
});
if (!res.ok) {
addToast('error', $t('settings.default_ai_settings_error'));
return;
}
const saved = await res.json();
defaultAiProvider = (saved.preferred_provider ?? '').trim().toLowerCase();
defaultAiModel = (saved.preferred_model ?? '').trim();
await loadDefaultAiModels(defaultAiProvider);
if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
defaultAiModel = '';
}
addToast('success', $t('settings.default_ai_settings_saved'));
} catch {
addToast('error', $t('settings.default_ai_settings_error'));
} finally {
isSavingDefaultAiSettings = false;
}
}
function getApiKeyProviderLabel(provider: string): string {
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
if (catalogProvider) {
@@ -133,7 +236,8 @@
];
onMount(async () => {
void loadProviderCatalog();
await loadProviderCatalog();
await initializeDefaultAiSettings();
if (browser) {
const queryParams = new URLSearchParams($page.url.search);
@@ -1570,6 +1674,71 @@
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">
{$t('settings.default_ai_settings_title')}
</h3>
<p class="text-sm text-base-content/70 mb-4">
{$t('settings.default_ai_settings_desc')}
</p>
{#if getConfiguredChatProviders().length === 0}
<div class="alert alert-warning">
<span>{$t('settings.default_ai_no_providers')}</span>
</div>
{:else}
<form class="space-y-4" on:submit={saveDefaultAiSettings}>
<div class="form-control">
<label class="label" for="default-ai-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="default-ai-provider"
class="select select-bordered select-primary w-full"
bind:value={defaultAiProvider}
on:change={onDefaultAiProviderChange}
>
{#each getConfiguredChatProviders() as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="default-ai-model">
<span class="label-text font-medium">{$t('chat.model_label')}</span>
</label>
<select
id="default-ai-model"
class="select select-bordered select-primary w-full"
bind:value={defaultAiModel}
disabled={defaultAiModelsLoading}
>
<option value="">{$t('chat.model_placeholder')}</option>
{#if defaultAiModelsLoading}
<option value="" disabled>Loading...</option>
{:else}
{#each defaultAiModels as model}
<option value={model}>{model}</option>
{/each}
{/if}
</select>
</div>
<button
class="btn btn-primary"
type="submit"
disabled={isSavingDefaultAiSettings}
>
{#if isSavingDefaultAiSettings}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{$t('settings.default_ai_save')}
</button>
</form>
{/if}
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">