fix(chat): improve OpenCode Zen integration and error handling

- Fetch models dynamically from OpenCode Zen API (36 models vs 5 hardcoded)
- Add function calling support check before using tools
- Add retry logic (num_retries=2) for transient failures
- Improve logging for debugging API calls and errors
- Update system prompt for multi-stop itinerary context
- Clean up unused imports in frontend components
- Remove deleted views.py (moved to views/__init__.py)
This commit is contained in:
2026-03-09 16:11:14 +00:00
parent 21ef73f49d
commit 21954df3ee
24 changed files with 1523 additions and 1669 deletions

View File

@@ -52,8 +52,7 @@
class="link link-primary"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Alex-Wiesner/voyage"
>documentation</a
href="https://github.com/Alex-Wiesner/voyage">documentation</a
>.
</p>
</div>

View File

@@ -348,12 +348,12 @@
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{activeView === 'collections'
? $t('collection.no_collections_yet')
: $t('collection.no_archived_collections')}
: $t('collection.no_archived_collections')}
</h3>
<p class="text-base-content/50 text-center max-w-md">
{activeView === 'collections'
? $t('collection.create_first')
: $t('collection.archived_appear_here')}
: $t('collection.archived_appear_here')}
</p>
{#if activeView === 'collections'}
<button
@@ -426,137 +426,137 @@
<!-- Status Filter -->
<div class="card bg-base-200/50 p-4 mb-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Filter class="w-5 h-5" />
{$t('adventures.status_filter')}
</h3>
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Filter class="w-5 h-5" />
{$t('adventures.status_filter')}
</h3>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === ''}
on:change={() => updateStatusFilter('')}
/>
<span class="label-text">{$t('adventures.all')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'folder'}
on:change={() => updateStatusFilter('folder')}
/>
<span class="label-text">📁 {$t('adventures.folder')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'upcoming'}
on:change={() => updateStatusFilter('upcoming')}
/>
<span class="label-text">🚀 {$t('adventures.upcoming')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'in_progress'}
on:change={() => updateStatusFilter('in_progress')}
/>
<span class="label-text">🎯 {$t('adventures.in_progress')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'completed'}
on:change={() => updateStatusFilter('completed')}
/>
<span class="label-text">{$t('adventures.completed')}</span>
</label>
</div>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === ''}
on:change={() => updateStatusFilter('')}
/>
<span class="label-text">{$t('adventures.all')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'folder'}
on:change={() => updateStatusFilter('folder')}
/>
<span class="label-text">📁 {$t('adventures.folder')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'upcoming'}
on:change={() => updateStatusFilter('upcoming')}
/>
<span class="label-text">🚀 {$t('adventures.upcoming')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'in_progress'}
on:change={() => updateStatusFilter('in_progress')}
/>
<span class="label-text">🎯 {$t('adventures.in_progress')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="status_filter"
class="radio radio-primary radio-sm"
checked={statusFilter === 'completed'}
on:change={() => updateStatusFilter('completed')}
/>
<span class="label-text">{$t('adventures.completed')}</span>
</label>
</div>
</div>
<!-- Sort Form - Updated to use URL navigation -->
<div class="card bg-base-200/50 p-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Sort class="w-5 h-5" />
{$t(`adventures.sort`)}
</h3>
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Sort class="w-5 h-5" />
{$t(`adventures.sort`)}
</h3>
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
</label>
<div class="join w-full">
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'asc')}
>
{$t(`adventures.ascending`)}
</button>
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'desc')}
>
{$t(`adventures.descending`)}
</button>
</div>
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
</label>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'updated_at'}
on:change={() => updateSort('updated_at', orderDirection)}
/>
<span class="label-text">{$t('adventures.updated')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'start_date'}
on:change={() => updateSort('start_date', orderDirection)}
/>
<span class="label-text">{$t('adventures.start_date')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'name'}
on:change={() => updateSort('name', orderDirection)}
/>
<span class="label-text">{$t('adventures.name')}</span>
</label>
</div>
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
</label>
<div class="join w-full">
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'asc')}
>
{$t(`adventures.ascending`)}
</button>
<button
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
? 'btn-active'
: ''}"
on:click={() => updateSort(orderBy, 'desc')}
>
{$t(`adventures.descending`)}
</button>
</div>
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
</label>
<div class="space-y-2">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'updated_at'}
on:change={() => updateSort('updated_at', orderDirection)}
/>
<span class="label-text">{$t('adventures.updated')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'start_date'}
on:change={() => updateSort('start_date', orderDirection)}
/>
<span class="label-text">{$t('adventures.start_date')}</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="order_by_radio"
class="radio radio-primary radio-sm"
checked={orderBy === 'name'}
on:change={() => updateSort('name', orderDirection)}
/>
<span class="label-text">{$t('adventures.name')}</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@
import FolderMultiple from '~icons/mdi/folder-multiple';
import FormatListBulleted from '~icons/mdi/format-list-bulleted';
import Timeline from '~icons/mdi/timeline';
import Map from '~icons/mdi/map';
import MapIcon from '~icons/mdi/map';
import Lightbulb from '~icons/mdi/lightbulb';
import ChartBar from '~icons/mdi/chart-bar';
import Plus from '~icons/mdi/plus';
@@ -261,20 +261,43 @@
return undefined;
}
const firstLocation = current.locations.find((loc) =>
Boolean(loc.city?.name || loc.country?.name || loc.location || loc.name)
);
if (!firstLocation) {
const maxStops = 4;
const stops: string[] = [];
const seen = new Set<string>();
for (const loc of current.locations) {
const cityName = loc.city?.name?.trim();
const countryName = loc.country?.name?.trim();
if (cityName || countryName) {
const label =
cityName && countryName ? `${cityName}, ${countryName}` : cityName || countryName;
if (!label) continue;
const key = `geo:${(cityName || '').toLowerCase()}|${(countryName || '').toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
stops.push(label);
continue;
}
const fallbackName = (loc.location || loc.name || '').trim();
if (!fallbackName) continue;
const key = `name:${fallbackName.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
stops.push(fallbackName);
}
if (stops.length === 0) {
return undefined;
}
const cityName = firstLocation.city?.name;
const countryName = firstLocation.country?.name;
if (cityName && countryName) {
return `${cityName}, ${countryName}`;
const summarizedStops = stops.slice(0, maxStops).join('; ');
if (stops.length > maxStops) {
return `${summarizedStops}; +${stops.length - maxStops} more`;
}
return cityName || countryName || firstLocation.location || firstLocation.name || undefined;
return summarizedStops;
}
$: collectionDestination = deriveCollectionDestination(collection);
@@ -1138,7 +1161,7 @@
class:btn-active={currentView === 'map'}
on:click={() => switchView('map')}
>
<Map class="w-5 h-5 sm:mr-2" aria-hidden="true" />
<MapIcon class="w-5 h-5 sm:mr-2" aria-hidden="true" />
<span class="hidden sm:inline">{$t('navbar.map')}</span>
</button>
{/if}

View File

@@ -511,24 +511,25 @@
<div class="text-sm">
{#if visit.timezone}
<strong>{$t('adventures.start')}:</strong>
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED, { locale: 'en-GB' })}<br />
<strong>{$t('adventures.end')}:</strong>
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED, { locale: 'en-GB' })}
{:else}
<strong>Start:</strong>
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED,
{ locale: 'en-GB' }
)}<br />
<strong>End:</strong>
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED,
{ locale: 'en-GB' }
)}
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED, { locale: 'en-GB' })}<br
/>
<strong>{$t('adventures.end')}:</strong>
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED, { locale: 'en-GB' })}
{:else}
<strong>Start:</strong>
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED,
{ locale: 'en-GB' }
)}<br />
<strong>End:</strong>
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED,
{ locale: 'en-GB' }
)}
{/if}
</div>
</div>

