feat: embed AI travel chat in collection recommendations

This commit is contained in:
2026-03-08 21:24:49 +00:00
parent 3526c963a4
commit 64f9fe7382
18 changed files with 6349 additions and 494 deletions

View File

@@ -10,6 +10,8 @@ Voyage is a self-hosted travel companion web application built with SvelteKit fr
**Key architectural pattern — API Proxy**: The frontend never calls the Django backend directly. All API calls go to `src/routes/api/[...path]/+server.ts`, which proxies requests to the Django server (`http://server:8000`), injecting CSRF tokens and managing session cookies. This means frontend fetches use relative URLs like `/api/locations/`.
**AI Chat**: The AI travel chat assistant is embedded in Collections → Recommendations (component: `AITravelChat.svelte`). There is no standalone `/chat` route. Chat providers are loaded dynamically from `GET /api/chat/providers/` (backed by LiteLLM runtime list + custom entries like `opencode_zen`). Chat conversations stream via SSE through `/api/chat/conversations/`. Provider config lives in `backend/server/chat/llm_client.py` (`CHAT_PROVIDER_CONFIG`).
**Services** (docker-compose):
- `web` → SvelteKit frontend at `:8015`
- `server` → Django (via Gunicorn + Nginx) at `:8016`
@@ -20,7 +22,7 @@ Voyage is a self-hosted travel companion web application built with SvelteKit fr
## Codebase Conventions
**Backend layout**: The Django project lives in `backend/server/`. Apps are `adventures` (core: locations, collections, itineraries, notes, transportation), `users`, `worldtravel` (countries/regions), and `integrations`. Views inside `adventures` are split into per-domain files under `adventures/views/` (e.g. `location_view.py`, `collection_view.py`).
**Backend layout**: The Django project lives in `backend/server/`. Apps are `adventures` (core: locations, collections, itineraries, notes, transportation), `users`, `worldtravel` (countries/regions), `integrations`, `achievements`, and `chat` (LLM chat agent with dynamic provider catalog). Views inside `adventures` are split into per-domain files under `adventures/views/` (e.g. `location_view.py`, `collection_view.py`).
**Backend patterns**:
- DRF `ModelViewSet` subclasses for all CRUD resources; custom actions with `@action`
@@ -33,7 +35,7 @@ Voyage is a self-hosted travel companion web application built with SvelteKit fr
- `src/lib/types.ts` — all TypeScript interfaces (`Location`, `Collection`, `User`, `Visit`, etc.)
- `src/lib/index.ts` — general utility functions
- `src/lib/index.server.ts` — server-only utilities (used in `+page.server.ts` and `+server.ts` files)
- `src/lib/components/` — Svelte components organized by domain (`locations/`, `collections/`, `map/`, `cards/`, `shared/`)
- `src/lib/components/` — Svelte components organized by domain (`locations/`, `collections/`, `map/`, `cards/`, `shared/`); includes `AITravelChat.svelte` for Collections chat
- `src/locales/` — i18n JSON files (uses `svelte-i18n`); wrap all user-visible strings in `$t('key')`
**Frontend patterns**:
@@ -57,19 +59,20 @@ Run these commands in order:
- Wait 30+ seconds for services to fully initialize before testing functionality
### Development Workflow Commands
**Frontend (SvelteKit with Node.js):**
- `cd frontend && npm install` - **45+ seconds, NEVER CANCEL. Set timeout to 60+ minutes**
- `cd frontend && npm run build` - **32 seconds, set timeout to 60 seconds**
- `cd frontend && npm run dev` - Start development server (requires backend running)
- `cd frontend && npm run format` - **6 seconds** - Fix code formatting (ALWAYS run before committing)
- `cd frontend && npm run lint` - **6 seconds** - Check code formatting
- `cd frontend && npm run check` - **12 seconds** - Run Svelte type checking (3 errors, 19 warnings expected)
**Frontend (SvelteKit — prefer Bun):**
- `cd frontend && bun install` - **45+ seconds, NEVER CANCEL. Set timeout to 60+ minutes**
- `cd frontend && bun run build` - **32 seconds, set timeout to 60 seconds**
- `cd frontend && bun run dev` - Start development server (requires backend running)
- `cd frontend && bun run format` - **6 seconds** - Fix code formatting (ALWAYS run before committing)
- `cd frontend && bun run lint` - **6 seconds** - Check code formatting
- `cd frontend && bun run check` - **12 seconds** - Run Svelte type checking (3 errors, 19 warnings expected)
**Backend (Django with Python):**
**Backend (Django with Python — prefer uv for local tooling):**
- Backend development requires Docker - local Python pip install fails due to network timeouts
- `docker compose exec server python3 manage.py test` - **7 seconds** - Run tests (2/3 tests fail, this is expected)
- `docker compose exec server python3 manage.py help` - View Django commands
- `docker compose exec server python3 manage.py migrate` - Run database migrations
- Use `uv` for local Python dependency/tooling commands when applicable
**Full Application:**
- Frontend runs on: http://localhost:8015
@@ -87,10 +90,10 @@ Run these commands in order:
### Pre-Commit Validation (ALWAYS run before committing)
**ALWAYS run these commands to ensure CI will pass:**
- `cd frontend && npm run format` - **6 seconds** - Fix formatting issues
- `cd frontend && npm run lint` - **6 seconds** - Verify formatting is correct (should pass after format)
- `cd frontend && npm run check` - **12 seconds** - Type checking (some warnings expected)
- `cd frontend && npm run build` - **32 seconds** - Verify build succeeds
- `cd frontend && bun run format` - **6 seconds** - Fix formatting issues
- `cd frontend && bun run lint` - **6 seconds** - Verify formatting is correct (should pass after format)
- `cd frontend && bun run check` - **12 seconds** - Type checking (some warnings expected)
- `cd frontend && bun run build` - **32 seconds** - Verify build succeeds
## Critical Development Notes
@@ -112,7 +115,7 @@ Run these commands in order:
### Build Timing (NEVER CANCEL)
- **Docker first startup**: 25+ minutes (image downloads)
- **Docker subsequent startups**: <1 second (images cached)
- **Frontend npm install**: 45 seconds
- **Frontend bun install**: 45 seconds
- **Frontend build**: 32 seconds
- **Tests and checks**: 6-12 seconds each
@@ -149,7 +152,7 @@ Voyage/
- **"500: Internal Error"**: Frontend-backend communication issue (expected in dev setup)
- **"Cannot connect to backend"**: Backend not started or wrong URL configuration
- **"pip install timeout"**: Network issue, use Docker instead of local Python
- **"Frontend build fails"**: Run `npm install` first, check Node.js version compatibility
- **"Frontend build fails"**: Run `bun install` first, check Node.js version compatibility
## Troubleshooting Commands
```bash

View File

@@ -7,6 +7,7 @@
## Architecture Overview
- **API proxy pattern**: Frontend never calls Django directly. All API calls go through `frontend/src/routes/api/[...path]/+server.ts`, which proxies to `http://server:8000`, handles cookies, and injects CSRF behavior.
- **AI chat**: Embedded in Collections → Recommendations via `AITravelChat.svelte` component. No standalone `/chat` route. Provider list is dynamic from backend `GET /api/chat/providers/` (sourced from LiteLLM runtime + custom entries like `opencode_zen`). Chat conversations use SSE streaming via `/api/chat/conversations/`.
- **Service ports**:
- `web``:8015`
- `server``:8016`
@@ -17,21 +18,23 @@
## Codebase Layout
- **Backend**: `backend/server/`
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
- Chat provider config: `backend/server/chat/llm_client.py` (`CHAT_PROVIDER_CONFIG`)
- **Frontend**: `frontend/src/`
- Routes: `src/routes/`
- Shared types: `src/lib/types.ts`
- Components: `src/lib/components/`
- Shared types: `src/lib/types.ts` (includes `ChatProviderCatalogEntry`)
- Components: `src/lib/components/` (includes `AITravelChat.svelte`)
- i18n: `src/locales/`
## Development Commands
### Frontend
- `cd frontend && npm run format`
- `cd frontend && npm run lint`
- `cd frontend && npm run check`
- `cd frontend && npm run build`
### Frontend (prefer Bun)
- `cd frontend && bun run format`
- `cd frontend && bun run lint`
- `cd frontend && bun run check`
- `cd frontend && bun run build`
- `cd frontend && bun install`
### Backend
### Backend (Docker required; prefer uv for local Python tooling)
- `docker compose exec server python3 manage.py test`
- `docker compose exec server python3 manage.py migrate`
@@ -41,13 +44,13 @@
## Pre-Commit Checklist
Run in this order:
1. `cd frontend && npm run format`
2. `cd frontend && npm run lint`
3. `cd frontend && npm run check`
4. `cd frontend && npm run build`
1. `cd frontend && bun run format`
2. `cd frontend && bun run lint`
3. `cd frontend && bun run check`
4. `cd frontend && bun run build`
## Known Issues (Expected)
- Frontend `npm run check`: **3 type errors + 19 warnings** expected
- Frontend `bun run check`: **3 type errors + 19 warnings** expected
- Backend tests: **2/3 fail** (expected)
- Docker dev setup has frontend-backend communication issues (500 errors beyond homepage)
@@ -56,6 +59,8 @@ Run in this order:
- API calls: route through proxy at `/api/[...path]/+server.ts`
- Styling: use DaisyUI semantic colors/classes (`bg-primary`, `text-base-content`, etc.)
- Security: handle CSRF tokens via `/auth/csrf/` and `X-CSRFToken`
- Chat providers: dynamic catalog from `GET /api/chat/providers/`; configured in `CHAT_PROVIDER_CONFIG`
## Conventions
- Do **not** attempt to fix known test/configuration issues as part of feature work.
- Use `bun` for frontend commands, `uv` for local Python tooling where applicable.

