fix(ai): critical fixes for agent-redesign - provider selection and auto-learn

Fix 1: Provider/Model Selection (Critical - unblocks LLM)
- Add /api/chat/providers/{id}/models/ endpoint to fetch available models
- Auto-select first configured provider instead of hardcoded 'openai'
- Add model dropdown populated from provider API
- Filter provider list to only show configured providers
- Show helpful error when no providers configured

Fix 2: Auto-Learn Preferences (Replaces manual input)
- Create auto_profile.py utility to infer preferences from user data
- Learn interests from Activity sport types and Location categories
- Learn trip style from Lodging types (hostel=budget, resort=luxury, etc.)
- Learn geographic preferences from VisitedRegion/VisitedCity
- Call auto-learn on every chat start (send_message)
- System prompt now indicates preferences are auto-inferred

Fix 3: Remove Manual Preference UI
- Remove travel_preferences section from Settings
- Remove preference form fields and save logic
- Remove preference fetch from server-side load
- Keep UserRecommendationPreferenceProfile type for backend use

The LLM should now work correctly:
- Users with any configured provider will have it auto-selected
- Model list is fetched dynamically from provider API
- Preferences are learned from actual travel history
This commit is contained in:
2026-03-09 00:20:11 +00:00
parent 9d5681b1ef
commit 91d907204a
8 changed files with 587 additions and 408 deletions

View File

