feat:adds LLM capabilities to chat with your portfolios and holdings
This commit is contained in:
@@ -24,6 +24,21 @@ class HoldingController extends Controller
|
|||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
return view('holding.show', compact(['portfolio', 'holding']));
|
$formattedTransactions = $this->getFormattedTransactions($holding);
|
||||||
|
|
||||||
|
return view('holding.show', compact(['portfolio', 'holding', 'formattedTransactions']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedTransactions($holding)
|
||||||
|
{
|
||||||
|
$formattedTransactions = '';
|
||||||
|
foreach($holding->transactions->where('symbol', $holding->symbol)->sortByDesc('date') as $transaction) {
|
||||||
|
$formattedTransactions .= " * ".$transaction->date->format('Y-m-d')
|
||||||
|
." ". $transaction->transaction_type
|
||||||
|
." ". $transaction->quantity
|
||||||
|
." @ ". $transaction->cost_basis
|
||||||
|
." each \n\n";
|
||||||
|
}
|
||||||
|
return $formattedTransactions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,26 @@ class PortfolioController extends Controller
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$formattedHoldings = $this->getFormattedHoldings($portfolio);
|
||||||
|
|
||||||
return view('portfolio.show', compact(['portfolio', 'metrics']));
|
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedHoldings($portfolio)
|
||||||
|
{
|
||||||
|
$formattedHoldings = '';
|
||||||
|
foreach($portfolio->holdings as $holding) {
|
||||||
|
$formattedHoldings .= " * Holding of ".$holding->market_data->name." (".$holding->symbol.")"
|
||||||
|
."; own ". ($holding->quantity > 0 ? $holding->quantity : 'ZERO') . " shares"
|
||||||
|
."; avg cost basis ". $holding->average_cost_basis
|
||||||
|
."; curr market value ". $holding->market_data->market_value
|
||||||
|
."; unrealized gains ". $holding->market_gain_dollars
|
||||||
|
."; realized gains ". $holding->realized_gain_dollars
|
||||||
|
."; dividends earned ". $holding->dividends_earned
|
||||||
|
."\n\n";
|
||||||
|
|
||||||
|
}
|
||||||
|
return $formattedHoldings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
||||||
|
class AiChat extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'role',
|
||||||
|
'content'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($chat) {
|
||||||
|
|
||||||
|
$chat->user_id = auth()->user()->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user() {
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Split;
|
use App\Models\Split;
|
||||||
|
use App\Models\AiChat;
|
||||||
use App\Models\Dividend;
|
use App\Models\Dividend;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
@@ -131,6 +132,16 @@ class Holding extends Model
|
|||||||
->orderBy('date', 'DESC');
|
->orderBy('date', 'DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Related chats for holding
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function chats()
|
||||||
|
{
|
||||||
|
return $this->morphMany(AiChat::class, 'chatable');
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeWithMarketData($query)
|
public function scopeWithMarketData($query)
|
||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\AiChat;
|
||||||
use Carbon\CarbonPeriod;
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -64,6 +65,16 @@ class Portfolio extends Model
|
|||||||
return $this->hasMany(DailyChange::class);
|
return $this->hasMany(DailyChange::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Related chats for portfolio
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function chats()
|
||||||
|
{
|
||||||
|
return $this->morphMany(AiChat::class, 'chatable');
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeMyPortfolios()
|
public function scopeMyPortfolios()
|
||||||
{
|
{
|
||||||
return $this->whereHas('users', function ($query) {
|
return $this->whereHas('users', function ($query) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"livewire/livewire": "^3.5",
|
"livewire/livewire": "^3.5",
|
||||||
"livewire/volt": "^1.6",
|
"livewire/volt": "^1.6",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
|
"openai-php/laravel": "^0.10.2",
|
||||||
"predis/predis": "^2.2",
|
"predis/predis": "^2.2",
|
||||||
"robsontenorio/mary": "^1.35",
|
"robsontenorio/mary": "^1.35",
|
||||||
"scheb/yahoo-finance-api": "^4.11",
|
"scheb/yahoo-finance-api": "^4.11",
|
||||||
|
|||||||
Generated
+1108
-173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| OpenAI API Key and Organization
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify your OpenAI API Key and organization. This will be
|
||||||
|
| used to authenticate with the OpenAI API - you can find your API key
|
||||||
|
| and organization on your OpenAI dashboard, at https://openai.com.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'api_key' => env('OPENAI_API_KEY'),
|
||||||
|
'organization' => env('OPENAI_ORGANIZATION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Request Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The timeout may be used to specify the maximum number of seconds to wait
|
||||||
|
| for a response. By default, the client will time out after 30 seconds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'request_timeout' => env('OPENAI_REQUEST_TIMEOUT', 30),
|
||||||
|
|
||||||
|
//
|
||||||
|
'model' => env('OPENAI_MODEL', 'gpt-4o'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Builder;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class CreateAiChatsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Builder::morphUsingUuids();
|
||||||
|
|
||||||
|
Schema::create('ai_chats', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->morphs('chatable');
|
||||||
|
$table->string('role');
|
||||||
|
$table->text('content');
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ai_chats');
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -19,6 +19,7 @@
|
|||||||
"and": "and",
|
"and": "and",
|
||||||
"Yes": "Yes",
|
"Yes": "Yes",
|
||||||
"you": "you",
|
"you": "you",
|
||||||
|
"You": "You",
|
||||||
"Nothing to show here yet": "Nothing to show here yet",
|
"Nothing to show here yet": "Nothing to show here yet",
|
||||||
"Try again": "Try again",
|
"Try again": "Try again",
|
||||||
|
|
||||||
@@ -369,5 +370,11 @@
|
|||||||
"Importing transactions...": "Importing transactions...",
|
"Importing transactions...": "Importing transactions...",
|
||||||
"Importing daily changes...": "Importing daily changes...",
|
"Importing daily changes...": "Importing daily changes...",
|
||||||
"Import completed successfully!": "Import completed successfully!",
|
"Import completed successfully!": "Import completed successfully!",
|
||||||
"Your import will continue in the background": "Your import will continue in the background"
|
"Your import will continue in the background": "Your import will continue in the background",
|
||||||
|
|
||||||
|
"AI Chat": "AI Chat",
|
||||||
|
"Hi, how can I help?": "Hi, how can I help?",
|
||||||
|
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
|
||||||
|
"Feel free to ask me a question!": "Feel free to ask me a question!",
|
||||||
|
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor."
|
||||||
}
|
}
|
||||||
+8
-1
@@ -19,6 +19,7 @@
|
|||||||
"and": "y",
|
"and": "y",
|
||||||
"Yes": "Sí",
|
"Yes": "Sí",
|
||||||
"you": "tú",
|
"you": "tú",
|
||||||
|
"You": "Tú",
|
||||||
"Nothing to show here yet": "No hay nada que mostrar aquí todavía",
|
"Nothing to show here yet": "No hay nada que mostrar aquí todavía",
|
||||||
"Try again": "Intentar otra vez",
|
"Try again": "Intentar otra vez",
|
||||||
|
|
||||||
@@ -369,5 +370,11 @@
|
|||||||
"Importing transactions...": "Importando transacciones...",
|
"Importing transactions...": "Importando transacciones...",
|
||||||
"Importing daily changes...": "Importando cambios diarios...",
|
"Importing daily changes...": "Importando cambios diarios...",
|
||||||
"Import completed successfully!": "¡La importación se completó con éxito!",
|
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||||
"Your import will continue in the background": "La importación continuará en segundo plano"
|
"Your import will continue in the background": "La importación continuará en segundo plano",
|
||||||
|
|
||||||
|
"AI Chat": "Chat de AI",
|
||||||
|
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
|
||||||
|
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
|
||||||
|
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
|
||||||
|
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia."
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
@if ($showClose)
|
@if ($showClose)
|
||||||
<x-button
|
<x-button
|
||||||
icon="o-x-mark"
|
icon="o-x-mark"
|
||||||
|
title="{{ __('Close') }}"
|
||||||
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($showClose)
|
@if ($showClose)
|
||||||
<x-button icon="o-x-mark" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
|
<x-button icon="o-x-mark" title="{{ __('Close') }}" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
@if ($showClose)
|
@if ($showClose)
|
||||||
<x-button
|
<x-button
|
||||||
icon="o-x-mark"
|
icon="o-x-mark"
|
||||||
|
title="{{ __('Close') }}"
|
||||||
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
||||||
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
'rows' => 4
|
'rows' => 4
|
||||||
])
|
])
|
||||||
|
|
||||||
<div class="mt-1">
|
<div {{ $attributes->class([]) }}>
|
||||||
<!-- STANDARD LABEL -->
|
<!-- STANDARD LABEL -->
|
||||||
@if($label)
|
@if($label)
|
||||||
<label for="{{ $uuid }}" class="pt-0 label label-text font-semibold">
|
<label for="{{ $uuid }}" class="pt-0 label label-text font-semibold">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
->class([
|
->class([
|
||||||
'textarea textarea-primary w-full peer',
|
'textarea textarea-primary w-full peer',
|
||||||
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
|
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
|
||||||
'textarea-error' => $errors->has($errorField)
|
'textarea-error' => $errors->has($errorField),
|
||||||
])
|
])
|
||||||
}}
|
}}
|
||||||
x-data="{
|
x-data="{
|
||||||
|
|||||||
@@ -161,6 +161,56 @@
|
|||||||
|
|
||||||
</x-ib-card>
|
</x-ib-card>
|
||||||
|
|
||||||
|
{{-- // TODO: add to system prompt:
|
||||||
|
// Additionally, here is some recent news about {$this->holding->symbol}:
|
||||||
|
// And their latest SEC filings: --}}
|
||||||
|
@livewire('ai-chat-window', [
|
||||||
|
'chatable' => $holding,
|
||||||
|
'suggested_prompts' => [
|
||||||
|
[
|
||||||
|
'text' => 'What are the key risks?',
|
||||||
|
'value' => 'What are the key risks for the company?'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'Should I invest more?',
|
||||||
|
'value' => 'Is it worthwhile to invest more?'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'Should I sell?',
|
||||||
|
'value' => 'When is a good time for me to sell?'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'What are the key strengths?',
|
||||||
|
'value' => 'What are the key strengths for this company?'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'Is this a successful position?',
|
||||||
|
'value' => 'Is this a successful holding in my portfolio?'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'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):
|
||||||
|
|
||||||
|
The investor owns ". ($holding->quantity > 0 ? 'a total of '.$holding->quantity : 'ZERO') ." shares of {$holding->market_data->name} (ticker: {$holding->symbol}) with an average cost basis of {$holding->average_cost_basis}. Here are the relevant transactions - sales and purchases of {$holding->symbol}:
|
||||||
|
|
||||||
|
{$formattedTransactions}
|
||||||
|
|
||||||
|
This investor has earned $ {$holding->dividends_earned} in dividends so far and earned {$holding->realized_gains_dollars} in realized gains (sales) from {$holding->symbol} in this portfolio.
|
||||||
|
|
||||||
|
The current market price for {$holding->symbol} is {$holding->market_data->market_value}. Additionally, here's other critical fundamentals for {$holding->market_data->name} that might help:
|
||||||
|
* Market cap: {$holding->market_data->market_cap}
|
||||||
|
* Forward PE: {$holding->market_data->forward_pe}
|
||||||
|
* Trailing PE: {$holding->market_data->trailing_pe}
|
||||||
|
* Book value: {$holding->market_data->book_value}
|
||||||
|
* 52 week low: {$holding->market_data->fifty_two_week_low}
|
||||||
|
* 52 week high: {$holding->market_data->fifty_two_week_high}
|
||||||
|
* Dividend yield: {$holding->market_data->dividend_yield}
|
||||||
|
|
||||||
|
Based on this current market data, quantity owned, and average cost basis, you should determine if the {$holding->symbol} holding is making or losing money.
|
||||||
|
|
||||||
|
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework:"
|
||||||
|
])
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<?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>
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ new class extends Component {
|
|||||||
<x-ib-form wire:submit="{{ $portfolio ? 'update' : 'save' }}" >
|
<x-ib-form wire:submit="{{ $portfolio ? 'update' : 'save' }}" >
|
||||||
<x-input label="{{ __('Title') }}" wire:model="title" required />
|
<x-input label="{{ __('Title') }}" wire:model="title" required />
|
||||||
|
|
||||||
<x-ib-textarea label="{{ __('Notes') }}" wire:model="notes" rows="4" />
|
<x-ib-textarea class="mt-1" label="{{ __('Notes') }}" wire:model="notes" rows="4" />
|
||||||
|
|
||||||
@if (isset($this->portfolio))
|
@if (isset($this->portfolio))
|
||||||
@livewire('share-portfolio-form', ['portfolio' => $portfolio])
|
@livewire('share-portfolio-form', ['portfolio' => $portfolio])
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ new class extends Component {
|
|||||||
class="btn-sm btn-ghost btn-circle"
|
class="btn-sm btn-ghost btn-circle"
|
||||||
wire:click="deleteUser('{{ $user->id }}')"
|
wire:click="deleteUser('{{ $user->id }}')"
|
||||||
spinner="deleteUser('{{ $user->id }}')"
|
spinner="deleteUser('{{ $user->id }}')"
|
||||||
|
title="{{ __('Remove Access') }}"
|
||||||
>
|
>
|
||||||
<x-icon name="o-x-mark" class="w-4" />
|
<x-icon name="o-x-mark" class="w-4" />
|
||||||
</x-button>
|
</x-button>
|
||||||
|
|||||||
@@ -153,6 +153,30 @@
|
|||||||
|
|
||||||
</x-ib-card> --}}
|
</x-ib-card> --}}
|
||||||
|
|
||||||
|
@livewire('ai-chat-window', [
|
||||||
|
'chatable' => $portfolio,
|
||||||
|
'suggested_prompts' => [
|
||||||
|
[
|
||||||
|
'text' => 'Which holding is most successful?',
|
||||||
|
'value' => 'Which holding is most successful in this portfolio?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'text' => 'Should I diversify more?',
|
||||||
|
'value' => 'Is my portfolio diverse enough?',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'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):
|
||||||
|
|
||||||
|
The investor has the following holdings in this portfolio:
|
||||||
|
|
||||||
|
{$formattedHoldings}
|
||||||
|
|
||||||
|
Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
|
||||||
|
|
||||||
|
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework:"
|
||||||
|
])
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
Reference in New Issue
Block a user