View File

@@ -9,6 +9,7 @@
- Use the API proxy pattern: never call Django directly from frontend components.
- Route all frontend API calls through `frontend/src/routes/api/[...path]/+server.ts`.
- Proxy target is `http://server:8000`; preserve session cookies and CSRF behavior.
- AI chat is embedded in Collections → Recommendations via `AITravelChat.svelte`. There is no standalone `/chat` route. Chat providers are loaded dynamically from `GET /api/chat/providers/` (backed by LiteLLM runtime providers + custom entries like `opencode_zen`). Chat conversations stream via SSE through `/api/chat/conversations/`.
- Service ports:
- `web``:8015`
- `server``:8016`
@@ -21,23 +22,25 @@
## Codebase Layout
- Backend root: `backend/server/`
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
- Chat provider config: `backend/server/chat/llm_client.py` (`CHAT_PROVIDER_CONFIG`)
- Frontend root: `frontend/src/`
- Routes: `src/routes/`
- Shared types: `src/lib/types.ts`
- Components: `src/lib/components/`
- Shared types: `src/lib/types.ts` (includes `ChatProviderCatalogEntry`)
- Components: `src/lib/components/` (includes `AITravelChat.svelte`)
- Locales: `src/locales/`
## Development Workflow
- Develop Docker-first. Start services with Docker before backend-dependent work.
- Use these commands:
### Frontend
- `cd frontend && npm run format`
- `cd frontend && npm run lint`
- `cd frontend && npm run check`
- `cd frontend && npm run build`
### Frontend (prefer Bun)
- `cd frontend && bun run format`
- `cd frontend && bun run lint`
- `cd frontend && bun run check`
- `cd frontend && bun run build`
- `cd frontend && bun install`
### Backend
### Backend (Docker required; prefer uv for local Python tooling)
- `docker compose exec server python3 manage.py test`
- `docker compose exec server python3 manage.py migrate`
@@ -47,15 +50,15 @@
## Pre-Commit Checklist
Run in this exact order:
1. `cd frontend && npm run format`
2. `cd frontend && npm run lint`
3. `cd frontend && npm run check`
4. `cd frontend && npm run build`
1. `cd frontend && bun run format`
2. `cd frontend && bun run lint`
3. `cd frontend && bun run check`
4. `cd frontend && bun run build`
**ALWAYS run format before committing.**
## Known Issues (Expected)
- Frontend `npm run check`: **3 type errors + 19 warnings** expected
- Frontend `bun run check`: **3 type errors + 19 warnings** expected
- Backend tests: **2/3 fail** (expected)
- Docker dev setup has frontend-backend communication issues (500 errors beyond homepage)
@@ -64,6 +67,8 @@ Run in this exact order:
- API access: always use proxy route `/api/[...path]/+server.ts`
- Styling: prefer DaisyUI semantic classes (`bg-primary`, `text-base-content`)
- CSRF handling: use `/auth/csrf/` + `X-CSRFToken`
- Chat providers: dynamic catalog from `GET /api/chat/providers/`; configured in `CHAT_PROVIDER_CONFIG`
## Conventions
- Do **not** attempt to fix known test/configuration issues as part of feature work.
- Use `bun` for frontend commands, `uv` for local Python tooling where applicable.

