merge: collections AI chat integration

# Conflicts:
#	backend/server/chat/llm_client.py
#	frontend/src/routes/chat/+page.svelte
#	frontend/src/routes/settings/+page.svelte
This commit is contained in:
2026-03-08 21:32:22 +00:00
13 changed files with 302 additions and 252 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

@@ -75,9 +75,10 @@ def _safe_get(obj, key, default=None):
def _normalize_provider_id(provider_id):
value = str(provider_id or "").strip()
if value.startswith("LlmProviders."):
value = value.split(".", 1)[1]
return value.lower()
lowered = value.lower()
if lowered.startswith("llmproviders."):
return lowered.split(".", 1)[1]
return lowered
def _default_provider_label(provider_id):

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.

View File

@@ -16,6 +16,8 @@
name?: string;
};
export let embedded = false;
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
let messages: ChatMessage[] = [];
@@ -208,147 +210,164 @@
}
</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>
<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-xs btn-ghost"
on:click={() => deleteConversation(conv)}
title={$t('chat.delete_conversation')}
class="btn btn-sm btn-ghost"
on:click={createConversation}
title={$t('chat.new_conversation')}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiDelete}></path>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiPlus}></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}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{provider.label}</option>
<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}
</select>
{#if conversations.length === 0}
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
{/if}
</div>
</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">
<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-16 h-16 text-primary opacity-40 mb-4"
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-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}
<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>
{/each}
{/if}
</div>
</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>
<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}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{#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}
</button>
</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>

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

@@ -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

@@ -497,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;
@@ -1276,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 -->
@@ -1307,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>
@@ -1371,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>
@@ -1486,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>
@@ -1560,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>
@@ -1572,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">
@@ -1587,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>
@@ -1616,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,20 +1642,20 @@
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
disabled={providerCatalog.length === 0}
>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
disabled={providerCatalog.length === 0}
>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
@@ -1987,14 +1986,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 -->
@@ -2059,21 +2058,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>