View File

@@ -362,20 +362,20 @@
zoom={13}
>
<DefaultMarker lngLat={[lodging.longitude, lodging.latitude]}>
<Popup openOn="click" offset={[0, -10]}>
<div class="p-2">
<div class="text-lg font-bold text-black mb-1">{lodging.name}</div>
<p class="font-semibold text-black text-sm mb-2">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</p>
{#if lodging.location}
<div class="text-xs text-black">
📍 {lodging.location}
</div>
{/if}
</div>
</Popup>
<Popup openOn="click" offset={[0, -10]}>
<div class="p-2">
<div class="text-lg font-bold text-black mb-1">{lodging.name}</div>
<p class="font-semibold text-black text-sm mb-2">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</p>
{#if lodging.location}
<div class="text-xs text-black">
📍 {lodging.location}
</div>
{/if}
</div>
</Popup>
</DefaultMarker>
</MapLibre>
</div>

View File

@@ -746,20 +746,20 @@
</div>
{/if}
<div class="flex flex-wrap items-center gap-2">
<div class="badge badge-ghost badge-sm">
Visits: {hoveredLocation.visits?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Media: {hoveredLocation.images?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Files: {hoveredLocation.attachments?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Trails: {hoveredLocation.trails?.length ?? 0}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="badge badge-ghost badge-sm">
Visits: {hoveredLocation.visits?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Media: {hoveredLocation.images?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Files: {hoveredLocation.attachments?.length ?? 0}
</div>
<div class="badge badge-ghost badge-sm">
Trails: {hoveredLocation.trails?.length ?? 0}
</div>
</div>
{#if hoveredLocation.visits && hoveredLocation.visits.length > 0}
<div class="text-xs text-base-content/70">

View File

@@ -333,12 +333,12 @@
<div class="flex items-center justify-center gap-2 text-base-content/60">
<Calendar class="w-5 h-5" />
<span class="text-lg">
{$t('profile.member_since')}
{new Date(user.date_joined).toLocaleDateString('en-GB', {
timeZone: 'UTC',
year: 'numeric',
month: 'long'
})}
{$t('profile.member_since')}
{new Date(user.date_joined).toLocaleDateString('en-GB', {
timeZone: 'UTC',
year: 'numeric',
month: 'long'
})}
</span>
</div>
{/if}

View File

@@ -726,14 +726,14 @@
<p class="text-sm">{$t('worldtravel.no_country_data_available_desc')}</p>
</div>
</div>
<a
class="link link-primary mt-4 inline-block"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/updating.md#updating-region-data"
target="_blank"
rel="noopener noreferrer"
>
{$t('settings.documentation_link')}
</a>
<a
class="link link-primary mt-4 inline-block"
href="https://github.com/Alex-Wiesner/voyage/blob/main/documentation/docs/configuration/updating.md#updating-region-data"
target="_blank"
rel="noopener noreferrer"
>
{$t('settings.documentation_link')}
</a>
</div>
{/if}
</div>