diff --git a/backend/server/chat/__init__.py b/backend/server/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/server/chat/admin.py b/backend/server/chat/admin.py new file mode 100644 index 00000000..5189e3d3 --- /dev/null +++ b/backend/server/chat/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import ChatConversation, ChatMessage + + +@admin.register(ChatConversation) +class ChatConversationAdmin(admin.ModelAdmin): + list_display = ("id", "user", "title", "updated_at", "created_at") + search_fields = ("title", "user__username") + list_filter = ("created_at", "updated_at") + + +@admin.register(ChatMessage) +class ChatMessageAdmin(admin.ModelAdmin): + list_display = ("id", "conversation", "role", "name", "created_at") + search_fields = ("conversation__id", "content", "name") + list_filter = ("role", "created_at") diff --git a/backend/server/chat/agent_tools.py b/backend/server/chat/agent_tools.py new file mode 100644 index 00000000..8dbbeb24 --- /dev/null +++ b/backend/server/chat/agent_tools.py @@ -0,0 +1,556 @@ +import json +import logging +from datetime import date as date_cls + +import requests +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from adventures.models import Collection, CollectionItineraryItem, Location + +logger = logging.getLogger(__name__) + +AGENT_TOOLS = [ + { + "type": "function", + "function": { + "name": "search_places", + "description": "Search for places of interest near a location. Returns tourist attractions, restaurants, hotels, etc.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "Location name or address to search near", + }, + "category": { + "type": "string", + "enum": ["tourism", "food", "lodging"], + "description": "Category of places", + }, + "radius": { + "type": "number", + "description": "Search radius in km (default 10)", + }, + }, + "required": ["location"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_trips", + "description": "List the user's trip collections with dates and descriptions", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "get_trip_details", + "description": "Get full details of a trip including all itinerary items, locations, transportation, and lodging", + "parameters": { + "type": "object", + "properties": { + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + } + }, + "required": ["collection_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_to_itinerary", + "description": "Add a new location to a trip's itinerary on a specific date", + "parameters": { + "type": "object", + "properties": { + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + }, + "name": {"type": "string", "description": "Name of the location"}, + "description": { + "type": "string", + "description": "Description of why to visit", + }, + "latitude": { + "type": "number", + "description": "Latitude coordinate", + }, + "longitude": { + "type": "number", + "description": "Longitude coordinate", + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format", + }, + "location_address": { + "type": "string", + "description": "Full address of the location", + }, + }, + "required": ["collection_id", "name", "latitude", "longitude"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get temperature/weather data for a location on specific dates", + "parameters": { + "type": "object", + "properties": { + "latitude": {"type": "number", "description": "Latitude"}, + "longitude": {"type": "number", "description": "Longitude"}, + "dates": { + "type": "array", + "items": {"type": "string"}, + "description": "List of dates in YYYY-MM-DD format", + }, + }, + "required": ["latitude", "longitude", "dates"], + }, + }, + }, +] + +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" +OVERPASS_URL = "https://overpass-api.de/api/interpreter" +OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" +OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"} + + +def _build_overpass_query(latitude, longitude, radius_meters, category): + if category == "food": + node_filter = '["amenity"~"restaurant|cafe|bar|fast_food"]' + elif category == "lodging": + node_filter = '["tourism"~"hotel|hostel|guest_house|motel|apartment"]' + else: + node_filter = '["tourism"~"attraction|museum|viewpoint|gallery|theme_park"]' + + return f""" +[out:json][timeout:25]; +( + node{node_filter}(around:{int(radius_meters)},{latitude},{longitude}); + way{node_filter}(around:{int(radius_meters)},{latitude},{longitude}); + relation{node_filter}(around:{int(radius_meters)},{latitude},{longitude}); +); +out center 20; +""" + + +def _parse_address(tags): + if not tags: + return "" + if tags.get("addr:full"): + return tags["addr:full"] + street = tags.get("addr:street", "") + house = tags.get("addr:housenumber", "") + city = ( + tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village") or "" + ) + parts = [f"{street} {house}".strip(), city] + return ", ".join([p for p in parts if p]) + + +def search_places(user, **kwargs): + try: + location_name = kwargs.get("location") + if not location_name: + return {"error": "location is required"} + + category = kwargs.get("category") or "tourism" + radius_km = float(kwargs.get("radius") or 10) + radius_meters = max(500, min(int(radius_km * 1000), 50000)) + + geocode_resp = requests.get( + NOMINATIM_URL, + params={"q": location_name, "format": "json", "limit": 1}, + headers=REQUEST_HEADERS, + timeout=10, + ) + geocode_resp.raise_for_status() + geocode_data = geocode_resp.json() + if not geocode_data: + return {"error": f"Could not geocode location: {location_name}"} + + base_lat = float(geocode_data[0]["lat"]) + base_lon = float(geocode_data[0]["lon"]) + query = _build_overpass_query(base_lat, base_lon, radius_meters, category) + + overpass_resp = requests.post( + OVERPASS_URL, + data={"data": query}, + headers=REQUEST_HEADERS, + timeout=20, + ) + overpass_resp.raise_for_status() + overpass_data = overpass_resp.json() + + results = [] + for item in (overpass_data.get("elements") or [])[:20]: + tags = item.get("tags") or {} + name = tags.get("name") + if not name: + continue + + latitude = item.get("lat") + longitude = item.get("lon") + if latitude is None or longitude is None: + center = item.get("center") or {} + latitude = center.get("lat") + longitude = center.get("lon") + + if latitude is None or longitude is None: + continue + + results.append( + { + "name": name, + "address": _parse_address(tags), + "latitude": latitude, + "longitude": longitude, + "category": category, + } + ) + + if len(results) >= 5: + break + + return { + "location": location_name, + "category": category, + "results": results, + } + except requests.RequestException as exc: + return {"error": f"Places API request failed: {exc}"} + except (TypeError, ValueError) as exc: + return {"error": f"Invalid search parameters: {exc}"} + except Exception: + logger.exception("search_places failed") + return {"error": "An unexpected error occurred during place search"} + + +def list_trips(user, **kwargs): + try: + collections = Collection.objects.filter(user=user).prefetch_related("locations") + trips = [] + for collection in collections: + trips.append( + { + "id": str(collection.id), + "name": collection.name, + "start_date": collection.start_date.isoformat() + if collection.start_date + else None, + "end_date": collection.end_date.isoformat() + if collection.end_date + else None, + "description": collection.description or "", + "location_count": collection.locations.count(), + } + ) + return {"trips": trips} + except Exception: + logger.exception("list_trips failed") + return {"error": "An unexpected error occurred while listing trips"} + + +def get_trip_details(user, **kwargs): + try: + collection_id = kwargs.get("collection_id") + if not collection_id: + return {"error": "collection_id is required"} + + collection = ( + Collection.objects.filter(user=user) + .prefetch_related( + "locations", + "transportation_set", + "lodging_set", + "itinerary_items__content_type", + ) + .get(id=collection_id) + ) + + itinerary = [] + for item in collection.itinerary_items.all().order_by("date", "order"): + content_obj = item.item + itinerary.append( + { + "id": str(item.id), + "date": item.date.isoformat() if item.date else None, + "order": item.order, + "is_global": item.is_global, + "content_type": item.content_type.model, + "object_id": str(item.object_id), + "name": getattr(content_obj, "name", ""), + } + ) + + return { + "trip": { + "id": str(collection.id), + "name": collection.name, + "description": collection.description or "", + "start_date": collection.start_date.isoformat() + if collection.start_date + else None, + "end_date": collection.end_date.isoformat() + if collection.end_date + else None, + "locations": [ + { + "id": str(location.id), + "name": location.name, + "description": location.description or "", + "location": location.location or "", + "latitude": float(location.latitude) + if location.latitude is not None + else None, + "longitude": float(location.longitude) + if location.longitude is not None + else None, + } + for location in collection.locations.all() + ], + "transportation": [ + { + "id": str(t.id), + "name": t.name, + "type": t.type, + "date": t.date.isoformat() if t.date else None, + "end_date": t.end_date.isoformat() if t.end_date else None, + } + for t in collection.transportation_set.all() + ], + "lodging": [ + { + "id": str(l.id), + "name": l.name, + "type": l.type, + "check_in": l.check_in.isoformat() if l.check_in else None, + "check_out": l.check_out.isoformat() if l.check_out else None, + "location": l.location or "", + } + for l in collection.lodging_set.all() + ], + "itinerary": itinerary, + } + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("get_trip_details failed") + return {"error": "An unexpected error occurred while fetching trip details"} + + +def add_to_itinerary(user, **kwargs): + try: + collection_id = kwargs.get("collection_id") + name = kwargs.get("name") + latitude = kwargs.get("latitude") + longitude = kwargs.get("longitude") + description = kwargs.get("description") + location_address = kwargs.get("location_address") + date = kwargs.get("date") + + if not collection_id or not name or latitude is None or longitude is None: + return { + "error": "collection_id, name, latitude, and longitude are required" + } + + collection = Collection.objects.get(id=collection_id, user=user) + + location = Location.objects.create( + user=user, + name=name, + latitude=latitude, + longitude=longitude, + description=description or "", + location=location_address or "", + ) + + collection.locations.add(location) + content_type = ContentType.objects.get_for_model(Location) + + itinerary_date = date + if not itinerary_date: + if collection.start_date: + itinerary_date = collection.start_date.isoformat() + else: + itinerary_date = date_cls.today().isoformat() + + try: + itinerary_date_obj = date_cls.fromisoformat(itinerary_date) + except ValueError: + return {"error": "date must be in YYYY-MM-DD format"} + + max_order = ( + CollectionItineraryItem.objects.filter( + collection=collection, + date=itinerary_date_obj, + is_global=False, + ).aggregate(models.Max("order"))["order__max"] + or 0 + ) + + itinerary_item = CollectionItineraryItem.objects.create( + collection=collection, + content_type=content_type, + object_id=location.id, + date=itinerary_date_obj, + order=max_order + 1, + ) + + return { + "success": True, + "location": { + "id": str(location.id), + "name": location.name, + "latitude": float(location.latitude), + "longitude": float(location.longitude), + }, + "itinerary_item": { + "id": str(itinerary_item.id), + "date": itinerary_date_obj.isoformat(), + "order": itinerary_item.order, + }, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("add_to_itinerary failed") + return {"error": "An unexpected error occurred while adding to itinerary"} + + +def _fetch_temperature_for_date(latitude, longitude, date_value): + for url in (OPEN_METEO_ARCHIVE_URL, OPEN_METEO_FORECAST_URL): + try: + response = requests.get( + url, + params={ + "latitude": latitude, + "longitude": longitude, + "start_date": date_value, + "end_date": date_value, + "daily": "temperature_2m_max,temperature_2m_min", + "timezone": "UTC", + }, + timeout=8, + ) + response.raise_for_status() + data = response.json() + except requests.RequestException: + continue + except ValueError: + continue + + daily = data.get("daily") or {} + max_values = daily.get("temperature_2m_max") or [] + min_values = daily.get("temperature_2m_min") or [] + if not max_values or not min_values: + continue + + try: + avg = (float(max_values[0]) + float(min_values[0])) / 2 + except (TypeError, ValueError, IndexError): + continue + + return { + "date": date_value, + "available": True, + "temperature_c": round(avg, 1), + } + + return { + "date": date_value, + "available": False, + "temperature_c": None, + } + + +def get_weather(user, **kwargs): + try: + raw_latitude = kwargs.get("latitude") + raw_longitude = kwargs.get("longitude") + if raw_latitude is None or raw_longitude is None: + return {"error": "latitude and longitude are required"} + + latitude = float(raw_latitude) + longitude = float(raw_longitude) + dates = kwargs.get("dates") or [] + + if not isinstance(dates, list) or not dates: + return {"error": "dates must be a non-empty list"} + + results = [ + _fetch_temperature_for_date(latitude, longitude, date_value) + for date_value in dates + ] + return { + "latitude": latitude, + "longitude": longitude, + "results": results, + } + except (TypeError, ValueError): + return {"error": "latitude and longitude must be numeric"} + except Exception: + logger.exception("get_weather failed") + return {"error": "An unexpected error occurred while fetching weather data"} + + +ALLOWED_KWARGS = { + "search_places": {"location", "category", "radius"}, + "list_trips": set(), + "get_trip_details": {"collection_id"}, + "add_to_itinerary": { + "collection_id", + "name", + "description", + "latitude", + "longitude", + "date", + "location_address", + }, + "get_weather": {"latitude", "longitude", "dates"}, +} + + +def execute_tool(tool_name, user, **kwargs): + tool_map = { + "search_places": search_places, + "list_trips": list_trips, + "get_trip_details": get_trip_details, + "add_to_itinerary": add_to_itinerary, + "get_weather": get_weather, + } + + tool_fn = tool_map.get(tool_name) + if not tool_fn: + return {"error": f"Unknown tool: {tool_name}"} + + allowed = ALLOWED_KWARGS.get(tool_name, set()) + filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed} + + try: + return tool_fn(user, **filtered_kwargs) + except Exception: + logger.exception("Tool execution failed: %s", tool_name) + return {"error": "An unexpected error occurred while executing the tool"} + + +def serialize_tool_result(result): + try: + return json.dumps(result) + except TypeError: + return json.dumps({"error": "Tool returned non-serializable data"}) diff --git a/backend/server/chat/apps.py b/backend/server/chat/apps.py new file mode 100644 index 00000000..5f75238d --- /dev/null +++ b/backend/server/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chat" diff --git a/backend/server/chat/llm_client.py b/backend/server/chat/llm_client.py new file mode 100644 index 00000000..648044ae --- /dev/null +++ b/backend/server/chat/llm_client.py @@ -0,0 +1,146 @@ +import json +import logging + +import litellm + +from integrations.models import UserAPIKey + +logger = logging.getLogger(__name__) + +PROVIDER_MODELS = { + "openai": "gpt-4o", + "anthropic": "anthropic/claude-sonnet-4-20250514", + "gemini": "gemini/gemini-2.0-flash", + "ollama": "ollama/llama3.1", + "groq": "groq/llama-3.3-70b-versatile", + "mistral": "mistral/mistral-large-latest", + "github_models": "github/gpt-4o", + "openrouter": "openrouter/auto", +} + + +def _safe_get(obj, key, default=None): + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def get_llm_api_key(user, provider): + """Get the user's API key for the given provider.""" + normalized_provider = (provider or "").strip().lower() + try: + key_obj = UserAPIKey.objects.get(user=user, provider=normalized_provider) + return key_obj.get_api_key() + except UserAPIKey.DoesNotExist: + return None + + +def get_system_prompt(user, collection=None): + """Build the system prompt with user context.""" + from integrations.models import UserRecommendationPreferenceProfile + + base_prompt = """You are a helpful travel planning assistant for the Voyage travel app. You help users discover places, plan trips, and organize their itineraries. + +Your capabilities: +- Search for interesting places (restaurants, tourist attractions, hotels) near any location +- View and manage the user's trip collections and itineraries +- Add new locations to trip itineraries +- Check weather/temperature data for travel dates + +When suggesting places: +- Be specific with names, addresses, and why a place is worth visiting +- Consider the user's travel dates and weather conditions +- Group suggestions logically (by area, by type, by day) + +When modifying itineraries: +- Always confirm with the user before adding items +- Suggest logical ordering based on geography +- Consider travel time between locations + +Be conversational, helpful, and enthusiastic about travel. Keep responses concise but informative.""" + + try: + profile = UserRecommendationPreferenceProfile.objects.get(user=user) + prefs = [] + if profile.cuisines: + prefs.append(f"Cuisine preferences: {profile.cuisines}") + if profile.interests: + prefs.append(f"Interests: {profile.interests}") + if profile.trip_style: + prefs.append(f"Travel style: {profile.trip_style}") + if profile.notes: + prefs.append(f"Additional notes: {profile.notes}") + if prefs: + base_prompt += "\n\nUser preferences:\n" + "\n".join(prefs) + except UserRecommendationPreferenceProfile.DoesNotExist: + pass + + return base_prompt + + +async def stream_chat_completion(user, messages, provider, tools=None): + """Stream a chat completion using LiteLLM. + + Yields SSE-formatted strings. + """ + normalized_provider = (provider or "").strip().lower() + api_key = get_llm_api_key(user, normalized_provider) + if not api_key: + payload = { + "error": f"No API key found for provider: {normalized_provider}. Please add one in Settings." + } + yield f"data: {json.dumps(payload)}\n\n" + return + + model = PROVIDER_MODELS.get(normalized_provider, "gpt-4o") + + try: + response = await litellm.acompletion( + model=model, + messages=messages, + tools=tools, + tool_choice="auto" if tools else None, + stream=True, + api_key=api_key, + ) + + async for chunk in response: + choices = _safe_get(chunk, "choices", []) or [] + if not choices: + continue + + delta = _safe_get(choices[0], "delta") + if not delta: + continue + + chunk_data = {} + content = _safe_get(delta, "content") + if content: + chunk_data["content"] = content + + tool_calls = _safe_get(delta, "tool_calls") or [] + if tool_calls: + serialized = [] + for tool_call in tool_calls: + function = _safe_get(tool_call, "function") + serialized.append( + { + "id": _safe_get(tool_call, "id"), + "type": _safe_get(tool_call, "type"), + "function": { + "name": _safe_get(function, "name", "") or "", + "arguments": _safe_get(function, "arguments", "") or "", + }, + } + ) + chunk_data["tool_calls"] = serialized + + if chunk_data: + yield f"data: {json.dumps(chunk_data)}\n\n" + + yield "data: [DONE]\n\n" + except Exception: + logger.exception("LLM streaming error") + yield f"data: {json.dumps({'error': 'An error occurred while processing your request. Please try again.'})}\n\n" diff --git a/backend/server/chat/migrations/0001_initial.py b/backend/server/chat/migrations/0001_initial.py new file mode 100644 index 00000000..d9ca1251 --- /dev/null +++ b/backend/server/chat/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.12 on 2026-03-08 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ChatConversation", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_conversations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + }, + ), + migrations.CreateModel( + name="ChatMessage", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "role", + models.CharField( + choices=[ + ("user", "User"), + ("assistant", "Assistant"), + ("system", "System"), + ("tool", "Tool"), + ], + max_length=20, + ), + ), + ("content", models.TextField(blank=True, default="")), + ("tool_calls", models.JSONField(blank=True, null=True)), + ( + "tool_call_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("name", models.CharField(blank=True, max_length=255, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "conversation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chat.chatconversation", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + ] diff --git a/backend/server/chat/migrations/__init__.py b/backend/server/chat/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/server/chat/models.py b/backend/server/chat/models.py new file mode 100644 index 00000000..0ee5bc24 --- /dev/null +++ b/backend/server/chat/models.py @@ -0,0 +1,47 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class ChatConversation(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="chat_conversations", + ) + title = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self): + return f"{self.title or 'Untitled'} ({self.user.username})" + + +class ChatMessage(models.Model): + ROLE_CHOICES = [ + ("user", "User"), + ("assistant", "Assistant"), + ("system", "System"), + ("tool", "Tool"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + ChatConversation, + on_delete=models.CASCADE, + related_name="messages", + ) + role = models.CharField(max_length=20, choices=ROLE_CHOICES) + content = models.TextField(blank=True, default="") + tool_calls = models.JSONField(null=True, blank=True) + tool_call_id = models.CharField(max_length=255, blank=True, null=True) + name = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] diff --git a/backend/server/chat/serializers.py b/backend/server/chat/serializers.py new file mode 100644 index 00000000..76eda39e --- /dev/null +++ b/backend/server/chat/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from .models import ChatConversation, ChatMessage + + +class ChatMessageSerializer(serializers.ModelSerializer): + class Meta: + model = ChatMessage + fields = [ + "id", + "role", + "content", + "tool_calls", + "tool_call_id", + "name", + "created_at", + ] + + +class ChatConversationSerializer(serializers.ModelSerializer): + messages = ChatMessageSerializer(many=True, read_only=True) + + class Meta: + model = ChatConversation + fields = ["id", "title", "created_at", "updated_at", "messages"] diff --git a/backend/server/chat/urls.py b/backend/server/chat/urls.py new file mode 100644 index 00000000..b9d4588b --- /dev/null +++ b/backend/server/chat/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ChatViewSet + +router = DefaultRouter() +router.register(r"conversations", ChatViewSet, basename="chat-conversation") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/server/chat/views.py b/backend/server/chat/views.py new file mode 100644 index 00000000..2f76ada6 --- /dev/null +++ b/backend/server/chat/views.py @@ -0,0 +1,264 @@ +import asyncio +import json + +from asgiref.sync import sync_to_async +from django.http import StreamingHttpResponse +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .agent_tools import AGENT_TOOLS, execute_tool, serialize_tool_result +from .llm_client import get_system_prompt, stream_chat_completion +from .models import ChatConversation, ChatMessage +from .serializers import ChatConversationSerializer + + +class ChatViewSet(viewsets.ModelViewSet): + serializer_class = ChatConversationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return ChatConversation.objects.filter(user=self.request.user).prefetch_related( + "messages" + ) + + def list(self, request, *args, **kwargs): + conversations = self.get_queryset().only("id", "title", "updated_at") + data = [ + { + "id": str(conversation.id), + "title": conversation.title, + "updated_at": conversation.updated_at, + } + for conversation in conversations + ] + return Response(data) + + def create(self, request, *args, **kwargs): + conversation = ChatConversation.objects.create( + user=request.user, + title=(request.data.get("title") or "").strip(), + ) + serializer = self.get_serializer(conversation) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def _build_llm_messages(self, conversation, user): + messages = [{"role": "system", "content": get_system_prompt(user)}] + for message in conversation.messages.all().order_by("created_at"): + payload = { + "role": message.role, + "content": message.content, + } + if message.role == "assistant" and message.tool_calls: + payload["tool_calls"] = message.tool_calls + if message.role == "tool": + payload["tool_call_id"] = message.tool_call_id + payload["name"] = message.name + messages.append(payload) + return messages + + def _async_to_sync_generator(self, async_gen): + loop = asyncio.new_event_loop() + try: + while True: + try: + yield loop.run_until_complete(async_gen.__anext__()) + except StopAsyncIteration: + break + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + @staticmethod + def _merge_tool_call_delta(accumulator, tool_calls_delta): + for idx, tool_call in enumerate(tool_calls_delta or []): + idx = tool_call.get("index", idx) + while len(accumulator) <= idx: + accumulator.append( + { + "id": None, + "type": "function", + "function": {"name": "", "arguments": ""}, + } + ) + + current = accumulator[idx] + if tool_call.get("id"): + current["id"] = tool_call.get("id") + if tool_call.get("type"): + current["type"] = tool_call.get("type") + + function_data = tool_call.get("function") or {} + if function_data.get("name"): + current["function"]["name"] = function_data.get("name") + if function_data.get("arguments"): + current["function"]["arguments"] += function_data.get("arguments") + + @action(detail=True, methods=["post"]) + def send_message(self, request, pk=None): + conversation = self.get_object() + user_content = (request.data.get("message") or "").strip() + if not user_content: + return Response( + {"error": "message is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + provider = (request.data.get("provider") or "openai").strip().lower() + + ChatMessage.objects.create( + conversation=conversation, + role="user", + content=user_content, + ) + conversation.save(update_fields=["updated_at"]) + + if not conversation.title: + conversation.title = user_content[:120] + conversation.save(update_fields=["title", "updated_at"]) + + llm_messages = self._build_llm_messages(conversation, request.user) + + MAX_TOOL_ITERATIONS = 10 + + async def event_stream(): + current_messages = list(llm_messages) + encountered_error = False + tool_iterations = 0 + + while tool_iterations < MAX_TOOL_ITERATIONS: + content_chunks = [] + tool_calls_accumulator = [] + + async for chunk in stream_chat_completion( + request.user, + current_messages, + provider, + tools=AGENT_TOOLS, + ): + if not chunk.startswith("data: "): + yield chunk + continue + + payload = chunk[len("data: ") :].strip() + if payload == "[DONE]": + continue + + yield chunk + + try: + data = json.loads(payload) + except json.JSONDecodeError: + continue + + if data.get("error"): + encountered_error = True + break + + if data.get("content"): + content_chunks.append(data["content"]) + + if data.get("tool_calls"): + self._merge_tool_call_delta( + tool_calls_accumulator, + data["tool_calls"], + ) + + if encountered_error: + break + + assistant_content = "".join(content_chunks) + + if tool_calls_accumulator: + assistant_with_tools = { + "role": "assistant", + "content": assistant_content, + "tool_calls": tool_calls_accumulator, + } + current_messages.append(assistant_with_tools) + + await sync_to_async( + ChatMessage.objects.create, thread_sensitive=True + )( + conversation=conversation, + role="assistant", + content=assistant_content, + tool_calls=tool_calls_accumulator, + ) + await sync_to_async(conversation.save, thread_sensitive=True)( + update_fields=["updated_at"] + ) + + for tool_call in tool_calls_accumulator: + function_payload = tool_call.get("function") or {} + function_name = function_payload.get("name") or "" + raw_arguments = function_payload.get("arguments") or "{}" + + try: + arguments = json.loads(raw_arguments) + except json.JSONDecodeError: + arguments = {} + if not isinstance(arguments, dict): + arguments = {} + + result = await sync_to_async( + execute_tool, thread_sensitive=True + )( + function_name, + request.user, + **arguments, + ) + result_content = serialize_tool_result(result) + + current_messages.append( + { + "role": "tool", + "tool_call_id": tool_call.get("id"), + "name": function_name, + "content": result_content, + } + ) + + await sync_to_async( + ChatMessage.objects.create, thread_sensitive=True + )( + conversation=conversation, + role="tool", + content=result_content, + tool_call_id=tool_call.get("id"), + name=function_name, + ) + await sync_to_async(conversation.save, thread_sensitive=True)( + update_fields=["updated_at"] + ) + + tool_event = { + "tool_result": { + "tool_call_id": tool_call.get("id"), + "name": function_name, + "result": result, + } + } + yield f"data: {json.dumps(tool_event)}\n\n" + + continue + + await sync_to_async(ChatMessage.objects.create, thread_sensitive=True)( + conversation=conversation, + role="assistant", + content=assistant_content, + ) + await sync_to_async(conversation.save, thread_sensitive=True)( + update_fields=["updated_at"] + ) + yield "data: [DONE]\n\n" + break + + response = StreamingHttpResponse( + streaming_content=self._async_to_sync_generator(event_stream()), + content_type="text/event-stream", + ) + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + return response diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index fdd79d4d..40554746 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -69,6 +69,7 @@ INSTALLED_APPS = ( "worldtravel", "users", "integrations", + "chat", "mcp_server", "django.contrib.gis", # 'achievements', # Not done yet, will be added later in a future update diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index df212dcb..0fe9bbe8 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -31,6 +31,7 @@ schema_view = get_schema_view( urlpatterns = [ path("api/", include("adventures.urls")), path("api/", include("worldtravel.urls")), + path("api/chat/", include("chat.urls")), path( getattr(settings, "DJANGO_MCP_ENDPOINT", "api/mcp"), MCPServerStreamableHttpView.as_view( diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 6355a2bf..034f4916 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -33,3 +33,4 @@ legacy-cgi==2.6.4 requests>=2.32.5 cryptography>=46.0.5 django-mcp-server>=0.5.7 +litellm>=1.72.3 diff --git a/frontend/bun.lock b/frontend/bun.lock index 1f6bc7ed..09de30d1 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -6,6 +6,7 @@ "name": "voyage-frontend", "dependencies": { "@lukulent/svelte-umami": "^0.0.3", + "@mdi/js": "^7.4.47", "dompurify": "^3.3.2", "emoji-picker-element": "^1.29.1", "gsap": "^3.14.2", @@ -47,7 +48,6 @@ }, }, "overrides": { - "cookie@<0.7.0": ">=0.7.0", "devalue": "^5.6.2", "esbuild": "^0.26.0", "minimatch": "^10.2.1", @@ -170,6 +170,8 @@ "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@20.4.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^0.0.1", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^2.0.0", "rw": "^1.3.3", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw=="], + "@mdi/js": ["@mdi/js@7.4.47", "", {}, "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], diff --git a/frontend/package.json b/frontend/package.json index 8e77abc3..b8ef0772 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ }, "type": "module", "dependencies": { + "@mdi/js": "^7.4.47", "@lukulent/svelte-umami": "^0.0.3", "dompurify": "^3.3.2", "emoji-picker-element": "^1.29.1", diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 50455d1c..31838839 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; export let data: any; import type { SubmitFunction } from '@sveltejs/kit'; + import { mdiRobotOutline } from '@mdi/js'; import DotsHorizontal from '~icons/mdi/dots-horizontal'; import Calendar from '~icons/mdi/calendar'; @@ -111,7 +112,8 @@ type NavigationItem = { path: string; - icon: any; + icon?: any; + iconPath?: string; label: string; external?: boolean; }; @@ -120,6 +122,7 @@ const navigationItems: NavigationItem[] = [ { path: '/locations', icon: MapMarker, label: 'locations.locations' }, { path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' }, + { path: '/chat', iconPath: mdiRobotOutline, label: 'navbar.chat' }, { path: '/invites', icon: AccountMultiple, label: 'invites.title' }, { path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' }, { path: '/map', icon: MapIcon, label: 'navbar.map' }, @@ -161,7 +164,13 @@ class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl" class:btn-active={!item.external && $page.url.pathname === item.path} > - + {#if item.icon} + + {:else if item.iconPath} + + {/if} {$t(item.label)} @@ -233,7 +242,13 @@ class:bg-primary-10={!item.external && $page.url.pathname === item.path} class:text-primary={!item.external && $page.url.pathname === item.path} > - + {#if item.icon} + + {:else if item.iconPath} + + {/if} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 2cef69d6..5d3c05d1 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1,6 +1,7 @@ { "navbar": { "collections": "Collections", + "chat": "Travel Agent", "map": "Map", "users": "Users", "search": "Search", @@ -31,6 +32,20 @@ }, "navigation": "Navigation" }, + "chat": { + "title": "Travel Agent", + "conversations": "Conversations", + "new_conversation": "New Conversation", + "untitled": "Untitled Conversation", + "no_conversations": "No conversations yet. Start chatting!", + "welcome_title": "Welcome to the Travel Agent", + "welcome_message": "I can help you discover amazing places, plan your trips, and organize your itineraries. What would you like to explore?", + "input_placeholder": "Ask me about travel destinations, plan a trip, or get recommendations...", + "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." + }, "about": { "about": "About", "license": "Licensed under the GPL-3.0 License.", diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index f267b252..26897700 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -86,11 +86,20 @@ async function handleRequest( }); } - const responseData = await response.arrayBuffer(); - // Create a new Headers object without the 'set-cookie' header + const contentType = response.headers.get('content-type') || ''; const cleanHeaders = new Headers(response.headers); cleanHeaders.delete('set-cookie'); + // Stream SSE responses through without buffering + if (contentType.includes('text/event-stream')) { + return new Response(response.body, { + status: response.status, + headers: cleanHeaders + }); + } + + const responseData = await response.arrayBuffer(); + return new Response(responseData, { status: response.status, headers: cleanHeaders diff --git a/frontend/src/routes/chat/+page.svelte b/frontend/src/routes/chat/+page.svelte new file mode 100644 index 00000000..e6b7fc0a --- /dev/null +++ b/frontend/src/routes/chat/+page.svelte @@ -0,0 +1,342 @@ + + + + {$t('chat.title')} | Voyage + + +
+
+
+

{$t('chat.conversations')}

+ +
+
+ {#each conversations as conv} +
+ + +
+ {/each} + {#if conversations.length === 0} +

{$t('chat.no_conversations')}

+ {/if} +
+
+ +
+
+ + +

{$t('chat.title')}

+
+ +
+
+ +
+ {#if messages.length === 0 && !activeConversation} +
+ +

{$t('chat.welcome_title')}

+

{$t('chat.welcome_message')}

+
+ {:else} + {#each messages as msg} +
+ {#if msg.role === 'tool'} +
+
+
🔧 {msg.name}
+
{msg.content}
+
+
+ {:else} +
+
+
{msg.content}
+ {#if msg.role === 'assistant' && + isStreaming && + msg.id === messages[messages.length - 1]?.id && + !msg.content} + + {/if} +
+
+ {/if} +
+ {/each} + {/if} +
+ +
+
+ + +
+
+
+