feat(ai): implement agent-redesign plan with enhanced AI travel features
Phase 1 - Configuration Infrastructure (WS1): - Add instance-level AI env vars (VOYAGE_AI_PROVIDER, VOYAGE_AI_MODEL, VOYAGE_AI_API_KEY) - Implement fallback chain: user key → instance key → error - Add UserAISettings model for per-user provider/model preferences - Enhance provider catalog with instance_configured and user_configured flags - Optimize provider catalog to avoid N+1 queries Phase 1 - User Preference Learning (WS2): - Add Travel Preferences tab to Settings page - Improve preference formatting in system prompt with emoji headers - Add multi-user preference aggregation for shared collections Phase 2 - Day-Level Suggestions Modal (WS3): - Create ItinerarySuggestionModal with 3-step flow (category → filters → results) - Add AI suggestions button to itinerary Add dropdown - Support restaurant, activity, event, and lodging categories - Backend endpoint POST /api/chat/suggestions/day/ with context-aware prompts Phase 3 - Collection-Level Chat Improvements (WS4): - Inject collection context (destination, dates) into chat system prompt - Add quick action buttons for common queries - Add 'Add to itinerary' button on search_places results - Update chat UI with travel-themed branding and improved tool result cards Phase 3 - Web Search Capability (WS5): - Add web_search agent tool using DuckDuckGo - Support location_context parameter for biased results - Handle rate limiting gracefully Phase 4 - Extensibility Architecture (WS6): - Implement decorator-based @agent_tool registry - Convert existing tools to use decorators - Add GET /api/chat/capabilities/ endpoint for tool discovery - Refactor execute_tool() to use registry pattern
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Collection } from '$lib/types';
|
||||
|
||||
export let collection: Collection;
|
||||
export let user: any;
|
||||
export let targetDate: string;
|
||||
export let displayDate: string;
|
||||
$: void user;
|
||||
|
||||
type SuggestionCategory = 'restaurant' | 'activity' | 'event' | 'lodging' | 'surprise';
|
||||
type SuggestionItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
why_fits?: string;
|
||||
location?: string;
|
||||
rating?: number | string | null;
|
||||
price_level?: string | null;
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
let step = 0; // 0: category, 1: filters, 2: results
|
||||
let selectedCategory: SuggestionCategory | '' = '';
|
||||
let filters: Record<string, any> = {};
|
||||
let suggestions: SuggestionItem[] = [];
|
||||
let isLoading = false;
|
||||
let isAdding = false;
|
||||
let addingSuggestionName = '';
|
||||
let error = '';
|
||||
|
||||
const categories: Array<{
|
||||
id: SuggestionCategory;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
skipFilters?: boolean;
|
||||
}> = [
|
||||
{ id: 'restaurant', icon: '🍽️', labelKey: 'suggestions.category_restaurant' },
|
||||
{ id: 'activity', icon: '🎯', labelKey: 'suggestions.category_activity' },
|
||||
{ id: 'event', icon: '🎉', labelKey: 'suggestions.category_event' },
|
||||
{ id: 'lodging', icon: '🏨', labelKey: 'suggestions.category_lodging' },
|
||||
{ id: 'surprise', icon: '✨', labelKey: 'suggestions.surprise_me', skipFilters: true }
|
||||
];
|
||||
|
||||
const supportedApiCategories = ['restaurant', 'activity', 'event', 'lodging'];
|
||||
|
||||
const activityTypes = ['outdoor', 'cultural', 'entertainment', 'other'];
|
||||
const durations = ['few hours', 'half-day', 'full-day'];
|
||||
const timePreferences = ['morning', 'afternoon', 'evening', 'night'];
|
||||
const lodgingTypes = ['hotel', 'hostel', 'apartment', 'resort'];
|
||||
const amenities = ['wifi', 'pool', 'parking', 'breakfast'];
|
||||
|
||||
onMount(() => {
|
||||
modal = document.getElementById('suggestion_modal') as HTMLDialogElement;
|
||||
if (modal) modal.showModal();
|
||||
});
|
||||
|
||||
function getCollectionLocation(): string {
|
||||
const firstLocation = collection?.locations?.[0];
|
||||
if (!firstLocation) return collection?.name || '';
|
||||
return (
|
||||
firstLocation.location ||
|
||||
firstLocation.city?.name ||
|
||||
firstLocation.country?.name ||
|
||||
firstLocation.name ||
|
||||
collection?.name ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function resetFiltersForCategory(category: SuggestionCategory) {
|
||||
if (category === 'restaurant') {
|
||||
filters = { cuisine_type: '', price_range: '', dietary: '' };
|
||||
} else if (category === 'activity') {
|
||||
filters = { activity_type: '', duration: '' };
|
||||
} else if (category === 'event') {
|
||||
filters = { event_type: '', time_preference: '' };
|
||||
} else if (category === 'lodging') {
|
||||
filters = { lodging_type: '', amenities: [] };
|
||||
} else {
|
||||
filters = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCategory(category: SuggestionCategory, skipFilters = false) {
|
||||
selectedCategory = category;
|
||||
error = '';
|
||||
suggestions = [];
|
||||
resetFiltersForCategory(category);
|
||||
|
||||
if (skipFilters) {
|
||||
await fetchSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
step = 1;
|
||||
}
|
||||
|
||||
function getApiCategory(): string {
|
||||
if (selectedCategory !== 'surprise') return selectedCategory;
|
||||
const randomIndex = Math.floor(Math.random() * supportedApiCategories.length);
|
||||
return supportedApiCategories[randomIndex];
|
||||
}
|
||||
|
||||
function getActiveFilters() {
|
||||
if (selectedCategory === 'surprise') return {};
|
||||
const nextFilters: Record<string, any> = {};
|
||||
Object.entries(filters || {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value) && value.length > 0) nextFilters[key] = value;
|
||||
else if (!Array.isArray(value) && value) nextFilters[key] = value;
|
||||
});
|
||||
return nextFilters;
|
||||
}
|
||||
|
||||
async function fetchSuggestions() {
|
||||
if (!selectedCategory) return;
|
||||
|
||||
step = 2;
|
||||
error = '';
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/suggestions/day/', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
collection_id: collection.id,
|
||||
date: targetDate,
|
||||
category: getApiCategory(),
|
||||
filters: getActiveFilters(),
|
||||
location_context: getCollectionLocation()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get suggestions');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
} catch (_err) {
|
||||
error = $t('suggestions.error');
|
||||
suggestions = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBackToFilters() {
|
||||
error = '';
|
||||
suggestions = [];
|
||||
step = selectedCategory === 'surprise' ? 0 : 1;
|
||||
}
|
||||
|
||||
function toggleAmenity(value: string, enabled: boolean) {
|
||||
const currentAmenities = Array.isArray(filters.amenities) ? filters.amenities : [];
|
||||
if (enabled) {
|
||||
filters = { ...filters, amenities: [...new Set([...currentAmenities, value])] };
|
||||
return;
|
||||
}
|
||||
filters = { ...filters, amenities: currentAmenities.filter((a: string) => a !== value) };
|
||||
}
|
||||
|
||||
function handleAmenityChange(value: string, event: Event) {
|
||||
toggleAmenity(value, (event.currentTarget as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
async function handleAddSuggestion(suggestion: SuggestionItem) {
|
||||
if (!suggestion?.name || isAdding) return;
|
||||
|
||||
isAdding = true;
|
||||
addingSuggestionName = suggestion.name;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
if (!createLocationResponse.ok) {
|
||||
throw new Error('Failed to create location');
|
||||
}
|
||||
|
||||
const location = await createLocationResponse.json();
|
||||
if (!location?.id) {
|
||||
throw new Error('Location was not created');
|
||||
}
|
||||
|
||||
dispatch('addItem', {
|
||||
type: 'location',
|
||||
itemId: location.id,
|
||||
updateDate: false
|
||||
});
|
||||
} catch (_err) {
|
||||
error = $t('suggestions.error');
|
||||
} finally {
|
||||
isAdding = false;
|
||||
addingSuggestionName = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="suggestion_modal" class="modal backdrop-blur-sm">
|
||||
<div
|
||||
class="modal-box w-11/12 max-w-4xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-primary">
|
||||
{$t('suggestions.title')}
|
||||
{$t('suggestions.for_date').replace('{date}', displayDate)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-2 max-h-[28rem] overflow-y-auto space-y-4">
|
||||
{#if step === 0}
|
||||
<p class="text-sm opacity-80">{$t('suggestions.select_category')}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each categories as category}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline justify-start h-auto py-4"
|
||||
on:click={() => selectCategory(category.id, !!category.skipFilters)}
|
||||
>
|
||||
<span class="text-lg">{category.icon}</span>
|
||||
<span>{$t(category.labelKey)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if step === 1}
|
||||
<p class="text-sm opacity-80">{$t('suggestions.refine_filters')}</p>
|
||||
|
||||
{#if selectedCategory === 'restaurant'}
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">{$t('suggestions.cuisine_type')}</span>
|
||||
</div>
|
||||
<input class="input input-bordered" type="text" bind:value={filters.cuisine_type} />
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">{$t('suggestions.price_range')}</span></div>
|
||||
<select class="select select-bordered" bind:value={filters.price_range}>
|
||||
<option value="">{$t('recomendations.any')}</option>
|
||||
<option value="$">$</option>
|
||||
<option value="$$">$$</option>
|
||||
<option value="$$$">$$$</option>
|
||||
<option value="$$$$">$$$$</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">{$t('suggestions.dietary')}</span></div>
|
||||
<input class="input input-bordered" type="text" bind:value={filters.dietary} />
|
||||
</label>
|
||||
{:else if selectedCategory === 'activity'}
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">{$t('suggestions.activity_type')}</span>
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={filters.activity_type}>
|
||||
<option value="">{$t('recomendations.any')}</option>
|
||||
{#each activityTypes as activityType}
|
||||
<option value={activityType}>{activityType}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">{$t('suggestions.duration')}</span></div>
|
||||
<select class="select select-bordered" bind:value={filters.duration}>
|
||||
<option value="">{$t('recomendations.any')}</option>
|
||||
{#each durations as duration}
|
||||
<option value={duration}>{duration}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if selectedCategory === 'event'}
|
||||
<label class="form-control w-full">
|
||||
<div class="label"><span class="label-text">{$t('suggestions.event_type')}</span></div>
|
||||
<input class="input input-bordered" type="text" bind:value={filters.event_type} />
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">{$t('suggestions.time_preference')}</span>
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={filters.time_preference}>
|
||||
<option value="">{$t('recomendations.any')}</option>
|
||||
{#each timePreferences as timePreference}
|
||||
<option value={timePreference}>{timePreference}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if selectedCategory === 'lodging'}
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">{$t('suggestions.lodging_type')}</span>
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={filters.lodging_type}>
|
||||
<option value="">{$t('recomendations.any')}</option>
|
||||
{#each lodgingTypes as lodgingType}
|
||||
<option value={lodgingType}>{lodgingType}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<div class="label"><span class="label-text">{$t('suggestions.amenities')}</span></div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each amenities as amenity}
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={Array.isArray(filters.amenities) &&
|
||||
filters.amenities.includes(amenity)}
|
||||
on:change={(event) => handleAmenityChange(amenity, event)}
|
||||
/>
|
||||
<span class="label-text capitalize">{amenity}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="opacity-80">{$t('suggestions.loading')}</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else if suggestions.length === 0}
|
||||
<div class="alert alert-info">
|
||||
<span>{$t('suggestions.no_results')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each suggestions as suggestion}
|
||||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="font-semibold text-base">{suggestion.name}</h4>
|
||||
{#if suggestion.description}
|
||||
<p class="text-sm opacity-80 mt-1">{suggestion.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if suggestion.why_fits}
|
||||
<div class="mt-2 p-3 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<p class="text-xs uppercase tracking-wide opacity-70 mb-1">
|
||||
{$t('suggestions.why_fits')}
|
||||
</p>
|
||||
<p class="text-sm">{suggestion.why_fits}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{#if suggestion.location}
|
||||
<span class="badge badge-outline">📍 {suggestion.location}</span>
|
||||
{/if}
|
||||
{#if suggestion.rating !== null && suggestion.rating !== undefined && suggestion.rating !== ''}
|
||||
<span class="badge badge-outline">⭐ {suggestion.rating}</span>
|
||||
{/if}
|
||||
{#if suggestion.price_level}
|
||||
<span class="badge badge-outline">{suggestion.price_level}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
disabled={isAdding}
|
||||
on:click={() => handleAddSuggestion(suggestion)}
|
||||
>
|
||||
{#if isAdding && addingSuggestionName === suggestion.name}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
{$t('suggestions.add_to_day')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-4 py-3 mt-6"
|
||||
>
|
||||
<div class="flex justify-between gap-3">
|
||||
<button class="btn" type="button" on:click={close}>{$t('adventures.cancel')}</button>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline" type="button" on:click={() => (step = 0)}
|
||||
>{$t('adventures.back')}</button
|
||||
>
|
||||
<button class="btn btn-primary" type="button" on:click={fetchSuggestions}
|
||||
>{$t('suggestions.get_suggestions')}</button
|
||||
>
|
||||
</div>
|
||||
{:else if step === 2}
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
type="button"
|
||||
on:click={goBackToFilters}
|
||||
disabled={isLoading || isAdding}
|
||||
>
|
||||
{$t('suggestions.try_again')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" on:click={close}>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
Reference in New Issue
Block a user