Skip to content
Brok UI

Loading…

No results

AI Prompt

Open source

A chat composer card with an auto-resizing textarea, model pill, attach action, and a smart send button.

Version
v1.3.1
Stability
stable
License
MIT
Related
Chat Thread
Chat Bubble
AI Function Call

Preview

Connect apps
Last payload (dispatched as `ai-prompt-send`)
Send a message to see the payload.
previews.components.ai-prompt.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/ai/prompt.blade.php
  • js resources/js/ui/ai/prompt.js
Registry dependencies
dropdown popover input
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

ai-prompt.md
# 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

ai-prompt ai-prompt-actions ai-prompt-footer ai-prompt-frame ai-prompt-improve

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

AI-assisted interaction

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
root
Theming hooks
ai-prompt ai-prompt-actions ai-prompt-footer ai-prompt-frame ai-prompt-improve

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/ai/prompt.blade.php Blade
@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
resources/js/ui/ai/prompt.js JS
/**
 * 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