feat(chat): add LLM-powered travel agent with multi-provider support
Implement a full chat-based travel agent using LiteLLM for multi-provider LLM support (OpenAI, Anthropic, Gemini, Ollama, Groq, Mistral, etc.). Backend: - New 'chat' Django app with ChatConversation and ChatMessage models - Streaming SSE endpoint via StreamingHttpResponse - 5 agent tools: search_places, list_trips, get_trip_details, add_to_itinerary, get_weather - LiteLLM client wrapper with per-user API key retrieval - System prompt with user preference context injection Frontend: - New /chat route with full-page chat UI (DaisyUI + Tailwind) - Collapsible conversation sidebar with CRUD - SSE streaming response display with tool call visualization - Provider selector dropdown - SSE proxy fix to stream text/event-stream without buffering - Navbar link and i18n keys
This commit is contained in:
0
backend/server/chat/__init__.py
Normal file
0
backend/server/chat/__init__.py
Normal file
17
backend/server/chat/admin.py
Normal file
17
backend/server/chat/admin.py
Normal file
@@ -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")
|
||||
527
backend/server/chat/agent_tools.py
Normal file
527
backend/server/chat/agent_tools.py
Normal file
@@ -0,0 +1,527 @@
|
||||
import json
|
||||
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
|
||||
|
||||
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 as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
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 as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
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 as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
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 as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
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 as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
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}"}
|
||||
|
||||
try:
|
||||
return tool_fn(user, **kwargs)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def serialize_tool_result(result):
|
||||
try:
|
||||
return json.dumps(result)
|
||||
except TypeError:
|
||||
return json.dumps({"error": "Tool returned non-serializable data"})
|
||||
6
backend/server/chat/apps.py
Normal file
6
backend/server/chat/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chat"
|
||||
142
backend/server/chat/llm_client.py
Normal file
142
backend/server/chat/llm_client.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
|
||||
import litellm
|
||||
|
||||
from integrations.models import UserAPIKey
|
||||
|
||||
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 as exc:
|
||||
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
||||
90
backend/server/chat/migrations/0001_initial.py
Normal file
90
backend/server/chat/migrations/0001_initial.py
Normal file
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/server/chat/migrations/__init__.py
Normal file
0
backend/server/chat/migrations/__init__.py
Normal file
47
backend/server/chat/models.py
Normal file
47
backend/server/chat/models.py
Normal file
@@ -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"]
|
||||
25
backend/server/chat/serializers.py
Normal file
25
backend/server/chat/serializers.py
Normal file
@@ -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"]
|
||||
11
backend/server/chat/urls.py
Normal file
11
backend/server/chat/urls.py
Normal file
@@ -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)),
|
||||
]
|
||||
260
backend/server/chat/views.py
Normal file
260
backend/server/chat/views.py
Normal file
@@ -0,0 +1,260 @@
|
||||
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 []):
|
||||
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)
|
||||
|
||||
async def event_stream():
|
||||
current_messages = list(llm_messages)
|
||||
encountered_error = False
|
||||
|
||||
while True:
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<svelte:component this={item.icon} class="w-5 h-5" />
|
||||
{#if item.icon}
|
||||
<svelte:component this={item.icon} class="w-5 h-5" />
|
||||
{:else if item.iconPath}
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={item.iconPath}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
{$t(item.label)}
|
||||
</a>
|
||||
</li>
|
||||
@@ -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}
|
||||
>
|
||||
<svelte:component this={item.icon} class="w-4 h-4" />
|
||||
{#if item.icon}
|
||||
<svelte:component this={item.icon} class="w-4 h-4" />
|
||||
{:else if item.iconPath}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={item.iconPath}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="hidden xl:inline">{$t(item.label)}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
342
frontend/src/routes/chat/+page.svelte
Normal file
342
frontend/src/routes/chat/+page.svelte
Normal file
@@ -0,0 +1,342 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
|
||||
|
||||
type Provider = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
let conversations: Conversation[] = [];
|
||||
let activeConversation: Conversation | null = null;
|
||||
let messages: ChatMessage[] = [];
|
||||
let inputMessage = '';
|
||||
let isStreaming = false;
|
||||
let sidebarOpen = true;
|
||||
let streamingContent = '';
|
||||
|
||||
let selectedProvider = 'openai';
|
||||
const providers: Provider[] = [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'gemini', label: 'Google Gemini' },
|
||||
{ value: 'ollama', label: 'Ollama' },
|
||||
{ value: 'groq', label: 'Groq' },
|
||||
{ value: 'mistral', label: 'Mistral' },
|
||||
{ value: 'github_models', label: 'GitHub Models' },
|
||||
{ value: 'openrouter', label: 'OpenRouter' }
|
||||
];
|
||||
|
||||
onMount(loadConversations);
|
||||
|
||||
async function loadConversations() {
|
||||
const res = await fetch('/api/chat/conversations/');
|
||||
if (res.ok) {
|
||||
conversations = await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function createConversation(): Promise<Conversation | null> {
|
||||
const res = await fetch('/api/chat/conversations/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conv: Conversation = await res.json();
|
||||
conversations = [conv, ...conversations];
|
||||
activeConversation = conv;
|
||||
messages = [];
|
||||
return conv;
|
||||
}
|
||||
|
||||
async function selectConversation(conv: Conversation) {
|
||||
activeConversation = conv;
|
||||
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
messages = data.messages || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(conv: Conversation) {
|
||||
await fetch(`/api/chat/conversations/${conv.id}/`, { method: 'DELETE' });
|
||||
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
|
||||
if (activeConversation?.id === conv.id) {
|
||||
activeConversation = null;
|
||||
messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputMessage.trim() || isStreaming) return;
|
||||
|
||||
let conversation = activeConversation;
|
||||
if (!conversation) {
|
||||
conversation = await createConversation();
|
||||
if (!conversation) return;
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: inputMessage, id: crypto.randomUUID() };
|
||||
messages = [...messages, userMsg];
|
||||
const msgText = inputMessage;
|
||||
inputMessage = '';
|
||||
isStreaming = true;
|
||||
streamingContent = '';
|
||||
|
||||
const assistantMsg: ChatMessage = { role: 'assistant', content: '', id: crypto.randomUUID() };
|
||||
messages = [...messages, assistantMsg];
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/chat/conversations/${conversation.id}/send_message/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: msgText, provider: selectedProvider })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
assistantMsg.content = err.error || $t('chat.connection_error');
|
||||
messages = [...messages];
|
||||
isStreaming = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
if (!reader) {
|
||||
isStreaming = false;
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (parsed.error) {
|
||||
assistantMsg.content = parsed.error;
|
||||
messages = [...messages];
|
||||
break;
|
||||
}
|
||||
|
||||
if (parsed.content) {
|
||||
streamingContent += parsed.content;
|
||||
assistantMsg.content = streamingContent;
|
||||
messages = [...messages];
|
||||
}
|
||||
|
||||
if (parsed.tool_result) {
|
||||
const toolMsg: ChatMessage = {
|
||||
role: 'tool',
|
||||
content: JSON.stringify(parsed.tool_result, null, 2),
|
||||
name: parsed.tool_result.tool || 'tool',
|
||||
id: crypto.randomUUID()
|
||||
};
|
||||
|
||||
const idx = messages.findIndex((m) => m.id === assistantMsg.id);
|
||||
messages = [...messages.slice(0, idx), toolMsg, ...messages.slice(idx)];
|
||||
|
||||
streamingContent = '';
|
||||
assistantMsg.content = '';
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadConversations();
|
||||
} catch {
|
||||
assistantMsg.content = $t('chat.connection_error');
|
||||
messages = [...messages];
|
||||
} finally {
|
||||
isStreaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
let messagesContainer: HTMLElement;
|
||||
$: if (messages && messagesContainer) {
|
||||
setTimeout(() => {
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('chat.title')} | Voyage</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-[calc(100vh-64px)]">
|
||||
<div class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen ? '' : 'hidden'} lg:flex">
|
||||
<div class="p-3 flex items-center justify-between border-b border-base-300">
|
||||
<h2 class="text-lg font-semibold">{$t('chat.conversations')}</h2>
|
||||
<button class="btn btn-sm btn-ghost" on:click={createConversation} title={$t('chat.new_conversation')}>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiPlus}></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each conversations as conv}
|
||||
<div
|
||||
class="w-full p-3 hover:bg-base-300 flex items-center gap-2 {activeConversation?.id === conv.id
|
||||
? 'bg-base-300'
|
||||
: ''}"
|
||||
>
|
||||
<button class="flex-1 text-left truncate text-sm" on:click={() => selectConversation(conv)}>
|
||||
{conv.title || $t('chat.untitled')}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
on:click={() => deleteConversation(conv)}
|
||||
title={$t('chat.delete_conversation')}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiDelete}></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if conversations.length === 0}
|
||||
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-3 border-b border-base-300 flex items-center gap-3">
|
||||
<button class="btn btn-sm btn-ghost lg:hidden" on:click={() => (sidebarOpen = !sidebarOpen)}>
|
||||
{#if sidebarOpen}
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiClose}></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiMenu}></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={mdiRobot}></path>
|
||||
</svg>
|
||||
<h1 class="text-lg font-semibold">{$t('chat.title')}</h1>
|
||||
<div class="ml-auto">
|
||||
<select class="select select-bordered select-sm" bind:value={selectedProvider}>
|
||||
{#each providers as provider}
|
||||
<option value={provider.value}>{provider.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
|
||||
{#if messages.length === 0 && !activeConversation}
|
||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-primary opacity-40 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={mdiRobot}></path>
|
||||
</svg>
|
||||
<h2 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h2>
|
||||
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as msg}
|
||||
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
|
||||
{#if msg.role === 'tool'}
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-base-200 rounded-lg p-3 text-xs">
|
||||
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
|
||||
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
|
||||
</div>
|
||||
</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' &&
|
||||
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="flex gap-2 max-w-4xl mx-auto">
|
||||
<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()}
|
||||
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>
|
||||
Reference in New Issue
Block a user