Files
investbrain/resources/views/components/ui/ai-chat-window.blade.php
T
hackerESQ 66889abc72 Migrate to laravel ai sdk (#181)
Also
* upgrade to livewire 4
* replace rappsoft tables with filament
2026-03-13 15:21:22 -05:00

359 lines
12 KiB
PHP

<?php
use App\Ai\Agents\ChatWithHoldingAgent;
use App\Ai\Agents\ChatWithPortfolioAgent;
use App\Ai\Agents\ChatWithSuggestedPromptsAgent;
use App\Models\ChatWithConversation;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Livewire\Attributes\Async;
use Livewire\Volt\Component;
new class extends Component
{
// props
public Model $chatable;
public array $suggested_prompts = [];
public array $messages = [];
public ?string $prompt = null;
public ?string $answer = null;
public bool $streaming = false;
public ?string $agent_conversation_id = null;
// methods
public function mount(): void
{
$chatWith = ChatWithConversation::firstOrCreate(
[
'chatable_type' => $this->chatable::class,
'chatable_id' => $this->chatable->id,
'user_id' => auth()->id(),
],
['title' => 'Chat with investments']
);
$this->agent_conversation_id = $chatWith->id;
$this->messages = $chatWith->messages()
->orderBy('id', 'desc')
->limit(20)
->get(['role', 'content', 'created_at'])
->map(fn ($m) => ['role' => $m->role, 'content' => $m->content, 'created_at' => $m->created_at])
->reverse()
->values()
->toArray();
}
public function startCompletion(?string $suggestedPrompt = null): void
{
if ($this->streaming) {
return;
}
// prevent spam
if ($this->isRateLimited()) {
array_push($this->messages, [
'role' => 'assistant',
'content' => __('Hang on! You\'re doing that too much.'),
'created_at' => now(),
]);
$this->js('scrollChatWindow(250)');
return;
}
if ($suggestedPrompt) {
$this->prompt = $suggestedPrompt;
$this->suggested_prompts = [];
}
if (empty(trim($this->prompt ?? ''))) {
$this->resetPrompt();
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!'), 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
return;
}
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt, 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
$this->resetPrompt();
$this->streaming = true;
$this->js('$wire.generateCompletion()');
}
public function generateCompletion(): void
{
$userPrompt = end($this->messages)['content'] ?? '';
try {
$agent = $this->makeAgent()->continue($this->agent_conversation_id, auth()->user());
$stream = $agent->stream($userPrompt);
} catch (Exception $e) {
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage(), 'created_at' => now()]);
$this->resetPrompt();
return;
}
$this->stream(to: 'answer', content: '', replace: true);
foreach ($stream as $event) {
if ($event instanceof TextDelta) {
$this->stream(to: 'answer', content: $event->delta, replace: false);
$this->answer .= $event->delta;
}
$this->js('scrollChatWindow()');
}
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer, 'created_at' => now()]);
$this->resetPrompt();
$this->js('$wire.generateSuggestedPrompts()');
}
#[Async]
public function generateSuggestedPrompts(): void
{
try {
$response = ChatWithSuggestedPromptsAgent::make(messages: array_slice($this->messages, -3))->prompt('');
$this->suggested_prompts = $response->toArray()['suggested_prompts'] ?? [];
} catch (Exception $e) {
$this->suggested_prompts = [];
$this->error($e->getMessage());
}
}
public function resetPrompt(): void
{
$this->answer = null;
$this->prompt = null;
$this->streaming = false;
}
public function isRateLimited(): bool
{
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return true;
}
RateLimiter::hit($rateLimitKey, 60);
return false;
}
private function makeAgent(): Agent
{
return match (true) {
$this->chatable instanceof Portfolio => new ChatWithPortfolioAgent($this->chatable),
$this->chatable instanceof Holding => new ChatWithHoldingAgent($this->chatable),
};
}
}; ?>
<div
x-data="{
open: false,
async scrollChatWindow(delay = 0) {
await new Promise(resolve => setTimeout(resolve, delay));
this.$refs.chatWindow.scrollBy({
top: this.$refs.chatWindow.scrollHeight,
behavior: 'smooth'
});
}
}"
class="fixed z-50 bottom-8 right-8"
>
{{-- toggle button --}}
<x-ui.button
x-show="!open"
@click="$dispatch('toggle-ai-chat')"
@keyup.escape.window="open = false"
class="flex btn btn-circle md:btn-lg btn-primary"
>
<x-slot:label>
<x-ui.icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-ui.icon>
</x-slot:label>
</x-ui.button>
{{-- popup --}}
<div
x-on:toggle-ai-chat.window="open = !open"
x-show="open"
x-trap="open"
x-bind:inert="!open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-y-full"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform translate-y-full"
x-cloak
key="ai-chat"
class="fixed bg-base-300 shadow-2xl rounded-none md:rounded-lg
inset-0 h-screen w-full md:inset-auto md:right-6
md:bottom-6 md:w-[32rem] md:h-[46rem]"
>
<div
class="absolute inset-0 flex flex-col overflow-hidden p-4"
x-intersect="scrollChatWindow()"
>
<div class="flex grow-0 justify-between items-center pb-4 ">
<h2 class="text-lg text-bold select-none">{{ __('AI Chat') }}</h2>
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
title="{{ __('Close') }}"
@click="open = false"
/>
</div>
{{-- chat window --}}
<div class="grow overflow-hidden overflow-y-scroll ai-chat" x-ref="chatWindow">
<div class="flex gap-3 mb-5 flex-1">
<span class="
flex
rounded-full
w-10 h-10
border border-gray-600
dark:border-gray-400
text-gray-600
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<p class="leading-relaxed w-full">
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
</p>
</div>
@foreach($messages as $message)
<div class="flex gap-3 mb-5 flex-1">
@if ($message['role'] == 'user')
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
<x-ui.avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
</span>
<p class="leading-relaxed">
<span class="block font-bold" title="{{ $message['created_at'] }}">{{ __('You') }} </span> {{ $message['content'] }}
</p>
@else
<span class="
flex
rounded-full
w-10 h-10
border border-gray-600
dark:border-gray-400
text-gray-600
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<div class="leading-relaxed" >
<span class="block font-bold" title="{{ $message['created_at'] }}">AI </span> {!! Str::markdown($message['content']) !!}
</div>
@endif
</div>
@endforeach
@if($streaming)
<div class="flex gap-3 mb-10 flex-1">
<span class="
flex
rounded-full
w-10 h-10
border border-gray-600
dark:border-gray-400
text-gray-600
dark:text-gray-400
bg-slate-200
dark:bg-slate-800
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
<p class="leading-relaxed" >
<span class="block font-bold ">AI </span> <span wire:stream="answer">{{ $answer }}</span>
</p>
</div>
@endif
</div>
{{-- prompt input --}}
<div class="mt-3 grow-0">
<form wire:submit.prevent>
<div class="">
@foreach($suggested_prompts as $prompt)
<x-ui.button
class="btn-xs btn-primary btn-outline mr-1 mb-2"
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
>{{ $prompt['text'] }}</x-ui.button>
@endforeach
</div>
<div class="flex justify-between align-bottom space-x-2 mt-1">
<div class="w-full">
<x-ui.textarea
wire:model="prompt"
class="h-18 resize-none bg-base-200"
placeholder="{{ __('Have a question? AI might be able to help...') }}"
wire:keydown.enter.prevent="startCompletion"
autofocus
@toggle-ai-chat.window="setTimeout(() => $el.focus(), 250)"
x-trap="true"
></x-ui.textarea>
</div>
<x-ui.button
spinner="startCompletion, generateCompletion"
wire:click.prevent="startCompletion"
class="btn btn-ghost h-32"
icon="o-paper-airplane"
></x-ui.button>
</div>
<div class="w-full mt-2">
<p class="text-xs text-secondary leading-tight select-none">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
</div>
</form>
</div>
</div>
</div>
</div>