merge: collections AI chat integration

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

View File

@@ -16,6 +16,8 @@
name?: string;
};
export let embedded = false;
let conversations: Conversation[] = [];
let activeConversation: Conversation | null = null;
let messages: ChatMessage[] = [];
@@ -208,147 +210,164 @@
}
</script>
<svelte:head>
<title>{$t('chat.title')} | Voyage</title>
</svelte:head>
<div class="flex h-[calc(100vh-64px)]">
<div class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen ? '' : 'hidden'} lg:flex">
<div class="p-3 flex items-center justify-between border-b border-base-300">
<h2 class="text-lg font-semibold">{$t('chat.conversations')}</h2>
<button class="btn btn-sm btn-ghost" on:click={createConversation} title={$t('chat.new_conversation')}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiPlus}></path>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto">
{#each conversations as conv}
<div
class="w-full p-3 hover:bg-base-300 flex items-center gap-2 {activeConversation?.id === conv.id
? 'bg-base-300'
: ''}"
>
<button class="flex-1 text-left truncate text-sm" on:click={() => selectConversation(conv)}>
{conv.title || $t('chat.untitled')}
</button>
<div class="card bg-base-200 shadow-xl">
<div class="card-body p-0">
<div class="flex" class:h-[calc(100vh-64px)]={!embedded} class:h-[70vh]={embedded}>
<div
class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen
? ''
: 'hidden'} lg:flex"
>
<div class="p-3 flex items-center justify-between border-b border-base-300">
<h2 class="text-lg font-semibold">{$t('chat.conversations')}</h2>
<button
class="btn btn-xs btn-ghost"
on:click={() => deleteConversation(conv)}
title={$t('chat.delete_conversation')}
class="btn btn-sm btn-ghost"
on:click={createConversation}
title={$t('chat.new_conversation')}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiDelete}></path>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiPlus}></path>
</svg>
</button>
</div>
{/each}
{#if conversations.length === 0}
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
{/if}
</div>
</div>
<div class="flex-1 flex flex-col">
<div class="p-3 border-b border-base-300 flex items-center gap-3">
<button class="btn btn-sm btn-ghost lg:hidden" on:click={() => (sidebarOpen = !sidebarOpen)}>
{#if sidebarOpen}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiClose}></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiMenu}></path>
</svg>
{/if}
</button>
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiRobot}></path>
</svg>
<h1 class="text-lg font-semibold">{$t('chat.title')}</h1>
<div class="ml-auto">
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{provider.label}</option>
<div class="flex-1 overflow-y-auto">
{#each conversations as conv}
<div
class="w-full p-3 hover:bg-base-300 flex items-center gap-2 {activeConversation?.id ===
conv.id
? 'bg-base-300'
: ''}"
>
<button
class="flex-1 text-left truncate text-sm"
on:click={() => selectConversation(conv)}
>
{conv.title || $t('chat.untitled')}
</button>
<button
class="btn btn-xs btn-ghost"
on:click={() => deleteConversation(conv)}
title={$t('chat.delete_conversation')}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiDelete}></path>
</svg>
</button>
</div>
{/each}
</select>
{#if conversations.length === 0}
<p class="p-4 text-sm opacity-60">{$t('chat.no_conversations')}</p>
{/if}
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<div class="flex-1 flex flex-col min-w-0">
<div class="p-3 border-b border-base-300 flex items-center gap-3">
<button
class="btn btn-sm btn-ghost lg:hidden"
on:click={() => (sidebarOpen = !sidebarOpen)}
>
{#if sidebarOpen}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiClose}></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiMenu}></path>
</svg>
{/if}
</button>
<svg
class="w-16 h-16 text-primary opacity-40 mb-4"
class="w-6 h-6 text-primary"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h2 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h2>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs">
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' &&
isStreaming &&
msg.id === messages[messages.length - 1]?.id &&
!msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
{/if}
<h2 class="text-lg font-semibold">{$t('chat.title')}</h2>
<div class="ml-auto">
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
{/each}
{/if}
</div>
</div>
<div class="p-4 border-t border-base-300">
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
<div class="flex-1 overflow-y-auto p-4 space-y-4" bind:this={messagesContainer}>
{#if messages.length === 0 && !activeConversation}
<div class="flex flex-col items-center justify-center h-full text-center">
<svg
class="w-16 h-16 text-primary opacity-40 mb-4"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={mdiRobot}></path>
</svg>
<h3 class="text-2xl font-bold mb-2">{$t('chat.welcome_title')}</h3>
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{#each messages as msg}
<div class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
{#if msg.role === 'tool'}
<div class="max-w-2xl w-full">
<div class="bg-base-200 rounded-lg p-3 text-xs">
<div class="font-semibold mb-1 text-primary">🔧 {msg.name}</div>
<pre class="whitespace-pre-wrap overflow-x-auto">{msg.content}</pre>
</div>
</div>
{:else}
<div class="chat {msg.role === 'user' ? 'chat-end' : 'chat-start'}">
<div
class="chat-bubble {msg.role === 'user'
? 'chat-bubble-primary'
: 'chat-bubble-neutral'}"
>
<div class="whitespace-pre-wrap">{msg.content}</div>
{#if msg.role === 'assistant' && isStreaming && msg.id === messages[messages.length - 1]?.id && !msg.content}
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
{/if}
</button>
</div>
<div class="p-4 border-t border-base-300">
<div class="flex gap-2 max-w-4xl mx-auto">
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
bind:value={inputMessage}
on:keydown={handleKeydown}
rows="1"
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}
>
{#if isStreaming}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiSend}></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -497,7 +497,9 @@
updated[existingIndex] = payload;
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
} else {
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
userApiKeys = [...userApiKeys, payload].sort((a, b) =>
a.provider.localeCompare(b.provider)
);
}
newApiKeyValue = '';
apiKeysConfigError = null;
@@ -1276,14 +1278,14 @@
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/immich_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
</div>
<!-- Google maps integration - displayt only if its connected -->
@@ -1307,14 +1309,14 @@
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !googleMapsEnabled}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/google_maps_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !googleMapsEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
@@ -1371,14 +1373,14 @@
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !stravaGlobalEnabled}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/strava_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
{:else if !stravaGlobalEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
@@ -1486,14 +1488,14 @@
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/wanderer_integration.md"
target="_blank"
rel="noopener noreferrer">{$t('navbar.documentation')}</a
>
</p>
</div>
{/if}
</div>
</div>
@@ -1560,10 +1562,9 @@
>
<a
class="link link-primary"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/guides/travel_agent.md"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/guides/travel_agent.md"
target="_blank"
rel="noopener noreferrer"
>{$t('settings.travel_agent_help_setup_guide')}</a
rel="noopener noreferrer">{$t('settings.travel_agent_help_setup_guide')}</a
>
</p>
</div>
@@ -1572,8 +1573,8 @@
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
Create or fetch your personal token for MCP clients. The same token is reused if one
already exists.
Create or fetch your personal token for MCP clients. The same token is reused if
one already exists.
</p>
<div class="flex flex-wrap gap-3 mb-4">
@@ -1587,11 +1588,7 @@
{/if}
{mcpToken ? 'Refresh token' : 'Get MCP token'}
</button>
<button
class="btn btn-outline"
on:click={copyMcpAuthHeader}
disabled={!mcpToken}
>
<button class="btn btn-outline" on:click={copyMcpAuthHeader} disabled={!mcpToken}>
{$t('settings.copy')}
</button>
</div>
@@ -1616,7 +1613,9 @@
{:else}
<div class="space-y-3">
{#each userApiKeys as apiKey}
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
<div
class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg"
>
<div>
<div class="font-medium">{getApiKeyProviderLabel(apiKey.provider)}</div>
<div class="text-sm text-base-content/70 font-mono">
@@ -1643,20 +1642,20 @@
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
disabled={providerCatalog.length === 0}
>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
disabled={providerCatalog.length === 0}
>
{#each providerCatalog as provider}
<option value={provider.id}>{provider.label}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
@@ -1987,14 +1986,14 @@
</svg>
<div>
<span>{$t('settings.social_auth_desc_2')}</span>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
class="link link-neutral font-medium"
target="_blank"
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
>
</div>
</div>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/social_auth.md"
class="link link-neutral font-medium"
target="_blank"
rel="noopener noreferrer">{$t('settings.documentation_link')}</a
>
</div>
</div>
</div>
<!-- Debug Information -->
@@ -2059,21 +2058,21 @@
Sean Morley. {$t('settings.all_rights_reserved')}
</p>
<div class="flex justify-center gap-3 mt-2">
<a
href="https://github.com/Alex-Wiesner/voyage"
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-sm"
>
GitHub
<a
href="https://github.com/Alex-Wiesner/voyage"
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-sm"
>
GitHub
</a>
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="link link-secondary text-sm"
>
{$t('settings.license')}
<a
href="https://github.com/Alex-Wiesner/voyage/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="link link-secondary text-sm"
>
{$t('settings.license')}
</a>
</div>
</div>