AI Prompt
A chat composer card with an auto-resizing textarea, model pill, attach action, and a smart send button.
Preview
Generation
Last payload (dispatched as `ai-prompt-send`)
Send a message to see the payload.
Generation
<div class="mx-auto w-full max-w-xl space-y-6">
<div class="space-y-3">
<x-ui.ai.prompt
placeholder="Ask me anything…"
:models="[
['id' => 'claude-opus-5', 'label' => 'Opus 5', 'hint' => __('Most capable')],
['id' => 'claude-sonnet-5', 'label' => 'Sonnet 5', 'hint' => __('Fast and smart')],
['id' => 'claude-haiku-4-5', 'label' => 'Haiku 4.5', 'hint' => __('Fastest')],
]"
model="claude-sonnet-5"
:tools="[['id' => 'web', 'label' => __('Web search')], ['id' => 'code', 'label' => __('Code'), 'on' => true]]"
improve
x-on:ai-prompt-send="document.getElementById('ai-prompt-demo-log').textContent = JSON.stringify($event.detail, null, 2)"
x-on:ai-prompt:improve="$event.detail.replace($event.detail.value.trim() + ' — with more detail and a clear call to action.')"
>
<x-slot:actions>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert link') }}"
title="{{ __('Insert link') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert image') }}"
title="{{ __('Insert image') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/></svg>
</button>
</x-slot:actions>
<x-slot:footer>
<span class="inline-flex items-center gap-1.5">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{{ __('Connect apps') }}
</span>
<span class="flex items-center gap-1">
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M4 4h16v16H4z"/><path d="M4 9h16M9 4v16"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 2v10l7 4"/></svg>
</span>
</span>
</x-slot:footer>
</x-ui.ai.prompt>
<details class="text-xs text-muted-foreground">
<summary class="cursor-pointer">{{ __('Last payload (dispatched as `ai-prompt-send`)') }}</summary>
<pre id="ai-prompt-demo-log" class="mt-2 overflow-auto rounded-md bg-muted p-3 font-mono text-2xs text-foreground">{{ __('Send a message to see the payload.') }}</pre>
</details>
</div>
{{-- A plain composer: no model picker, tools, or new opt-ins. --}}
<x-ui.ai.prompt placeholder="{{ __('Send a message…') }}" />
</div>
Installation
php artisan ui:add ai-prompt
Note
This component ships an Alpine behavior module at
resources/js/ui/ai-prompt.js. Import it once from your bundle so it registers on alpine:init:
import './ai-prompt.js';
Registry contract
php artisan ui:add ai-prompt
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/ai/prompt.blade.php -
resources/js/ui/ai/prompt.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: AI Prompt (`ai-prompt`)
A chat composer card with an auto-resizing textarea, model pill, attach action, and a smart send button.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add ai-prompt
```
## Usage
```blade
<div class="mx-auto w-full max-w-xl space-y-6">
<div class="space-y-3">
<x-ui.ai.prompt
placeholder="Ask me anything…"
:models="[
['id' => 'claude-opus-5', 'label' => 'Opus 5', 'hint' => __('Most capable')],
['id' => 'claude-sonnet-5', 'label' => 'Sonnet 5', 'hint' => __('Fast and smart')],
['id' => 'claude-haiku-4-5', 'label' => 'Haiku 4.5', 'hint' => __('Fastest')],
]"
model="claude-sonnet-5"
:tools="[['id' => 'web', 'label' => __('Web search')], ['id' => 'code', 'label' => __('Code'), 'on' => true]]"
improve
x-on:ai-prompt-send="document.getElementById('ai-prompt-demo-log').textContent = JSON.stringify($event.detail, null, 2)"
x-on:ai-prompt:improve="$event.detail.replace($event.detail.value.trim() + ' — with more detail and a clear call to action.')"
>
<x-slot:actions>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert link') }}"
title="{{ __('Insert link') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert image') }}"
title="{{ __('Insert image') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/></svg>
</button>
</x-slot:actions>
<x-slot:footer>
<span class="inline-flex items-center gap-1.5">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{{ __('Connect apps') }}
</span>
<span class="flex items-center gap-1">
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M4 4h16v16H4z"/><path d="M4 9h16M9 4v16"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 2v10l7 4"/></svg>
</span>
</span>
</x-slot:footer>
</x-ui.ai.prompt>
<details class="text-xs text-muted-foreground">
<summary class="cursor-pointer">{{ __('Last payload (dispatched as `ai-prompt-send`)') }}</summary>
<pre id="ai-prompt-demo-log" class="mt-2 overflow-auto rounded-md bg-muted p-3 font-mono text-2xs text-foreground">{{ __('Send a message to see the payload.') }}</pre>
</details>
</div>
{{-- A plain composer: no model picker, tools, or new opt-ins. --}}
<x-ui.ai.prompt placeholder="{{ __('Send a message…') }}" />
</div>
```
## Props
- `placeholder` (string, default `Ask anything…`)
- `value` (string, default ``)
- `models` (mixed|null, default `null`)
- `model` (mixed|null, default `null`)
- `settings` (array, default `[]`)
- `endpoint` (mixed|null, default `null`)
- `tools` (array, default `[]`)
- `attachments` (bool, default `true`)
- `accept` (string, default ``)
- `name` (string, default `message`)
- `improve` (bool|string, default `false`) — Opt-in "Improve prompt" toolbar action. true for the default label, a string to relabel it, false to leave it out.
- `improveEndpoint` (mixed|null, default `null`) — Where improve() POSTs { prompt } and reads back { prompt } to replace the text. Without one only the ai-prompt:improve event fires.
## Use when
- Use to expose AI-specific prompts, states, or reasoning metadata when those details help users supervise or understand an AI workflow.
- Composing a message to an assistant, with the model, effort and tool choices kept beside the text.
- Building a chat surface that needs attachments and a send control that also stops generation.
## Avoid when
- Do not surface internal AI mechanics when they add confusion or implementation detail without user value.
- Collecting a plain form field; textarea and input are the general controls.
- Rendering the conversation itself; chat-thread and chat-bubble lay out the messages.
## Anti-patterns
- Decorative AI chrome
- Exposing internal mechanics without user value
## Rules
- Use the `<brok:ai-prompt>` tag (or `<x-ui.ai-prompt>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/ai-prompt
- Registry JSON (files, props, contract): https://brokui.dev/r/open/ai-prompt.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Improve Endpoint
{{-- A single composer wired to `improveEndpoint`, for exercising the busy /
success / error states of the server-backed improve() flow in isolation
from the default example's client-side `ai-prompt:improve` listener. --}}
<div class="mx-auto w-full max-w-xl">
<x-ui.ai.prompt
placeholder="Ask anything…"
value="draft a reply"
improve
improve-endpoint="/__playwright/ai-prompt-improve"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | Ask anything… | |
| value | string | ||
| models | mixed | null | null | |
| model | mixed | null | null | |
| settings | array | [] | |
| endpoint | mixed | null | null | |
| tools | array | [] | |
| attachments | bool | true | |
| accept | string | ||
| name | string | message | |
| improve | bool | string | false | Opt-in "Improve prompt" toolbar action. true for the default label, a string to relabel it, false to leave it out. |
| improveEndpoint | mixed | null | null | Where improve() POSTs { prompt } and reads back { prompt } to replace the text. Without one only the ai-prompt:improve event fires. |
Slots
default—actions— Extra icon buttons rendered in the toolbar beside the attach button (e.g. link, image).footer— A strip under the composer. When present, the composer sits inside a muted frame with the footer below it (e.g. "Connect apps" plus app icons).
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The textarea grows with its content, and Enter sends while Shift+Enter inserts a line; an IME composition is never sent by accident.
- The send button is disabled while the message is empty and turns into a stop control while a response streams.
- With endpoint set the message posts to that URL; without it the component emits its payload for the page to handle.
- Model, reasoning effort and tool toggles are pills inside the composer, and attachments render as removable chips.
- With improve set, an "Improve prompt" button follows a separator after the attach button and the actions slot; clicking it dispatches ai-prompt:improve ({ value, replace(text) }) so a listener can rewrite the text, and with improveEndpoint set also POSTs { prompt } and replaces the text from the JSON { prompt } reply, leaving the text unchanged on failure.
- Autosizes the textarea, submits on Enter, allows Shift+Enter for line breaks, and dispatches the prompt value to the host.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Help users prompt, inspect, or supervise an AI-assisted operation.
Use when
- Use to expose AI-specific prompts, states, or reasoning metadata when those details help users supervise or understand an AI workflow.
- Composing a message to an assistant, with the model, effort and tool choices kept beside the text.
- Building a chat surface that needs attachments and a send control that also stops generation.
Avoid when
- Do not surface internal AI mechanics when they add confusion or implementation detail without user value.
- Collecting a plain form field; textarea and input are the general controls.
- Rendering the conversation itself; chat-thread and chat-bubble lay out the messages.
Use instead
- Deterministic conventional workflow
Anti-patterns
- Decorative AI chrome
- Exposing internal mechanics without user value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter
- Focus
managed
- The model and effort pickers are radiogroups and each tool toggle is a switch, so every choice reports its state.
- Every icon-only control carries a translated aria-label, and a submit error is announced through role="alert".
- The Improve prompt button is disabled while the message is empty and carries aria-busy while a request is in flight.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="ai-prompt-{{ $record->id }}">
<div class="mx-auto w-full max-w-xl space-y-6">
<div class="space-y-3">
<x-ui.ai.prompt
placeholder="Ask me anything…"
:models="[
['id' => 'claude-opus-5', 'label' => 'Opus 5', 'hint' => __('Most capable')],
['id' => 'claude-sonnet-5', 'label' => 'Sonnet 5', 'hint' => __('Fast and smart')],
['id' => 'claude-haiku-4-5', 'label' => 'Haiku 4.5', 'hint' => __('Fastest')],
]"
model="claude-sonnet-5"
:tools="[['id' => 'web', 'label' => __('Web search')], ['id' => 'code', 'label' => __('Code'), 'on' => true]]"
improve
x-on:ai-prompt-send="document.getElementById('ai-prompt-demo-log').textContent = JSON.stringify($event.detail, null, 2)"
x-on:ai-prompt:improve="$event.detail.replace($event.detail.value.trim() + ' — with more detail and a clear call to action.')"
>
<x-slot:actions>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert link') }}"
title="{{ __('Insert link') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<button
type="button"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Insert image') }}"
title="{{ __('Insert image') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/></svg>
</button>
</x-slot:actions>
<x-slot:footer>
<span class="inline-flex items-center gap-1.5">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{{ __('Connect apps') }}
</span>
<span class="flex items-center gap-1">
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M4 4h16v16H4z"/><path d="M4 9h16M9 4v16"/></svg>
</span>
<span class="flex size-5 items-center justify-center rounded-sm bg-muted text-muted-foreground" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-3"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 2v10l7 4"/></svg>
</span>
</span>
</x-slot:footer>
</x-ui.ai.prompt>
<details class="text-xs text-muted-foreground">
<summary class="cursor-pointer">{{ __('Last payload (dispatched as `ai-prompt-send`)') }}</summary>
<pre id="ai-prompt-demo-log" class="mt-2 overflow-auto rounded-md bg-muted p-3 font-mono text-2xs text-foreground">{{ __('Send a message to see the payload.') }}</pre>
</details>
</div>
{{-- A plain composer: no model picker, tools, or new opt-ins. --}}
<x-ui.ai.prompt placeholder="{{ __('Send a message…') }}" />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:ai-prompt
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'placeholder' => 'Ask anything…',
'value' => '',
// Models the picker offers: each ['id' => …, 'label' => …, 'hint' => …]
// (or a plain string id). `model` is the selected id; a lone string keeps
// the old single-chip behaviour.
'models' => null,
'model' => null,
// Generation settings the popover exposes. Set a key to null to hide it.
'settings' => [],
// Where `send()` POSTs { message, model, settings, tools, attachments }.
// Without one the composer only dispatches `ai-prompt-send` for the host.
'endpoint' => null,
// Optional tool toggles: each ['id' => 'web', 'label' => 'Web search', 'on' => false].
'tools' => [],
// Let the paperclip attach files (names are sent, files go in FormData).
'attachments' => true,
'accept' => '',
'name' => 'message',
// Opt-in "Improve prompt" toolbar action: true for the default label, a
// string to relabel it, or false (default) to leave it out.
'improve' => false,
// Where improve() POSTs { prompt } and reads back a JSON { prompt } to
// replace the text. Without one, only the `ai-prompt:improve` event fires
// — a host listener can call detail.replace(text) itself.
'improveEndpoint' => null,
])
@php
$models = collect(is_array($models) ? $models : ($model ? [$model] : []))
->map(fn ($m) => is_array($m) ? ['id' => (string) ($m['id'] ?? $m['label']), 'label' => (string) ($m['label'] ?? $m['id']), 'hint' => $m['hint'] ?? null] : ['id' => (string) $m, 'label' => (string) $m, 'hint' => null])
->values();
$selected = $model ?? ($models->first()['id'] ?? null);
$defaults = [
'effort' => 'medium', // reasoning effort: low | medium | high
'temperature' => 1.0,
'maxTokens' => 4096,
'topP' => 1.0,
'system' => '',
];
$settings = array_merge($defaults, is_array($settings) ? $settings : []);
$improveEnabled = $improve !== false && $improve !== null && $improve !== '';
$improveLabel = is_string($improve) && $improve !== '' ? $improve : __('Improve prompt');
$hasFooter = isset($footer);
$config = [
'value' => (string) $value,
'model' => $selected,
'models' => $models->all(),
'settings' => $settings,
'tools' => collect($tools)->map(fn ($t) => ['id' => (string) ($t['id'] ?? $t['label']), 'label' => (string) ($t['label'] ?? $t['id']), 'on' => (bool) ($t['on'] ?? false)])->values()->all(),
'endpoint' => $endpoint,
'improveEndpoint' => $improveEndpoint,
'name' => $name,
];
@endphp
{{--
AI Prompt — the composer: a growing text field, a model picker, a settings
popover (reasoning effort, temperature, max tokens, top-p, system prompt),
optional tool toggles and file attachments, and a send/stop control.
`send()` POSTs to `endpoint` (JSON, or FormData when files are attached) and
always dispatches `ai-prompt-send` with the payload, so a Livewire or JS host
can take over without an endpoint. `ai-prompt-response` carries the reply.
Opt-in `improve` renders an "Improve prompt" toolbar action after a
separator that follows the attach button and the `actions` slot; clicking
it dispatches `ai-prompt:improve` ({ value, replace(text) }) and, with
`improveEndpoint` set, POSTs { prompt } and replaces the text from the
JSON { prompt } reply. The `actions` slot adds extra icon buttons beside
attach; the `footer` slot sits under the composer inside a muted frame.
--}}
@if ($hasFooter)
<div data-slot="ai-prompt-frame" class="rounded-md border border-border bg-muted/40 p-2">
@endif
<div
data-slot="ai-prompt"
x-data="uiAiPrompt(@js($config))"
x-id="['ai-prompt-textarea', 'ai-temp', 'ai-topp', 'ai-max', 'ai-system']"
x-bind:data-state="sending ? 'sending' : (error ? 'error' : 'idle')"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; addFiles($event.dataTransfer.files)"
{{ $attributes->merge(['class' => 'flex flex-col gap-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm transition-colors focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/30 data-[dragging=true]:border-primary motion-reduce:transition-none']) }}
x-bind:data-dragging="dragging"
>
{{-- Attachments --}}
<ul x-show="files.length" x-cloak class="flex flex-wrap gap-2" aria-label="{{ __('Attachments') }}">
<template x-for="(file, i) in files" :key="file.key">
<li class="inline-flex max-w-52 items-center gap-1 rounded-md border border-border bg-muted/60 py-1 pe-1 ps-2 text-xs text-foreground">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<span class="truncate" x-text="file.name"></span>
<span class="shrink-0 text-muted-foreground" x-text="file.size"></span>
<button type="button" @click="removeFile(i)" :aria-label="'{{ __('Remove') }} ' + file.name" class="ms-1 inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-3"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</li>
</template>
</ul>
<label class="sr-only" :for="$id('ai-prompt-textarea')">{{ __('Message') }}</label>
<textarea
x-ref="textarea"
:id="$id('ai-prompt-textarea')"
name="{{ $name }}"
x-model="value"
@input="autosize()"
@keydown.enter="onEnter($event)"
@paste="onPaste($event)"
rows="1"
placeholder="{{ $placeholder }}"
:disabled="sending"
class="max-h-48 min-h-6 w-full resize-none bg-transparent px-1 py-1 text-sm leading-6 text-foreground placeholder:text-muted-foreground focus:outline-none disabled:opacity-60"
>{{ $value }}</textarea>
<div class="flex items-end gap-1">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{{-- Attach --}}
@if ($attachments)
{{-- `hidden` (display:none), not `sr-only`: this native input is never meant to be
an independent, perceivable target — it's opened only via the sized Attach
button below, and a clip-hidden 1x1 box still counts as a target for the
WCAG 2.5.8 size-or-spacing check. A hidden file input still opens the native
picker on .click(). --}}
<input type="file" x-ref="file" class="hidden" multiple @if ($accept) accept="{{ $accept }}" @endif @change="addFiles($event.target.files); $event.target.value = ''" tabindex="-1" aria-hidden="true" />
<button
type="button"
@click="$refs.file.click()"
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
title="{{ __('Attach files') }}"
>
<span class="sr-only">{{ __('Attach files') }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M21.44 11.05l-8.49 8.49a5 5 0 0 1-7.07-7.07l8.49-8.49a3.5 3.5 0 0 1 4.95 4.95l-8.49 8.49a2 2 0 0 1-2.83-2.83l7.78-7.78" />
</svg>
</button>
@endif
{{-- Extra host-supplied toolbar actions (e.g. link, image) --}}
@isset($actions)
<div data-slot="ai-prompt-actions" class="flex items-center gap-1">{{ $actions }}</div>
@endisset
@if ($improveEnabled)
<span aria-hidden="true" class="mx-1 h-5 w-px shrink-0 bg-border"></span>
{{-- Improve prompt --}}
<button
type="button"
data-slot="ai-prompt-improve"
@click="improve()"
:disabled="promptEmpty || improving"
:aria-busy="improving ? 'true' : 'false'"
class="inline-flex h-8 shrink-0 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
<span x-text="improving ? {{ Js::from(__('Improving…')) }} : {{ Js::from($improveLabel) }}"></span>
</button>
@endif
{{-- Model picker --}}
@if ($models->isNotEmpty())
<x-ui.dropdown>
<x-ui.dropdown.trigger
class="inline-flex h-8 items-center gap-1 rounded-full px-2 text-xs font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Model') }}"
>
<span x-text="modelLabel">{{ $models->firstWhere('id', $selected)['label'] ?? $selected }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-muted-foreground"><path d="m6 9 6 6 6-6"/></svg>
</x-ui.dropdown.trigger>
<x-ui.dropdown.content align="start" class="w-64">
<x-ui.dropdown.label>{{ __('Model') }}</x-ui.dropdown.label>
<x-ui.dropdown.radio-group>
@foreach ($models as $m)
<x-ui.dropdown.radio-item
::aria-checked="model === {{ Js::from($m['id']) }}"
::data-state="model === {{ Js::from($m['id']) }} ? 'checked' : 'unchecked'"
@click="model = {{ Js::from($m['id']) }}"
class="flex-col items-start gap-0"
>
<span>{{ $m['label'] }}</span>
@if ($m['hint'])
<span class="text-xs font-normal text-muted-foreground">{{ $m['hint'] }}</span>
@endif
</x-ui.dropdown.radio-item>
@endforeach
</x-ui.dropdown.radio-group>
</x-ui.dropdown.content>
</x-ui.dropdown>
@endif
{{-- Reasoning effort (a quick segmented switch beside the model) --}}
@if ($settings['effort'] !== null)
<div role="radiogroup" aria-label="{{ __('Reasoning effort') }}" class="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-xs">
@foreach (['low' => __('Low'), 'medium' => __('Medium'), 'high' => __('High')] as $lvl => $lbl)
<button
type="button"
role="radio"
:aria-checked="settings.effort === '{{ $lvl }}'"
@click="settings.effort = '{{ $lvl }}'"
class="rounded-full px-2 py-1 font-medium text-muted-foreground transition-colors aria-checked:bg-background aria-checked:text-foreground aria-checked:shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
>{{ $lbl }}</button>
@endforeach
</div>
@endif
{{-- Tool toggles --}}
<template x-for="tool in tools" :key="tool.id">
<button
type="button"
role="switch"
:aria-checked="tool.on"
@click="tool.on = !tool.on"
class="inline-flex h-8 items-center gap-1 rounded-full border border-border px-2 text-xs font-medium text-muted-foreground transition-colors aria-checked:border-primary/40 aria-checked:bg-primary/10 aria-checked:text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" x-show="tool.on"><path d="M20 6 9 17l-5-5"/></svg>
<span x-text="tool.label"></span>
</button>
</template>
</div>
<div class="flex shrink-0 items-center gap-1">
{{-- Settings --}}
<x-ui.popover>
<x-ui.popover.trigger
class="inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="{{ __('Generation settings') }}"
title="{{ __('Settings') }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>
</x-ui.popover.trigger>
<x-ui.popover.content side="top" align="end" class="w-72 space-y-4 p-4">
<p class="text-sm font-medium text-foreground">{{ __('Generation') }}</p>
@if ($settings['temperature'] !== null)
<div>
<label class="flex items-center justify-between text-xs text-muted-foreground" :for="$id('ai-temp')">
<span>{{ __('Temperature') }}</span>
<span class="tabular-nums text-foreground" x-text="Number(settings.temperature).toFixed(1)"></span>
</label>
<input :id="$id('ai-temp')" type="range" min="0" max="2" step="0.1" x-model.number="settings.temperature" class="mt-1 block h-3 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-primary [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-secondary [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-secondary [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-primary focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
</div>
@endif
@if ($settings['topP'] !== null)
<div>
<label class="flex items-center justify-between text-xs text-muted-foreground" :for="$id('ai-topp')">
<span>{{ __('Top-p') }}</span>
<span class="tabular-nums text-foreground" x-text="Number(settings.topP).toFixed(2)"></span>
</label>
<input :id="$id('ai-topp')" type="range" min="0" max="1" step="0.05" x-model.number="settings.topP" class="mt-1 block h-3 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-primary [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-secondary [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-secondary [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-primary focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
</div>
@endif
@if ($settings['maxTokens'] !== null)
<div class="flex items-center justify-between gap-3">
<label class="text-xs text-muted-foreground" :for="$id('ai-max')">{{ __('Max tokens') }}</label>
<x-ui.input size="sm" type="number" min="1" step="1" inputmode="numeric" ::id="$id('ai-max')" x-model.number="settings.maxTokens" class="!w-24 text-end tabular-nums" />
</div>
@endif
@if ($settings['system'] !== null)
<div>
<label class="text-xs text-muted-foreground" :for="$id('ai-system')">{{ __('System prompt') }}</label>
<textarea :id="$id('ai-system')" x-model="settings.system" rows="3" placeholder="{{ __('You are a helpful assistant…') }}" class="mt-1 block w-full resize-none rounded-md border border-input bg-background px-2 py-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"></textarea>
</div>
@endif
</x-ui.popover.content>
</x-ui.popover>
{{-- Send / stop --}}
<button
type="button"
x-show="!sending"
@click="send()"
:disabled="empty"
:aria-disabled="empty"
aria-label="{{ __('Send message') }}"
class="inline-flex size-8 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
:class="empty ? 'cursor-not-allowed bg-muted text-muted-foreground' : 'bg-primary text-primary-foreground hover:opacity-90'"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
</button>
<button
type="button"
x-show="sending"
x-cloak
@click="stop()"
aria-label="{{ __('Stop generating') }}"
class="inline-flex size-8 items-center justify-center rounded-full bg-foreground text-background transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-3"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>
</button>
</div>
</div>
<p x-show="error" x-cloak class="text-xs text-destructive" role="alert" x-text="error"></p>
</div>
@if ($hasFooter)
<div data-slot="ai-prompt-footer" class="mt-2 flex items-center justify-between gap-2 px-1 text-xs text-muted-foreground">
{{ $footer }}
</div>
</div>
@endif
/**
* AI Prompt composer behaviour.
*
* State: the message `value`, the chosen `model`, generation `settings`
* (effort, temperature, maxTokens, topP, system), tool toggles and attached
* `files`. The field grows with its content; Enter sends, Shift+Enter breaks a
* line; pasted files attach.
*
* send() builds { message, model, settings, tools, attachments } and always
* dispatches a bubbling `ai-prompt-send` event with it, so a Livewire or JS
* host can handle the turn itself (call preventDefault() to stop the built-in
* request). With an `endpoint` it POSTs the payload — JSON, or multipart
* FormData when files are attached — and dispatches `ai-prompt-response`
* ({ response, payload }) or `ai-prompt-error`. stop() aborts the request.
*
* improve() dispatches a bubbling, cancelable `ai-prompt:improve` event with
* { value, replace(text) }; a listener can call replace() itself to rewrite
* the text synchronously, or call preventDefault() to take over entirely.
* Otherwise, with an `improveEndpoint` set, it POSTs { prompt } and replaces
* the text from a JSON { prompt } reply — leaving the text untouched on
* failure — while `improving` drives the button's busy state.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAiPrompt', (config = {}) => ({
value: config.value ?? '',
model: config.model ?? null,
settings: { effort: 'medium', temperature: 1, maxTokens: 4096, topP: 1, system: '', ...(config.settings ?? {}) },
tools: Array.isArray(config.tools) ? config.tools : [],
endpoint: config.endpoint || '',
improveEndpoint: config.improveEndpoint || '',
name: config.name || 'message',
files: [],
sending: false,
improving: false,
dragging: false,
error: '',
_controller: null,
_key: 0,
get empty() {
return this.value.trim().length === 0 && this.files.length === 0;
},
get promptEmpty() {
return this.value.trim().length === 0;
},
models: Array.isArray(config.models) ? config.models : [],
get modelLabel() {
return this.models.find((m) => m.id === this.model)?.label || this.model || '';
},
init() {
// Size the field to any initial content once the DOM is ready.
this.$nextTick(() => this.autosize());
},
destroy() {
this.stop();
},
autosize() {
const el = this.$refs.textarea;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
},
onEnter(event) {
// Allow Shift+Enter (and IME composition) to insert a newline.
if (event.shiftKey || event.isComposing) return;
event.preventDefault();
this.send();
},
onPaste(event) {
const items = [...(event.clipboardData?.files ?? [])];
if (items.length) this.addFiles(items);
},
addFiles(list) {
for (const file of [...(list ?? [])]) {
this.files.push({ key: ++this._key, name: file.name, size: this.formatSize(file.size), file });
}
},
removeFile(index) {
this.files.splice(index, 1);
},
formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
},
payload() {
return {
message: this.value.trim(),
model: this.model,
settings: { ...this.settings },
tools: this.tools.filter((t) => t.on).map((t) => t.id),
attachments: this.files.map((f) => f.name),
};
},
async send() {
if (this.empty || this.sending) return;
const payload = this.payload();
this.error = '';
// The host gets first refusal.
const event = new CustomEvent('ai-prompt-send', { detail: payload, bubbles: true, cancelable: true });
if (!this.$root.dispatchEvent(event)) return;
if (!this.endpoint) {
// No endpoint: the event was the send. Clear the composer.
this.reset();
return;
}
this.sending = true;
this._controller = new AbortController();
try {
const headers = { Accept: 'application/json' };
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
let body;
if (this.files.length) {
body = new FormData();
body.append(this.name, payload.message);
body.append('payload', JSON.stringify(payload));
this.files.forEach((f) => body.append('files[]', f.file, f.name));
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({ [this.name]: payload.message, ...payload });
}
const res = await fetch(this.endpoint, { method: 'POST', credentials: 'same-origin', headers, body, signal: this._controller.signal });
if (!res.ok) throw new Error(`Request failed (HTTP ${res.status}).`);
const response = await res.json().catch(() => ({}));
this.$root.dispatchEvent(new CustomEvent('ai-prompt-response', { detail: { response, payload }, bubbles: true }));
this.reset();
} catch (e) {
if (e?.name !== 'AbortError') {
this.error = e?.message || 'Request failed.';
this.$root.dispatchEvent(new CustomEvent('ai-prompt-error', { detail: { error: this.error, payload }, bubbles: true }));
}
} finally {
this.sending = false;
this._controller = null;
}
},
stop() {
this._controller?.abort();
this._controller = null;
this.sending = false;
},
async improve() {
if (this.promptEmpty || this.improving) return;
const value = this.value;
const detail = {
value,
replace: (text) => {
this.value = typeof text === 'string' ? text : '';
this.$nextTick(() => {
this.autosize();
this.$refs.textarea?.focus();
});
},
};
// The host gets first refusal, same as send(): it can rewrite the
// text itself via detail.replace(), synchronously or later.
const notCancelled = this.$root.dispatchEvent(
new CustomEvent('ai-prompt:improve', { detail, bubbles: true, cancelable: true })
);
if (!notCancelled || !this.improveEndpoint) return;
this.improving = true;
this.error = '';
try {
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
const res = await fetch(this.improveEndpoint, {
method: 'POST',
credentials: 'same-origin',
headers,
body: JSON.stringify({ prompt: value }),
});
if (!res.ok) throw new Error(`Request failed (HTTP ${res.status}).`);
const data = await res.json().catch(() => ({}));
if (typeof data.prompt === 'string') detail.replace(data.prompt);
} catch (e) {
// Leave the text unchanged on failure.
this.error = e?.message || 'Request failed.';
this.$root.dispatchEvent(new CustomEvent('ai-prompt-error', { detail: { error: this.error, context: 'improve' }, bubbles: true }));
} finally {
this.improving = false;
}
},
reset() {
this.value = '';
this.files = [];
this.$nextTick(() => this.autosize());
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0