290 lines
11 KiB
PHP
290 lines
11 KiB
PHP
<?php
|
|
|
|
use Mary\Traits\Toast;
|
|
use App\Models\AiChat;
|
|
use App\Models\Holding;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Livewire\Volt\Component;
|
|
use OpenAI\Laravel\Facades\OpenAI;
|
|
use OpenAI\Responses\StreamResponse;
|
|
|
|
new class extends Component {
|
|
|
|
use Toast;
|
|
|
|
// props
|
|
public Model $chatable;
|
|
public string $system_prompt = '';
|
|
public array $suggested_prompts = [];
|
|
|
|
public array $messages = [];
|
|
public ?string $prompt = null;
|
|
public ?string $answer = null;
|
|
public bool $streaming = false;
|
|
|
|
// methods
|
|
public function mount()
|
|
{
|
|
$this->messages = $this->chatable->chats()->orderByRaw('created_at, id')->get(['role', 'content'])->toArray();
|
|
}
|
|
|
|
public function startCompletion($suggestedPrompt = null)
|
|
{
|
|
// prevent spam
|
|
if ($this->isRateLimited() || $this->streaming) {
|
|
array_push($this->messages, [
|
|
'role' => 'assistant',
|
|
'content' => __('Hang on! You\'re doing that too much.')
|
|
]);
|
|
$this->js('scrollChatWindow(250)');
|
|
return;
|
|
}
|
|
|
|
if ($suggestedPrompt) {
|
|
$this->prompt = $suggestedPrompt;
|
|
}
|
|
|
|
if (empty(trim($this->prompt))) {
|
|
$this->resetPrompt();
|
|
|
|
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!')]);
|
|
$this->js('scrollChatWindow(250)');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->chatable->chats()->save(new AiChat(['role' => 'user', 'content' => $this->prompt]));
|
|
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt]);
|
|
$this->js('scrollChatWindow(250)');
|
|
|
|
$this->resetPrompt();
|
|
|
|
$this->streaming = true;
|
|
$this->js('$wire.generate()');
|
|
}
|
|
|
|
public function generate(): void
|
|
{
|
|
|
|
try {
|
|
$stream = OpenAI::chat()->createStreamed([
|
|
'model' => config('openai.model'),
|
|
'messages' => [
|
|
['role' => 'system', 'content' => $this->system_prompt],
|
|
...array_slice($this->messages, -10)
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
|
|
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $e->getMessage()]));
|
|
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage()]);
|
|
$this->resetPrompt();
|
|
return;
|
|
}
|
|
|
|
$this->stream(to: "answer", content: '', replace: true);
|
|
|
|
foreach($stream as $response){
|
|
|
|
if(!empty($response->choices[0]->delta->content)) {
|
|
$this->stream(to: 'answer', content: $response->choices[0]->delta->content, replace: false);
|
|
$this->answer .= $response->choices[0]->delta->content;
|
|
}
|
|
$this->js('scrollChatWindow()');
|
|
}
|
|
|
|
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $this->answer]));
|
|
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer]);
|
|
$this->resetPrompt();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}; ?>
|
|
|
|
<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="flex flex-col"
|
|
>
|
|
<x-button
|
|
@click="$dispatch('toggle-ai-chat')"
|
|
class="btn btn-circle btn-lg btn-primary fixed bottom-10 right-10"
|
|
>
|
|
<x-slot:label>
|
|
<x-icon name="o-sparkles" class="w-8 h-8"></x-icon>
|
|
</x-slot:label>
|
|
</x-button>
|
|
|
|
<div
|
|
x-on:toggle-ai-chat.window="open = !open"
|
|
x-show="open"
|
|
x-trap="open"
|
|
x-bind:inert="!open"
|
|
x-transition.opacity
|
|
x-cloak
|
|
key="ai-chat"
|
|
class="fixed
|
|
bottom-0 right-0 w-full h-screen
|
|
md:bottom-[7rem] md:right-10 md:w-[35rem] md:h-auto"
|
|
>
|
|
|
|
<x-card class="shadow-2xl" title="{{ __('AI Chat') }}" x-intersect="scrollChatWindow()">
|
|
{{-- close button --}}
|
|
<x-button
|
|
icon="o-x-mark"
|
|
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
|
|
title="{{ __('Close') }}"
|
|
@click="open = false"
|
|
/>
|
|
|
|
{{-- chat window --}}
|
|
<div class="h-[25rem] overflow-y-scroll" 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-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)
|
|
|
|
@if ($message['role'] == 'user')
|
|
<div class="flex gap-3 mb-5 flex-1">
|
|
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
|
|
|
|
<x-avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
|
|
|
|
</span>
|
|
<p class="leading-relaxed">
|
|
<span class="block font-bold ">{{ __('You') }} </span> {{ $message['content'] }}
|
|
</p>
|
|
</div>
|
|
|
|
@else
|
|
<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-icon name="o-sparkles" class="h-auto p-1 w-10" />
|
|
</span>
|
|
<p class="leading-relaxed" >
|
|
<span class="block font-bold ">AI </span> {{ $message['content'] }}
|
|
</p>
|
|
</div>
|
|
@endif
|
|
|
|
@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-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 --}}
|
|
<form submit="startCompletion" class="mt-3">
|
|
<div class="">
|
|
@foreach($suggested_prompts as $prompt)
|
|
<x-button
|
|
class="btn-xs btn-primary btn-outline mr-1 mb-2"
|
|
label="{{ $prompt['text'] }}"
|
|
wire:click="startCompletion('{{ $prompt['value'] }}')"
|
|
/>
|
|
@endforeach
|
|
|
|
</div>
|
|
|
|
<div class="flex justify-between align-bottom space-x-2 mt-1">
|
|
|
|
<div class="w-full">
|
|
|
|
<x-textarea
|
|
wire:model="prompt"
|
|
class="h-24 resize-none "
|
|
placeholder="{{ __('Have a question? AI might be able to help...') }}"
|
|
wire:keydown.enter.prevent="startCompletion"
|
|
></x-textarea>
|
|
</div>
|
|
<x-button
|
|
spinner="generate"
|
|
wire:click="startCompletion"
|
|
class="btn btn-ghost h-24"
|
|
icon="o-paper-airplane"
|
|
></x-button>
|
|
|
|
</div>
|
|
|
|
<div class="w-full mt-2">
|
|
<p class="text-xs text-secondary leading-tight">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
|
|
</div>
|
|
</form>
|
|
</x-card>
|
|
</div>
|
|
</div>
|
|
|