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:
@@ -335,25 +335,22 @@ Be conversational, helpful, and enthusiastic about travel. Keep responses concis
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
profile = UserRecommendationPreferenceProfile.objects.get(user=user)
|
profile = UserRecommendationPreferenceProfile.objects.get(user=user)
|
||||||
preference_lines = []
|
|
||||||
|
|
||||||
if profile.cuisines:
|
if profile.interests or profile.trip_style or profile.notes:
|
||||||
preference_lines.append(
|
base_prompt += "\n\n## Traveler Preferences\n"
|
||||||
f"🍽️ **Cuisine Preferences**: {profile.cuisines}"
|
base_prompt += "*(Automatically inferred from travel history)*\n\n"
|
||||||
)
|
|
||||||
if profile.interests:
|
|
||||||
preference_lines.append(
|
|
||||||
f"🎯 **Interests**: {_format_interests(profile.interests)}"
|
|
||||||
)
|
|
||||||
if profile.trip_style:
|
|
||||||
preference_lines.append(f"✈️ **Travel Style**: {profile.trip_style}")
|
|
||||||
if profile.notes:
|
|
||||||
preference_lines.append(f"📝 **Additional Notes**: {profile.notes}")
|
|
||||||
|
|
||||||
if preference_lines:
|
if profile.interests:
|
||||||
base_prompt += "\n\n## Traveler Preferences\n" + "\n".join(
|
interests_str = (
|
||||||
preference_lines
|
", ".join(profile.interests)
|
||||||
)
|
if isinstance(profile.interests, list)
|
||||||
|
else str(profile.interests)
|
||||||
|
)
|
||||||
|
base_prompt += f"🎯 **Interests**: {interests_str}\n"
|
||||||
|
if profile.trip_style:
|
||||||
|
base_prompt += f"✈️ **Travel Style**: {profile.trip_style}\n"
|
||||||
|
if profile.notes:
|
||||||
|
base_prompt += f"📍 **Patterns**: {profile.notes}\n"
|
||||||
except UserRecommendationPreferenceProfile.DoesNotExist:
|
except UserRecommendationPreferenceProfile.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from adventures.models import Collection
|
from adventures.models import Collection
|
||||||
@@ -19,6 +20,8 @@ from ..llm_client import (
|
|||||||
from ..models import ChatConversation, ChatMessage
|
from ..models import ChatConversation, ChatMessage
|
||||||
from ..serializers import ChatConversationSerializer
|
from ..serializers import ChatConversationSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ChatViewSet(viewsets.ModelViewSet):
|
class ChatViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ChatConversationSerializer
|
serializer_class = ChatConversationSerializer
|
||||||
@@ -108,6 +111,15 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def send_message(self, request, pk=None):
|
def send_message(self, request, pk=None):
|
||||||
|
# Auto-learn preferences from user's travel history
|
||||||
|
from integrations.utils.auto_profile import update_auto_preference_profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_auto_preference_profile(request.user)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Auto-profile update failed: %s", exc)
|
||||||
|
# Continue anyway - not critical
|
||||||
|
|
||||||
conversation = self.get_object()
|
conversation = self.get_object()
|
||||||
user_content = (request.data.get("message") or "").strip()
|
user_content = (request.data.get("message") or "").strip()
|
||||||
if not user_content:
|
if not user_content:
|
||||||
@@ -323,6 +335,93 @@ class ChatProviderCatalogViewSet(viewsets.ViewSet):
|
|||||||
def list(self, request):
|
def list(self, request):
|
||||||
return Response(get_provider_catalog(user=request.user))
|
return Response(get_provider_catalog(user=request.user))
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def models(self, request, pk=None):
|
||||||
|
"""Fetch available models from a provider's API."""
|
||||||
|
from chat.llm_client import get_llm_api_key
|
||||||
|
|
||||||
|
provider = (pk or "").lower()
|
||||||
|
|
||||||
|
api_key = get_llm_api_key(request.user, provider)
|
||||||
|
if not api_key:
|
||||||
|
return Response(
|
||||||
|
{"error": "No API key configured for this provider"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider == "openai":
|
||||||
|
import openai
|
||||||
|
|
||||||
|
client = openai.OpenAI(api_key=api_key)
|
||||||
|
models = client.models.list()
|
||||||
|
chat_models = [
|
||||||
|
model.id
|
||||||
|
for model in models
|
||||||
|
if any(prefix in model.id for prefix in ["gpt-", "o1-", "chatgpt"])
|
||||||
|
]
|
||||||
|
return Response({"models": sorted(set(chat_models), reverse=True)})
|
||||||
|
|
||||||
|
if provider in ["anthropic", "claude"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
"claude-sonnet-4-20250514",
|
||||||
|
"claude-opus-4-20250514",
|
||||||
|
"claude-3-5-sonnet-20241022",
|
||||||
|
"claude-3-5-haiku-20241022",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider in ["gemini", "google"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
"gemini-1.5-flash-8b",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider in ["groq"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
"llama-3.3-70b-versatile",
|
||||||
|
"llama-3.1-70b-versatile",
|
||||||
|
"llama-3.1-8b-instant",
|
||||||
|
"mixtral-8x7b-32768",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider in ["ollama"]:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
"http://localhost:11434/api/tags", timeout=5
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
data = response.json()
|
||||||
|
models = [item["name"] for item in data.get("models", [])]
|
||||||
|
return Response({"models": models})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Response({"models": []})
|
||||||
|
|
||||||
|
return Response({"models": []})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to fetch models for %s: %s", provider, exc)
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to fetch models: {str(exc)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from .capabilities import CapabilitiesView
|
from .capabilities import CapabilitiesView
|
||||||
from .day_suggestions import DaySuggestionsView
|
from .day_suggestions import DaySuggestionsView
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
class StandardResultsSetPagination(PageNumberPagination):
|
class StandardResultsSetPagination(PageNumberPagination):
|
||||||
page_size = 25
|
page_size = 25
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = "page_size"
|
||||||
max_page_size = 1000
|
max_page_size = 1000
|
||||||
168
backend/server/integrations/utils/auto_profile.py
Normal file
168
backend/server/integrations/utils/auto_profile.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Auto-learn user preferences from their travel history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from adventures.models import Activity, Location, Lodging
|
||||||
|
from integrations.models import UserRecommendationPreferenceProfile
|
||||||
|
from worldtravel.models import VisitedCity, VisitedRegion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of lodging types to travel styles
|
||||||
|
LODGING_STYLE_MAP = {
|
||||||
|
"hostel": "budget",
|
||||||
|
"campground": "outdoor",
|
||||||
|
"cabin": "outdoor",
|
||||||
|
"camping": "outdoor",
|
||||||
|
"resort": "luxury",
|
||||||
|
"villa": "luxury",
|
||||||
|
"hotel": "comfort",
|
||||||
|
"apartment": "independent",
|
||||||
|
"bnb": "local",
|
||||||
|
"boat": "adventure",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activity sport types to interest categories
|
||||||
|
ACTIVITY_INTEREST_MAP = {
|
||||||
|
"hiking": "hiking & nature",
|
||||||
|
"walking": "walking tours",
|
||||||
|
"running": "fitness",
|
||||||
|
"cycling": "cycling",
|
||||||
|
"swimming": "water sports",
|
||||||
|
"surfing": "water sports",
|
||||||
|
"kayaking": "water sports",
|
||||||
|
"skiing": "winter sports",
|
||||||
|
"snowboarding": "winter sports",
|
||||||
|
"climbing": "adventure sports",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_auto_preference_profile(user) -> dict:
|
||||||
|
"""
|
||||||
|
Automatically build preference profile from user's existing data.
|
||||||
|
|
||||||
|
Analyzes:
|
||||||
|
- Activities (sport types) → interests
|
||||||
|
- Location categories → interests
|
||||||
|
- Lodging types → trip style
|
||||||
|
- Visited regions/cities → geographic preferences
|
||||||
|
|
||||||
|
Returns dict with: cuisines, interests, trip_style, notes
|
||||||
|
"""
|
||||||
|
profile = {
|
||||||
|
"cuisines": None,
|
||||||
|
"interests": [],
|
||||||
|
"trip_style": None,
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
activity_interests = (
|
||||||
|
Activity.objects.filter(user=user)
|
||||||
|
.values("sport_type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.exclude(sport_type__isnull=True)
|
||||||
|
.exclude(sport_type="")
|
||||||
|
.order_by("-count")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
for activity in activity_interests:
|
||||||
|
sport = activity["sport_type"]
|
||||||
|
if sport:
|
||||||
|
interest = ACTIVITY_INTEREST_MAP.get(
|
||||||
|
sport.lower(), sport.replace("_", " ")
|
||||||
|
)
|
||||||
|
if interest not in profile["interests"]:
|
||||||
|
profile["interests"].append(interest)
|
||||||
|
|
||||||
|
category_interests = (
|
||||||
|
Location.objects.filter(user=user)
|
||||||
|
.values("category__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.exclude(category__name__isnull=True)
|
||||||
|
.exclude(category__name="")
|
||||||
|
.order_by("-count")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
for category in category_interests:
|
||||||
|
category_name = category["category__name"]
|
||||||
|
if category_name and category_name.lower() not in [
|
||||||
|
i.lower() for i in profile["interests"]
|
||||||
|
]:
|
||||||
|
profile["interests"].append(category_name)
|
||||||
|
|
||||||
|
top_lodging = (
|
||||||
|
Lodging.objects.filter(user=user)
|
||||||
|
.values("type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.exclude(type__isnull=True)
|
||||||
|
.exclude(type="")
|
||||||
|
.order_by("-count")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if top_lodging and top_lodging["type"]:
|
||||||
|
lodging_type = top_lodging["type"].lower()
|
||||||
|
profile["trip_style"] = LODGING_STYLE_MAP.get(lodging_type, lodging_type)
|
||||||
|
|
||||||
|
top_regions = list(
|
||||||
|
VisitedRegion.objects.filter(user=user)
|
||||||
|
.values("region__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.exclude(region__name__isnull=True)
|
||||||
|
.order_by("-count")[:3]
|
||||||
|
)
|
||||||
|
|
||||||
|
if top_regions:
|
||||||
|
region_names = [r["region__name"] for r in top_regions if r["region__name"]]
|
||||||
|
if region_names:
|
||||||
|
profile["notes"] = f"Frequently visits: {', '.join(region_names)}"
|
||||||
|
|
||||||
|
if not profile["notes"]:
|
||||||
|
top_cities = list(
|
||||||
|
VisitedCity.objects.filter(user=user)
|
||||||
|
.values("city__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.exclude(city__name__isnull=True)
|
||||||
|
.order_by("-count")[:3]
|
||||||
|
)
|
||||||
|
if top_cities:
|
||||||
|
city_names = [c["city__name"] for c in top_cities if c["city__name"]]
|
||||||
|
if city_names:
|
||||||
|
profile["notes"] = f"Frequently visits: {', '.join(city_names)}"
|
||||||
|
|
||||||
|
profile["interests"] = profile["interests"][:8]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error building auto profile for user %s: %s", user.id, exc)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def update_auto_preference_profile(user) -> UserRecommendationPreferenceProfile:
|
||||||
|
"""
|
||||||
|
Build and save auto-learned profile to database.
|
||||||
|
Called automatically when chat starts.
|
||||||
|
"""
|
||||||
|
auto_data = build_auto_preference_profile(user)
|
||||||
|
|
||||||
|
profile, created = UserRecommendationPreferenceProfile.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={
|
||||||
|
"cuisines": auto_data["cuisines"],
|
||||||
|
"interests": auto_data["interests"],
|
||||||
|
"trip_style": auto_data["trip_style"],
|
||||||
|
"notes": auto_data["notes"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s auto profile for user %s",
|
||||||
|
"Created" if created else "Updated",
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
return profile
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
tool_results?: ToolResultEntry[];
|
tool_results?: ToolResultEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChatProviderCatalogConfiguredEntry = ChatProviderCatalogEntry & {
|
||||||
|
instance_configured: boolean;
|
||||||
|
user_configured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export let embedded = false;
|
export let embedded = false;
|
||||||
export let collectionId: string | undefined = undefined;
|
export let collectionId: string | undefined = undefined;
|
||||||
export let collectionName: string | undefined = undefined;
|
export let collectionName: string | undefined = undefined;
|
||||||
@@ -46,15 +51,15 @@
|
|||||||
let sidebarOpen = true;
|
let sidebarOpen = true;
|
||||||
let streamingContent = '';
|
let streamingContent = '';
|
||||||
|
|
||||||
let selectedProvider = 'openai';
|
let selectedProvider = '';
|
||||||
let selectedModel = '';
|
let selectedModel = '';
|
||||||
let providerCatalog: ChatProviderCatalogEntry[] = [];
|
let availableModels: string[] = [];
|
||||||
|
let chatProviders: ChatProviderCatalogConfiguredEntry[] = [];
|
||||||
|
let providerError = '';
|
||||||
|
let selectedProviderDefaultModel = '';
|
||||||
let showDateSelector = false;
|
let showDateSelector = false;
|
||||||
let selectedPlaceToAdd: PlaceResult | null = null;
|
let selectedPlaceToAdd: PlaceResult | null = null;
|
||||||
let selectedDate = '';
|
let selectedDate = '';
|
||||||
$: chatProviders = providerCatalog.filter((provider) => provider.available_for_chat);
|
|
||||||
$: selectedProviderEntry =
|
|
||||||
chatProviders.find((provider) => provider.id === selectedProvider) ?? null;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
close: void;
|
close: void;
|
||||||
@@ -68,21 +73,67 @@
|
|||||||
await Promise.all([loadConversations(), loadProviderCatalog()]);
|
await Promise.all([loadConversations(), loadProviderCatalog()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadProviderCatalog() {
|
async function loadProviderCatalog(): Promise<void> {
|
||||||
const res = await fetch('/api/chat/providers/');
|
try {
|
||||||
if (!res.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalog = (await res.json()) as ChatProviderCatalogEntry[];
|
try {
|
||||||
providerCatalog = catalog;
|
const res = await fetch(`/api/chat/providers/${selectedProvider}/models/`, {
|
||||||
const availableProviders = catalog.filter((provider) => provider.available_for_chat);
|
credentials: 'include'
|
||||||
if (!availableProviders.length) {
|
});
|
||||||
return;
|
const data = await res.json();
|
||||||
}
|
|
||||||
|
|
||||||
if (!availableProviders.some((provider) => provider.id === selectedProvider)) {
|
if (data.models && data.models.length > 0) {
|
||||||
selectedProvider = availableProviders[0].id;
|
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) {
|
$: if (selectedProvider && initializedModelProvider !== selectedProvider) {
|
||||||
selectedModel =
|
selectedModel = loadModelPref(selectedProvider) || selectedProviderDefaultModel || '';
|
||||||
loadModelPref(selectedProvider) || (selectedProviderEntry.default_model ?? '') || '';
|
|
||||||
initializedModelProvider = selectedProvider;
|
initializedModelProvider = selectedProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (selectedProviderEntry && initializedModelProvider === selectedProvider) {
|
$: if (selectedProvider && initializedModelProvider === selectedProvider) {
|
||||||
saveModelPref(selectedProvider, selectedModel);
|
saveModelPref(selectedProvider, selectedModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: selectedProviderDefaultModel =
|
||||||
|
chatProviders.find((provider) => provider.id === selectedProvider)?.default_model ?? '';
|
||||||
|
|
||||||
|
$: if (selectedProvider) {
|
||||||
|
void loadModelsForProvider();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConversations() {
|
async function loadConversations() {
|
||||||
const res = await fetch('/api/chat/conversations/');
|
const res = await fetch('/api/chat/conversations/');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -199,7 +256,7 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: msgText,
|
message: msgText,
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
model: selectedModel.trim() || undefined,
|
model: selectedModel || undefined,
|
||||||
collection_id: collectionId,
|
collection_id: collectionId,
|
||||||
collection_name: collectionName,
|
collection_name: collectionName,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
@@ -525,233 +582,251 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<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
|
<select
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
bind:value={selectedProvider}
|
bind:value={selectedProvider}
|
||||||
disabled={chatProviders.length === 0}
|
disabled={chatProviders.length === 0}
|
||||||
>
|
>
|
||||||
{#each chatProviders as provider}
|
{#each chatProviders as provider}
|
||||||
<option value={provider.id}>{provider.label}</option>
|
<option value={provider.id}>
|
||||||
|
{provider.label}
|
||||||
|
{#if provider.user_configured}
|
||||||
|
✓{/if}
|
||||||
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
|
{#if chatProviders.length === 0}
|
||||||
{#if messages.length === 0 && !activeConversation}
|
<div class="p-4">
|
||||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
<div class="alert alert-warning">
|
||||||
<div class="text-6xl opacity-40 mb-4">🌍</div>
|
<span
|
||||||
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
|
>{providerError || 'No AI providers configured.'}
|
||||||
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
|
<a href="/settings" class="link">Add an API key in Settings</a></span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
{#each messages as msg}
|
{:else}
|
||||||
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
|
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
|
||||||
{#if msg.role === 'tool'}
|
{#if messages.length === 0 && !activeConversation}
|
||||||
<div class="max-w-2xl w-full">
|
<div class="flex flex-col items-center justify-center h-full text-center">
|
||||||
<div class="bg-base-200 rounded-lg p-3 text-xs space-y-2">
|
<div class="text-6xl opacity-40 mb-4">🌍</div>
|
||||||
<div class="font-semibold mb-1 text-primary">🗺️ {msg.name}</div>
|
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
|
||||||
{#each parseToolResults(msg) as result}
|
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
|
||||||
{#if hasPlaceResults(result)}
|
</div>
|
||||||
<div class="grid gap-2">
|
{:else}
|
||||||
{#each getPlaceResults(result) as place}
|
{#each messages as msg}
|
||||||
<div class="card card-compact bg-base-100 p-3">
|
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
|
||||||
<h4 class="font-semibold">{place.name}</h4>
|
{#if msg.role === 'tool'}
|
||||||
{#if place.address}
|
<div class="max-w-2xl w-full">
|
||||||
<p class="text-sm text-base-content/70">{place.address}</p>
|
<div class="bg-base-200 rounded-lg p-3 text-xs space-y-2">
|
||||||
{/if}
|
<div class="font-semibold mb-1 text-primary">🗺️ {msg.name}</div>
|
||||||
{#if place.rating}
|
{#each parseToolResults(msg) as result}
|
||||||
<div class="flex items-center gap-1 text-sm">
|
{#if hasPlaceResults(result)}
|
||||||
<span>⭐</span>
|
<div class="grid gap-2">
|
||||||
<span>{place.rating}</span>
|
{#each getPlaceResults(result) as place}
|
||||||
</div>
|
<div class="card card-compact bg-base-100 p-3">
|
||||||
{/if}
|
<h4 class="font-semibold">{place.name}</h4>
|
||||||
{#if collectionId}
|
{#if place.address}
|
||||||
<button
|
<p class="text-sm text-base-content/70">{place.address}</p>
|
||||||
class="btn btn-xs btn-primary btn-outline mt-2"
|
{/if}
|
||||||
on:click={() => openDateSelector(place)}
|
{#if place.rating}
|
||||||
disabled={!hasPlaceCoordinates(place)}
|
<div class="flex items-center gap-1 text-sm">
|
||||||
>
|
<span>⭐</span>
|
||||||
{$t('add_to_itinerary')}
|
<span>{place.rating}</span>
|
||||||
</button>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{#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}
|
{/each}
|
||||||
</div>
|
</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}
|
{/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>
|
||||||
</div>
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
|
{/each}
|
||||||
<div
|
{/if}
|
||||||
class="chat-bubble {msg.role === 'user'
|
</div>
|
||||||
? '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>
|
|
||||||
|
|
||||||
<div class="p-4 border-t border-base-300">
|
<div class="p-4 border-t border-base-300">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="flex flex-wrap gap-2 mb-3">
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
{#if destination}
|
{#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
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-ghost"
|
||||||
on:click={() =>
|
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}
|
disabled={isStreaming || chatProviders.length === 0}
|
||||||
>
|
>
|
||||||
🍽️ Restaurants
|
📅 Itinerary help
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
class="btn btn-sm btn-ghost"
|
</div>
|
||||||
on:click={() => sendPresetMessage(`What activities can I do in ${destination}?`)}
|
<div class="flex gap-2 max-w-4xl mx-auto">
|
||||||
disabled={isStreaming || chatProviders.length === 0}
|
<textarea
|
||||||
>
|
class="textarea textarea-bordered flex-1 resize-none"
|
||||||
🎯 Activities
|
placeholder={$t('chat.input_placeholder')}
|
||||||
</button>
|
bind:value={inputMessage}
|
||||||
{/if}
|
on:keydown={handleKeydown}
|
||||||
{#if startDate && endDate}
|
rows="1"
|
||||||
<button
|
disabled={isStreaming}
|
||||||
class="btn btn-sm btn-ghost"
|
></textarea>
|
||||||
on:click={() =>
|
|
||||||
sendPresetMessage(
|
|
||||||
`What should I pack for my trip from ${startDate} to ${endDate}?`
|
|
||||||
)}
|
|
||||||
disabled={isStreaming || chatProviders.length === 0}
|
|
||||||
>
|
|
||||||
🎒 Packing tips
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-primary"
|
||||||
on:click={() =>
|
on:click={sendMessage}
|
||||||
sendPresetMessage('Can you help me plan a day-by-day itinerary for this trip?')}
|
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
|
||||||
disabled={isStreaming || 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 max-w-4xl mx-auto">
|
{/if}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -573,6 +573,8 @@ export type ChatProviderCatalogEntry = {
|
|||||||
needs_api_key: boolean | null;
|
needs_api_key: boolean | null;
|
||||||
default_model: string | null;
|
default_model: string | null;
|
||||||
api_base: string | null;
|
api_base: string | null;
|
||||||
|
instance_configured: boolean;
|
||||||
|
user_configured: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserRecommendationPreferenceProfile = {
|
export type UserRecommendationPreferenceProfile = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from '../$types';
|
import type { PageServerLoad } from '../$types';
|
||||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
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';
|
import { fetchCSRFToken } from '$lib/index.server';
|
||||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -95,25 +95,11 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
|
|
||||||
let apiKeys: UserAPIKey[] = [];
|
let apiKeys: UserAPIKey[] = [];
|
||||||
let apiKeysConfigError: string | null = null;
|
let apiKeysConfigError: string | null = null;
|
||||||
let [apiKeysFetch, recommendationPreferencesFetch] = await Promise.all([
|
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
|
||||||
fetch(`${endpoint}/api/integrations/api-keys/`, {
|
headers: {
|
||||||
headers: {
|
Cookie: `sessionid=${sessionId}`
|
||||||
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) {
|
if (apiKeysFetch.ok) {
|
||||||
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
|
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
|
||||||
@@ -145,7 +131,6 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
stravaUserEnabled,
|
stravaUserEnabled,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
apiKeysConfigError,
|
apiKeysConfigError,
|
||||||
recommendationProfile,
|
|
||||||
wandererEnabled,
|
wandererEnabled,
|
||||||
wandererExpired
|
wandererExpired
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,6 @@
|
|||||||
usage_required: boolean;
|
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 new_email: string = '';
|
||||||
let public_url: string = data.props.publicUrl;
|
let public_url: string = data.props.publicUrl;
|
||||||
let immichIntegration = data.props.immichIntegration;
|
let immichIntegration = data.props.immichIntegration;
|
||||||
@@ -60,13 +50,6 @@
|
|||||||
let newApiKeyValue = '';
|
let newApiKeyValue = '';
|
||||||
let isSavingApiKey = false;
|
let isSavingApiKey = false;
|
||||||
let deletingApiKeyId: string | null = null;
|
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 mcpToken: string | null = null;
|
||||||
let isLoadingMcpToken = false;
|
let isLoadingMcpToken = false;
|
||||||
let activeSection: string = 'profile';
|
let activeSection: string = 'profile';
|
||||||
@@ -144,23 +127,12 @@
|
|||||||
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
|
||||||
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
|
||||||
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
|
{ 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: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
|
||||||
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
|
||||||
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
|
||||||
];
|
];
|
||||||
|
|
||||||
onMount(async () => {
|
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();
|
void loadProviderCatalog();
|
||||||
|
|
||||||
if (browser) {
|
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 {
|
function getMaskedMcpToken(token: string): string {
|
||||||
if (token.length <= 8) {
|
if (token.length <= 8) {
|
||||||
return '••••••••';
|
return '••••••••';
|
||||||
@@ -1755,87 +1688,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- import export -->
|
||||||
{#if activeSection === 'import_export'}
|
{#if activeSection === 'import_export'}
|
||||||
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user