fix(chat): clean up tool output and embedded UX
This commit is contained in:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user