Migrate to laravel ai sdk (#181)

Also
* upgrade to livewire 4
* replace rappsoft tables with filament
This commit is contained in:
hackerESQ
2026-03-13 15:21:22 -05:00
committed by GitHub
parent fc6b7a8c52
commit 66889abc72
57 changed files with 4064 additions and 1761 deletions
@@ -1,7 +1,16 @@
<?php
use App\Models\AiChat;
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
@@ -9,8 +18,6 @@ new class extends Component
// props
public Model $chatable;
public string $system_prompt = 'You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Use github style markdown for any formatting.';
public array $suggested_prompts = [];
public array $messages = [];
@@ -21,19 +28,44 @@ new class extends Component
public bool $streaming = false;
public ?string $agent_conversation_id = null;
// methods
public function mount()
public function mount(): void
{
$this->messages = $this->chatable->chats()->orderByRaw('created_at, id')->limit(25)->get(['role', 'content'])->toArray();
$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($suggestedPrompt = null)
public function startCompletion(?string $suggestedPrompt = null): void
{
if ($this->streaming) {
return;
}
// prevent spam
if ($this->isRateLimited() || $this->streaming) {
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)');
@@ -42,19 +74,19 @@ new class extends Component
if ($suggestedPrompt) {
$this->prompt = $suggestedPrompt;
$this->suggested_prompts = [];
}
if (empty(trim($this->prompt))) {
if (empty(trim($this->prompt ?? ''))) {
$this->resetPrompt();
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!')]);
array_push($this->messages, ['role' => 'assistant', 'content' => __('Feel free to ask me a question!'), 'created_at' => now()]);
$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]);
array_push($this->messages, ['role' => 'user', 'content' => $this->prompt, 'created_at' => now()]);
$this->js('scrollChatWindow(250)');
$this->resetPrompt();
@@ -65,23 +97,13 @@ new class extends Component
public function generateCompletion(): void
{
$userPrompt = end($this->messages)['content'] ?? '';
try {
$client = $this->createOpenAiClient();
$stream = $client->chat()->createStreamed([
'model' => config('openai.model'),
'messages' => [
['role' => 'system', 'content' => "Today's date is "
.now()->toDateString()
.".\n\n".$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()]);
$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;
@@ -89,91 +111,31 @@ new class extends Component
$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;
foreach ($stream as $event) {
if ($event instanceof TextDelta) {
$this->stream(to: 'answer', content: $event->delta, replace: false);
$this->answer .= $event->delta;
}
$this->js('scrollChatWindow()');
}
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $this->answer]));
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer]);
array_push($this->messages, ['role' => 'assistant', 'content' => $this->answer, 'created_at' => now()]);
$this->resetPrompt();
$this->js('$wire.generateSuggestedPrompts()');
}
#[Async]
public function generateSuggestedPrompts(): void
{
try {
$client = $this->createOpenAiClient();
$suggested_prompts = $client->chat()->create([
'model' => config('openai.model'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'suggested_prompts_schema',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'suggested_prompts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'text' => [
'type' => 'string',
'description' => 'The suggested prompt question (no more than 5 words)',
],
'value' => [
'type' => 'string',
'description' => 'The detailed version of the question',
],
],
'required' => ['text', 'value'],
'additionalProperties' => false,
],
],
],
'required' => ['suggested_prompts'],
'additionalProperties' => false,
],
],
],
'messages' => [
['role' => 'system', 'content' => '
Your role is to assist investors in asking thoughtful questions of their investment advisors.
When you help investors ask good questions, you should ensure the you questions you recommend
are based on the provided context. Be sure to keep the questions short!
The questions you recommend might be based on natural follow up from the given context, requests
to further refine a previous response, clarify undefined terms, common decision frameworks,
possible risks or benefits, or commonly understood investing concepts that may require additional
explanation.
Your response should only include valid JSON.
'],
['role' => 'user', 'content' => "
Generate between 1 and 5 (no more than 5) follow up questions a savvy investor might ask their
advisor based on the following conversation:
\n\n
".json_encode(array_slice($this->messages, -4)),
],
],
]);
$this->suggested_prompts = json_decode($suggested_prompts->choices[0]->message->content, true)['suggested_prompts'];
} catch (\Exception $e) {
$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());
return;
}
}
@@ -189,7 +151,6 @@ new class extends Component
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return true;
}
@@ -198,41 +159,34 @@ new class extends Component
return false;
}
private function createOpenAiClient()
private function makeAgent(): Agent
{
$apiKey = config('openai.api_key');
$organization = config('openai.organization');
$baseUri = config('openai.base_uri');
return OpenAI::factory()
->withApiKey($apiKey)
->withOrganization($organization)
->withHttpHeader('OpenAI-Beta', 'assistants=v2')
->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]))
->withBaseUri($baseUri)
->make();
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'
});
}
}"
<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-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"
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>
@@ -240,10 +194,10 @@ new class extends Component
</x-ui.button>
{{-- popup --}}
<div
<div
x-on:toggle-ai-chat.window="open = !open"
x-show="open"
x-trap="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"
@@ -252,22 +206,22 @@ new class extends Component
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"
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
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"
<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"
<x-ui.button
icon="o-x-mark"
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
title="{{ __('Close') }}"
@click="open = false"
@click="open = false"
/>
</div>
@@ -290,47 +244,50 @@ new class extends Component
</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)
@foreach($messages as $message)
<div class="flex gap-3 mb-5 flex-1">
@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-ui.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>
<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
<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>
<div class="leading-relaxed" >
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
</div>
<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="
@@ -343,7 +300,7 @@ new class extends Component
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" >
@@ -352,24 +309,26 @@ new class extends Component
</div>
@endif
</div>
{{-- prompt input --}}
<div class="mt-3 grow-0">
<form submit="startCompletion">
<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']) }}')"
<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" >
<div class="w-full">
<x-ui.textarea
wire:model="prompt"
class="h-18 resize-none bg-base-200"
@@ -377,18 +336,19 @@ new class extends Component
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="generateCompletion"
wire:click="startCompletion"
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>
@@ -396,4 +356,4 @@ new class extends Component
</div>
</div>
</div>
</div>
</div>