View File

@@ -108,6 +108,15 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
### AI Chat (Collections Recommendations)
Voyage includes an AI-powered travel chat assistant embedded in the Collections → Recommendations view. The chat uses LLM providers configured by the user (API keys set in Settings) and supports conversational trip planning within the context of a collection.
- **Provider catalog**: The backend dynamically lists all supported LLM providers via `GET /api/chat/providers/`, sourced from LiteLLM's runtime provider list plus custom entries.
- **Supported providers include**: OpenAI, Anthropic, Google Gemini, Ollama, Groq, Mistral, GitHub Models, OpenRouter, and OpenCode Zen.
- **OpenCode Zen**: An OpenAI-compatible provider (`opencode_zen`) routed through `https://opencode.ai/zen/v1`.
- **Configuration**: Users add API keys for their chosen provider in Settings → API Keys. No server-side environment variables required for chat providers — all keys are per-user.
### Travel Agent (MCP)
Voyage provides an authenticated Travel Agent MCP endpoint for programmatic itinerary workflows (list collections, inspect itinerary details, create items, reorder timelines). See the guide: [`documentation/docs/guides/travel_agent.md`](documentation/docs/guides/travel_agent.md).

View File

@@ -7,15 +7,61 @@ 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",
CHAT_PROVIDER_CONFIG = {
"openai": {
"label": "OpenAI",
"needs_api_key": True,
"default_model": "gpt-4o",
"api_base": None,
},
"anthropic": {
"label": "Anthropic",
"needs_api_key": True,
"default_model": "anthropic/claude-sonnet-4-20250514",
"api_base": None,
},
"gemini": {
"label": "Google Gemini",
"needs_api_key": True,
"default_model": "gemini/gemini-2.0-flash",
"api_base": None,
},
"ollama": {
"label": "Ollama",
"needs_api_key": True,
"default_model": "ollama/llama3.1",
"api_base": None,
},
"groq": {
"label": "Groq",
"needs_api_key": True,
"default_model": "groq/llama-3.3-70b-versatile",
"api_base": None,
},
"mistral": {
"label": "Mistral",
"needs_api_key": True,
"default_model": "mistral/mistral-large-latest",
"api_base": None,
},
"github_models": {
"label": "GitHub Models",
"needs_api_key": True,
"default_model": "github/gpt-4o",
"api_base": None,
},
"openrouter": {
"label": "OpenRouter",
"needs_api_key": True,
"default_model": "openrouter/auto",
"api_base": None,
},
"opencode_zen": {
"label": "OpenCode Zen",
"needs_api_key": True,
"default_model": "openai/gpt-4o-mini",
"api_base": "https://opencode.ai/zen/v1",
},
}
@@ -27,9 +73,81 @@ def _safe_get(obj, key, default=None):
return getattr(obj, key, default)
def _normalize_provider_id(provider_id):
value = str(provider_id or "").strip()
lowered = value.lower()
if lowered.startswith("llmproviders."):
return lowered.split(".", 1)[1]
return lowered
def _default_provider_label(provider_id):
return provider_id.replace("_", " ").title()
def is_chat_provider_available(provider_id):
normalized_provider = _normalize_provider_id(provider_id)
return normalized_provider in CHAT_PROVIDER_CONFIG
def get_provider_catalog():
seen = set()
catalog = []
for provider_id in getattr(litellm, "provider_list", []):
normalized_provider = _normalize_provider_id(provider_id)
if not normalized_provider or normalized_provider in seen:
continue
seen.add(normalized_provider)
provider_config = CHAT_PROVIDER_CONFIG.get(normalized_provider)
if provider_config:
catalog.append(
{
"id": normalized_provider,
"label": provider_config["label"],
"available_for_chat": True,
"needs_api_key": provider_config["needs_api_key"],
"default_model": provider_config["default_model"],
"api_base": provider_config["api_base"],
}
)
continue
catalog.append(
{
"id": normalized_provider,
"label": _default_provider_label(normalized_provider),
"available_for_chat": False,
"needs_api_key": None,
"default_model": None,
"api_base": None,
}
)
for provider_id, provider_config in CHAT_PROVIDER_CONFIG.items():
normalized_provider = _normalize_provider_id(provider_id)
if not normalized_provider or normalized_provider in seen:
continue
seen.add(normalized_provider)
catalog.append(
{
"id": normalized_provider,
"label": provider_config["label"],
"available_for_chat": True,
"needs_api_key": provider_config["needs_api_key"],
"default_model": provider_config["default_model"],
"api_base": provider_config["api_base"],
}
)
return catalog
def get_llm_api_key(user, provider):
"""Get the user's API key for the given provider."""
normalized_provider = (provider or "").strip().lower()
normalized_provider = _normalize_provider_id(provider)
try:
key_obj = UserAPIKey.objects.get(user=user, provider=normalized_provider)
return key_obj.get_api_key()
@@ -85,26 +203,36 @@ async def stream_chat_completion(user, messages, provider, tools=None):
Yields SSE-formatted strings.
"""
normalized_provider = (provider or "").strip().lower()
normalized_provider = _normalize_provider_id(provider)
provider_config = CHAT_PROVIDER_CONFIG.get(normalized_provider)
if not provider_config:
payload = {
"error": f"Provider is not available for chat: {normalized_provider}."
}
yield f"data: {json.dumps(payload)}\n\n"
return
api_key = get_llm_api_key(user, normalized_provider)
if not api_key:
if provider_config["needs_api_key"] and 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")
completion_kwargs = {
"model": provider_config["default_model"],
"messages": messages,
"tools": tools,
"tool_choice": "auto" if tools else None,
"stream": True,
"api_key": api_key,
}
if provider_config["api_base"]:
completion_kwargs["api_base"] = provider_config["api_base"]
try:
response = await litellm.acompletion(
model=model,
messages=messages,
tools=tools,
tool_choice="auto" if tools else None,
stream=True,
api_key=api_key,
)
response = await litellm.acompletion(**completion_kwargs)
async for chunk in response:
choices = _safe_get(chunk, "choices", []) or []

View File

@@ -1,10 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import ChatViewSet
from .views import ChatProviderCatalogViewSet, ChatViewSet
router = DefaultRouter()
router.register(r"conversations", ChatViewSet, basename="chat-conversation")
router.register(
r"providers", ChatProviderCatalogViewSet, basename="chat-provider-catalog"
)
urlpatterns = [
path("", include(router.urls)),

View File

@@ -9,7 +9,12 @@ 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 .llm_client import (
get_provider_catalog,
get_system_prompt,
is_chat_provider_available,
stream_chat_completion,
)
from .models import ChatConversation, ChatMessage
from .serializers import ChatConversationSerializer
@@ -106,6 +111,11 @@ class ChatViewSet(viewsets.ModelViewSet):
)
provider = (request.data.get("provider") or "openai").strip().lower()
if not is_chat_provider_available(provider):
return Response(
{"error": f"Provider is not available for chat: {provider}."},
status=status.HTTP_400_BAD_REQUEST,
)
ChatMessage.objects.create(
conversation=conversation,
@@ -262,3 +272,10 @@ class ChatViewSet(viewsets.ModelViewSet):
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
class ChatProviderCatalogViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
return Response(get_provider_catalog())

View File

@@ -26,7 +26,7 @@ Session-based via `django-allauth`. CSRF tokens from `/auth/csrf/`, passed as `X
- `worldtravel` — countries, regions, cities, visited tracking
- `integrations` — external service integrations
- `achievements` — gamification
- `chat` — LLM chat/agent
- `chat` — LLM chat/agent (AI travel chat in Collections → Recommendations; dynamic provider catalog via LiteLLM; `GET /api/chat/providers/`)
## Frontend Structure
- `src/routes/` — SvelteKit file-based routing

View File

@@ -2,6 +2,8 @@
Voyage includes a **Travel Agent** interface exposed through an **MCP-compatible HTTP endpoint**. This lets external MCP clients read and manage trip itineraries programmatically for authenticated users.
> **Note**: This is the *external* programmatic interface. For the *in-app* AI chat assistant (conversational trip planning), see the AI Travel Chat section in [How to use Voyage](../usage/usage.md#ai-travel-chat).
## Endpoint
- Default path: `api/mcp`

View File

@@ -23,7 +23,8 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
- Locations and itineraries can be shared via a public link or directly with other Voyage users.
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
- **Travel Agent (MCP) access** 🤖: Voyage exposes an authenticated MCP endpoint so external clients can list collections, inspect itineraries, create itinerary items, and reorder trip timelines. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
- **AI Travel Chat** 🤖: An AI-powered chat assistant lives inside Collections → Recommendations, letting you brainstorm destinations and plan trips conversationally. The provider list is dynamic — backed by LiteLLM's full provider catalog — and includes OpenAI, Anthropic, Gemini, Ollama, Groq, Mistral, GitHub Models, OpenRouter, and OpenCode Zen. Configure your preferred provider's API key in Settings.
- **Travel Agent (MCP) access** 🔧: Voyage exposes an authenticated MCP endpoint so external clients can list collections, inspect itineraries, create itinerary items, and reorder trip timelines. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
## Why Voyage?

View File

@@ -22,6 +22,10 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
- **Trail**: a trail is a path or route that is associated with a location. Trails can be used to document hiking paths, biking routes, or any other type of journey that has a specific path. Trails are linked to locations either by link to an external service (e.g., AllTrails) or from the [Wanderer](/docs/configuration/wanderer_integration) integration. When linked via the Wanderer integration, trails can provide additional context and information about the journey such as distance and elevation gain.
- **Activity**: an activity is what you actually do at a location. This can include things like hiking, biking, skiing, kayaking, or any other outdoor activity. Activities are associated with a visit and include fields such as the type of activity, time, distance, and trail taken. They can be manually entered or imported from the [Strava](/docs/configuration/strava_integration) integration. Once an activity is added, it will appear on the location map based on the data from the GPX file.
#### AI Travel Chat
The AI travel chat is embedded in the **Collections → Recommendations** view. Select a collection, switch to the Recommendations tab, and use the chat to brainstorm destinations, ask for travel advice, or get location suggestions. The chat supports multiple LLM providers — configure your API key in **Settings → API Keys** and pick a provider from the dropdown in the chat interface. The provider list is loaded dynamically from the backend, so any provider supported by LiteLLM (plus OpenCode Zen) is available.
#### Collections
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging placement follows directional rules: on check-in day it appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Connector rows link lodging to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and Optimize (nearest-neighbor route ordering for coordinate-backed stops). The day date pill displays a weather temperature summary when available, with graceful fallback if weather data is unavailable. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.

5628
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
import type { ChatProviderCatalogEntry } from '$lib/types.js';
type Conversation = {
id: string;
title?: string;
};
type ChatMessage = {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
name?: string;
};
export let embedded = false;
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
let messages: ChatMessage[] = [];
let inputMessage = '';
let isStreaming = false;
let sidebarOpen = true;
let streamingContent = '';
let selectedProvider = 'openai';
let providerCatalog: ChatProviderCatalogEntry[] = [];
$: chatProviders = providerCatalog.filter((provider) => provider.available_for_chat);
onMount(async () => {
await Promise.all([loadConversations(), loadProviderCatalog()]);
});
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
return;
}
const catalog = (await res.json()) as ChatProviderCatalogEntry[];
providerCatalog = catalog;
const availableProviders = catalog.filter((provider) => provider.available_for_chat);
if (!availableProviders.length) {
return;
}
if (!availableProviders.some((provider) => provider.id === selectedProvider)) {
selectedProvider = availableProviders[0].id;
}
}
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;
if (!chatProviders.some((provider) => provider.id === selectedProvider)) 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>
<div class="card bg-base-200 shadow-xl">
<div class="card-body p-0">
<div class="flex" class:h-[calc(100vh-64px)]={!embedded} class:h-[70vh]={embedded}>
<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 min-w-0">
<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>
<h2 class="text-lg font-semibold">{$t('chat.title')}</h2>
<div class="ml-auto">
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{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>
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{: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() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,6 @@
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';
@@ -122,7 +121,6 @@
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' },

View File

@@ -566,6 +566,15 @@ export type RecommendationResponse = {
};
};
export type ChatProviderCatalogEntry = {
id: string;
label: string;
available_for_chat: boolean;
needs_api_key: boolean | null;
default_model: string | null;
api_base: string | null;
};
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection

View File

@@ -1,342 +0,0 @@
<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>

View File

@@ -19,6 +19,7 @@
import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte';
import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte';
import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte';
import AITravelChat from '$lib/components/AITravelChat.svelte';
import CollectionMap from '$lib/components/collections/CollectionMap.svelte';
import CollectionStats from '$lib/components/collections/CollectionStats.svelte';
import LocationLink from '$lib/components/LocationLink.svelte';
@@ -1259,7 +1260,10 @@
<!-- Recommendations View -->
{#if currentView === 'recommendations'}
<CollectionRecommendationView bind:collection user={data.user} />
<div class="space-y-8">
<AITravelChat embedded={true} />
<CollectionRecommendationView bind:collection user={data.user} />
</div>
{/if}
</div>

View File

@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { addToast } from '$lib/toasts';
import { CURRENCY_LABELS, CURRENCY_OPTIONS } from '$lib/money';
import type { ImmichIntegration, User } from '$lib/types.js';
import type { ChatProviderCatalogEntry, ImmichIntegration, User } from '$lib/types.js';
import type { PageData } from './$types';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
@@ -46,6 +46,7 @@
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'anthropic';
let providerCatalog: ChatProviderCatalogEntry[] = [];
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
@@ -53,21 +54,26 @@
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
const API_KEY_PROVIDER_OPTIONS = [
{ value: 'anthropic', labelKey: 'settings.api_key_provider_anthropic' },
{ value: 'openai', labelKey: 'settings.api_key_provider_openai' },
{ value: 'gemini', labelKey: 'settings.api_key_provider_gemini' },
{ value: 'ollama', labelKey: 'settings.api_key_provider_ollama' },
{ value: 'groq', labelKey: 'settings.api_key_provider_groq' },
{ value: 'mistral', labelKey: 'settings.api_key_provider_mistral' },
{ value: 'github_models', labelKey: 'settings.api_key_provider_github_models' },
{ value: 'openrouter', labelKey: 'settings.api_key_provider_openrouter' }
];
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
return;
}
providerCatalog = await res.json();
if (!providerCatalog.length) {
return;
}
if (!providerCatalog.some((provider) => provider.id === newApiKeyProvider)) {
newApiKeyProvider = providerCatalog[0].id;
}
}
function getApiKeyProviderLabel(provider: string): string {
const option = API_KEY_PROVIDER_OPTIONS.find((entry) => entry.value === provider);
if (option) {
return $t(option.labelKey);
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
if (catalogProvider) {
return catalogProvider.label;
}
if (provider === 'google_maps') {
@@ -127,6 +133,8 @@
];
onMount(async () => {
void loadProviderCatalog();
if (browser) {
const queryParams = new URLSearchParams($page.url.search);
const pageParam = queryParams.get('page');
@@ -489,7 +497,9 @@
updated[existingIndex] = payload;
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
} else {
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
userApiKeys = [...userApiKeys, payload].sort((a, b) =>
a.provider.localeCompare(b.provider)
);
}
newApiKeyValue = '';
apiKeysConfigError = null;
@@ -1268,14 +1278,14 @@
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
</div>
<!-- Google maps integration - displayt only if its connected -->
@@ -1299,14 +1309,14 @@
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !googleMapsEnabled}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !googleMapsEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
@@ -1363,14 +1373,14 @@
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !stravaGlobalEnabled}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !stravaGlobalEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
@@ -1478,14 +1488,14 @@
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
{/if}
</div>
</div>
@@ -1552,10 +1562,9 @@
>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/guides/travel_agent.md"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/guides/travel_agent.md"
target="_blank"
rel="noopener noreferrer"
>{$t('settings.travel_agent_help_setup_guide')}</a
rel="noopener noreferrer">{$t('settings.travel_agent_help_setup_guide')}</a
>
</p>
</div>
@@ -1564,8 +1573,8 @@
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
Create or fetch your personal token for MCP clients. The same token is reused if one
already exists.
Create or fetch your personal token for MCP clients. The same token is reused if
one already exists.
</p>
<div class="flex flex-wrap gap-3 mb-4">
@@ -1579,11 +1588,7 @@
{/if}
{mcpToken ? 'Refresh token' : 'Get MCP token'}
</button>
<button
class="btn btn-outline"
on:click={copyMcpAuthHeader}
disabled={!mcpToken}
>
<button class="btn btn-outline" on:click={copyMcpAuthHeader} disabled={!mcpToken}>
{$t('settings.copy')}
</button>
</div>
@@ -1608,7 +1613,9 @@
{:else}
<div class="space-y-3">
{#each userApiKeys as apiKey}
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
<div
class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg"
>
<div>
<div class="font-medium">{getApiKeyProviderLabel(apiKey.provider)}</div>
<div class="text-sm text-base-content/70 font-mono">
@@ -1643,8 +1650,8 @@
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
>
{#each API_KEY_PROVIDER_OPTIONS as option}
<option value={option.value}>{$t(option.labelKey)}</option>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
@@ -1974,14 +1981,14 @@
</svg>
<div>
<span>{$t('settings.social_auth_desc_2')}</span>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
class="link link-neutral font-medium"
target="_blank"
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
>
</div>
</div>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
class="link link-neutral font-medium"
target="_blank"
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
>
</div>
</div>
</div>
<!-- Debug Information -->
@@ -2046,21 +2053,21 @@
Sean Morley. {$t('settings.all_rights_reserved')}
</p>
<div class="flex justify-center gap-3 mt-2">
<a
href="https://github.com/Alex-Wiesner/voyage"
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-sm"
>
GitHub
<a
href="https://github.com/Alex-Wiesner/voyage"
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-sm"
>
GitHub
</a>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="link link-secondary text-sm"
>
{$t('settings.license')}
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="link link-secondary text-sm"
>
{$t('settings.license')}
</a>
</div>
</div>