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}