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

@@ -36,6 +36,11 @@
user_configured: boolean;
};
type UserAISettingsResponse = {
preferred_provider: string | null;
preferred_model: string | null;
};
export let embedded = false;
export let collectionId: string | undefined = undefined;
export let collectionName: string | undefined = undefined;
@@ -58,6 +63,10 @@
let chatProviders: ChatProviderCatalogConfiguredEntry[] = [];
let providerError = '';
let selectedProviderDefaultModel = '';
let savedDefaultProvider = '';
let savedDefaultModel = '';
let initialDefaultsApplied = false;
let loadedModelsForProvider = '';
let showDateSelector = false;
let selectedPlaceToAdd: PlaceResult | null = null;
let selectedDate = '';
@@ -68,13 +77,65 @@
}>();
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
let initializedModelProvider = '';
$: promptTripContext = collectionName || destination || '';
onMount(async () => {
await Promise.all([loadConversations(), loadProviderCatalog()]);
await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
await applyInitialDefaults();
});
async function loadUserAISettings(): Promise<void> {
try {
const res = await fetch('/api/integrations/ai-settings/', {
credentials: 'include'
});
if (!res.ok) {
return;
}
const settings = (await res.json()) as UserAISettingsResponse[];
const first = settings[0];
if (!first) {
return;
}
savedDefaultProvider = (first.preferred_provider || '').trim().toLowerCase();
savedDefaultModel = (first.preferred_model || '').trim();
} catch (e) {
console.error('Failed to load AI settings:', e);
}
}
async function applyInitialDefaults(): Promise<void> {
if (initialDefaultsApplied || chatProviders.length === 0) {
return;
}
if (
savedDefaultProvider &&
chatProviders.some((provider) => provider.id === savedDefaultProvider)
) {
selectedProvider = savedDefaultProvider;
} else {
const userConfigured = chatProviders.find((provider) => provider.user_configured);
selectedProvider = (userConfigured || chatProviders[0]).id;
}
await loadModelsForProvider(selectedProvider);
if (savedDefaultModel && selectedProvider === savedDefaultProvider) {
selectedModel = availableModels.includes(savedDefaultModel)
? savedDefaultModel
: selectedProviderDefaultModel || availableModels[0] || '';
} else {
selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
}
saveModelPref(selectedProvider, selectedModel);
loadedModelsForProvider = selectedProvider;
initialDefaultsApplied = true;
}
async function loadProviderCatalog(): Promise<void> {
try {
const res = await fetch('/api/chat/providers/', {
@@ -98,9 +159,8 @@
if (usable.length > 0) {
providerError = '';
if (!selectedProvider || !usable.some((provider) => provider.id === selectedProvider)) {
const userConfigured = usable.find((provider) => provider.user_configured);
selectedProvider = (userConfigured || usable[0]).id;
if (selectedProvider && !usable.some((provider) => provider.id === selectedProvider)) {
selectedProvider = '';
}
} else {
selectedProvider = '';
@@ -113,24 +173,21 @@
}
}
async function loadModelsForProvider() {
if (!selectedProvider) {
async function loadModelsForProvider(providerId: string) {
if (!providerId) {
availableModels = [];
return;
}
modelsLoading = true;
try {
const res = await fetch(`/api/chat/providers/${selectedProvider}/models/`, {
const res = await fetch(`/api/chat/providers/${providerId}/models/`, {
credentials: 'include'
});
const data = await res.json();
if (data.models && data.models.length > 0) {
availableModels = data.models;
if (!selectedModel || !availableModels.includes(selectedModel)) {
selectedModel = availableModels[0];
}
} else {
availableModels = [];
}
@@ -142,25 +199,6 @@
}
}
function loadModelPref(provider: string): string {
if (typeof window === 'undefined') {
return '';
}
try {
const raw = window.localStorage.getItem(MODEL_PREFS_STORAGE_KEY);
if (!raw) {
return '';
}
const prefs = JSON.parse(raw) as Record<string, string>;
const value = prefs[provider];
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
function saveModelPref(provider: string, model: string) {
if (typeof window === 'undefined') {
return;
@@ -176,20 +214,26 @@
}
}
$: if (selectedProvider && initializedModelProvider !== selectedProvider) {
selectedModel = loadModelPref(selectedProvider) || selectedProviderDefaultModel || '';
initializedModelProvider = selectedProvider;
}
$: if (selectedProvider && initializedModelProvider === selectedProvider) {
saveModelPref(selectedProvider, selectedModel);
}
$: selectedProviderDefaultModel =
chatProviders.find((provider) => provider.id === selectedProvider)?.default_model ?? '';
$: if (selectedProvider) {
void loadModelsForProvider();
$: if (
selectedProvider &&
initialDefaultsApplied &&
loadedModelsForProvider !== selectedProvider
) {
loadedModelsForProvider = selectedProvider;
void (async () => {
await loadModelsForProvider(selectedProvider);
if (!selectedModel || !availableModels.includes(selectedModel)) {
selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
}
saveModelPref(selectedProvider, selectedModel);
})();
}
$: if (selectedProvider && initialDefaultsApplied) {
saveModelPref(selectedProvider, selectedModel);
}
async function loadConversations() {

View File

@@ -14,6 +14,7 @@
name: string;
description?: string;
why_fits?: string;
category?: string;
location?: string;
rating?: number | string | null;
price_level?: string | null;
@@ -118,6 +119,94 @@
return nextFilters;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeRating(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const match = value.match(/\d+(\.\d+)?/);
if (!match) return null;
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeSuggestionItem(value: unknown): SuggestionItem | null {
const item = asRecord(value);
if (!item) return null;
const name =
normalizeText(item.name) ||
normalizeText(item.title) ||
normalizeText(item.place_name) ||
normalizeText(item.venue);
const description =
normalizeText(item.description) || normalizeText(item.summary) || normalizeText(item.details);
const whyFits =
normalizeText(item.why_fits) || normalizeText(item.whyFits) || normalizeText(item.reason);
const location =
normalizeText(item.location) ||
normalizeText(item.address) ||
normalizeText(item.neighborhood);
const category = normalizeText(item.category);
const priceLevel =
normalizeText(item.price_level) ||
normalizeText(item.priceLevel) ||
normalizeText(item.price);
const rating = normalizeRating(item.rating ?? item.score);
const finalName = name || location;
if (!finalName) return null;
return {
name: finalName,
description: description || undefined,
why_fits: whyFits || undefined,
category: category || undefined,
location: location || undefined,
rating,
price_level: priceLevel || null
};
}
function buildLocationPayload(suggestion: SuggestionItem) {
const name =
normalizeText(suggestion.name) || normalizeText(suggestion.location) || 'Suggestion';
const locationText =
normalizeText(suggestion.location) ||
getCollectionLocation() ||
normalizeText(suggestion.name);
const description =
normalizeText(suggestion.description) ||
normalizeText(suggestion.why_fits) ||
(suggestion.category ? `${suggestion.category} suggestion` : '');
const rating = normalizeRating(suggestion.rating);
return {
name,
description,
location: locationText || name,
rating,
collections: [collection.id],
is_public: false
};
}
async function fetchSuggestions() {
if (!selectedCategory) return;
@@ -144,7 +233,11 @@
}
const data = await response.json();
suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
suggestions = Array.isArray(data?.suggestions)
? data.suggestions
.map((item: unknown) => normalizeSuggestionItem(item))
.filter((item: SuggestionItem | null): item is SuggestionItem => item !== null)
: [];
} catch (_err) {
error = $t('suggestions.error');
suggestions = [];
@@ -180,17 +273,12 @@
error = '';
try {
const payload = buildLocationPayload(suggestion);
const createLocationResponse = await fetch('/api/locations/', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: suggestion.name,
description: suggestion.description || suggestion.why_fits || '',
location: suggestion.location || getCollectionLocation() || suggestion.name,
collections: [collection.id],
is_public: false
})
body: JSON.stringify(payload)
});
if (!createLocationResponse.ok) {

View File

@@ -587,6 +587,14 @@ export type UserRecommendationPreferenceProfile = {
updated_at: string;
};
export type UserAISettings = {
id: string;
preferred_provider: string | null;
preferred_model: string | null;
created_at: string;
updated_at: string;
};
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection

View File

@@ -817,6 +817,13 @@
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
"travel_agent_help_open_collections": "Open Collections",
"travel_agent_help_setup_guide": "Travel agent setup guide",
"default_ai_settings_title": "Default AI Provider & Model",
"default_ai_settings_desc": "Choose the default AI provider and model used across chat experiences.",
"default_ai_no_providers": "No configured AI providers are available yet. Add an API key first.",
"default_ai_save": "Save default AI settings",
"default_ai_settings_saved": "Default AI settings saved.",
"default_ai_settings_error": "Unable to save default AI settings.",
"default_ai_provider_required": "Please select a provider.",
"travel_preferences": "Travel Preferences",
"travel_preferences_desc": "Customize your travel preferences for better AI recommendations",
"cuisines": "Favorite Cuisines",

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">