smwks / laravel-ai-chat-ui
Livewire chat UI and trace inspector for laravel/ai agents.
Requires
- php: ^8.3
- illuminate/support: ^12.0|^13.0
- laravel/ai: ^0.11.0
- livewire/livewire: ^4.1
Requires (Dev)
- orchestra/testbench: ^11.2
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
This package is auto-updated.
Last update: 2026-08-31 14:59:04 UTC
README
smwks/laravel-ai-chat-ui
A drop-in Livewire chat UI and trace inspector for laravel/ai
agents. Give your users a real conversation UI — messages run in a queued job and their status
survives a page reload, not just a live stream — and give yourself a "show thoughts" panel that
replays every LLM request/response, tool invocation, and raw HTTP exchange behind each reply —
without building any of it yourself.
It ships as three plain, presentation-only Livewire components with no opinion on your routes or page chrome. Drop them into pages your own app already owns — or straight into a Filament panel page. Same components, either way.
Features
- A real conversation UI — new-conversation form, searchable/paginated history, and a
threaded view, all wired to
laravel/ai's own conversation persistence. - A full trace inspector — every LLM request/response, tool call, and raw HTTP exchange behind a reply, correctly nested (a tool's own HTTP calls render under that tool, not interleaved chronologically above it) and collapsible per-message.
- A built-in JSON tree viewer — no external JS dependency; click-to-collapse, and a search box that highlights matches and auto-expands their ancestors.
- Multi-agent / multi-bot support — run one agent app-wide via config, or pass a different agent per conversation for a site running several distinct bots.
- Policy-gated by default — the trace inspector is guarded by a
viewThoughtsability you can override, in addition to a component-levelshowThoughtsprop. - Human tool approval — a tool that requires approval pauses the run and renders an inline Approve / Reject prompt (with its arguments and reason) in the thread; resolving it resumes the same turn. See "Human tool approval" below.
- Extensible — give any tool its own detail view or "thinking…" status message without forking or publishing anything.
- Themeable without forking views — every structural element carries a stable
data-ai-chat-ui="<role>"hook, and a component's own heading/width/padding can be turned off entirely for a host page that provides its own. - Works in plain Livewire pages and in Filament panels — see "Quick usage" below.
Requirements
- PHP ^8.3
- Laravel ^12.0 or ^13.0
- Livewire ^4.1
laravel/ai^0.11.0 — still pre-1.0, so its own API may change between releases; pin it deliberately in your owncomposer.json.
Prerequisite
This package does not store conversations or messages itself — it builds entirely on
laravel/ai's own agent_conversations / agent_conversation_messages tables. Its own
two tables, agent_conversation_turns and agent_conversation_events, follow that same
naming so they read as companions rather than a separate schema. Publish and run
laravel/ai's migrations first:
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
Run php artisan ai-chat-ui:install at any time to check this prerequisite is satisfied.
Installation
composer require smwks/laravel-ai-chat-ui php artisan vendor:publish --tag=ai-chat-ui-config php artisan vendor:publish --tag=ai-chat-ui-migrations php artisan vendor:publish --tag=ai-chat-ui-assets php artisan migrate
Out of the box, the package uses Smwks\LaravelAiChatUi\Testbench\EchoAgent — a trivial
agent with no tools — so the install is runnable without any host-app agent code, as long
as laravel/ai's own provider/API key is configured. See "Using your own agent" to swap
in your own.
Quick usage
The package registers no routes and owns no page chrome — you decide where these components live. Two common hosts:
A plain Livewire page you already own:
{{-- resources/views/pages/chat/⚡conversation.blade.php --}} <livewire:ai-chat-ui::components.chat.conversation :conversation="$conversation" :initial-message="$initialMessage" />
A Filament panel page:
// app/Filament/Pages/Chat.php use Filament\Pages\Page; use Laravel\Ai\Models\Conversation; class Chat extends Page { protected string $view = 'filament.pages.chat'; public ?Conversation $conversation = null; }
{{-- resources/views/filament/pages/chat.blade.php --}} <x-filament-panels::page> @if ($conversation) <livewire:ai-chat-ui::components.chat.conversation :conversation="$conversation" /> @else <livewire:ai-chat-ui::components.chat.new /> @endif </x-filament-panels::page>
Both examples above are deliberately trimmed for a first look — see "Embedding these
components" and "Navigation events" below for wiring chat.new/chat.history's browser
events into full page navigation, and the full prop reference for chat.conversation.
Embedding these components
This package registers no routes and owns no URL structure or page-level navigation —
that's entirely the consuming app's job. It ships three embeddable, presentation-only
Livewire components under the ai-chat-ui::components.chat namespace:
ai-chat-ui::components.chat.new— a form to start a new conversation.ai-chat-ui::components.chat.history— a searchable, paginated list of the authenticated user's conversations.ai-chat-ui::components.chat.conversation— the thread + "show thoughts" trace inspector for one conversation. Requires aconversationprop (aLaravel\Ai\Models\Conversationinstance).
All three components require an authenticated user — they call Auth::user()
internally and will throw rather than gracefully 403 for a guest. Your own routes/pages
must enforce authentication (e.g. Route::middleware(['web', 'auth']), or a Filament
panel's own auth guard) before embedding any of them.
Presentation props
All three components accept:
showHeader(bool, defaulttrue) — whether the component renders its own<h1>(and, forchat.conversation, the conversation id block below it). Turn off when the host page already renders its own page title, e.g. a Filament page's own heading.containerClass(string, defaultnull) — classes for the component's root element. Leftnull, each component falls back to a sensible standalone layout (centered, max width, padding); pass an empty string, or your own classes, when the host page already constrains width/padding.
chat.conversation additionally accepts:
initialMessage(string) — auto-send a first message on mount.agent(class name string) — use an agent other thanconfig('ai-chat-ui.agent')for this conversation; see "Using your own agent" for running more than one bot.showThoughts(bool, defaulttrue) — whether the "show thoughts" trace inspector is available at all. This is ANDed with theviewThoughtspolicy ability below — both must allow it for a user to see it.showIds(bool, defaulttrue) — whether the conversation id and each trace event's id are shown (click-to-copy) in the UI.fillHeight(bool, defaultfalse) — makes the message thread its own scroll container, filling whatever height the host gives it, with the composer pinned below it. Only turn this on when the host actually hands the component a bounded height (e.g. a fixed-height panel region) — otherwise the thread has nothing to fill and won't scroll internally at all. Left off, the component renders with no fixed height and relies on an ancestor (the page itself, or a host-provided container) to scroll, so it never produces two nested scrollbars.detailsZIndex(int, default50) — z-index for the trace details slide-over panel, which isx-teleport'd to the end of<body>. Raise this when a host app's own modal/toast layer (e.g. Filament's) sits above the default.
Styling hooks
Every structural element carries a data-ai-chat-ui="<role>" attribute — root,
header, thread, message (plus data-ai-chat-ui-role="user" / "assistant" on
message bubbles), reply-body, composer, thoughts-toggle, thought-event — so you
can theme any of it from your own stylesheet instead of forking views or writing
brittle descendant selectors:
[data-ai-chat-ui="message"][data-ai-chat-ui-role="user"] { /* restyle the user's own message bubble */ }
chat.conversation also ships a small default stylesheet for reply-body (list
markers, heading/paragraph spacing, code/pre) — published via --tag=ai-chat-ui-assets
alongside the JSON tree viewer's JS, so an assistant's Markdown reply still reads as
structured text under a CSS reset like Filament's or Tailwind's Preflight. Override any
of its rules from your own stylesheet, or skip loading it and publish your own.
Navigation events
Two of the components dispatch a Livewire browser event instead of redirecting themselves, since only your app knows what its own routes (or Filament page state) look like:
| Event | Payload | Dispatched by |
|---|---|---|
ai-chat-ui-conversation-started |
conversationId: string, message: string |
components.chat.new, after creating a new conversation |
ai-chat-ui-conversation-selected |
conversationId: string |
components.chat.history, when a row is picked |
Your own page-level Livewire component (or Filament page) listens for these (via
Livewire's #[On(...)] attribute) and decides where to go. On a plain route, that
usually means redirecting:
use Livewire\Attributes\On; use Livewire\Component; new class extends Component { #[On('ai-chat-ui-conversation-started')] public function onConversationStarted(string $conversationId, string $message): void { session()->flash('chat.initial_message', $message); $this->redirect(route('chat.conversation', $conversationId), navigate: true); } }; ?>
Your chat.conversation page then reads that stashed message back out of the session and
passes it through as the initialMessage prop:
public function mount(\Laravel\Ai\Models\Conversation $conversation): void { $this->conversation = $conversation; $this->initialMessage = session()->pull('chat.initial_message'); }
On a Filament page, there's no route to redirect to — the same event just swaps which component the page renders (see the Filament example under "Quick usage"):
#[On('ai-chat-ui-conversation-started')] public function onConversationStarted(string $conversationId, string $message): void { $this->conversation = Conversation::findOrFail($conversationId); $this->initialMessage = $message; }
Filament: linkable, bookmarkable conversations
The swap-in-place approach above is the fastest way to get started, but it keeps every conversation behind one URL — nothing to link to, bookmark, or open in a new tab. For that, give each of the three components its own Filament page instead, the same way you would with plain routes:
// app/Filament/Pages/Chat.php class Chat extends Page { protected static ?string $slug = 'chat'; public function sendMessage(): void {} // delegate to components.chat.new, or omit and let the component handle it directly }
// app/Filament/Pages/ChatHistory.php class ChatHistory extends Page { protected static ?string $slug = 'chat/history'; }
// app/Filament/Pages/ChatConversation.php class ChatConversation extends Page { protected static ?string $slug = 'chat/{conversationId}'; public ?Conversation $conversation = null; public ?string $initialMessage = null; public function mount(string $conversationId): void { $this->conversation = Conversation::findOrFail($conversationId); } }
Chat and ChatHistory's views listen for the two navigation events and redirect to
ChatConversation's URL instead of swapping a property, mirroring the plain-route
example above:
#[On('ai-chat-ui-conversation-started')] public function onConversationStarted(string $conversationId, string $message): void { session()->flash('chat.initial_message', $message); $this->redirect(ChatConversation::getUrl(['conversationId' => $conversationId])); } #[On('ai-chat-ui-conversation-selected')] public function onConversationSelected(string $conversationId): void { $this->redirect(ChatConversation::getUrl(['conversationId' => $conversationId])); }
// resources/views/filament/pages/chat-conversation.blade.php <x-filament-panels::page> <livewire:ai-chat-ui::components.chat.conversation :conversation="$conversation" :initial-message="$initialMessage ?? session()->pull('chat.initial_message')" :show-header="false" container-class="" /> </x-filament-panels::page>
showHeader="false" and an empty containerClass hand the page's own heading and width
constraints back to Filament's page chrome, instead of the component rendering its own
on top of them (see "Presentation props" above).
The JSON tree viewer
Bodies and raw payloads in the "show thoughts" detail panels (HTTP Exchange, Tool, LLM Request, LLM Response) render through a small built-in JSON tree viewer — no external JS dependency, just a recursive Blade partial with Alpine handling interaction. It's intentionally plain: monochrome, 2-space indentation, no visible buttons or chrome except the search box.
- Collapse/expand — click an opening
{or[to collapse that object/array to{ ... }/[ ... ]inline; click it again to re-expand. Every object/array has its own independent collapsed state. - Search — the box at the top does a plain-text search across the whole tree (not JSON-aware), highlights every match, and automatically expands any collapsed ancestor so a match is never hidden inside a collapsed node.
The generic fallback partial (used for event types with no dedicated view) still
renders its Raw tab through an older <json-viewer> custom element rather than this
one — a known inconsistency, not yet swapped over.
Using your own agent
Set config('ai-chat-ui.agent') to your own agent's class name. It only needs to satisfy
laravel/ai's own Agent + Conversational interfaces, using the Promptable and
RemembersConversations traits — nothing from this package is required on the agent
itself:
class SupportAgent implements Agent, Conversational { use Promptable, RemembersConversations; public function instructions(): string { /* ... */ } }
// config/ai-chat-ui.php 'agent' => App\Ai\Agents\SupportAgent::class,
Running more than one bot
config('ai-chat-ui.agent') is a single, app-wide default. For a site with several
distinct bots, pass the agent prop to components.chat.conversation instead — it
overrides the config default for that conversation:
{{-- resources/views/pages/support/chat/⚡conversation.blade.php --}} <livewire:ai-chat-ui::components.chat.conversation :conversation="$conversation" :initial-message="$initialMessage" agent="App\Ai\Agents\SupportAgent" />
{{-- resources/views/pages/sales/chat/⚡conversation.blade.php --}} <livewire:ai-chat-ui::components.chat.conversation :conversation="$conversation" :initial-message="$initialMessage" agent="App\Ai\Agents\SalesAgent" />
This package doesn't persist which agent a conversation belongs to — neither of its own
tables (see "Why this package needs its own tables") has a column for it. So resuming a
conversation correctly depends on your own routing consistently pairing a conversation
with the right agent, e.g. by
giving each bot its own URL prefix (/support/chat/{conversation} vs
/sales/chat/{conversation}) the way the two pages above illustrate, rather than one
shared chat.conversation route used for every bot.
A {conversation} route segment and implicit binding
Naming a route parameter conversation (as in the examples above) triggers Laravel's
implicit route-model binding for Laravel\Ai\Models\Conversation. Livewire's own
<livewire:...> tag hands back a serialized representation of a typed property as the
binding value, and implicit binding resolves it by comparing every column rather than
just the key — which 404s. This package registers its own explicit binding for the
conversation parameter name (Route::bind('conversation', ...), resolving by key
only) specifically to route around that, so {conversation} segments just work. If your
own app registers a competing Route::bind('conversation', ...) for something unrelated
elsewhere, that registration wins (whichever one runs last) — rename your route segment
in that case rather than fighting over the same parameter name.
Human tool approval
laravel/ai lets a tool require a human decision before it runs. Mark one by
implementing Approvable and using the InteractsWithApprovals concern, then calling
requireApproval():
use Laravel\Ai\Concerns\InteractsWithApprovals; use Laravel\Ai\Contracts\Approvable; use Laravel\Ai\Contracts\Tool; class UpdateMeetingTool implements Approvable, Tool { use InteractsWithApprovals; public function __construct() { $this->requireApproval('This changes a meeting record.'); } // ...description(), handle(), schema() }
When the agent calls that tool, the run pauses instead of executing it. The turn ends
in an AWAITING_APPROVAL state (rather than COMPLETE), and components.chat.conversation
renders an inline prompt beneath the assistant message — the tool name, the reason, and the
arguments the model wants to pass — with Approve and Reject buttons. When a single
turn pauses on more than one call, Approve all / Reject all are offered too, and a
mixed set is submitted once every call has a decision. The composer is disabled until the
pause is resolved.
Resolving dispatches a fresh turn that resumes the paused one: approved calls run and the
agent continues; rejected calls are reported back to the model as denied so it can carry on
without them. Nothing extra is persisted — this builds entirely on laravel/ai's own
approval_state column on the messages table.
Resolving an approval is authorized by the same sendMessage policy ability as sending a
message. Each pause and resolution is also recorded as a tool.approval_requested /
tool.approval_resolved trace event, visible under "show thoughts".
Only approve / reject are surfaced for now.
laravel/ai'sDecision::edit()— approving a call with modified arguments — isn't wired to the UI yet.
Trace correlation — important if you extend this package
Which conversation/turn is "in flight" is tracked via Laravel's Context facade, not
stored on the agent object — the agent binding is not a singleton. If you add code
that captures more trace data, read these Context keys rather than reaching for agent
identity:
| Key | Set by | Meaning |
|---|---|---|
ai-chat-ui.conversation_id / ai-chat-ui.turn_id |
ProcessChatMessage::handle(), once at the top |
Which conversation/turn is in flight. Every capture listener below is a no-op unless conversation_id is present. |
ai-chat-ui.tool_source |
CaptureToolInvoking, for the duration of that tool's handle() call |
Attributes any HTTP call captured during that window to the tool rather than to the LLM provider — see payload['source'] on an http.exchange event. |
ai-chat-ui.tool_invocation_id |
CaptureToolInvoking, for the duration of that tool's handle() call |
Correlates an http.exchange event back to the specific tool.invoked event it belongs to, so the UI can nest it under the right tool call even when the same tool runs more than once in a turn. |
ai-chat-ui.pending_http_request |
The request-side HTTP middleware, cleared by the response-side middleware | Correlates a request with its response into one http.exchange event. Assumes calls through Http:: happen one at a time; a concurrent caller (e.g. Http::pool()) would need its own correlation id instead. |
Extension points
-
Override
Smwks\LaravelAiChatUi\Policies\ConversationPolicy::viewThoughts()(or swap in your own policy viaGate::policy(Conversation::class, ...)) to restrict who can see the "show thoughts" trace inspector — it defaults to the same rule asview(). This is checked in addition to, not instead of, theshowThoughtscomponent prop. -
Publish views (
--tag=ai-chat-ui-views) and overridecomponents/chat/partials/thought-details/generic.blade.phpto render domain-specific structured-output payloads. -
Give a specific tool its own "show thoughts" detail view via
config('ai-chat-ui.tool_views'), keyed by the tool name found in atool.invokedevent'spayload['tool']:// config/ai-chat-ui.php 'tool_views' => [ 'weather' => 'chat.tools.weather-details', ],
The view receives an
eventprop (aConversationEvent). Tools not listed here keep rendering through the package's own generic tool partial — no need to publish or fork anything just to add one tool's view. -
Give a tool its own "thinking" status message by implementing
Smwks\LaravelAiChatUi\Contracts\HasStatusMessage:use Smwks\LaravelAiChatUi\Contracts\HasStatusMessage; class WeatherTool implements Tool, HasStatusMessage { public function statusMessage(array $arguments): string { return "Checking the weather in {$arguments['city']}…"; } // ...description(), handle(), schema() }
While that tool is running, the chat UI's "thinking" indicator shows this string instead of a generic "Thinking…" — the collapsed toggle it lives in also expands, on click, into the same live trace boxes the completed view shows. A tool that doesn't implement this interface falls back to its own
description(). -
Out of scope in this release: entity typeahead, Markdown export, an SSE/JSON API, broadcasting, and per-conversation rating/notes.
Testing
composer install vendor/bin/pest
License
MIT — see LICENSE.md.
Why this package needs its own tables
laravel/ai's agent_conversations / agent_conversation_messages tables model a
conversation as a sequence of finished messages — a row appears only once the agent has
actually replied. That's sufficient for laravel/ai itself, but not for a chat UI that
has to render while a reply is still being generated in a queued job.
agent_conversation_events is the obvious one: laravel/ai has nowhere to put trace
data at all. LLM requests/responses, tool invocations, and raw HTTP exchanges are the
entire data source behind the "show thoughts" panel, and none of it overlaps with
anything laravel/ai stores.
agent_conversation_turns is less obvious, since on the surface it looks like it
duplicates agent_conversation_messages. One row is created before the agent runs, and
it exists to answer questions the messages table structurally can't:
- Is this message still being processed? Sending a message dispatches
ProcessChatMessageto a queue and returns immediately — the request that showed the "thinking" indicator has already ended. Nothing durable would otherwise exist for the next poll to check, since no assistant message row exists yet to look at. - What failed, if the queue worker died? If the job throws,
agent_conversation_messagesgets no row and no error — a turn'sstatus(Pending→Processing→CompleteorFailed) is the only place that failure is ever recorded. - Which trace events belong to which reply? Every
agent_conversation_eventsrow is tagged with aturn_id.laravel/ai's own timeline has no equivalent of "the interaction that produced this one reply," so without a turn to key on, the trace inspector's per-message "show thoughts" toggle would have nothing to group by. - Resuming after a page reload mid-reply. On mount,
chat.conversationlooks up the conversation's most recent non-final turn to decide whether it should still be polling — there's no other row it could look up to reconstruct that state.