@@ -31,6 +31,11 @@
tool_results?: ToolResultEntry[];
};
type ChatProviderCatalogConfiguredEntry = ChatProviderCatalogEntry & {
instance_configured: boolean;
user_configured: boolean;
};
export let embedded = false;
export let collectionId: string | undefined = undefined;
export let collectionName: string | undefined = undefined;
@@ -46,15 +51,15 @@
let sidebarOpen = true;
let streamingContent = '';
let selectedProvider = 'openai';
let selectedProvider = '';
let selectedModel = '';
let providerCatalog: ChatProviderCatalogEntry[] = [];
let availableModels: string[] = [];
let chatProviders: ChatProviderCatalogConfiguredEntry[] = [];
let providerError = '';
let selectedProviderDefaultModel = '';
let showDateSelector = false;
let selectedPlaceToAdd: PlaceResult | null = null;
let selectedDate = '';
$: chatProviders = providerCatalog.filter((provider) => provider.available_for_chat);
$: selectedProviderEntry =
chatProviders.find((provider) => provider.id === selectedProvider) ?? null;
const dispatch = createEventDispatcher<{
close: void;
@@ -68,21 +73,67 @@
await Promise.all([loadConversations(), loadProviderCatalog()]);
});
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
async function loadProviderCatalog(): Promise<void> {
try {
const res = await fetch('/api/chat/providers/', {
credentials: 'include'
});
if (!res.ok) {
providerError = 'Failed to load AI providers';
return;
}
const data = await res.json();
const providers = Array.isArray(data)
? (data as ChatProviderCatalogConfiguredEntry[])
: ((data.providers || []) as ChatProviderCatalogConfiguredEntry[]);
const usable = providers.filter(
(provider) =>
provider.available_for_chat && (provider.user_configured || provider.instance_configured)
);
chatProviders = usable;
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;
}
} else {
selectedProvider = '';
availableModels = [];
providerError = 'No AI providers configured. Add an API key in Settings.';
}
} catch (e) {
console.error('Failed to load provider catalog:', e);
providerError = 'Failed to load AI providers';
}
}
async function loadModelsForProvider() {
if (!selectedProvider) {
availableModels = [];
return;
}
const catalog = (await res.json()) as ChatProviderCatalogEntry[];
providerCatalog = catalog;
const availableProviders = catalog.filter((provider) => provider.available_for_chat);
if (!availableProviders.length) {
return;
}
try {
const res = await fetch(`/api/chat/providers/${selectedProvider}/models/`, {
credentials: 'include'
});
const data = await res.json();
if (!availableProviders.some((provider) => provider.id === selectedProvider)) {
selectedProvider = availableProviders[0].id;
if (data.models && data.models.length > 0) {
availableModels = data.models;
if (!selectedModel || !availableModels.includes(selectedModel)) {
selectedModel = availableModels[0];
}
} else {
availableModels = [];
}
} catch (e) {
console.error('Failed to load models:', e);
availableModels = [];
}
}
@@ -120,16 +171,22 @@
}
}
$: if (selectedProviderEntry && initializedModelProvider !== selectedProvider) {
selectedModel =
loadModelPref(selectedProvider) || (selectedProviderEntry.default_model ?? '') || '';
$: if (selectedProvider && initializedModelProvider !== selectedProvider) {
selectedModel = loadModelPref(selectedProvider) || selectedProviderDefaultModel || '';
initializedModelProvider = selectedProvider;
}
$: if (selectedProviderEntry && initializedModelProvider === selectedProvider) {
$: if (selectedProvider && initializedModelProvider === selectedProvider) {
saveModelPref(selectedProvider, selectedModel);
}
$: selectedProviderDefaultModel =
chatProviders.find((provider) => provider.id === selectedProvider)?.default_model ?? '';
$: if (selectedProvider) {
void loadModelsForProvider();
}
async function loadConversations() {
const res = await fetch('/api/chat/conversations/');
if (res.ok) {
@@ -199,7 +256,7 @@
body: JSON.stringify({
message: msgText,
provider: selectedProvider,
model: selectedModel.trim() || undefined,
model: selectedModel || undefined,
collection_id: collectionId,
collection_name: collectionName,
start_date: startDate,
@@ -525,233 +582,251 @@
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<label for="chat-model-input" class="text-xs opacity-70 whitespace-nowrap"
>{$t('chat.model_label')}</label
>
<input
id="chat-model-input"
type="text"
class="input input-bordered input-sm w-44"
bind:value={selectedModel}
placeholder={selectedProviderEntry?.default_model || $t('chat.model_placeholder')}
disabled={chatProviders.length === 0}
/>
<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>
<option value={provider.id}>
{provider.label}
{#if provider.user_configured}
{/if}
</option>
{/each}
</select>
<select
class="select select-bordered select-sm"
bind:value={selectedModel}
disabled={chatProviders.length === 0}
>
{#if availableModels.length === 0}
<option value="">Loading...</option>
{:else}
{#each availableModels as model}
<option value={model}>{model}</option>
{/each}
{/if}
</select>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<div class="text-6xl opacity-40 mb-4">🌍</div>
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
{#if chatProviders.length === 0}
<div class="p-4">
<div class="alert alert-warning">
<span
>{providerError || 'No AI providers configured.'}
<a href="/settings" class="link">Add an API key in Settings</a></span
>
</div>
{:else}
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs space-y-2">
<div class="font-semibold mb-1 text-primary">🗺️ {msg.name}</div>
{#each parseToolResults(msg) as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-100 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
</div>
{:else}
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<div class="text-6xl opacity-40 mb-4">🌍</div>
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs space-y-2">
<div class="font-semibold mb-1 text-primary">🗺️ {msg.name}</div>
{#each parseToolResults(msg) as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-100 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-100 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-100 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' && msg.tool_results}
<div class="mt-2 space-y-2">
{#each msg.tool_results as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-200 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-200 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-200 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-100 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-100 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
{#if msg.role === 'assistant' && isStreaming && msg.id === messages[messages.length - 1]?.id && !msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' && msg.tool_results}
<div class="mt-2 space-y-2">
{#each msg.tool_results as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-200 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-200 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-200 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
</div>
{/if}
{#if msg.role === 'assistant' && isStreaming && msg.id === messages[messages.length - 1]?.id && !msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
<div class="p-4 border-t border-base-300">
<div class="max-w-4xl mx-auto">
<div class="flex flex-wrap gap-2 mb-3">
{#if destination}
<div class="p-4 border-t border-base-300">
<div class="max-w-4xl mx-auto">
<div class="flex flex-wrap gap-2 mb-3">
{#if destination}
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage(`What are the best restaurants in ${destination}?`)}
disabled={isStreaming || chatProviders.length === 0}
>
🍽️ Restaurants
</button>
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage(`What activities can I do in ${destination}?`)}
disabled={isStreaming || chatProviders.length === 0}
>
🎯 Activities
</button>
{/if}
{#if startDate && endDate}
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage(
`What should I pack for my trip from ${startDate} to ${endDate}?`
)}
disabled={isStreaming || chatProviders.length === 0}
>
🎒 Packing tips
</button>
{/if}
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage(`What are the best restaurants in ${destination}?`)}
sendPresetMessage('Can you help me plan a day-by-day itinerary for this trip?')}
disabled={isStreaming || chatProviders.length === 0}
>
🍽️ Restaurants
📅 Itinerary help
</button>
<button
class="btn btn-sm btn-ghost"
on:click={() => sendPresetMessage(`What activities can I do in ${destination}?`)}
disabled={isStreaming || chatProviders.length === 0}
>
🎯 Activities
</button>
{/if}
{#if startDate && endDate}
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage(
`What should I pack for my trip from ${startDate} to ${endDate}?`
)}
disabled={isStreaming || chatProviders.length === 0}
>
🎒 Packing tips
</button>
{/if}
</div>
</div>
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-sm btn-ghost"
on:click={() =>
sendPresetMessage('Can you help me plan a day-by-day itinerary for this trip?')}
disabled={isStreaming || chatProviders.length === 0}
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
📅 Itinerary help
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -573,6 +573,8 @@ export type ChatProviderCatalogEntry = {
needs_api_key: boolean | null;
default_model: string | null;
api_base: string | null;
instance_configured: boolean;
user_configured: boolean;
};
export type UserRecommendationPreferenceProfile = {

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, UserRecommendationPreferenceProfile } from '$lib/types';
import type { ImmichIntegration, User } from '$lib/types';
import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@@ -95,25 +95,11 @@ export const load: PageServerLoad = async (event) => {
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
let [apiKeysFetch, recommendationPreferencesFetch] = await Promise.all([
fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
}),
fetch(`${endpoint}/api/integrations/recommendation-preferences/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
})
]);
let recommendationProfile: UserRecommendationPreferenceProfile | null = null;
if (recommendationPreferencesFetch.ok) {
const recommendationProfiles =
(await recommendationPreferencesFetch.json()) as UserRecommendationPreferenceProfile[];
recommendationProfile = recommendationProfiles[0] ?? null;
}
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (apiKeysFetch.ok) {
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
@@ -145,7 +131,6 @@ export const load: PageServerLoad = async (event) => {
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
recommendationProfile,
wandererEnabled,
wandererExpired
}

View File

@@ -28,16 +28,6 @@
usage_required: boolean;
};
type UserRecommendationPreferenceProfile = {
id: string;
cuisines: string | null;
interests: string[];
trip_style: string | null;
notes: string | null;
created_at: string;
updated_at: string;
};
let new_email: string = '';
let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration;
@@ -60,13 +50,6 @@
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
let recommendationProfile: UserRecommendationPreferenceProfile | null = null;
let cuisinesValue = '';
let interestsValue = '';
let tripStyleValue = '';
let notesValue = '';
let isSavingPreferences = false;
let savePreferencesError = '';
let mcpToken: string | null = null;
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
@@ -144,23 +127,12 @@
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
{ id: 'travel_preferences', icon: '🧭', label: () => $t('settings.travel_preferences') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
];
onMount(async () => {
recommendationProfile =
(data.props as { recommendationProfile?: UserRecommendationPreferenceProfile | null })
.recommendationProfile ?? null;
if (recommendationProfile) {
cuisinesValue = recommendationProfile.cuisines ?? '';
interestsValue = (recommendationProfile.interests || []).join(', ');
tripStyleValue = recommendationProfile.trip_style ?? '';
notesValue = recommendationProfile.notes ?? '';
}
void loadProviderCatalog();
if (browser) {
@@ -583,45 +555,6 @@
}
}
async function savePreferences(event: SubmitEvent) {
event.preventDefault();
savePreferencesError = '';
isSavingPreferences = true;
try {
const res = await fetch('/api/integrations/recommendation-preferences/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
cuisines: cuisinesValue.trim() || null,
interests: interestsValue
.split(',')
.map((s) => s.trim())
.filter(Boolean),
trip_style: tripStyleValue.trim() || null,
notes: notesValue.trim() || null
})
});
if (!res.ok) {
savePreferencesError = $t('settings.preferences_save_error');
addToast('error', $t('settings.preferences_save_error'));
return;
}
recommendationProfile = (await res.json()) as UserRecommendationPreferenceProfile;
interestsValue = (recommendationProfile.interests || []).join(', ');
addToast('success', $t('settings.preferences_saved'));
} catch {
savePreferencesError = $t('settings.preferences_save_error');
addToast('error', $t('settings.preferences_save_error'));
} finally {
isSavingPreferences = false;
}
}
function getMaskedMcpToken(token: string): string {
if (token.length <= 8) {
return '••••••••';
@@ -1755,87 +1688,6 @@
</div>
{/if}
<!-- Travel Preferences Section -->
{#if activeSection === 'travel_preferences'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-primary/10 rounded-xl">
<span class="text-2xl">🧭</span>
</div>
<div>
<h2 class="text-2xl font-bold">{$t('settings.travel_preferences')}</h2>
<p class="text-base-content/70">
{$t('settings.travel_preferences_desc')}
</p>
</div>
</div>
<form class="space-y-4" on:submit={savePreferences}>
<div class="form-control">
<label class="label" for="travel-cuisines">
<span class="label-text font-medium">{$t('settings.cuisines')}</span>
</label>
<textarea
id="travel-cuisines"
class="textarea textarea-bordered textarea-primary min-h-24"
placeholder={$t('settings.cuisines_placeholder')}
bind:value={cuisinesValue}
></textarea>
</div>
<div class="form-control">
<label class="label" for="travel-interests">
<span class="label-text font-medium">{$t('settings.interests')}</span>
</label>
<textarea
id="travel-interests"
class="textarea textarea-bordered textarea-primary min-h-24"
placeholder={$t('settings.interests_placeholder')}
bind:value={interestsValue}
></textarea>
</div>
<div class="form-control">
<label class="label" for="travel-style">
<span class="label-text font-medium">{$t('settings.trip_style')}</span>
</label>
<input
id="travel-style"
type="text"
class="input input-bordered input-primary"
placeholder={$t('settings.trip_style_placeholder')}
bind:value={tripStyleValue}
/>
</div>
<div class="form-control">
<label class="label" for="travel-notes">
<span class="label-text font-medium">{$t('settings.notes')}</span>
</label>
<textarea
id="travel-notes"
class="textarea textarea-bordered textarea-primary min-h-28"
placeholder={$t('settings.notes_placeholder')}
bind:value={notesValue}
></textarea>
</div>
{#if savePreferencesError}
<div class="alert alert-error">
<span>{savePreferencesError}</span>
</div>
{/if}
<button class="btn btn-primary" type="submit" disabled={isSavingPreferences}>
{#if isSavingPreferences}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{$t('settings.update')}
</button>
</form>
</div>
{/if}
<!-- import export -->
{#if activeSection === 'import_export'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">