Merge branch 'feat/itinerary-collab-chat'

This commit is contained in:
2026-03-10 20:20:36 +00:00
5 changed files with 545 additions and 415 deletions

View File

@@ -10,6 +10,7 @@
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lucide-svelte": "^0.577.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^15.0.12", "marked": "^15.0.12",
"psl": "^1.15.0", "psl": "^1.15.0",
@@ -568,6 +569,8 @@
"lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], "lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="],
"lucide-svelte": ["lucide-svelte@0.577.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],

View File

@@ -41,11 +41,12 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47",
"@lukulent/svelte-umami": "^0.0.4", "@lukulent/svelte-umami": "^0.0.4",
"@mdi/js": "^7.4.47",
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lucide-svelte": "^0.577.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^15.0.12", "marked": "^15.0.12",
"psl": "^1.15.0", "psl": "^1.15.0",

View File

@@ -50,6 +50,7 @@
}; };
export let embedded = false; export let embedded = false;
export let panelMode = false;
export let collectionId: string | undefined = undefined; export let collectionId: string | undefined = undefined;
export let collectionName: string | undefined = undefined; export let collectionName: string | undefined = undefined;
export let startDate: string | undefined = undefined; export let startDate: string | undefined = undefined;
@@ -87,6 +88,10 @@
}>(); }>();
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs'; const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
const ACTIVE_CONV_FALLBACK_KEY = 'voyage_active_conversation';
$: activeConvKey = collectionId
? `voyage_active_conversation_${collectionId}`
: ACTIVE_CONV_FALLBACK_KEY;
$: promptTripContext = collectionName || destination || ''; $: promptTripContext = collectionName || destination || '';
onMount(() => { onMount(() => {
@@ -125,9 +130,42 @@
async function initializeChat(): Promise<void> { async function initializeChat(): Promise<void> {
await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]); await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
await restoreActiveConversation();
await applyInitialDefaults(); await applyInitialDefaults();
} }
function persistConversation(convId: string | null) {
if (typeof window === 'undefined') {
return;
}
try {
if (convId) {
window.localStorage.setItem(activeConvKey, convId);
} else {
window.localStorage.removeItem(activeConvKey);
}
} catch {
// ignore localStorage persistence failures
}
}
async function restoreActiveConversation() {
if (typeof window === 'undefined' || conversations.length === 0) {
return;
}
const savedId = window.localStorage.getItem(activeConvKey);
if (!savedId) {
return;
}
const savedConversation = conversations.find((conversation) => conversation.id === savedId);
if (savedConversation) {
await selectConversation(savedConversation);
}
}
async function loadUserAISettings(): Promise<void> { async function loadUserAISettings(): Promise<void> {
try { try {
const res = await fetch('/api/integrations/ai-settings/', { const res = await fetch('/api/integrations/ai-settings/', {
@@ -300,12 +338,14 @@
const conv: Conversation = await res.json(); const conv: Conversation = await res.json();
conversations = [conv, ...conversations]; conversations = [conv, ...conversations];
activeConversation = conv; activeConversation = conv;
persistConversation(conv.id);
messages = []; messages = [];
return conv; return conv;
} }
async function selectConversation(conv: Conversation) { async function selectConversation(conv: Conversation) {
activeConversation = conv; activeConversation = conv;
persistConversation(conv.id);
const res = await fetch(`/api/chat/conversations/${conv.id}/`); const res = await fetch(`/api/chat/conversations/${conv.id}/`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -440,6 +480,7 @@
conversations = conversations.filter((conversation) => conversation.id !== conv.id); conversations = conversations.filter((conversation) => conversation.id !== conv.id);
if (activeConversation?.id === conv.id) { if (activeConversation?.id === conv.id) {
activeConversation = null; activeConversation = null;
persistConversation(null);
messages = []; messages = [];
} }
} }
@@ -782,9 +823,10 @@
<div <div
class="flex" class="flex"
class:h-[calc(100vh-64px)]={!embedded} class:h-[calc(100vh-64px)]={!embedded}
class:h-[65vh]={embedded} class:h-full={panelMode}
class:min-h-[30rem]={embedded} class:h-[65vh]={embedded && !panelMode}
class:max-h-[46rem]={embedded} class:min-h-[30rem]={embedded && !panelMode}
class:max-h-[46rem]={embedded && !panelMode}
> >
<div <div
id="chat-conversations-sidebar" id="chat-conversations-sidebar"

View File

@@ -34,6 +34,7 @@
}, },
"chat": { "chat": {
"title": "Travel Agent", "title": "Travel Agent",
"travel_assistant": "Travel Assistant",
"conversations": "Conversations", "conversations": "Conversations",
"new_conversation": "New Conversation", "new_conversation": "New Conversation",
"untitled": "Untitled Conversation", "untitled": "Untitled Conversation",

View File

@@ -30,6 +30,7 @@
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';
import { MessageCircle, X } from 'lucide-svelte';
import { getBasemapUrl } from '$lib'; import { getBasemapUrl } from '$lib';
import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money'; import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money';
import FolderMultiple from '~icons/mdi/folder-multiple'; import FolderMultiple from '~icons/mdi/folder-multiple';
@@ -207,6 +208,8 @@
// View state from URL params // View state from URL params
type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats'; type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats';
let currentView: ViewType = 'itinerary'; let currentView: ViewType = 'itinerary';
let chatPanelOpen = false;
let innerWidth = 1024;
// Determine if this is a folder view (no dates) or itinerary view (has dates) // Determine if this is a folder view (no dates) or itinerary view (has dates)
$: isFolderView = !collection?.start_date && !collection?.end_date; $: isFolderView = !collection?.start_date && !collection?.end_date;
@@ -290,6 +293,10 @@
// Enforce recommendations visibility only for owner/shared users // Enforce recommendations visibility only for owner/shared users
$: availableViews.recommendations = !!canModifyCollection; $: availableViews.recommendations = !!canModifyCollection;
$: if (!canModifyCollection && chatPanelOpen) {
chatPanelOpen = false;
}
function deriveCollectionDestination(current: Collection | null): string | undefined { function deriveCollectionDestination(current: Collection | null): string | undefined {
if (!current?.locations?.length) { if (!current?.locations?.length) {
return undefined; return undefined;
@@ -763,6 +770,12 @@
isImageModalOpen = true; isImageModalOpen = true;
} }
function handleImageKeydown(event: KeyboardEvent, imageIndex: number) {
if (event.key === 'Enter') {
openImageModal(imageIndex);
}
}
function formatDate(dateString: string | null) { function formatDate(dateString: string | null) {
if (!dateString) return ''; if (!dateString) return '';
return DateTime.fromISO(dateString).toLocaleString(DateTime.DATE_MED, { locale: 'en-GB' }); return DateTime.fromISO(dateString).toLocaleString(DateTime.DATE_MED, { locale: 'en-GB' });
@@ -1028,6 +1041,8 @@
onClose={closeCalendarModal} onClose={closeCalendarModal}
/> />
<svelte:window bind:innerWidth />
{#if !collection && !notFound} {#if !collection && !notFound}
<div class="hero min-h-screen overflow-x-hidden"> <div class="hero min-h-screen overflow-x-hidden">
<div class="hero-content"> <div class="hero-content">
@@ -1168,6 +1183,7 @@
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl"> <div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
<!-- View Switcher --> <!-- View Switcher -->
<div class="flex justify-center mb-6"> <div class="flex justify-center mb-6">
<div class="flex items-center">
<div class="join"> <div class="join">
{#if availableViews.all} {#if availableViews.all}
<button <button
@@ -1230,11 +1246,35 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if canModifyCollection}
<button
class="btn btn-primary btn-sm gap-2 ml-4"
class:btn-outline={!chatPanelOpen}
on:click={() => (chatPanelOpen = !chatPanelOpen)}
title={$t('chat.travel_assistant')}
>
<MessageCircle class="w-4 h-4" />
<span class="hidden sm:inline">{$t('chat.travel_assistant')}</span>
</button>
{/if}
</div>
</div> </div>
<div
class="drawer drawer-end"
class:drawer-open={canModifyCollection && chatPanelOpen && innerWidth >= 1024}
>
<input
id="collection-chat-drawer"
type="checkbox"
class="drawer-toggle"
bind:checked={chatPanelOpen}
/>
<div class="drawer-content">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10"> <div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10">
<!-- Left Column - Main Content --> <!-- Left Column - Main Content -->
<div class="lg:col-span-3 space-y-8 sm:space-y-10"> <div class="{chatPanelOpen ? 'lg:col-span-4' : 'lg:col-span-3'} space-y-8 sm:space-y-10">
<!-- Description Card (always visible) --> <!-- Description Card (always visible) -->
{#if collection.description} {#if collection.description}
<div class="card bg-base-200 shadow-xl"> <div class="card bg-base-200 shadow-xl">
@@ -1341,21 +1381,13 @@
<!-- Recommendations View --> <!-- Recommendations View -->
{#if currentView === 'recommendations'} {#if currentView === 'recommendations'}
<div class="space-y-8"> <div class="space-y-8">
<AITravelChat
embedded={true}
collectionId={collection.id}
collectionName={collection.name}
startDate={collection.start_date || undefined}
endDate={collection.end_date || undefined}
destination={collectionDestination}
on:itemAdded={handleAssistantItemAdded}
/>
<CollectionRecommendationView bind:collection user={data.user} /> <CollectionRecommendationView bind:collection user={data.user} />
</div> </div>
{/if} {/if}
</div> </div>
<!-- Right Column - Sidebar --> <!-- Right Column - Sidebar -->
{#if !chatPanelOpen || innerWidth < 1024}
<div class="lg:col-span-1 space-y-4 sm:space-y-6"> <div class="lg:col-span-1 space-y-4 sm:space-y-6">
<!-- Progress Tracker (only for folder views) --> <!-- Progress Tracker (only for folder views) -->
{#if isFolderView && collection.locations && collection.locations.length > 0} {#if isFolderView && collection.locations && collection.locations.length > 0}
@@ -1381,7 +1413,9 @@
</div> </div>
</div> </div>
{#if progressPercent < 20 && progressPercent > 0} {#if progressPercent < 20 && progressPercent > 0}
<div class="text-center text-xs opacity-70">{Math.round(progressPercent)}%</div> <div class="text-center text-xs opacity-70">
{Math.round(progressPercent)}%
</div>
{/if} {/if}
<div class="grid grid-cols-2 gap-2 pt-2"> <div class="grid grid-cols-2 gap-2 pt-2">
<div class="stat bg-base-300 rounded-lg p-3"> <div class="stat bg-base-300 rounded-lg p-3">
@@ -1390,7 +1424,9 @@
</div> </div>
<div class="stat bg-base-300 rounded-lg p-3"> <div class="stat bg-base-300 rounded-lg p-3">
<div class="stat-title text-xs">{$t('adventures.planned')}</div> <div class="stat-title text-xs">{$t('adventures.planned')}</div>
<div class="stat-value text-lg text-warning">{totalCount - visitedCount}</div> <div class="stat-value text-lg text-warning">
{totalCount - visitedCount}
</div>
</div> </div>
</div> </div>
{#if visitedCount === totalCount && totalCount > 0} {#if visitedCount === totalCount && totalCount > 0}
@@ -1453,7 +1489,10 @@
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden" class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
> >
{#if person.profile_pic} {#if person.profile_pic}
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} /> <img
src={person.profile_pic}
alt={collaboratorDisplayName(person)}
/>
{:else} {:else}
<span <span
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10" class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
@@ -1464,12 +1503,18 @@
</div> </div>
</a> </a>
{:else} {:else}
<div class="avatar tooltip" data-tip={collaboratorDisplayName(person)}> <div
class="avatar tooltip"
data-tip={collaboratorDisplayName(person)}
>
<div <div
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden" class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
> >
{#if person.profile_pic} {#if person.profile_pic}
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} /> <img
src={person.profile_pic}
alt={collaboratorDisplayName(person)}
/>
{:else} {:else}
<span <span
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10" class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
@@ -1505,7 +1550,9 @@
{#if currencyCount > 0} {#if currencyCount > 0}
<span class="badge badge-primary badge-sm"> <span class="badge badge-primary badge-sm">
{currencyCount} {currencyCount}
{currencyCount === 1 ? $t('collections.currency') : $t('collections.currencies')} {currencyCount === 1
? $t('collections.currency')
: $t('collections.currencies')}
</span> </span>
{/if} {/if}
</div> </div>
@@ -1591,13 +1638,15 @@
class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105" class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105"
style="background-image: url({image.image})" style="background-image: url({image.image})"
on:click={() => openImageModal(index)} on:click={() => openImageModal(index)}
on:keydown={(e) => e.key === 'Enter' && openImageModal(index)} on:keydown={(event) => handleImageKeydown(event, index)}
role="button" role="button"
tabindex="0" tabindex="0"
></div> ></div>
{#if image.is_primary} {#if image.is_primary}
<div class="absolute top-1 right-1"> <div class="absolute top-1 right-1">
<span class="badge badge-primary badge-xs">{$t('settings.primary')}</span> <span class="badge badge-primary badge-xs"
>{$t('settings.primary')}</span
>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1605,20 +1654,54 @@
</div> </div>
{#if heroImages.length > 12} {#if heroImages.length > 12}
<div class="text-center mt-2 text-sm opacity-70"> <div class="text-center mt-2 text-sm opacity-70">
+{heroImages.length - 12} more {heroImages.length - 12 === 1 ? 'image' : 'images'} +{heroImages.length - 12} more {heroImages.length - 12 === 1
? 'image'
: 'images'}
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{/if}
</div>
</div>
{#if canModifyCollection}
<div class="drawer-side z-40">
<label for="collection-chat-drawer" class="drawer-overlay"></label>
<div class="bg-base-100 h-full w-full sm:w-96 border-l border-base-300 flex flex-col">
<div class="flex items-center justify-between p-3 border-b border-base-300">
<h3 class="font-semibold text-sm">{$t('chat.travel_assistant')}</h3>
<button
class="btn btn-ghost btn-xs btn-circle"
on:click={() => (chatPanelOpen = false)}
>
<X class="w-4 h-4" />
</button>
</div>
<div class="flex-1 overflow-hidden">
<AITravelChat
embedded={true}
panelMode={true}
collectionId={collection.id}
collectionName={collection.name}
startDate={collection.start_date || undefined}
endDate={collection.end_date || undefined}
destination={collectionDestination}
on:itemAdded={handleAssistantItemAdded}
/>
</div>
</div>
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Floating Action Button (FAB) - Only shown if user can modify collection --> <!-- Floating Action Button (FAB) - Only shown if user can modify collection -->
{#if collection && canModifyCollection && !collection.is_archived} {#if collection && canModifyCollection && !collection.is_archived}
<div class="fixed bottom-6 right-6 z-[999]"> <div class="fixed bottom-6 right-6 {chatPanelOpen ? 'z-30' : 'z-[999]'}">
<div class="dropdown dropdown-top dropdown-end"> <div class="dropdown dropdown-top dropdown-end">
<div <div
tabindex="0" tabindex="0"