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