feat: embed AI travel chat in collection recommendations
This commit is contained in:
35
.github/copilot-instructions.md
vendored
35
.github/copilot-instructions.md
vendored
@@ -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/`.
|
**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):
|
**Services** (docker-compose):
|
||||||
- `web` → SvelteKit frontend at `:8015`
|
- `web` → SvelteKit frontend at `:8015`
|
||||||
- `server` → Django (via Gunicorn + Nginx) at `:8016`
|
- `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
|
## 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**:
|
**Backend patterns**:
|
||||||
- DRF `ModelViewSet` subclasses for all CRUD resources; custom actions with `@action`
|
- 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/types.ts` — all TypeScript interfaces (`Location`, `Collection`, `User`, `Visit`, etc.)
|
||||||
- `src/lib/index.ts` — general utility functions
|
- `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/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')`
|
- `src/locales/` — i18n JSON files (uses `svelte-i18n`); wrap all user-visible strings in `$t('key')`
|
||||||
|
|
||||||
**Frontend patterns**:
|
**Frontend patterns**:
|
||||||
@@ -57,19 +59,20 @@ Run these commands in order:
|
|||||||
- Wait 30+ seconds for services to fully initialize before testing functionality
|
- Wait 30+ seconds for services to fully initialize before testing functionality
|
||||||
|
|
||||||
### Development Workflow Commands
|
### Development Workflow Commands
|
||||||
**Frontend (SvelteKit with Node.js):**
|
**Frontend (SvelteKit — prefer Bun):**
|
||||||
- `cd frontend && npm install` - **45+ seconds, NEVER CANCEL. Set timeout to 60+ minutes**
|
- `cd frontend && bun install` - **45+ seconds, NEVER CANCEL. Set timeout to 60+ minutes**
|
||||||
- `cd frontend && npm run build` - **32 seconds, set timeout to 60 seconds**
|
- `cd frontend && bun run build` - **32 seconds, set timeout to 60 seconds**
|
||||||
- `cd frontend && npm run dev` - Start development server (requires backend running)
|
- `cd frontend && bun run dev` - Start development server (requires backend running)
|
||||||
- `cd frontend && npm run format` - **6 seconds** - Fix code formatting (ALWAYS run before committing)
|
- `cd frontend && bun run format` - **6 seconds** - Fix code formatting (ALWAYS run before committing)
|
||||||
- `cd frontend && npm run lint` - **6 seconds** - Check code formatting
|
- `cd frontend && bun run lint` - **6 seconds** - Check code formatting
|
||||||
- `cd frontend && npm run check` - **12 seconds** - Run Svelte type checking (3 errors, 19 warnings expected)
|
- `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
|
- 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 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 help` - View Django commands
|
||||||
- `docker compose exec server python3 manage.py migrate` - Run database migrations
|
- `docker compose exec server python3 manage.py migrate` - Run database migrations
|
||||||
|
- Use `uv` for local Python dependency/tooling commands when applicable
|
||||||
|
|
||||||
**Full Application:**
|
**Full Application:**
|
||||||
- Frontend runs on: http://localhost:8015
|
- Frontend runs on: http://localhost:8015
|
||||||
@@ -87,10 +90,10 @@ Run these commands in order:
|
|||||||
|
|
||||||
### Pre-Commit Validation (ALWAYS run before committing)
|
### Pre-Commit Validation (ALWAYS run before committing)
|
||||||
**ALWAYS run these commands to ensure CI will pass:**
|
**ALWAYS run these commands to ensure CI will pass:**
|
||||||
- `cd frontend && npm run format` - **6 seconds** - Fix formatting issues
|
- `cd frontend && bun run format` - **6 seconds** - Fix formatting issues
|
||||||
- `cd frontend && npm run lint` - **6 seconds** - Verify formatting is correct (should pass after format)
|
- `cd frontend && bun 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 && bun run check` - **12 seconds** - Type checking (some warnings expected)
|
||||||
- `cd frontend && npm run build` - **32 seconds** - Verify build succeeds
|
- `cd frontend && bun run build` - **32 seconds** - Verify build succeeds
|
||||||
|
|
||||||
## Critical Development Notes
|
## Critical Development Notes
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ Run these commands in order:
|
|||||||
### Build Timing (NEVER CANCEL)
|
### Build Timing (NEVER CANCEL)
|
||||||
- **Docker first startup**: 25+ minutes (image downloads)
|
- **Docker first startup**: 25+ minutes (image downloads)
|
||||||
- **Docker subsequent startups**: <1 second (images cached)
|
- **Docker subsequent startups**: <1 second (images cached)
|
||||||
- **Frontend npm install**: 45 seconds
|
- **Frontend bun install**: 45 seconds
|
||||||
- **Frontend build**: 32 seconds
|
- **Frontend build**: 32 seconds
|
||||||
- **Tests and checks**: 6-12 seconds each
|
- **Tests and checks**: 6-12 seconds each
|
||||||
|
|
||||||
@@ -149,7 +152,7 @@ Voyage/
|
|||||||
- **"500: Internal Error"**: Frontend-backend communication issue (expected in dev setup)
|
- **"500: Internal Error"**: Frontend-backend communication issue (expected in dev setup)
|
||||||
- **"Cannot connect to backend"**: Backend not started or wrong URL configuration
|
- **"Cannot connect to backend"**: Backend not started or wrong URL configuration
|
||||||
- **"pip install timeout"**: Network issue, use Docker instead of local Python
|
- **"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
|
## Troubleshooting Commands
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
31
AGENTS.md
31
AGENTS.md
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
## Architecture Overview
|
## 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.
|
- **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**:
|
- **Service ports**:
|
||||||
- `web` → `:8015`
|
- `web` → `:8015`
|
||||||
- `server` → `:8016`
|
- `server` → `:8016`
|
||||||
@@ -17,21 +18,23 @@
|
|||||||
## Codebase Layout
|
## Codebase Layout
|
||||||
- **Backend**: `backend/server/`
|
- **Backend**: `backend/server/`
|
||||||
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
|
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
|
||||||
|
- Chat provider config: `backend/server/chat/llm_client.py` (`CHAT_PROVIDER_CONFIG`)
|
||||||
- **Frontend**: `frontend/src/`
|
- **Frontend**: `frontend/src/`
|
||||||
- Routes: `src/routes/`
|
- Routes: `src/routes/`
|
||||||
- Shared types: `src/lib/types.ts`
|
- Shared types: `src/lib/types.ts` (includes `ChatProviderCatalogEntry`)
|
||||||
- Components: `src/lib/components/`
|
- Components: `src/lib/components/` (includes `AITravelChat.svelte`)
|
||||||
- i18n: `src/locales/`
|
- i18n: `src/locales/`
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Frontend
|
### Frontend (prefer Bun)
|
||||||
- `cd frontend && npm run format`
|
- `cd frontend && bun run format`
|
||||||
- `cd frontend && npm run lint`
|
- `cd frontend && bun run lint`
|
||||||
- `cd frontend && npm run check`
|
- `cd frontend && bun run check`
|
||||||
- `cd frontend && npm run build`
|
- `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 test`
|
||||||
- `docker compose exec server python3 manage.py migrate`
|
- `docker compose exec server python3 manage.py migrate`
|
||||||
|
|
||||||
@@ -41,13 +44,13 @@
|
|||||||
|
|
||||||
## Pre-Commit Checklist
|
## Pre-Commit Checklist
|
||||||
Run in this order:
|
Run in this order:
|
||||||
1. `cd frontend && npm run format`
|
1. `cd frontend && bun run format`
|
||||||
2. `cd frontend && npm run lint`
|
2. `cd frontend && bun run lint`
|
||||||
3. `cd frontend && npm run check`
|
3. `cd frontend && bun run check`
|
||||||
4. `cd frontend && npm run build`
|
4. `cd frontend && bun run build`
|
||||||
|
|
||||||
## Known Issues (Expected)
|
## 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)
|
- Backend tests: **2/3 fail** (expected)
|
||||||
- Docker dev setup has frontend-backend communication issues (500 errors beyond homepage)
|
- 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`
|
- API calls: route through proxy at `/api/[...path]/+server.ts`
|
||||||
- Styling: use DaisyUI semantic colors/classes (`bg-primary`, `text-base-content`, etc.)
|
- Styling: use DaisyUI semantic colors/classes (`bg-primary`, `text-base-content`, etc.)
|
||||||
- Security: handle CSRF tokens via `/auth/csrf/` and `X-CSRFToken`
|
- 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
|
## Conventions
|
||||||
- Do **not** attempt to fix known test/configuration issues as part of feature work.
|
- 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.
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -9,6 +9,7 @@
|
|||||||
- Use the API proxy pattern: never call Django directly from frontend components.
|
- 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`.
|
- 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.
|
- 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:
|
- Service ports:
|
||||||
- `web` → `:8015`
|
- `web` → `:8015`
|
||||||
- `server` → `:8016`
|
- `server` → `:8016`
|
||||||
@@ -21,23 +22,25 @@
|
|||||||
## Codebase Layout
|
## Codebase Layout
|
||||||
- Backend root: `backend/server/`
|
- Backend root: `backend/server/`
|
||||||
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
|
- Apps: `adventures/`, `users/`, `worldtravel/`, `integrations/`, `achievements/`, `chat/`
|
||||||
|
- Chat provider config: `backend/server/chat/llm_client.py` (`CHAT_PROVIDER_CONFIG`)
|
||||||
- Frontend root: `frontend/src/`
|
- Frontend root: `frontend/src/`
|
||||||
- Routes: `src/routes/`
|
- Routes: `src/routes/`
|
||||||
- Shared types: `src/lib/types.ts`
|
- Shared types: `src/lib/types.ts` (includes `ChatProviderCatalogEntry`)
|
||||||
- Components: `src/lib/components/`
|
- Components: `src/lib/components/` (includes `AITravelChat.svelte`)
|
||||||
- Locales: `src/locales/`
|
- Locales: `src/locales/`
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
- Develop Docker-first. Start services with Docker before backend-dependent work.
|
- Develop Docker-first. Start services with Docker before backend-dependent work.
|
||||||
- Use these commands:
|
- Use these commands:
|
||||||
|
|
||||||
### Frontend
|
### Frontend (prefer Bun)
|
||||||
- `cd frontend && npm run format`
|
- `cd frontend && bun run format`
|
||||||
- `cd frontend && npm run lint`
|
- `cd frontend && bun run lint`
|
||||||
- `cd frontend && npm run check`
|
- `cd frontend && bun run check`
|
||||||
- `cd frontend && npm run build`
|
- `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 test`
|
||||||
- `docker compose exec server python3 manage.py migrate`
|
- `docker compose exec server python3 manage.py migrate`
|
||||||
|
|
||||||
@@ -47,15 +50,15 @@
|
|||||||
|
|
||||||
## Pre-Commit Checklist
|
## Pre-Commit Checklist
|
||||||
Run in this exact order:
|
Run in this exact order:
|
||||||
1. `cd frontend && npm run format`
|
1. `cd frontend && bun run format`
|
||||||
2. `cd frontend && npm run lint`
|
2. `cd frontend && bun run lint`
|
||||||
3. `cd frontend && npm run check`
|
3. `cd frontend && bun run check`
|
||||||
4. `cd frontend && npm run build`
|
4. `cd frontend && bun run build`
|
||||||
|
|
||||||
**ALWAYS run format before committing.**
|
**ALWAYS run format before committing.**
|
||||||
|
|
||||||
## Known Issues (Expected)
|
## 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)
|
- Backend tests: **2/3 fail** (expected)
|
||||||
- Docker dev setup has frontend-backend communication issues (500 errors beyond homepage)
|
- 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`
|
- API access: always use proxy route `/api/[...path]/+server.ts`
|
||||||
- Styling: prefer DaisyUI semantic classes (`bg-primary`, `text-base-content`)
|
- Styling: prefer DaisyUI semantic classes (`bg-primary`, `text-base-content`)
|
||||||
- CSRF handling: use `/auth/csrf/` + `X-CSRFToken`
|
- CSRF handling: use `/auth/csrf/` + `X-CSRFToken`
|
||||||
|
- Chat providers: dynamic catalog from `GET /api/chat/providers/`; configured in `CHAT_PROVIDER_CONFIG`
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- Do **not** attempt to fix known test/configuration issues as part of feature work.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- **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)
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -7,15 +7,61 @@ from integrations.models import UserAPIKey
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROVIDER_MODELS = {
|
CHAT_PROVIDER_CONFIG = {
|
||||||
"openai": "gpt-4o",
|
"openai": {
|
||||||
"anthropic": "anthropic/claude-sonnet-4-20250514",
|
"label": "OpenAI",
|
||||||
"gemini": "gemini/gemini-2.0-flash",
|
"needs_api_key": True,
|
||||||
"ollama": "ollama/llama3.1",
|
"default_model": "gpt-4o",
|
||||||
"groq": "groq/llama-3.3-70b-versatile",
|
"api_base": None,
|
||||||
"mistral": "mistral/mistral-large-latest",
|
},
|
||||||
"github_models": "github/gpt-4o",
|
"anthropic": {
|
||||||
"openrouter": "openrouter/auto",
|
"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)
|
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):
|
def get_llm_api_key(user, provider):
|
||||||
"""Get the user's API key for the given provider."""
|
"""Get the user's API key for the given provider."""
|
||||||
normalized_provider = (provider or "").strip().lower()
|
normalized_provider = _normalize_provider_id(provider)
|
||||||
try:
|
try:
|
||||||
key_obj = UserAPIKey.objects.get(user=user, provider=normalized_provider)
|
key_obj = UserAPIKey.objects.get(user=user, provider=normalized_provider)
|
||||||
return key_obj.get_api_key()
|
return key_obj.get_api_key()
|
||||||
@@ -85,26 +203,36 @@ async def stream_chat_completion(user, messages, provider, tools=None):
|
|||||||
|
|
||||||
Yields SSE-formatted strings.
|
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)
|
api_key = get_llm_api_key(user, normalized_provider)
|
||||||
if not api_key:
|
if provider_config["needs_api_key"] and not api_key:
|
||||||
payload = {
|
payload = {
|
||||||
"error": f"No API key found for provider: {normalized_provider}. Please add one in Settings."
|
"error": f"No API key found for provider: {normalized_provider}. Please add one in Settings."
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(payload)}\n\n"
|
yield f"data: {json.dumps(payload)}\n\n"
|
||||||
return
|
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:
|
try:
|
||||||
response = await litellm.acompletion(
|
response = await litellm.acompletion(**completion_kwargs)
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
tools=tools,
|
|
||||||
tool_choice="auto" if tools else None,
|
|
||||||
stream=True,
|
|
||||||
api_key=api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async for chunk in response:
|
async for chunk in response:
|
||||||
choices = _safe_get(chunk, "choices", []) or []
|
choices = _safe_get(chunk, "choices", []) or []
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import ChatViewSet
|
from .views import ChatProviderCatalogViewSet, ChatViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"conversations", ChatViewSet, basename="chat-conversation")
|
router.register(r"conversations", ChatViewSet, basename="chat-conversation")
|
||||||
|
router.register(
|
||||||
|
r"providers", ChatProviderCatalogViewSet, basename="chat-provider-catalog"
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ from rest_framework.permissions import IsAuthenticated
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from .agent_tools import AGENT_TOOLS, execute_tool, serialize_tool_result
|
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 .models import ChatConversation, ChatMessage
|
||||||
from .serializers import ChatConversationSerializer
|
from .serializers import ChatConversationSerializer
|
||||||
|
|
||||||
@@ -106,6 +111,11 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
provider = (request.data.get("provider") or "openai").strip().lower()
|
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(
|
ChatMessage.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -262,3 +272,10 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||||||
response["Cache-Control"] = "no-cache"
|
response["Cache-Control"] = "no-cache"
|
||||||
response["X-Accel-Buffering"] = "no"
|
response["X-Accel-Buffering"] = "no"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ChatProviderCatalogViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def list(self, request):
|
||||||
|
return Response(get_provider_catalog())
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Session-based via `django-allauth`. CSRF tokens from `/auth/csrf/`, passed as `X
|
|||||||
- `worldtravel` — countries, regions, cities, visited tracking
|
- `worldtravel` — countries, regions, cities, visited tracking
|
||||||
- `integrations` — external service integrations
|
- `integrations` — external service integrations
|
||||||
- `achievements` — gamification
|
- `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
|
## Frontend Structure
|
||||||
- `src/routes/` — SvelteKit file-based routing
|
- `src/routes/` — SvelteKit file-based routing
|
||||||
|
|||||||
@@ -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.
|
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
|
## Endpoint
|
||||||
|
|
||||||
- Default path: `api/mcp`
|
- Default path: `api/mcp`
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- **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?
|
## Why Voyage?
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
#### 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.
|
- **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
5628
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
374
frontend/src/lib/components/AITravelChat.svelte
Normal file
374
frontend/src/lib/components/AITravelChat.svelte
Normal 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>
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
export let data: any;
|
export let data: any;
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import { mdiRobotOutline } from '@mdi/js';
|
|
||||||
|
|
||||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
import Calendar from '~icons/mdi/calendar';
|
import Calendar from '~icons/mdi/calendar';
|
||||||
@@ -122,7 +121,6 @@
|
|||||||
const navigationItems: NavigationItem[] = [
|
const navigationItems: NavigationItem[] = [
|
||||||
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
|
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
|
||||||
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
|
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
|
||||||
{ path: '/chat', iconPath: mdiRobotOutline, label: 'navbar.chat' },
|
|
||||||
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
|
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
|
||||||
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
|
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
|
||||||
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
|
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
|
||||||
|
|||||||
@@ -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 = {
|
export type CollectionItineraryDay = {
|
||||||
id: string;
|
id: string;
|
||||||
collection: string; // UUID of the collection
|
collection: string; // UUID of the collection
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte';
|
import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte';
|
||||||
import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte';
|
import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte';
|
||||||
import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte';
|
import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte';
|
||||||
|
import AITravelChat from '$lib/components/AITravelChat.svelte';
|
||||||
import CollectionMap from '$lib/components/collections/CollectionMap.svelte';
|
import CollectionMap from '$lib/components/collections/CollectionMap.svelte';
|
||||||
import CollectionStats from '$lib/components/collections/CollectionStats.svelte';
|
import CollectionStats from '$lib/components/collections/CollectionStats.svelte';
|
||||||
import LocationLink from '$lib/components/LocationLink.svelte';
|
import LocationLink from '$lib/components/LocationLink.svelte';
|
||||||
@@ -1259,7 +1260,10 @@
|
|||||||
|
|
||||||
<!-- Recommendations View -->
|
<!-- Recommendations View -->
|
||||||
{#if currentView === 'recommendations'}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { addToast } from '$lib/toasts';
|
import { addToast } from '$lib/toasts';
|
||||||
import { CURRENCY_LABELS, CURRENCY_OPTIONS } from '$lib/money';
|
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 type { PageData } from './$types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
|
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
|
||||||
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
|
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
|
||||||
let newApiKeyProvider = 'anthropic';
|
let newApiKeyProvider = 'anthropic';
|
||||||
|
let providerCatalog: ChatProviderCatalogEntry[] = [];
|
||||||
let newApiKeyValue = '';
|
let newApiKeyValue = '';
|
||||||
let isSavingApiKey = false;
|
let isSavingApiKey = false;
|
||||||
let deletingApiKeyId: string | null = null;
|
let deletingApiKeyId: string | null = null;
|
||||||
@@ -53,21 +54,26 @@
|
|||||||
let isLoadingMcpToken = false;
|
let isLoadingMcpToken = false;
|
||||||
let activeSection: string = 'profile';
|
let activeSection: string = 'profile';
|
||||||
|
|
||||||
const API_KEY_PROVIDER_OPTIONS = [
|
async function loadProviderCatalog() {
|
||||||
{ value: 'anthropic', labelKey: 'settings.api_key_provider_anthropic' },
|
const res = await fetch('/api/chat/providers/');
|
||||||
{ value: 'openai', labelKey: 'settings.api_key_provider_openai' },
|
if (!res.ok) {
|
||||||
{ value: 'gemini', labelKey: 'settings.api_key_provider_gemini' },
|
return;
|
||||||
{ value: 'ollama', labelKey: 'settings.api_key_provider_ollama' },
|
}
|
||||||
{ value: 'groq', labelKey: 'settings.api_key_provider_groq' },
|
|
||||||
{ value: 'mistral', labelKey: 'settings.api_key_provider_mistral' },
|
providerCatalog = await res.json();
|
||||||
{ value: 'github_models', labelKey: 'settings.api_key_provider_github_models' },
|
if (!providerCatalog.length) {
|
||||||
{ value: 'openrouter', labelKey: 'settings.api_key_provider_openrouter' }
|
return;
|
||||||
];
|
}
|
||||||
|
|
||||||
|
if (!providerCatalog.some((provider) => provider.id === newApiKeyProvider)) {
|
||||||
|
newApiKeyProvider = providerCatalog[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getApiKeyProviderLabel(provider: string): string {
|
function getApiKeyProviderLabel(provider: string): string {
|
||||||
const option = API_KEY_PROVIDER_OPTIONS.find((entry) => entry.value === provider);
|
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
|
||||||
if (option) {
|
if (catalogProvider) {
|
||||||
return $t(option.labelKey);
|
return catalogProvider.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'google_maps') {
|
if (provider === 'google_maps') {
|
||||||
@@ -127,6 +133,8 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
void loadProviderCatalog();
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const queryParams = new URLSearchParams($page.url.search);
|
const queryParams = new URLSearchParams($page.url.search);
|
||||||
const pageParam = queryParams.get('page');
|
const pageParam = queryParams.get('page');
|
||||||
@@ -489,7 +497,9 @@
|
|||||||
updated[existingIndex] = payload;
|
updated[existingIndex] = payload;
|
||||||
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
|
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||||
} else {
|
} else {
|
||||||
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
|
userApiKeys = [...userApiKeys, payload].sort((a, b) =>
|
||||||
|
a.provider.localeCompare(b.provider)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
newApiKeyValue = '';
|
newApiKeyValue = '';
|
||||||
apiKeysConfigError = null;
|
apiKeysConfigError = null;
|
||||||
@@ -1268,14 +1278,14 @@
|
|||||||
<div class="mt-4 p-4 bg-info/10 rounded-lg">
|
<div class="mt-4 p-4 bg-info/10 rounded-lg">
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
📖 {$t('immich.need_help')}
|
📖 {$t('immich.need_help')}
|
||||||
<a
|
<a
|
||||||
class="link link-primary"
|
class="link link-primary"
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Google maps integration - displayt only if its connected -->
|
<!-- Google maps integration - displayt only if its connected -->
|
||||||
@@ -1299,14 +1309,14 @@
|
|||||||
{#if user.is_staff}
|
{#if user.is_staff}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
📖 {$t('immich.need_help')}
|
📖 {$t('immich.need_help')}
|
||||||
<a
|
<a
|
||||||
class="link link-primary"
|
class="link link-primary"
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{:else if !googleMapsEnabled}
|
{:else if !googleMapsEnabled}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
ℹ️ {$t('google_maps.google_maps_integration_desc_no_staff')}
|
ℹ️ {$t('google_maps.google_maps_integration_desc_no_staff')}
|
||||||
</p>
|
</p>
|
||||||
@@ -1363,14 +1373,14 @@
|
|||||||
{#if user.is_staff}
|
{#if user.is_staff}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
📖 {$t('immich.need_help')}
|
📖 {$t('immich.need_help')}
|
||||||
<a
|
<a
|
||||||
class="link link-primary"
|
class="link link-primary"
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{:else if !stravaGlobalEnabled}
|
{:else if !stravaGlobalEnabled}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
ℹ️ {$t('google_maps.google_maps_integration_desc_no_staff')}
|
ℹ️ {$t('google_maps.google_maps_integration_desc_no_staff')}
|
||||||
</p>
|
</p>
|
||||||
@@ -1478,14 +1488,14 @@
|
|||||||
<div class="mt-4 p-4 bg-info/10 rounded-lg">
|
<div class="mt-4 p-4 bg-info/10 rounded-lg">
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
📖 {$t('immich.need_help')}
|
📖 {$t('immich.need_help')}
|
||||||
<a
|
<a
|
||||||
class="link link-primary"
|
class="link link-primary"
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
rel="noopener noreferrer">{$t('navbar.documentation')}</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1552,10 +1562,9 @@
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="link link-primary"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer">{$t('settings.travel_agent_help_setup_guide')}</a
|
||||||
>{$t('settings.travel_agent_help_setup_guide')}</a
|
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1564,8 +1573,8 @@
|
|||||||
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
||||||
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
|
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
|
||||||
<p class="text-sm text-base-content/70 mb-4">
|
<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
|
Create or fetch your personal token for MCP clients. The same token is reused if
|
||||||
already exists.
|
one already exists.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 mb-4">
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
@@ -1579,11 +1588,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{mcpToken ? 'Refresh token' : 'Get MCP token'}
|
{mcpToken ? 'Refresh token' : 'Get MCP token'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="btn btn-outline" on:click={copyMcpAuthHeader} disabled={!mcpToken}>
|
||||||
class="btn btn-outline"
|
|
||||||
on:click={copyMcpAuthHeader}
|
|
||||||
disabled={!mcpToken}
|
|
||||||
>
|
|
||||||
{$t('settings.copy')}
|
{$t('settings.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1608,7 +1613,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each userApiKeys as apiKey}
|
{#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>
|
||||||
<div class="font-medium">{getApiKeyProviderLabel(apiKey.provider)}</div>
|
<div class="font-medium">{getApiKeyProviderLabel(apiKey.provider)}</div>
|
||||||
<div class="text-sm text-base-content/70 font-mono">
|
<div class="text-sm text-base-content/70 font-mono">
|
||||||
@@ -1643,8 +1650,8 @@
|
|||||||
class="select select-bordered select-primary w-full"
|
class="select select-bordered select-primary w-full"
|
||||||
bind:value={newApiKeyProvider}
|
bind:value={newApiKeyProvider}
|
||||||
>
|
>
|
||||||
{#each API_KEY_PROVIDER_OPTIONS as option}
|
{#each providerCatalog as provider}
|
||||||
<option value={option.value}>{$t(option.labelKey)}</option>
|
<option value={provider.id}>{provider.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1974,14 +1981,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<span>{$t('settings.social_auth_desc_2')}</span>
|
<span>{$t('settings.social_auth_desc_2')}</span>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
|
||||||
class="link link-neutral font-medium"
|
class="link link-neutral font-medium"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
|
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug Information -->
|
<!-- Debug Information -->
|
||||||
@@ -2046,21 +2053,21 @@
|
|||||||
Sean Morley. {$t('settings.all_rights_reserved')}
|
Sean Morley. {$t('settings.all_rights_reserved')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center gap-3 mt-2">
|
<div class="flex justify-center gap-3 mt-2">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Alex-Wiesner/voyage"
|
href="https://github.com/Alex-Wiesner/voyage"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="link link-primary text-sm"
|
class="link link-primary text-sm"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
|
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="link link-secondary text-sm"
|
class="link link-secondary text-sm"
|
||||||
>
|
>
|
||||||
{$t('settings.license')}
|
{$t('settings.license')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user