Files
investbrain/resources/views/components/ui/ai-chat-window.blade.php
T

359 lines
12 KiB
PHP
Raw Normal View History

<?php
2026-03-13 15:21:22 -05:00
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;
2024-10-31 15:19:59 -05:00
use Illuminate\Database\Eloquent\Model;
2026-03-13 15:21:22 -05:00
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Livewire\Attributes\Async;
2024-10-31 15:19:59 -05:00
use Livewire\Volt\Component;
new class extends Component
{
2024-10-31 15:19:59 -05:00
// props
public Model $chatable;
2024-10-31 15:19:59 -05:00
public array $suggested_prompts = [];
public array $messages = [];
2024-10-31 15:19:59 -05:00
public ?string $prompt = null;
2024-10-31 15:19:59 -05:00
public ?string $answer = null;
2024-10-31 15:19:59 -05:00
public bool $streaming = false;
2026-03-13 15:21:22 -05:00
public ?string $agent_conversation_id = null;
2024-10-31 15:19:59 -05:00
// methods
2026-03-13 15:21:22 -05:00
public function mount(): void
2024-10-31 15:19:59 -05:00
{
2026-03-13 15:21:22 -05:00
$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();
2024-10-31 15:19:59 -05:00
}
2026-03-13 15:21:22 -05:00
public function startCompletion(?string $suggestedPrompt = null): void
2024-10-31 15:19:59 -05:00
{
2026-03-13 15:21:22 -05:00
if ($this->streaming) {
return;
}
2024-10-31 15:19:59 -05:00
// prevent spam
2026-03-13 15:21:22 -05:00
if ($this->isRateLimited()) {
2024-10-31 15:19:59 -05:00
array_push($this->messages, [
'role' => 'assistant',
'content' => __('Hang on! You\'re doing that too much.'),
2026-03-13 15:21:22 -05:00
'created_at' => now(),
2024-10-31 15:19:59 -05:00
]);
$this->js('scrollChatWindow(250)');
2024-10-31 15:19:59 -05:00
return;
}
2024-10-31 15:19:59 -05:00
if ($suggestedPrompt) {
$this->prompt = $suggestedPrompt;
2026-03-13 15:21:22 -05:00
$this->suggested_prompts = [];
2024-10-31 15:19:59 -05:00
}
2026-03-13 15:21:22 -05:00
if (empty(trim($this->prompt ?? ''))) {
2024-10-31 15:19:59 -05:00
$this->resetPrompt();
2026-03-13 15:21:22 -05:00
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!'), 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
2024-10-31 15:19:59 -05:00
return;
}
2024-10-31 15:19:59 -05:00
2026-03-13 15:21:22 -05:00
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt, 'created_at' => now()]);
2024-10-31 15:19:59 -05:00
$this->js('scrollChatWindow(250)');
$this->resetPrompt();
$this->streaming = true;
$this->js('$wire.generateCompletion()');
2024-10-31 15:19:59 -05:00
}
public function generateCompletion(): void
2024-10-31 15:19:59 -05:00
{
2026-03-13 15:21:22 -05:00
$userPrompt = end($this->messages)['content'] ?? '';
2024-10-31 15:19:59 -05:00
try {
2026-03-13 15:21:22 -05:00
$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();
2024-10-31 15:19:59 -05:00
return;
}
$this->stream(to: 'answer', content: '', replace: true);
2026-03-13 15:21:22 -05:00
foreach ($stream as $event) {
if ($event instanceof TextDelta) {
$this->stream(to: 'answer', content: $event->delta, replace: false);
$this->answer .= $event->delta;
}
2024-10-31 15:19:59 -05:00
$this->js('scrollChatWindow()');
}
2026-03-13 15:21:22 -05:00
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer, 'created_at' => now()]);
2024-10-31 15:19:59 -05:00
$this->resetPrompt();
$this->js('$wire.generateSuggestedPrompts()');
}
2026-03-13 15:21:22 -05:00
#[Async]
public function generateSuggestedPrompts(): void
{
2026-03-13 15:21:22 -05:00
try {
$response = ChatWithSuggestedPromptsAgent::make(messages: array_slice($this->messages, -3))->prompt('');
2026-03-13 15:21:22 -05:00
$this->suggested_prompts = $response->toArray()['suggested_prompts'] ?? [];
} catch (Exception $e) {
$this->suggested_prompts = [];
$this->error($e->getMessage());
}
2024-10-31 15:19:59 -05:00
}
public function resetPrompt(): void
{
$this->answer = null;
$this->prompt = null;
$this->streaming = false;
}
public function isRateLimited(): bool
{
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
2024-10-31 15:19:59 -05:00
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return true;
}
2024-10-31 15:19:59 -05:00
RateLimiter::hit($rateLimitKey, 60);
2024-10-31 15:19:59 -05:00
return false;
}
2026-03-13 15:21:22 -05:00
private function makeAgent(): Agent
2024-12-06 16:01:53 -06:00
{
2026-03-13 15:21:22 -05:00
return match (true) {
$this->chatable instanceof Portfolio => new ChatWithPortfolioAgent($this->chatable),
$this->chatable instanceof Holding => new ChatWithHoldingAgent($this->chatable),
};
2024-12-06 16:01:53 -06:00
}
2024-10-31 15:19:59 -05:00
}; ?>
2026-03-13 15:21:22 -05:00
<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'
});
}
}"
2024-11-01 22:13:40 -05:00
class="fixed z-50 bottom-8 right-8"
2024-10-31 15:19:59 -05:00
>
2024-11-01 22:13:40 -05:00
{{-- toggle button --}}
2026-03-13 15:21:22 -05:00
<x-ui.button
2024-11-01 22:13:40 -05:00
x-show="!open"
2024-10-31 15:19:59 -05:00
@click="$dispatch('toggle-ai-chat')"
@keyup.escape.window="open = false"
2026-03-13 15:21:22 -05:00
class="flex btn btn-circle md:btn-lg btn-primary"
2024-10-31 15:19:59 -05:00
>
<x-slot:label>
<x-ui.icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-ui.icon>
2024-10-31 15:19:59 -05:00
</x-slot:label>
</x-ui.button>
2024-11-01 22:13:40 -05:00
{{-- popup --}}
2026-03-13 15:21:22 -05:00
<div
2024-10-31 15:19:59 -05:00
x-on:toggle-ai-chat.window="open = !open"
x-show="open"
2026-03-13 15:21:22 -05:00
x-trap="open"
2024-10-31 15:19:59 -05:00
x-bind:inert="!open"
2024-11-01 22:13:40 -05:00
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"
2024-10-31 15:19:59 -05:00
x-cloak
2026-03-13 15:21:22 -05:00
key="ai-chat"
class="fixed bg-base-300 shadow-2xl rounded-none md:rounded-lg
2026-03-13 15:21:22 -05:00
inset-0 h-screen w-full md:inset-auto md:right-6
md:bottom-6 md:w-[32rem] md:h-[46rem]"
>
2026-03-13 15:21:22 -05:00
<div
class="absolute inset-0 flex flex-col overflow-hidden p-4"
2024-11-01 22:13:40 -05:00
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>
2026-03-13 15:21:22 -05:00
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
2024-11-01 22:13:40 -05:00
title="{{ __('Close') }}"
2026-03-13 15:21:22 -05:00
@click="open = false"
2024-11-01 22:13:40 -05:00
/>
</div>
2024-10-31 15:19:59 -05:00
{{-- chat window --}}
2024-11-01 22:13:40 -05:00
<div class="grow overflow-hidden overflow-y-scroll ai-chat" x-ref="chatWindow">
2024-10-31 15:19:59 -05:00
<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" />
2024-10-31 15:19:59 -05:00
</span>
<p class="leading-relaxed w-full">
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
2026-03-13 15:21:22 -05:00
2024-10-31 15:19:59 -05:00
</p>
</div>
2026-03-13 15:21:22 -05:00
@foreach($messages as $message)
<div class="flex gap-3 mb-5 flex-1">
2024-10-31 15:19:59 -05:00
@if ($message['role'] == 'user')
2026-03-13 15:21:22 -05:00
<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>
2024-10-31 15:19:59 -05:00
@else
2026-03-13 15:21:22 -05:00
<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']) !!}
2024-10-31 15:19:59 -05:00
</div>
2026-03-13 15:21:22 -05:00
2024-10-31 15:19:59 -05:00
@endif
2026-03-13 15:21:22 -05:00
</div>
2024-10-31 15:19:59 -05:00
@endforeach
2026-03-13 15:21:22 -05:00
2024-10-31 15:19:59 -05:00
@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
2026-03-13 15:21:22 -05:00
">
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
</span>
2024-10-31 15:19:59 -05:00
<p class="leading-relaxed" >
<span class="block font-bold ">AI </span> <span wire:stream="answer">{{ $answer }}</span>
</p>
</div>
2024-10-31 15:19:59 -05:00
@endif
</div>
2026-03-13 15:21:22 -05:00
2024-10-31 15:19:59 -05:00
{{-- prompt input --}}
2024-11-01 22:13:40 -05:00
<div class="mt-3 grow-0">
2026-03-13 15:21:22 -05:00
<form wire:submit.prevent>
2024-11-01 22:13:40 -05:00
<div class="">
@foreach($suggested_prompts as $prompt)
2026-03-13 15:21:22 -05:00
<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>
2024-11-01 22:13:40 -05:00
@endforeach
2026-03-13 15:21:22 -05:00
</div>
2026-03-13 15:21:22 -05:00
2024-11-01 22:13:40 -05:00
<div class="flex justify-between align-bottom space-x-2 mt-1">
2026-03-13 15:21:22 -05:00
<div class="w-full">
<x-ui.textarea
2024-11-01 22:13:40 -05:00
wire:model="prompt"
class="h-18 resize-none bg-base-200"
2024-11-01 22:13:40 -05:00
placeholder="{{ __('Have a question? AI might be able to help...') }}"
wire:keydown.enter.prevent="startCompletion"
autofocus
@toggle-ai-chat.window="setTimeout(() => $el.focus(), 250)"
2026-03-13 15:21:22 -05:00
x-trap="true"
></x-ui.textarea>
2026-03-13 15:21:22 -05:00
2024-11-01 22:13:40 -05:00
</div>
<x-ui.button
2026-03-13 15:21:22 -05:00
spinner="startCompletion, generateCompletion"
wire:click.prevent="startCompletion"
class="btn btn-ghost h-32"
2024-11-01 22:13:40 -05:00
icon="o-paper-airplane"
></x-ui.button>
2026-03-13 15:21:22 -05:00
2024-11-01 22:13:40 -05:00
</div>
2026-03-13 15:21:22 -05:00
2024-11-01 22:13:40 -05:00
<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>
2024-11-01 22:13:40 -05:00
</div>
</form>
</div>
</div>
</div>
2026-03-13 15:21:22 -05:00
</div>