fix(chat): clean up tool output and embedded UX

This commit is contained in:
2026-03-09 21:12:46 +00:00
parent bb54503235
commit d8c8ecf2bd
13 changed files with 588 additions and 198 deletions

View File

@@ -10,6 +10,11 @@
result: unknown;
};
type ToolSummary = {
icon: string;
text: string;
};
type PlaceResult = {
name: string;
address?: string;
@@ -28,6 +33,8 @@
role: 'user' | 'assistant' | 'tool';
content: string;
name?: string;
tool_calls?: Array<{ id?: string }>;
tool_call_id?: string;
tool_results?: ToolResultEntry[];
};
@@ -53,7 +60,7 @@
let messages: ChatMessage[] = [];
let inputMessage = '';
let isStreaming = false;
let sidebarOpen = true;
let sidebarOpen = !embedded;
let streamingContent = '';
let selectedProvider = '';
@@ -70,6 +77,7 @@
let showDateSelector = false;
let selectedPlaceToAdd: PlaceResult | null = null;
let selectedDate = '';
let settingsOpen = false;
const dispatch = createEventDispatcher<{
close: void;
@@ -265,10 +273,72 @@
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
if (res.ok) {
const data = await res.json();
messages = data.messages || [];
messages = rebuildConversationMessages(data.messages || []);
}
}
function parseStoredToolResult(msg: ChatMessage): ToolResultEntry | null {
if (msg.role !== 'tool') {
return null;
}
try {
return {
name: msg.name || 'tool',
result: JSON.parse(msg.content)
};
} catch {
return {
name: msg.name || 'tool',
result: msg.content
};
}
}
function rebuildConversationMessages(rawMessages: ChatMessage[]): ChatMessage[] {
const rebuilt = rawMessages.map((msg) => ({
...msg,
tool_results: msg.tool_results ? [...msg.tool_results] : undefined
}));
let activeAssistant: ChatMessage | null = null;
for (const msg of rebuilt) {
if (msg.role === 'assistant') {
activeAssistant = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0 ? msg : null;
continue;
}
if (msg.role !== 'tool' || !activeAssistant) {
continue;
}
const toolCallIds = (activeAssistant.tool_calls || [])
.map((toolCall) => toolCall?.id)
.filter((toolCallId): toolCallId is string => !!toolCallId);
if (msg.tool_call_id && toolCallIds.length > 0 && !toolCallIds.includes(msg.tool_call_id)) {
continue;
}
const parsedResult = parseStoredToolResult(msg);
if (!parsedResult) {
continue;
}
activeAssistant.tool_results = [...(activeAssistant.tool_results || []), parsedResult];
if (
toolCallIds.length > 0 &&
(activeAssistant.tool_results?.length || 0) >= toolCallIds.length
) {
activeAssistant = null;
}
}
return rebuilt;
}
async function deleteConversation(conv: Conversation) {
await fetch(`/api/chat/conversations/${conv.id}/`, { method: 'DELETE' });
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
@@ -398,22 +468,6 @@
}
}
function parseToolResults(msg: ChatMessage): ToolResultEntry[] {
if (msg.tool_results?.length) {
return msg.tool_results;
}
if (msg.role !== 'tool') {
return [];
}
try {
return [{ name: msg.name || 'tool', result: JSON.parse(msg.content) }];
} catch {
return [{ name: msg.name || 'tool', result: msg.content }];
}
}
function hasPlaceResults(result: ToolResultEntry): boolean {
return (
result.name === 'search_places' &&
@@ -541,20 +595,100 @@
}
let messagesContainer: HTMLElement;
$: visibleMessages = messages.filter((msg) => msg.role !== 'tool');
$: lastVisibleMessageId = visibleMessages[visibleMessages.length - 1]?.id;
$: if (messages && messagesContainer) {
setTimeout(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
}, 50);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function getToolSummary(result: ToolResultEntry): ToolSummary {
const payload = asRecord(result.result);
const hasError = !!(payload && typeof payload.error === 'string' && payload.error.trim());
if (hasError) {
return {
icon: '⚠️',
text: `${result.name.replaceAll('_', ' ')} could not be completed.`
};
}
if (result.name === 'list_trips') {
const tripCount = Array.isArray(payload?.trips) ? payload.trips.length : 0;
return {
icon: '🧳',
text:
tripCount > 0
? `Found ${tripCount} trip${tripCount === 1 ? '' : 's'}.`
: 'No trips found.'
};
}
if (result.name === 'get_trip_details') {
const trip = asRecord(payload?.trip);
const tripName = typeof trip?.name === 'string' ? trip.name : 'trip';
const itineraryCount = Array.isArray(trip?.itinerary) ? trip.itinerary.length : 0;
return {
icon: '🗺️',
text: `Loaded details for ${tripName} (${itineraryCount} itinerary item${itineraryCount === 1 ? '' : 's'}).`
};
}
if (result.name === 'add_to_itinerary') {
const location = asRecord(payload?.location);
const locationName = typeof location?.name === 'string' ? location.name : 'location';
return {
icon: '📌',
text: `Added ${locationName} to the itinerary.`
};
}
if (result.name === 'get_weather') {
const entries = Array.isArray(payload?.results) ? payload.results : [];
const availableCount = entries.filter((entry) => asRecord(entry)?.available === true).length;
return {
icon: '🌤️',
text: `Checked weather for ${entries.length} date${entries.length === 1 ? '' : 's'} (${availableCount} available).`
};
}
return {
icon: '🛠️',
text: `${result.name.replaceAll('_', ' ')} completed.`
};
}
</script>
<div class="card bg-base-200 shadow-xl">
<div
class="card"
class:bg-base-200={!embedded}
class:bg-base-100={embedded}
class:shadow-xl={!embedded}
class:border={embedded}
class:border-base-300={embedded}
>
<div class="card-body p-0">
<div class="flex" class:h-[calc(100vh-64px)]={!embedded} class:h-[70vh]={embedded}>
<div
class="flex"
class:h-[calc(100vh-64px)]={!embedded}
class:h-[65vh]={embedded}
class:min-h-[30rem]={embedded}
class:max-h-[46rem]={embedded}
>
<div
class="w-72 bg-base-200 flex flex-col border-r border-base-300 {sidebarOpen
? ''
: 'hidden'} lg:flex"
id="chat-conversations-sidebar"
class="bg-base-200 flex flex-col border-r border-base-300 {embedded
? 'w-60'
: 'w-72'} {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>
@@ -604,6 +738,9 @@
<button
class="btn btn-sm btn-ghost lg:hidden"
on:click={() => (sidebarOpen = !sidebarOpen)}
aria-controls="chat-conversations-sidebar"
aria-expanded={sidebarOpen}
aria-label={sidebarOpen ? 'Hide conversations' : 'Show conversations'}
>
{#if sidebarOpen}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -628,34 +765,57 @@
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<select
class="select select-bordered select-sm"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>
{provider.label}
{#if provider.user_configured}
{/if}
</option>
{/each}
</select>
<select
class="select select-bordered select-sm"
bind:value={selectedModel}
disabled={chatProviders.length === 0}
>
{#if modelsLoading}
<option value="">Loading...</option>
{:else if availableModels.length === 0}
<option value="">Default</option>
{:else}
{#each availableModels as model}
<option value={model}>{model}</option>
{/each}
{/if}
</select>
<details class="dropdown dropdown-end" bind:open={settingsOpen}>
<summary
class="btn btn-sm btn-ghost"
aria-label="AI settings"
aria-expanded={settingsOpen}
>
⚙️
</summary>
<div
class="dropdown-content z-20 mt-2 w-72 rounded-box border border-base-300 bg-base-100 p-3 shadow"
>
<div class="space-y-2">
<label class="label py-0" for="chat-provider-select">
<span class="label-text text-xs opacity-70">{$t('settings.provider')}</span>
</label>
<select
id="chat-provider-select"
class="select select-bordered select-sm w-full"
bind:value={selectedProvider}
disabled={chatProviders.length === 0}
>
{#each chatProviders as provider}
<option value={provider.id}>
{provider.label}
{#if provider.user_configured}
{/if}
</option>
{/each}
</select>
<label class="label py-0" for="chat-model-select">
<span class="label-text text-xs opacity-70">{$t('chat.model_label')}</span>
</label>
<select
id="chat-model-select"
class="select select-bordered select-sm w-full"
bind:value={selectedModel}
disabled={chatProviders.length === 0}
>
{#if modelsLoading}
<option value="">Loading...</option>
{:else if availableModels.length === 0}
<option value="">{$t('chat.model_placeholder')}</option>
{:else}
{#each availableModels as model}
<option value={model}>{model}</option>
{/each}
{/if}
</select>
</div>
</div>
</details>
</div>
</div>
@@ -677,141 +837,96 @@
<p class="text-base-content/60 max-w-md">{$t('chat.welcome_message')}</p>
</div>
{:else}
{#each messages as msg}
{#each visibleMessages 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 space-y-2">
<div class="font-semibold mb-1 text-primary">🗺️ {msg.name}</div>
{#each parseToolResults(msg) as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-100 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-100 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-100 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
</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' && msg.tool_results}
<div class="mt-2 space-y-2">
{#each msg.tool_results as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-200 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-200 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-200 rounded p-2 text-sm">
<pre>{JSON.stringify(result.result, null, 2)}</pre>
</div>
{/if}
{/each}
</div>
{/if}
{#if msg.role === 'assistant' && isStreaming && msg.id === messages[messages.length - 1]?.id && !msg.content}
<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' && msg.tool_results}
<div class="mt-2 space-y-2">
{#each msg.tool_results as result}
{#if hasPlaceResults(result)}
<div class="grid gap-2">
{#each getPlaceResults(result) as place}
<div class="card card-compact bg-base-200 p-3">
<h4 class="font-semibold">{place.name}</h4>
{#if place.address}
<p class="text-sm text-base-content/70">{place.address}</p>
{/if}
{#if place.rating}
<div class="flex items-center gap-1 text-sm">
<span></span>
<span>{place.rating}</span>
</div>
{/if}
{#if collectionId}
<button
class="btn btn-xs btn-primary btn-outline mt-2"
on:click={() => openDateSelector(place)}
disabled={!hasPlaceCoordinates(place)}
>
{$t('add_to_itinerary')}
</button>
{/if}
</div>
{/each}
</div>
{:else if hasWebSearchResults(result)}
<div class="grid gap-2">
{#each getWebSearchResults(result) as item}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="card card-compact bg-base-200 p-3 hover:bg-base-300 transition-colors block"
>
<h4 class="font-semibold link">{item.title}</h4>
<p class="text-sm text-base-content/70 line-clamp-2">
{item.snippet}
</p>
</a>
{/each}
</div>
{:else}
<div class="bg-base-200 rounded p-2 text-sm flex items-center gap-2">
<span>{getToolSummary(result).icon}</span>
<span>{getToolSummary(result).text}</span>
</div>
{/if}
{/each}
</div>
{/if}
{#if msg.role === 'assistant' && isStreaming && msg.id === lastVisibleMessageId}
<div class="mt-2 inline-flex items-center gap-2 text-xs opacity-70">
<span class="loading loading-dots loading-sm"></span>
{/if}
</div>
<span>{$t('processing')}</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<div class="p-4 border-t border-base-300">
<div class="max-w-4xl mx-auto">
<div class="flex flex-wrap gap-2 mb-3">
<div class="border-t border-base-300 p-3 sm:p-4">
<div class:mx-auto={!embedded} class:max-w-4xl={!embedded}>
<div
class="mb-3 flex gap-2"
class:flex-wrap={!embedded}
class:overflow-x-auto={embedded}
class:pb-1={embedded}
>
{#if promptTripContext}
<button
class="btn btn-sm btn-ghost"
class="btn btn-ghost"
class:btn-xs={embedded}
class:btn-sm={!embedded}
class:whitespace-nowrap={embedded}
on:click={() =>
sendPresetMessage(
`What are the best restaurants to include across my ${promptTripContext} itinerary?`
@@ -821,7 +936,10 @@
🍽️ Restaurants
</button>
<button
class="btn btn-sm btn-ghost"
class="btn btn-ghost"
class:btn-xs={embedded}
class:btn-sm={!embedded}
class:whitespace-nowrap={embedded}
on:click={() =>
sendPresetMessage(
`What activities should I plan across my ${promptTripContext} itinerary?`
@@ -833,7 +951,10 @@
{/if}
{#if startDate && endDate}
<button
class="btn btn-sm btn-ghost"
class="btn btn-ghost"
class:btn-xs={embedded}
class:btn-sm={!embedded}
class:whitespace-nowrap={embedded}
on:click={() =>
sendPresetMessage(
`What should I pack for my trip from ${startDate} to ${endDate}?`
@@ -844,7 +965,10 @@
</button>
{/if}
<button
class="btn btn-sm btn-ghost"
class="btn btn-ghost"
class:btn-xs={embedded}
class:btn-sm={!embedded}
class:whitespace-nowrap={embedded}
on:click={() =>
sendPresetMessage('Can you help me plan a day-by-day itinerary for this trip?')}
disabled={isStreaming || chatProviders.length === 0}
@@ -853,7 +977,7 @@
</button>
</div>
</div>
<div class="flex gap-2 max-w-4xl mx-auto">
<div class="flex items-end gap-2" class:mx-auto={!embedded} class:max-w-4xl={!embedded}>
<textarea
class="textarea textarea-bordered flex-1 resize-none"
placeholder={$t('chat.input_placeholder')}
@@ -863,7 +987,7 @@
disabled={isStreaming}
></textarea>
<button
class="btn btn-primary"
class="btn btn-primary self-end"
on:click={sendMessage}
disabled={isStreaming || !inputMessage.trim() || chatProviders.length === 0}
title={$t('chat.send')}