feat: embed AI travel chat in collection recommendations

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

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
import type { ChatProviderCatalogEntry } from '$lib/types.js';
type Conversation = {
id: string;
title?: string;
};
type ChatMessage = {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
name?: string;
};
export let embedded = false;
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
let messages: ChatMessage[] = [];
let inputMessage = '';
let isStreaming = false;
let sidebarOpen = true;
let streamingContent = '';
let selectedProvider = 'openai';
let providerCatalog: ChatProviderCatalogEntry[] = [];
$: chatProviders = providerCatalog.filter((provider) => provider.available_for_chat);
onMount(async () => {
await Promise.all([loadConversations(), loadProviderCatalog()]);
});
async function loadProviderCatalog() {
const res = await fetch('/api/chat/providers/');
if (!res.ok) {
return;
}
const catalog = (await res.json()) as ChatProviderCatalogEntry[];
providerCatalog = catalog;
const availableProviders = catalog.filter((provider) => provider.available_for_chat);
if (!availableProviders.length) {
return;
}
if (!availableProviders.some((provider) => provider.id === selectedProvider)) {
selectedProvider = availableProviders[0].id;
}
}
async function loadConversations() {
const res = await fetch('/api/chat/conversations/');
if (res.ok) {
conversations = await res.json();
}
}
async function createConversation(): Promise<Conversation | null> {
const res = await fetch('/api/chat/conversations/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!res.ok) {
return null;
}
const conv: Conversation = await res.json();
conversations = [conv, ...conversations];
activeConversation = conv;
messages = [];
return conv;
}
async function selectConversation(conv: Conversation) {
activeConversation = conv;
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
if (res.ok) {
const data = await res.json();
messages = data.messages || [];
}
}
async function deleteConversation(conv: Conversation) {
await fetch(`/api/chat/conversations/${conv.id}/`, { method: 'DELETE' });
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
if (activeConversation?.id === conv.id) {
activeConversation = null;
messages = [];
}
}
async function sendMessage() {
if (!inputMessage.trim() || isStreaming) return;
if (!chatProviders.some((provider) => provider.id === selectedProvider)) return;
let conversation = activeConversation;
if (!conversation) {
conversation = await createConversation();
if (!conversation) return;
}
const userMsg: ChatMessage = { role: 'user', content: inputMessage, id: crypto.randomUUID() };
messages = [...messages, userMsg];
const msgText = inputMessage;
inputMessage = '';
isStreaming = true;
streamingContent = '';
const assistantMsg: ChatMessage = { role: 'assistant', content: '', id: crypto.randomUUID() };
messages = [...messages, assistantMsg];
try {
const res = await fetch(`/api/chat/conversations/${conversation.id}/send_message/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msgText, provider: selectedProvider })
});
if (!res.ok) {
const err = await res.json();
assistantMsg.content = err.error || $t('chat.connection_error');
messages = [...messages];
isStreaming = false;
return;
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
if (!reader) {
isStreaming = false;
return;
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data || data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
assistantMsg.content = parsed.error;
messages = [...messages];
break;
}
if (parsed.content) {
streamingContent += parsed.content;
assistantMsg.content = streamingContent;
messages = [...messages];
}
if (parsed.tool_result) {
const toolMsg: ChatMessage = {
role: 'tool',
content: JSON.stringify(parsed.tool_result, null, 2),
name: parsed.tool_result.tool || 'tool',
id: crypto.randomUUID()
};
const idx = messages.findIndex((m) => m.id === assistantMsg.id);
messages = [...messages.slice(0, idx), toolMsg, ...messages.slice(idx)];
streamingContent = '';
assistantMsg.content = '';
}
} catch {
// ignore malformed chunks
}
}
}
loadConversations();
} catch {
assistantMsg.content = $t('chat.connection_error');
messages = [...messages];
} finally {
isStreaming = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
let messagesContainer: HTMLElement;
$: if (messages && messagesContainer) {
setTimeout(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
}, 50);
}
</script>
<div class="card bg-base-200 shadow-xl">
<div class="card-body p-0">
<div class="flex" class:h-[calc(100vh-64px)]={!embedded} class:h-[70vh]={embedded}>
<div
class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen
? ''
: 'hidden'} lg:flex"
>
<div class="p-3 flex items-center justify-between border-b border-base-300">
<h2 class="text-lg font-semibold">{$t('chat.conversations')}</h2>
<button
class="btn btn-sm btn-ghost"
on:click={createConversation}
title={$t('chat.new_conversation')}
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiPlus}></path>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
{#each conversations as conv}
<div
class="w-full p-3 hover:bg-base-300 flex items-center gap-2 {activeConversation?.id ===
conv.id
? 'bg-base-300'
: ''}"
>
<button
class="flex-1 text-left truncate text-sm"
on:click={() => selectConversation(conv)}
>
{conv.title || $t('chat.untitled')}
</button>
<button
class="btn btn-xs btn-ghost"
on:click={() => deleteConversation(conv)}
title={$t('chat.delete_conversation')}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiDelete}></path>
</svg>
</button>
</div>
{/each}
{#if conversations.length === 0}
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
{/if}
</div>
</div>
<div class="flex-1 flex flex-col min-w-0">
<div class="p-3 border-b border-base-300 flex items-center gap-3">
<button
class="btn btn-sm btn-ghost lg:hidden"
on:click={() => (sidebarOpen = !sidebarOpen)}
>
{#if sidebarOpen}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiClose}></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiMenu}></path>
</svg>
{/if}
</button>
<svg
class="w-6 h-6 text-primary"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h2 class="text-lg font-semibold">{$t('chat.title')}</h2>
<div class="ml-auto">
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<svg
class="w-16 h-16 text-primary opacity-40 mb-4"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs">
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' && isStreaming && msg.id === messages[messages.length - 1]?.id && !msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
<div class="p-4 border-t border-base-300">
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,6 @@
import { goto } from '$app/navigation';
export let data: any;
import type { SubmitFunction } from '@sveltejs/kit';
import { mdiRobotOutline } from '@mdi/js';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import Calendar from '~icons/mdi/calendar';
@@ -122,7 +121,6 @@
const navigationItems: NavigationItem[] = [
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
{ path: '/chat', iconPath: mdiRobotOutline, label: 'navbar.chat' },
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
{ path: '/map', icon: MapIcon, label: 'navbar.map' },

View File

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

View File

@@ -1,342 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiRobot, mdiSend, mdiPlus, mdiDelete, mdiMenu, mdiClose } from '@mdi/js';
type Provider = {
value: string;
label: string;
};
type Conversation = {
id: string;
title?: string;
};
type ChatMessage = {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
name?: string;
};
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
let messages: ChatMessage[] = [];
let inputMessage = '';
let isStreaming = false;
let sidebarOpen = true;
let streamingContent = '';
let selectedProvider = 'openai';
const providers: Provider[] = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Google Gemini' },
{ value: 'ollama', label: 'Ollama' },
{ value: 'groq', label: 'Groq' },
{ value: 'mistral', label: 'Mistral' },
{ value: 'github_models', label: 'GitHub Models' },
{ value: 'openrouter', label: 'OpenRouter' }
];
onMount(loadConversations);
async function loadConversations() {
const res = await fetch('/api/chat/conversations/');
if (res.ok) {
conversations = await res.json();
}
}
async function createConversation(): Promise<Conversation | null> {
const res = await fetch('/api/chat/conversations/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!res.ok) {
return null;
}
const conv: Conversation = await res.json();
conversations = [conv, ...conversations];
activeConversation = conv;
messages = [];
return conv;
}
async function selectConversation(conv: Conversation) {
activeConversation = conv;
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
if (res.ok) {
const data = await res.json();
messages = data.messages || [];
}
}
async function deleteConversation(conv: Conversation) {
await fetch(`/api/chat/conversations/${conv.id}/`, { method: 'DELETE' });
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
if (activeConversation?.id === conv.id) {
activeConversation = null;
messages = [];
}
}
async function sendMessage() {
if (!inputMessage.trim() || isStreaming) return;
let conversation = activeConversation;
if (!conversation) {
conversation = await createConversation();
if (!conversation) return;
}
const userMsg: ChatMessage = { role: 'user', content: inputMessage, id: crypto.randomUUID() };
messages = [...messages, userMsg];
const msgText = inputMessage;
inputMessage = '';
isStreaming = true;
streamingContent = '';
const assistantMsg: ChatMessage = { role: 'assistant', content: '', id: crypto.randomUUID() };
messages = [...messages, assistantMsg];
try {
const res = await fetch(`/api/chat/conversations/${conversation.id}/send_message/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msgText, provider: selectedProvider })
});
if (!res.ok) {
const err = await res.json();
assistantMsg.content = err.error || $t('chat.connection_error');
messages = [...messages];
isStreaming = false;
return;
}
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
if (!reader) {
isStreaming = false;
return;
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data || data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
assistantMsg.content = parsed.error;
messages = [...messages];
break;
}
if (parsed.content) {
streamingContent += parsed.content;
assistantMsg.content = streamingContent;
messages = [...messages];
}
if (parsed.tool_result) {
const toolMsg: ChatMessage = {
role: 'tool',
content: JSON.stringify(parsed.tool_result, null, 2),
name: parsed.tool_result.tool || 'tool',
id: crypto.randomUUID()
};
const idx = messages.findIndex((m) => m.id === assistantMsg.id);
messages = [...messages.slice(0, idx), toolMsg, ...messages.slice(idx)];
streamingContent = '';
assistantMsg.content = '';
}
} catch {
// ignore malformed chunks
}
}
}
loadConversations();
} catch {
assistantMsg.content = $t('chat.connection_error');
messages = [...messages];
} finally {
isStreaming = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
let messagesContainer: HTMLElement;
$: if (messages && messagesContainer) {
setTimeout(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
}, 50);
}
</script>
<svelte:head>
<title>{$t('chat.title')} | Voyage</title>
</svelte:head>
<div class="flex h-[calc(100vh-64px)]">
<div class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen ? '' : 'hidden'} lg:flex">
<div class="p-3 flex items-center justify-between border-b border-base-300">
<h2 class="text-lg font-semibold">{$t('chat.conversations')}</h2>
<button class="btn btn-sm btn-ghost" on:click={createConversation} title={$t('chat.new_conversation')}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiPlus}></path>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
{#each conversations as conv}
<div
class="w-full p-3 hover:bg-base-300 flex items-center gap-2 {activeConversation?.id === conv.id
? 'bg-base-300'
: ''}"
>
<button class="flex-1 text-left truncate text-sm" on:click={() => selectConversation(conv)}>
{conv.title || $t('chat.untitled')}
</button>
<button
class="btn btn-xs btn-ghost"
on:click={() => deleteConversation(conv)}
title={$t('chat.delete_conversation')}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiDelete}></path>
</svg>
</button>
</div>
{/each}
{#if conversations.length === 0}
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
{/if}
</div>
</div>
<div class="flex-1 flex flex-col">
<div class="p-3 border-b border-base-300 flex items-center gap-3">
<button class="btn btn-sm btn-ghost lg:hidden" on:click={() => (sidebarOpen = !sidebarOpen)}>
{#if sidebarOpen}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiClose}></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiMenu}></path>
</svg>
{/if}
</button>
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiRobot}></path>
</svg>
<h1 class="text-lg font-semibold">{$t('chat.title')}</h1>
<div class="ml-auto">
<select class="select select-bordered select-sm" bind:value={selectedProvider}>
{#each providers as provider}
<option value={provider.value}>{provider.label}</option>
{/each}
</select>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<svg
class="w-16 h-16 text-primary opacity-40 mb-4"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h2 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h2>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs">
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' &&
isStreaming &&
msg.id === messages[messages.length - 1]?.id &&
!msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
<div class="p-4 border-t border-base-300">
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim()}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</div>

View File

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

View File

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