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:
2026-03-08 23:53:14 +00:00
parent 246b081d97
commit 9d5681b1ef
22 changed files with 2358 additions and 255 deletions

View File

@@ -1,8 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
import { mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
import type { ChatProviderCatalogEntry } from '$lib/types.js';
import { addToast } from '$lib/toasts';
type ToolResultEntry = {
name: string;
result: unknown;
};
type PlaceResult = {
name: string;
address?: string;
rating?: number;
latitude?: number | string;
longitude?: number | string;
};
type Conversation = {
id: string;
@@ -14,9 +28,15 @@
role: 'user' | 'assistant' | 'tool';
content: string;
name?: string;
tool_results?: ToolResultEntry[];
};
export let embedded = false;
export let collectionId: string | undefined = undefined;
export let collectionName: string | undefined = undefined;
export let startDate: string | undefined = undefined;
export let endDate: string | undefined = undefined;
export let destination: string | undefined = undefined;
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
@@ -27,8 +47,22 @@
let streamingContent = '';
let selectedProvider = 'openai';
let selectedModel = '';
let providerCatalog: ChatProviderCatalogEntry[] = [];
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;
itemAdded: { locationId: string; date: string };
}>();
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
let initializedModelProvider = '';
onMount(async () => {
await Promise.all([loadConversations(), loadProviderCatalog()]);
@@ -52,6 +86,50 @@
}
}
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;
}
try {
const raw = window.localStorage.getItem(MODEL_PREFS_STORAGE_KEY);
const prefs: Record<string, string> = raw ? JSON.parse(raw) : {};
prefs[provider] = model;
window.localStorage.setItem(MODEL_PREFS_STORAGE_KEY, JSON.stringify(prefs));
} catch {
// ignore localStorage persistence failures
}
}
$: if (selectedProviderEntry && initializedModelProvider !== selectedProvider) {
selectedModel =
loadModelPref(selectedProvider) || (selectedProviderEntry.default_model ?? '') || '';
initializedModelProvider = selectedProvider;
}
$: if (selectedProviderEntry && initializedModelProvider === selectedProvider) {
saveModelPref(selectedProvider, selectedModel);
}
async function loadConversations() {
const res = await fetch('/api/chat/conversations/');
if (res.ok) {
@@ -118,7 +196,16 @@
const res = await fetch(`/api/chat/conversations/${conversation.id}/send_message/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msgText, provider: selectedProvider })
body: JSON.stringify({
message: msgText,
provider: selectedProvider,
model: selectedModel.trim() || undefined,
collection_id: collectionId,
collection_name: collectionName,
start_date: startDate,
end_date: endDate,
destination
})
});
if (!res.ok) {
@@ -167,18 +254,12 @@
}
if (parsed.tool_result) {
const toolMsg: ChatMessage = {
role: 'tool',
content: JSON.stringify(parsed.tool_result, null, 2),
name: parsed.tool_result.tool || 'tool',
id: crypto.randomUUID()
const toolResult: ToolResultEntry = {
name: parsed.tool_result.name || parsed.tool_result.tool || 'tool',
result: parsed.tool_result.result
};
const idx = messages.findIndex((m) => m.id === assistantMsg.id);
messages = [...messages.slice(0, idx), toolMsg, ...messages.slice(idx)];
streamingContent = '';
assistantMsg.content = '';
assistantMsg.tool_results = [...(assistantMsg.tool_results || []), toolResult];
messages = [...messages];
}
} catch {
// ignore malformed chunks
@@ -195,6 +276,15 @@
}
}
async function sendPresetMessage(message: string) {
if (isStreaming || chatProviders.length === 0) {
return;
}
inputMessage = message;
await sendMessage();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -202,6 +292,148 @@
}
}
function parseToolResults(msg: ChatMessage): ToolResultEntry[] {
if (msg.tool_results?.length) {
return msg.tool_results;
}
if (msg.role !== 'tool') {
return [];
}
try {
return [{ name: msg.name || 'tool', result: JSON.parse(msg.content) }];
} catch {
return [{ name: msg.name || 'tool', result: msg.content }];
}
}
function hasPlaceResults(result: ToolResultEntry): boolean {
return (
result.name === 'search_places' &&
typeof result.result === 'object' &&
result.result !== null &&
Array.isArray((result.result as { places?: unknown[] }).places)
);
}
function getPlaceResults(result: ToolResultEntry): any[] {
if (!hasPlaceResults(result)) {
return [];
}
return (result.result as { places: any[] }).places;
}
function hasWebSearchResults(result: ToolResultEntry): boolean {
return (
result.name === 'web_search' &&
typeof result.result === 'object' &&
result.result !== null &&
Array.isArray((result.result as { results?: unknown[] }).results)
);
}
function getWebSearchResults(result: ToolResultEntry): any[] {
if (!hasWebSearchResults(result)) {
return [];
}
return (result.result as { results: any[] }).results;
}
function parseCoordinate(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function hasPlaceCoordinates(place: PlaceResult): boolean {
return parseCoordinate(place.latitude) !== null && parseCoordinate(place.longitude) !== null;
}
function openDateSelector(place: PlaceResult) {
selectedPlaceToAdd = place;
selectedDate = startDate || '';
showDateSelector = true;
}
function closeDateSelector() {
showDateSelector = false;
selectedPlaceToAdd = null;
selectedDate = '';
}
async function addPlaceToItinerary(place: PlaceResult, date: string) {
if (!collectionId || !date) {
return;
}
const latitude = parseCoordinate(place.latitude);
const longitude = parseCoordinate(place.longitude);
if (latitude === null || longitude === null) {
addToast('error', $t('chat.connection_error'));
return;
}
try {
const locationResponse = await fetch('/api/locations/', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: place.name,
location: place.address || place.name,
latitude,
longitude,
collections: [collectionId],
is_public: false
})
});
if (!locationResponse.ok) {
throw new Error('Failed to create location');
}
const location = await locationResponse.json();
const itineraryResponse = await fetch('/api/itineraries/', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
collection: collectionId,
content_type: 'location',
object_id: location.id,
date,
order: 0
})
});
if (!itineraryResponse.ok) {
throw new Error('Failed to add to itinerary');
}
dispatch('itemAdded', { locationId: location.id, date });
addToast('success', $t('added_successfully'));
closeDateSelector();
} catch (error) {
console.error('Failed to add to itinerary:', error);
addToast('error', $t('chat.connection_error'));
}
}
let messagesContainer: HTMLElement;
$: if (messages && messagesContainer) {
setTimeout(() => {
@@ -277,16 +509,33 @@
</svg>
{/if}
</button>
<svg
class="w-6 h-6 text-primary"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h2 class="text-lg font-semibold">{$t('chat.title')}</h2>
<div class="ml-auto">
<div class="flex items-center gap-2">
<span class="text-2xl">✈️</span>
<div>
<h3 class="text-lg font-bold">
{#if collectionName}
{$t('travel_assistant')} · {collectionName}
{:else}
{$t('travel_assistant')}
{/if}
</h3>
{#if destination}
<p class="text-sm text-base-content/70">{destination}</p>
{/if}
</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}
@@ -302,14 +551,7 @@
<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">
<svg
class="w-16 h-16 text-primary opacity-40 mb-4"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<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>
@@ -318,9 +560,57 @@
<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">
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
<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}
@@ -331,6 +621,59 @@
: '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}
@@ -343,6 +686,47 @@
</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}
<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('Can you help me plan a day-by-day itinerary for this trip?')}
disabled={isStreaming || chatProviders.length === 0}
>
📅 Itinerary help
</button>
</div>
</div>
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
@@ -372,3 +756,35 @@
</div>
</div>
</div>
{#if showDateSelector && selectedPlaceToAdd}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg">{$t('add_to_itinerary')}</h3>
<p class="py-4">
{$t('add_to_which_day', { values: { placeName: selectedPlaceToAdd.name } })}
</p>
<input
type="date"
class="input input-bordered w-full"
bind:value={selectedDate}
min={startDate}
max={endDate}
/>
<div class="modal-action">
<button class="btn btn-ghost" on:click={closeDateSelector}>{$t('adventures.cancel')}</button
>
<button
class="btn btn-primary"
on:click={() =>
selectedPlaceToAdd && addPlaceToItinerary(selectedPlaceToAdd, selectedDate)}
disabled={!selectedDate}
>
{$t('adventures.add')}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -29,6 +29,7 @@
import NoteModal from '$lib/components/NoteModal.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import ItineraryLinkModal from '$lib/components/collections/ItineraryLinkModal.svelte';
import ItinerarySuggestionModal from '$lib/components/collections/ItinerarySuggestionModal.svelte';
import ItineraryDayPickModal from '$lib/components/collections/ItineraryDayPickModal.svelte';
import Car from '~icons/mdi/car';
import Walk from '~icons/mdi/walk';
@@ -382,6 +383,7 @@
let isNoteModalOpen = false;
let isChecklistModalOpen = false;
let isItineraryLinkModalOpen = false;
let isSuggestionModalOpen = false;
let noteToEdit: Note | null = null;
let checklistToEdit: Checklist | null = null;
@@ -389,6 +391,8 @@
// Store the target date and display date for the link modal
let linkModalTargetDate: string = '';
let linkModalDisplayDate: string = '';
let suggestionModalTargetDate: string = '';
let suggestionModalDisplayDate: string = '';
// Day picker modal state for unscheduled items
let isDayPickModalOpen = false;
@@ -649,8 +653,7 @@
if (!result?.date) continue;
nextMap[result.date] = {
available: !!result.available,
temperature_c:
typeof result.temperature_c === 'number' ? result.temperature_c : null
temperature_c: typeof result.temperature_c === 'number' ? result.temperature_c : null
};
}
@@ -730,9 +733,7 @@
function getDayTimelineItems(day: DayGroup): ResolvedItineraryItem[] {
const boundaryIds = new Set(
[day.preTimelineLodging?.id, day.postTimelineLodging?.id].filter(
(id): id is string => !!id
)
[day.preTimelineLodging?.id, day.postTimelineLodging?.id].filter((id): id is string => !!id)
);
if (boundaryIds.size === 0) return day.items;
@@ -804,10 +805,7 @@
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(nextItem, 'origin');
if (!fromCoordinates || !toCoordinates) return null;
const key = getLocationConnectorKey(currentItem, nextItem);
@@ -1141,7 +1139,9 @@
};
})
.filter(
(entry): entry is {
(
entry
): entry is {
item: ResolvedItineraryItem;
originalIndex: number;
primaryTimestamp: number;
@@ -1175,9 +1175,7 @@
return;
}
const anchorsByPosition = [...anchorEntries].sort(
(a, b) => a.originalIndex - b.originalIndex
);
const anchorsByPosition = [...anchorEntries].sort((a, b) => a.originalIndex - b.originalIndex);
const chronologicalAnchors = [...anchorEntries]
.sort((a, b) => {
if (a.primaryTimestamp !== b.primaryTimestamp) {
@@ -1301,10 +1299,7 @@
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(nextItem, 'origin');
if (!fromCoordinates || !toCoordinates) return unavailableConnector;
const distanceKm = haversineDistanceKm(
@@ -1352,10 +1347,7 @@
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(nextItem, 'origin');
if (!fromCoordinates || !toCoordinates) return null;
const fromLatitude = fromCoordinates.latitude;
@@ -2624,6 +2616,25 @@
/>
{/if}
{#if isSuggestionModalOpen}
<ItinerarySuggestionModal
{collection}
{user}
targetDate={suggestionModalTargetDate}
displayDate={suggestionModalDisplayDate}
on:close={() => (isSuggestionModalOpen = false)}
on:addItem={(e) => {
addItineraryItemForObject(
e.detail.type,
e.detail.itemId,
suggestionModalTargetDate,
e.detail.updateDate
);
isSuggestionModalOpen = false;
}}
/>
{/if}
{#if isDayPickModalOpen}
<ItineraryDayPickModal
isOpen={isDayPickModalOpen}
@@ -2896,7 +2907,9 @@
<div class="text-xs opacity-70">{weekday}</div>
<div class="text-2xl font-bold -mt-1">{dayOfMonth}</div>
<div class="text-xs opacity-70">{monthAbbrev}</div>
<div class="text-[10px] opacity-80 mt-1">{formatDayTemperature(day, dayTemperatures)}</div>
<div class="text-[10px] opacity-80 mt-1">
{formatDayTemperature(day, dayTemperatures)}
</div>
</div>
</div>
@@ -3129,7 +3142,11 @@
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index)}
{@const locationConnector = getLocationConnector(item, nextConnectableItem, connectorMetricsMap)}
{@const locationConnector = getLocationConnector(
item,
nextConnectableItem,
connectorMetricsMap
)}
{@const directionsUrl = buildDirectionsUrl(
item,
nextConnectableItem,
@@ -3484,6 +3501,19 @@
{$t('itinerary.link_existing_item')}
</button>
</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
suggestionModalTargetDate = day.date;
suggestionModalDisplayDate = day.displayDate;
isSuggestionModalOpen = true;
}}
>
✨ {$t('suggestions.get_suggestions')}
</button>
</li>
<li class="menu-title">{$t('adventures.create_new')}</li>
<li>
<button

View File

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

View File

@@ -575,6 +575,16 @@ export type ChatProviderCatalogEntry = {
api_base: string | null;
};
export type UserRecommendationPreferenceProfile = {
id: string;
cuisines: string | null;
interests: string[];
trip_style: string | null;
notes: string | null;
created_at: string;
updated_at: string;
};
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection

View File

@@ -44,7 +44,41 @@
"send": "Send",
"delete_conversation": "Delete Conversation",
"connection_error": "Connection error. Please try again.",
"no_api_key": "No API key found. Please add one in Settings."
"no_api_key": "No API key found. Please add one in Settings.",
"model_label": "Model",
"model_placeholder": "Default model"
},
"travel_assistant": "Travel Assistant",
"quick_actions": "Quick actions",
"add_to_itinerary": "Add to Itinerary",
"add_to_which_day": "Add \"{placeName}\" to which day?",
"added_successfully": "Added to itinerary!",
"suggestions": {
"title": "AI Suggestions",
"for_date": "for {date}",
"select_category": "What would you like suggestions for?",
"category_restaurant": "Restaurant",
"category_activity": "Activity",
"category_event": "Event",
"category_lodging": "Lodging",
"surprise_me": "Surprise me!",
"refine_filters": "Refine your preferences",
"cuisine_type": "Cuisine type",
"price_range": "Price range",
"dietary": "Dietary restrictions",
"activity_type": "Activity type",
"duration": "Duration",
"event_type": "Event type",
"time_preference": "Time preference",
"lodging_type": "Lodging type",
"amenities": "Amenities",
"get_suggestions": "Get Suggestions",
"loading": "Finding great options...",
"no_results": "No suggestions found. Try adjusting your filters.",
"try_again": "Try different filters",
"add_to_day": "Add to this day",
"why_fits": "Why it's a great fit",
"error": "Failed to get suggestions. Please try again."
},
"about": {
"about": "About",
@@ -782,7 +816,19 @@
"travel_agent_help_title": "How to use the travel agent",
"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"
"travel_agent_help_setup_guide": "Travel agent setup guide",
"travel_preferences": "Travel Preferences",
"travel_preferences_desc": "Customize your travel preferences for better AI recommendations",
"cuisines": "Favorite Cuisines",
"cuisines_placeholder": "e.g., Italian, Japanese, Mexican...",
"interests": "Travel Interests",
"interests_placeholder": "e.g., hiking, museums, beaches, nightlife...",
"trip_style": "Travel Style",
"trip_style_placeholder": "e.g., adventure, luxury, budget, cultural",
"notes": "Additional Notes",
"notes_placeholder": "Any other preferences or considerations for your trips...",
"preferences_saved": "Preferences saved successfully!",
"preferences_save_error": "Failed to save preferences"
},
"collection": {
"collection_created": "Collection created successfully!",

View File

@@ -256,6 +256,29 @@
// Enforce recommendations visibility only for owner/shared users
$: availableViews.recommendations = !!canModifyCollection;
function deriveCollectionDestination(current: Collection | null): string | undefined {
if (!current?.locations?.length) {
return undefined;
}
const firstLocation = current.locations.find((loc) =>
Boolean(loc.city?.name || loc.country?.name || loc.location || loc.name)
);
if (!firstLocation) {
return undefined;
}
const cityName = firstLocation.city?.name;
const countryName = firstLocation.country?.name;
if (cityName && countryName) {
return `${cityName}, ${countryName}`;
}
return cityName || countryName || firstLocation.location || firstLocation.name || undefined;
}
$: collectionDestination = deriveCollectionDestination(collection);
// Build calendar events from collection visits
type TimezoneMode = 'event' | 'local';
@@ -1261,7 +1284,14 @@
<!-- Recommendations View -->
{#if currentView === 'recommendations'}
<div class="space-y-8">
<AITravelChat embedded={true} />
<AITravelChat
embedded={true}
collectionId={collection.id}
collectionName={collection.name}
startDate={collection.start_date || undefined}
endDate={collection.end_date || undefined}
destination={collectionDestination}
/>
<CollectionRecommendationView bind:collection user={data.user} />
</div>
{/if}

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

View File

@@ -28,6 +28,16 @@
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;
@@ -50,6 +60,13 @@
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';
@@ -127,12 +144,23 @@
{ 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) {
@@ -555,6 +583,45 @@
}
}
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 '••••••••';
@@ -1642,9 +1709,9 @@
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<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"
@@ -1688,6 +1755,87 @@
</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">