Compare commits

...

33 Commits

Author SHA1 Message Date
hackerESQ d3337c7c01 Change runner to self-hosted for build job 2026-03-15 17:53:43 -05:00
hackerESQ 23a9b84659 fix: upgrade @alpinejs/persist from 3.15.0 to 3.15.4 (#178)
Snyk has created this PR to upgrade @alpinejs/persist from 3.15.0 to 3.15.4.

See this package in npm:
@alpinejs/persist

See this project in Snyk:
https://app.snyk.io/org/investbrain/project/6a72866f-936e-45fc-83bd-dd298e632086?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-03-15 17:04:40 -05:00
Carlos E. Barboza 6bc174a87b Update Dividend.php (#176)
* Adds a second join condition requiring holdings.symbol = dividends.symbol. 
Without this, the join only matches on portfolio_id, which could incorrectly associate dividends with holdings of a different symbol within the same portfolio.

* Add test

---------

Co-authored-by: hackerESQ <corey@coreyvarma.com>
2026-03-15 17:00:29 -05:00
hackerESQ 401b0eef91 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2026-03-13 20:16:18 -05:00
hackerESQ 98f47baa73 wip 2026-03-13 20:16:14 -05:00
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
hackerESQ fc6b7a8c52 dev: add laravel boost 2026-02-25 22:31:20 -06:00
hackerESQ 34223960f8 chore: Bump PHP version to 8.4
see #150
2025-11-06 21:10:53 -06:00
hackerESQ 5f583de857 fix: export daily change dates transposition
reference #148
2025-11-04 19:59:33 -06:00
hackerESQ bb0a0ef928 fix: date for transactions in api requests
references #148
2025-11-04 19:44:14 -06:00
Fexiven 2d4c7002a7 fix: Create nginx directory (#143)
fixes:

nginx: [emerg] open() "/run/nginx/nginx.pid" failed (2: No such file or directory)
2025-10-30 16:43:26 -05:00
hackerESQ 939e46eb61 fix: dividends should be cast to float 2025-10-09 19:02:45 -05:00
hackerESQ 04f1d8cbcd fix: transactions table 2025-10-06 20:01:29 -05:00
hackerESQ c6032c5b66 cleanup 2025-09-28 21:13:52 -05:00
hackerESQ 8908e2da02 Fix: mobile responsive with table 2025-09-28 19:54:49 -05:00
hackerESQ 892d5a30e0 fix: default filesystem name 2025-09-27 21:52:57 -05:00
hackerESQ b896513be9 fix: disable wire navigate for social login 2025-09-26 20:36:29 -05:00
hackerESQ 013ccba050 update system prompt 2025-09-26 20:11:51 -05:00
hackerESQ a10f94a570 revert 2025-09-26 20:03:39 -05:00
hackerESQ 5b8b9ae39e wip 2025-09-26 18:32:00 -05:00
hackerESQ 3e84ed7572 string currency 2025-09-26 18:20:00 -05:00
hackerESQ 39458ef44e optional currency 2025-09-26 18:16:17 -05:00
hackerESQ 0e47b7538e Merge branch 'main' of https://github.com/investbrainapp/investbrain 2025-09-26 17:54:46 -05:00
hackerESQ 0aaa51e736 Fix: touch log file during start up
fixes laravel.log permissions #137
2025-09-26 17:54:44 -05:00
hackerESQ e6f38d9481 Chore: Upgrade to Laravel 12 + remove Mary and Jetstream dependencies (#141)
* docs: remove requirement for setting APP_KEY manually

* optimize date picker

* clean up modals

* spot light working

* reorganization

* add lazy load

* wip

* remove filament

* styling
2025-09-26 17:41:28 -05:00
hackerESQ 910d426ad4 add test 2025-09-13 22:24:02 -05:00
hackerESQ 72ad02de4b fix: standardize currency 2025-09-13 22:24:02 -05:00
hackerESQ 50285a3d51 Update SECURITY.md 2025-09-05 21:00:48 -05:00
hackerESQ ff31e3d48b Delete .github/dependabot.yml 2025-09-05 18:34:01 -05:00
hackerESQ 3d944afeb4 auto-bump deps using dependabot 2025-09-05 18:24:15 -05:00
hackerESQ 8e625107c1 keep APP_KEY reference in configuration section 2025-09-05 11:53:26 -05:00
enterprised1 df034863c7 Remove references to add APP_KEY in README.md
The current instructions specify an APP_KEY needs to be manually generated and added to the environment properties.  However, doing so results in a 500 error and the following post mentions APP_KEY is now generated automatically.  

https://github.com/orgs/investbrainapp/discussions/74#discussioncomment-14269950
2025-09-05 11:53:26 -05:00
hackerESQ 70cdfc9fd8 Delete .shift 2025-09-01 21:32:25 -05:00
202 changed files with 9666 additions and 5030 deletions
+1 -2
View File
@@ -16,9 +16,8 @@ REGISTRATION_ENABLED=true
# Enable or disable AI chat feature # Enable or disable AI chat feature
AI_CHAT_ENABLED=false AI_CHAT_ENABLED=false
# API key for OpenAI (for Llama support, see docs) # API key for OpenAI (for other labs or Ollama support, see docs)
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_ORGANIZATION=
# Market data provider to use (comma separated list) # Market data provider to use (comma separated list)
MARKET_DATA_PROVIDER=yahoo MARKET_DATA_PROVIDER=yahoo
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-22.04 #ubuntu-latest runs-on: self-hosted
steps: steps:
- name: Increase swap space - name: Increase swap space
run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1 run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}
-4
View File
@@ -1,4 +0,0 @@
This file was added by Shift #157267 in order to open a
Pull Request since no other commits were made.
You should remove this file.
+452
View File
@@ -0,0 +1,452 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.18
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== herd rules ===
## Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`.
- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
### Volt Functional Component Example
<code-snippet name="Volt Functional Component Example" lang="php">
@volt
<?php
use function Livewire\Volt\{state, computed};
state(['count' => 0]);
$increment = fn () => $this->count++;
$decrement = fn () => $this->count--;
$double = computed(fn () => $this->count * 2);
?>
<div>
<h1>Count: {{ $count }}</h1>
<h2>Double: {{ $this->double }}</h2>
<button wire:click="increment">+</button>
<button wire:click="decrement">-</button>
</div>
@endvolt
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
## PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should test all of the happy paths, failure paths, and weird paths.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
### Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
## Tailwind CSS
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind CSS 4
- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
</laravel-boost-guidelines>
+12 -10
View File
@@ -50,8 +50,6 @@ curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker
Adjust the `environment` properties in the compose file to your preferences. Adjust the `environment` properties in the compose file to your preferences.
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
**3. Run `docker compose up`** **3. Run `docker compose up`**
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting: It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
@@ -66,9 +64,11 @@ Congrats! You've just installed Investbrain!
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions. Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
When self-hosting, you can enable the chat assistant by configuring your OpenAI Secret Key and Organization ID in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file. Navigate to OpenAI to [create your keys](https://platform.openai.com/api-keys). Most of the major labs are currently supported (OpenAI, Anthropic, Gemini, xAI, etc). You'll need to obtain API keys from your selected provider and configure that in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file using the appropriate keys.
If you are self-hosting your own large language models ("LLMs") that expose an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you can update the `OPENAI_BASE_URI` configuration to your self-hosted instance. Ensure you also update the `OPENAI_MODEL` to an available model. Investbrain is also compatible with Ollama (and other OpenAI compatible APIs). If you are self-hosting your own large language models ("LLMs") that exposes an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you'll need to configure your local endpoint in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file.
See available options [below](#configuration).
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor. Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
@@ -76,7 +76,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails. Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration ### Market Data Configuration
You can specify the market data provider you want to use in your environment variables: You can specify the market data provider you want to use in your environment variables:
@@ -137,7 +137,7 @@ There are several optional configurations available when installing using the re
| ------------- | ------------- | ------------- | | ------------- | ------------- | ------------- |
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost | | APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 | | APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` | | APP_KEY | Encryption key for various security-related functions | Set automatically during install |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo | | MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` | | ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
| FINNHUB_API_KEY | If using the Finnhub provider | `null` | | FINNHUB_API_KEY | If using the Finnhub provider | `null` |
@@ -147,10 +147,12 @@ There are several optional configurations available when installing using the re
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 | | MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC | | APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` | | AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` | | CHAT_PROVIDER | Which chat provider to use (one of `openai`, `anthropic`, `gemini`, `azure`, `groq`, `xai`, `deepseek`, `mistral`, `ollama`) | `openai` |
| OPENAI_ORGANIZATION | OpenAI org id (required for AI chat) | `null` | | CHAT_MODEL | The selected LLM used for AI chat | defaults to current smartest model from lab |
| OPENAI_MODEL | The selected LLM used for AI chat | gpt-4o | | ANTHROPIC_API_KEY | If using Anthropic for chat | `null` |
| OPENAI_BASE_URI | The URI for your self-hosted LLM | api.openai.com/v1 | | OPENAI_API_KEY | If using OpenAI for chat | `null` |
| OLLAMA_BASE_URL | If using Ollama for chat | `http://localhost:11434` |
| OLLAMA_API_KEY | May be required if using Ollama for chat | `null` |
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 | | DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` | | REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
+2 -1
View File
@@ -4,7 +4,8 @@
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 1.1.x | :white_check_mark: | | 1.2.x | :white_check_mark: |
| 1.1.x | :x: |
| 1.0.x | :x: | | 1.0.x | :x: |
| < 1.0.0 | :x: | | < 1.0.0 | :x: |
-21
View File
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\User;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*/
public function delete(User $user): void
{
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use App\Models\Holding;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithHoldingAgent implements Agent, Conversational, HasTools
{
use Promptable;
use RemembersConversations;
public function __construct(public readonly Holding $holding) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
$holding = $this->holding;
$quantity = $holding->quantity > 0
? 'a total of '.$holding->quantity
: 'ZERO';
return 'Most recent training data: '.now()->toDateString().'.
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). Do not apologize. Be polite, but minimize gratuitous niceties. If something is unclear, ask for clarification. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
The investor owns '.$quantity.' 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.':
'.$holding->getFormattedTransactions().'
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. Use github style markdown for any formatting:';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use App\Models\Portfolio;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithPortfolioAgent implements Agent, Conversational, HasTools
{
use Promptable;
use RemembersConversations;
public function __construct(public readonly Portfolio $portfolio) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Most recent training data: '.now()->toDateString().'.
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\' in lieu of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
The investor has the following holdings in this portfolio:
'.$this->portfolio->getFormattedHoldings().'
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. Use github style markdown for any formatting:';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Messages;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Promptable;
use Stringable;
class ChatWithSuggestedPromptsAgent implements Agent, HasStructuredOutput, HasTools
{
use Promptable;
public function __construct(
public array $messages
) {}
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'Your role is a savvy helper that assists curious investors in asking thoughtful questions to investment advisors.
You should recommend between 1 and 5 (no more than 5) questions. You should ensure the 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.
Generate between 1 and 5 (no more than 5) follow up questions a curious investor might ask their advisor based on the provided conversation.';
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
/**
* Get the messages available to the agent.
*
* @return Messages[]
*/
public function messages(): iterable
{
return $this->messages;
}
/**
* Get the agent's structured output schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'suggested_prompts' => $schema->array()->items(
$schema->object([
'text' => $schema->string()
->description('Short description of suggested prompt (no more than 5 words)')
->required(),
'value' => $schema->string()
->description('The detailed version of the prompt (think good prompt engineering!)')
->required(),
])->withoutAdditionalProperties()
)->required(),
];
}
}
+18 -1
View File
@@ -33,7 +33,24 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get(); if ($this->empty) {
return collect();
}
return DailyChange::myDailyChanges()
->withDailyPerformance()
->get()
->map(function ($daily_change) {
return [
'date' => date_format($daily_change->date, 'Y-m-d'),
'portfolio_id' => $daily_change->portfolio_id,
'total_market_value' => $daily_change->total_market_value,
'total_cost_basis' => $daily_change->total_cost_basis,
'realized_gains' => $daily_change->realized_gain_dollars,
'total_dividends_earned' => $daily_change->total_dividends_earned,
'annotation' => $daily_change->annotation,
];
});
} }
public function title(): string public function title(): string
+1 -1
View File
@@ -58,7 +58,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'currency' => $transaction->market_data_currency, 'currency' => $transaction->market_data_currency,
'split' => $transaction->split, 'split' => $transaction->split,
'reinvested_dividend' => $transaction->reinvested_dividend, 'reinvested_dividend' => $transaction->reinvested_dividend,
'date' => $transaction->date, 'date' => date_format($transaction->date, 'Y-m-d'),
'created_at' => $transaction->created_at, 'created_at' => $transaction->created_at,
'updated_at' => $transaction->updated_at, 'updated_at' => $transaction->updated_at,
]; ];
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ApiTokenController extends Controller
{
/**
* Show the user API token screen.
*
* @return \Illuminate\View\View
*/
public function index(Request $request)
{
return view('api.index', [
'request' => $request,
'user' => $request->user(),
]);
}
}
@@ -124,7 +124,7 @@ class ConnectedAccountController extends Controller
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]), 'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
'description' => null, 'description' => null,
'css' => 'alert-success', 'css' => 'alert-success',
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"), 'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='o-check-circle' />"),
'position' => 'toast-top toast-end', 'position' => 'toast-top toast-end',
'timeout' => '5000', 'timeout' => '5000',
], ],
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class PrivacyPolicyController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the privacy policy for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$policyFile = $this->localizedMarkdownPath('policy.md');
return view('policy', [
'policy' => Str::markdown(file_get_contents($policyFile)),
]);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class TermsOfServiceController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the terms of service for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$termsFile = $this->localizedMarkdownPath('terms.md');
return view('terms', [
'terms' => Str::markdown(file_get_contents($termsFile)),
]);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UserProfileController extends Controller
{
/**
* Show the user profile screen.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
return view('profile.show', [
'request' => $request,
'user' => $request->user(),
]);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ class TransactionResource extends JsonResource
'sale_price' => $this->sale_price, 'sale_price' => $this->sale_price,
'split' => $this->split, 'split' => $this->split,
'reinvested_dividend' => $this->reinvested_dividend, 'reinvested_dividend' => $this->reinvested_dividend,
'date' => $this->date, 'date' => date_format($this->date, 'Y-m-d'),
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
]; ];
@@ -101,7 +101,7 @@ class AlphaVantageMarketData implements MarketDataInterface
return new Dividend([ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')), 'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
'dividend_amount' => Arr::get($dividend, 'amount'), 'dividend_amount' => (float) Arr::get($dividend, 'amount'),
]); ]);
}); });
} }
@@ -121,7 +121,7 @@ class AlphaVantageMarketData implements MarketDataInterface
return new Split([ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'effective_date')), 'date' => Carbon::parse(Arr::get($split, 'effective_date')),
'split_amount' => Arr::get($split, 'split_factor'), 'split_amount' => (float) Arr::get($split, 'split_factor'),
]); ]);
}); });
} }
@@ -38,6 +38,14 @@ class Quote extends MarketDataType
public function setCurrency(string $currency): self public function setCurrency(string $currency): self
{ {
// need to standardize to ISO 4217
$currency = match ($currency) {
'US' => 'USD',
'CA' => 'CAD',
'GBp' => 'GBX',
default => $currency
};
$this->items['currency'] = strtoupper((string) $currency); $this->items['currency'] = strtoupper((string) $currency);
return $this; return $this;
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Tables;
use App\Models\Holding;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Number;
use Livewire\Component;
class HoldingsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public $portfolio;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->query(
Holding::query()
->portfolio($this->portfolio->id)
->withMarketData()
->withCount(['transactions as num_transactions' => function ($query) {
return $query->whereRaw('transactions.symbol = holdings.symbol');
}])
->withPerformance()
)
->defaultSort('symbol', 'asc')
->paginated(false)
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
->columns([
TextColumn::make('symbol')
->label(__('Symbol'))
->sortable(),
TextColumn::make('market_data.name')
->label(__('Name'))
->sortable(),
TextColumn::make('quantity')
->label(__('Quantity'))
->sortable(),
TextColumn::make('average_cost_basis')
->label(__('Average Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('total_cost_basis')
->label(__('Total Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.market_value')
->label(__('Market Value'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('total_market_value')
->label(__('Total Market Value'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_gain_dollars')
->label(__('Market Gain/Loss'))
->sortable()
->html()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
'costBasis' => $record->average_cost_basis,
'marketValue' => $record->market_data?->market_value,
'small' => true,
])->render()),
TextColumn::make('realized_gain_dollars')
->label(__('Realized Gain/Loss'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('dividends_earned')
->label(__('Dividends Earned'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.fifty_two_week_low')
->label(__('52 week low'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('market_data.fifty_two_week_high')
->label(__('52 week high'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
TextColumn::make('num_transactions')
->label(__('Number of Transactions'))
->sortable(),
TextColumn::make('market_data.updated_at')
->label(__('Last Refreshed'))
->sortable()
->since(),
])
->stackedOnMobile();
}
public function render(): string
{
return <<<'HTML'
<div>
{{ $this->table }}
</div>
HTML;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Tables;
use App\Models\Transaction;
use Carbon\Carbon;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Number;
use Livewire\Component;
class TransactionsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->filters([
SelectFilter::make('transaction_type')
->options([
'BUY' => 'BUY',
'SELL' => 'SELL',
]),
SelectFilter::make('portfolio')
->relationship('portfolio', 'title'),
])
->deferFilters(false)
->query(
Transaction::query()
->with(['portfolio', 'market_data'])
->myTransactions()
->addSelect(['transactions.*'])
->selectRaw('
(CASE
WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price, 0)
ELSE COALESCE((SELECT market_value FROM market_data WHERE market_data.symbol = transactions.symbol LIMIT 1), 0)
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars')
)
->defaultSort('date', 'desc')
->extremePaginationLinks()
->paginated([10])
->defaultPaginationPageOption(10)
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
->columns([
TextColumn::make('date')
->label(__('Date'))
->sortable()
->formatStateUsing(fn ($state) => Carbon::parse($state)->format('M d, Y')),
TextColumn::make('portfolio.title')
->label(__('Portfolio'))
->sortable(),
TextColumn::make('symbol')
->label(__('Symbol'))
->sortable(),
TextColumn::make('market_data.name')
->label(__('Name'))
->sortable(),
TextColumn::make('transaction_type')
->label(__('Type'))
->sortable()
->html()
->formatStateUsing(fn ($state, $record) => view('components.ui.badge', [
'value' => $record->split ? 'SPLIT' : ($record->reinvested_dividend ? 'REINVEST' : $record->transaction_type),
'class' => ($record->transaction_type == 'BUY' ? 'badge-success' : 'badge-error').' badge-sm mr-3',
])->render()),
TextColumn::make('quantity')
->label(__('Quantity'))
->sortable(),
TextColumn::make('cost_basis')
->label(__('Cost Basis'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
TextColumn::make('gain_dollars')
->label(__('Gain/Loss'))
->sortable()
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
]);
}
public function render(): string
{
return <<<'HTML'
<div>
{{ $this->table }}
</div>
HTML;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AgentConversationMessage extends Model
{
protected $table = 'agent_conversation_messages';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'conversation_id',
'user_id',
'agent',
'role',
'content',
];
public function conversation(): BelongsTo
{
return $this->belongsTo(ChatWithConversation::class, 'conversation_id');
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
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();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
class ChatWithConversation extends Model
{
protected $table = 'agent_conversations';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'user_id',
'title',
'chatable_type',
'chatable_id',
];
protected static function boot(): void
{
parent::boot();
static::creating(function (ChatWithConversation $model) {
if (empty($model->id)) {
$model->id = (string) Str::uuid7();
}
});
}
public function chatable(): MorphTo
{
return $this->morphTo();
}
public function messages(): HasMany
{
return $this->hasMany(AgentConversationMessage::class, 'conversation_id');
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ class DailyChange extends Model
->groupBy('portfolio_id', 'date'); ->groupBy('portfolio_id', 'date');
return $query return $query
->select(['daily_change.portfolio_id', 'daily_change.date']) ->select(['daily_change.date', 'daily_change.portfolio_id'])
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value') ->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis') ->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) ->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
+4 -1
View File
@@ -155,7 +155,10 @@ class Dividend extends Model
* dividends.dividend_amount * dividends.dividend_amount
AS total_received AS total_received
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') ")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id') ->join('holdings', function ($join) {
$join->on('transactions.portfolio_id', '=', 'holdings.portfolio_id')
->on('holdings.symbol', '=', 'dividends.symbol');
})
->where('dividends.symbol', $symbol) ->where('dividends.symbol', $symbol)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base'); ->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
+8 -10
View File
@@ -8,7 +8,9 @@ use App\Traits\HasMarketData;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -161,14 +163,10 @@ class Holding extends Model
->orderBy('date', 'DESC'); ->orderBy('date', 'DESC');
} }
/** public function chatWithConversation(): MorphOne
* Related chats for holding
*
* @return void
*/
public function chats()
{ {
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id); return $this->morphOne(ChatWithConversation::class, 'chatable')
->where('user_id', auth()->id());
} }
public function scopeWithMarketData($query) public function scopeWithMarketData($query)
@@ -449,7 +447,7 @@ class Holding extends Model
$this->save(); $this->save();
} }
public function qtyOwned(?\Illuminate\Support\Carbon $date = null) public function qtyOwned(?Carbon $date = null)
{ {
if ($date == null) { if ($date == null) {
$date = now(); $date = now();
@@ -470,8 +468,8 @@ class Holding extends Model
* @return void * @return void
*/ */
public function dailyPerformance( public function dailyPerformance(
?\Illuminate\Support\Carbon $start_date = null, ?Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null, ?Carbon $end_date = null,
) { ) {
if ($start_date == null) { if ($start_date == null) {
$start_date = now(); $start_date = now();
+4 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use App\Models\ChatWithConversation;
use App\Notifications\InvitedOnboardingNotification; use App\Notifications\InvitedOnboardingNotification;
use Carbon\CarbonPeriod; use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -68,14 +69,10 @@ class Portfolio extends Model
return $this->hasMany(DailyChange::class); return $this->hasMany(DailyChange::class);
} }
/** public function chatWithConversation(): \Illuminate\Database\Eloquent\Relations\MorphOne
* Related chats for portfolio
*
* @return void
*/
public function chats()
{ {
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id); return $this->morphOne(ChatWithConversation::class, 'chatable')
->where('user_id', auth()->id());
} }
public function scopeMyPortfolios() public function scopeMyPortfolios()
+1 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\HasConnectedAccounts; use App\Traits\HasConnectedAccounts;
use App\Traits\HasProfilePhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,6 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep; use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Staudenmeir\EloquentHasManyDeep\HasRelationships; use Staudenmeir\EloquentHasManyDeep\HasRelationships;
+15 -2
View File
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Interfaces\MarketData\FallbackInterface;
use App\Interfaces\MarketData\MarketDataInterface;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Number; use Illuminate\Support\Number;
@@ -18,8 +22,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->bind( $this->app->bind(
\App\Interfaces\MarketData\MarketDataInterface::class, MarketDataInterface::class,
\App\Interfaces\MarketData\FallbackInterface::class FallbackInterface::class
); );
} }
@@ -28,6 +32,15 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
FilamentColor::register([
'primary' => Color::Stone,
'gray' => Color::Zinc,
'info' => Color::Blue,
'success' => Color::Emerald,
'warning' => Color::Amber,
'danger' => Color::Red,
]);
JsonResource::withoutWrapping(); JsonResource::withoutWrapping();
Arr::macro('skipEmptyValues', function (array $array) { Arr::macro('skipEmptyValues', function (array $array) {
+1
View File
@@ -30,6 +30,7 @@ class FortifyServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
Fortify::viewPrefix('auth.');
Fortify::createUsersUsing(CreateNewUser::class); Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Jetstream\DeleteUser;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configurePermissions();
Jetstream::deleteUsersUsing(DeleteUser::class);
}
/**
* Configure the permissions that are available within the application.
*/
protected function configurePermissions(): void
{
Jetstream::defaultApiTokenPermissions([
// 'portfolio:read',
// 'portfolio:write',
// 'holding:read',
// 'holding:write',
// 'transaction:read',
// 'transaction:write',
]);
Jetstream::permissions([
// 'Read Portfolios' => 'portfolio:read',
// 'Create Portfolios' => 'portfolio:write',
// 'Read Holdings' => 'holding:read',
// 'Update Holdings' => 'holding:write',
// 'Read Transactions' => 'transaction:read',
// 'Create Transactions' => 'transaction:write',
]);
}
}
+3 -1
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt; use Livewire\Volt\Volt;
use Illuminate\Support\ServiceProvider;
class VoltServiceProvider extends ServiceProvider class VoltServiceProvider extends ServiceProvider
{ {
@@ -26,9 +26,11 @@ class VoltServiceProvider extends ServiceProvider
// config('livewire.view_path', resource_path('views/livewire')), // config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/components'), resource_path('views/components'),
resource_path('views/profile'), resource_path('views/profile'),
resource_path('views/api'),
resource_path('views/holding'), resource_path('views/holding'),
resource_path('views/transaction'), resource_path('views/transaction'),
resource_path('views/portfolio'), resource_path('views/portfolio'),
resource_path('views/import-export'),
resource_path('views/auth'), resource_path('views/auth'),
]); ]);
} }
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Traits;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmPassword;
trait ConfirmsPasswords
{
/**
* Indicates if the user's password is being confirmed.
*
* @var bool
*/
public $confirmingPassword = false;
/**
* The ID of the operation being confirmed.
*
* @var string|null
*/
public $confirmableId = null;
/**
* The user's password.
*
* @var string
*/
public $confirmablePassword = '';
/**
* Start confirming the user's password.
*
* @param string $confirmableId
* @return void
*/
public function startConfirmingPassword(string $confirmableId)
{
$this->resetErrorBag();
if ($this->passwordIsConfirmed()) {
return $this->dispatch('password-confirmed',
id: $confirmableId,
);
}
$this->confirmingPassword = true;
$this->confirmableId = $confirmableId;
$this->confirmablePassword = '';
$this->dispatch('confirming-password');
}
/**
* Stop confirming the user's password.
*
* @return void
*/
public function stopConfirmingPassword()
{
$this->confirmingPassword = false;
$this->confirmableId = null;
$this->confirmablePassword = '';
}
/**
* Confirm the user's password.
*
* @return void
*/
public function confirmPassword()
{
if (! app(ConfirmPassword::class)(app(StatefulGuard::class), Auth::user(), $this->confirmablePassword)) {
throw ValidationException::withMessages([
'confirmable_password' => [__('This password does not match our records.')],
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->dispatch('password-confirmed',
id: $this->confirmableId,
);
$this->stopConfirmingPassword();
}
/**
* Ensure that the user's password has been recently confirmed.
*
* @param int|null $maximumSecondsSinceConfirmation
* @return void
*/
protected function ensurePasswordIsConfirmed($maximumSecondsSinceConfirmation = null)
{
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
$this->passwordIsConfirmed($maximumSecondsSinceConfirmation) ? null : abort(403);
}
/**
* Determine if the user's password has been recently confirmed.
*
* @param int|null $maximumSecondsSinceConfirmation
* @return bool
*/
protected function passwordIsConfirmed($maximumSecondsSinceConfirmation = null)
{
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
return (time() - session('auth.password_confirmed_at', 0)) < $maximumSecondsSinceConfirmation;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Support\Arr;
trait HasLocalizedMarkdown
{
public function localizedMarkdownPath($name)
{
$localName = preg_replace('#(\.md)$#i', '.'.app()->getLocale().'$1', $name);
return Arr::first([
resource_path('markdown/'.$localName),
resource_path('markdown/'.$name),
], function ($path) {
return file_exists($path);
});
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasProfilePhoto
{
/**
* Update the user's profile photo.
*
* @param string $storagePath
* @return void
*/
public function updateProfilePhoto(UploadedFile $photo, $storagePath = 'profile-photos')
{
tap($this->profile_photo_path, function ($previous) use ($photo, $storagePath) {
$this->forceFill([
'profile_photo_path' => $photo->storePublicly(
$storagePath, ['disk' => $this->profilePhotoDisk()]
),
])->save();
if ($previous) {
Storage::disk($this->profilePhotoDisk())->delete($previous);
}
});
}
/**
* Delete the user's profile photo.
*
* @return void
*/
public function deleteProfilePhoto()
{
if (is_null($this->profile_photo_path)) {
return;
}
Storage::disk($this->profilePhotoDisk())->delete($this->profile_photo_path);
$this->forceFill([
'profile_photo_path' => null,
])->save();
}
/**
* Get the URL to the user's profile photo.
*/
protected function profilePhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->profile_photo_path
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
: $this->defaultProfilePhotoUrl();
});
}
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*
* @return string
*/
protected function defaultProfilePhotoUrl()
{
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
return mb_substr($segment, 0, 1);
})->join(' '));
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
}
/**
* Get the disk that profile photos should be stored on.
*
* @return string
*/
protected function profilePhotoDisk()
{
return config('filesystems.default', 'local');
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Support\Facades\Blade;
trait Toast
{
public function toast(
string $type,
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-information-circle',
string $css = 'alert-info',
int $timeout = 3000,
?string $redirectTo = null
) {
$toast = [
'type' => $type,
'title' => $title,
'description' => $description,
'position' => $position,
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='".$icon."' />"),
'css' => $css,
'timeout' => $timeout,
];
$this->js('toast('.json_encode(['toast' => $toast]).')');
if ($redirectTo) {
return $this->redirect($redirectTo, navigate: true);
}
}
public function success(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-check-circle',
string $css = 'alert-success',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('success', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function warning(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-exclamation-triangle',
string $css = 'alert-warning',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('warning', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function error(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-x-circle',
string $css = 'alert-error',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('error', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
public function info(
string $title,
?string $description = null,
?string $position = null,
string $icon = 'o-information-circle',
string $css = 'alert-info',
int $timeout = 3000,
?string $redirectTo = null
) {
return $this->toast('info', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render()
{
return <<<'HTML'
<x-main-layout>
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
<div>
<x-partials.nav-bar />
<x-partials.main with-nav full-width>
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
@livewire('partials.side-bar')
</x-slot:sidebar>
<x-slot:content>
{{ $slot }}
</x-slot:content>
</x-partials.main>
@if(session('toast'))
<script lang="text/javascript">
window.addEventListener('DOMContentLoaded', function () {
window.toast(JSON.parse(@json(session('toast'))))
});
</script>
@endif
<x-toast />
</div>
</x-slot:body>
</x-main-layout>
HTML;
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render()
{
return <<<'HTML'
<x-main-layout>
<x-slot:body class="font-sans text-gray-900 dark:text-gray-100 antialiased">
{{ $slot }}
<x-theme-toggle class="hidden" darkTheme="business" lightTheme="corporate"/>
</x-slot:body>
</x-main-layout>
HTML;
}
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class MainLayout extends Component
{
public function __construct(
// Slots
public mixed $body = null,
) {}
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.main-layout');
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"agents": [
"claude_code"
],
"editors": [
"claude_code",
"vscode"
],
"guidelines": [],
"herd_mcp": true
}
-1
View File
@@ -5,6 +5,5 @@ declare(strict_types=1);
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\VoltServiceProvider::class, App\Providers\VoltServiceProvider::class,
]; ];
+19 -9
View File
@@ -2,28 +2,36 @@
"name": "investbrainapp/investbrain", "name": "investbrainapp/investbrain",
"type": "project", "type": "project",
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages", "description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
"keywords": ["stocks", "dividends", "investments", "tracking"], "keywords": [
"stocks",
"dividends",
"investments",
"tracking"
],
"license": "CC-BY-NC 4.0", "license": "CC-BY-NC 4.0",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"ext-gd": "*", "ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-zip": "*", "ext-zip": "*",
"blade-ui-kit/blade-heroicons": "^2.6",
"filament/tables": "^5.0",
"finnhub/client": "master@dev", "finnhub/client": "master@dev",
"hackeresq/filter-models": "dev-main", "hackeresq/filter-models": "dev-main",
"investbrainapp/frankfurter-client": "dev-main", "investbrainapp/frankfurter-client": "dev-main",
"laravel/framework": "^11.35", "laravel/ai": "^0.2.5",
"laravel/jetstream": "^5.1", "laravel/fortify": "^1.30.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.16", "laravel/socialite": "^5.16",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^3.5", "livewire/livewire": "^4.0",
"livewire/volt": "^1.6", "livewire/volt": "^1.6",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"openai-php/client": "^0.10.3", "openai-php/client": "^0.10.3",
"predis/predis": "^2.2", "predis/predis": "^2.2",
"robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^5.0", "scheb/yahoo-finance-api": "^5.0",
"staudenmeir/eloquent-has-many-deep": "^1.20", "staudenmeir/eloquent-has-many-deep": "^1.20",
"symfony/cache": "^7.3", "symfony/cache": "^7.3",
@@ -31,8 +39,8 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/pint": "^1.13", "laravel/boost": "^1.8",
"laravel/sail": "^1.26", "laravel/pint": "^1.25",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0" "phpunit/phpunit": "^11.0"
@@ -69,10 +77,12 @@
"scripts": { "scripts": {
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
], ],
"post-update-cmd": [ "post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force" "@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update --ansi; exit 0"
], ],
"post-root-package-install": [ "post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
Generated
+2716 -1266
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default AI Provider Names
|--------------------------------------------------------------------------
|
| Here you may specify which of the AI providers below should be the
| default for AI operations when no explicit provider is provided
| for the operation. This should be any provider defined below.
|
*/
'default' => env('CHAT_PROVIDER', 'openai'),
'default_for_images' => 'gemini',
'default_for_audio' => 'openai',
'default_for_transcription' => 'openai',
'default_for_embeddings' => 'openai',
'default_for_reranking' => 'cohere',
'models' => [
'text' => [
'default' => env('CHAT_MODEL', null),
],
],
/*
|--------------------------------------------------------------------------
| Caching
|--------------------------------------------------------------------------
|
| Below you may configure caching strategies for AI related operations
| such as embedding generation. You are free to adjust these values
| based on your application's available caching stores and needs.
|
*/
'caching' => [
'embeddings' => [
'cache' => false,
'store' => env('CACHE_STORE', 'database'),
],
],
/*
|--------------------------------------------------------------------------
| AI Providers
|--------------------------------------------------------------------------
|
| Below are each of your AI providers defined for this application. Each
| represents an AI provider and API key combination which can be used
| to perform tasks like text, image, and audio creation via agents.
|
*/
'providers' => [
'anthropic' => [
'driver' => 'anthropic',
'key' => env('ANTHROPIC_API_KEY'),
],
'azure' => [
'driver' => 'azure',
'key' => env('AZURE_OPENAI_API_KEY'),
'url' => env('AZURE_OPENAI_URL'),
'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'),
'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'),
'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'),
],
'cohere' => [
'driver' => 'cohere',
'key' => env('COHERE_API_KEY'),
],
'deepseek' => [
'driver' => 'deepseek',
'key' => env('DEEPSEEK_API_KEY'),
],
'eleven' => [
'driver' => 'eleven',
'key' => env('ELEVENLABS_API_KEY'),
],
'gemini' => [
'driver' => 'gemini',
'key' => env('GEMINI_API_KEY'),
],
'groq' => [
'driver' => 'groq',
'key' => env('GROQ_API_KEY'),
],
'jina' => [
'driver' => 'jina',
'key' => env('JINA_API_KEY'),
],
'mistral' => [
'driver' => 'mistral',
'key' => env('MISTRAL_API_KEY'),
],
'ollama' => [
'driver' => 'ollama',
'key' => env('OLLAMA_API_KEY', ''),
'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
],
'openai' => [
'driver' => 'openai',
'key' => env('OPENAI_API_KEY'),
],
'openrouter' => [
'driver' => 'openrouter',
'key' => env('OPENROUTER_API_KEY'),
],
'voyageai' => [
'driver' => 'voyageai',
'key' => env('VOYAGEAI_API_KEY'),
],
'xai' => [
'driver' => 'xai',
'key' => env('XAI_API_KEY'),
],
],
];
-81
View File
@@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
return [
/*
|--------------------------------------------------------------------------
| Jetstream Stack
|--------------------------------------------------------------------------
|
| This configuration value informs Jetstream which "stack" you will be
| using for your application. In general, this value is set for you
| during installation and will not need to be changed after that.
|
*/
'stack' => 'livewire',
/*
|--------------------------------------------------------------------------
| Jetstream Route Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Jetstream will assign to the routes
| that it registers with the application. When necessary, you may modify
| these middleware; however, this default value is usually sufficient.
|
*/
'middleware' => ['web'],
'auth_session' => AuthenticateSession::class,
/*
|--------------------------------------------------------------------------
| Jetstream Guard
|--------------------------------------------------------------------------
|
| Here you may specify the authentication guard Jetstream will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'sanctum',
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of Jetstream's features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::profilePhotos(),
Features::api(),
Features::accountDeletion(),
],
/*
|--------------------------------------------------------------------------
| Profile Photo Disk
|--------------------------------------------------------------------------
|
| This configuration value determines the default disk that will be used
| when storing profile photos for your application's users. Typically
| this will be the "public" disk but you may adjust this if needed.
|
*/
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
];
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'theme' => 'tailwind',
/**
* Enable or Disable automatic injection of core assets
*/
'inject_core_assets_enabled' => false,
/**
* Enable or Disable automatic injection of third-party assets
*/
'inject_third_party_assets_enabled' => false,
/**
* Enable Blade Directives (Not required if automatically injecting or using bundler approaches)
*/
'enable_blade_directives ' => false,
];
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
return [
/**
* Default component prefix.
*
* Make sure to clear view cache after renaming with `php artisan view:clear`
*
* prefix => ''
* <x-button />
* <x-card />
*
* prefix => 'mary-'
* <x-mary-button />
* <x-mary-card />
*/
'prefix' => '',
/**
* Default route prefix.
*
* Some maryUI components make network request to its internal routes.
*
* route_prefix => ''
* - Spotlight: '/mary/spotlight'
* - Editor: '/mary/upload'
* - ...
*
* route_prefix => 'my-components'
* - Spotlight: '/my-components/mary/spotlight'
* - Editor: '/my-components/mary/upload'
* - ...
*/
'route_prefix' => '',
/**
* Components settings
*/
'components' => [
'spotlight' => [
'class' => 'App\Support\Spotlight',
],
],
];
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Ai\Migrations\AiMigration;
return new class extends AiMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('agent_conversations', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade')->nullable();
$table->string('title');
$table->timestamps();
$table->index(['user_id', 'updated_at']);
});
Schema::create('agent_conversation_messages', function (Blueprint $table) {
$table->string('id', 36)->primary();
$table->string('conversation_id', 36)->index();
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade')->nullable();
$table->string('agent');
$table->string('role', 25);
$table->text('content');
$table->text('attachments');
$table->text('tool_calls');
$table->text('tool_results');
$table->text('usage');
$table->text('meta');
$table->timestamps();
$table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index');
$table->index(['user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('agent_conversations');
Schema::dropIfExists('agent_conversation_messages');
}
};
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('agent_conversations', function (Blueprint $table) {
$table->nullableUuidMorphs('chatable');
$table->unique(['user_id', 'chatable_type', 'chatable_id'], 'chat_with_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('agent_conversations', function (Blueprint $table) {
$table->dropUnique('chat_with_unique');
$table->dropMorphs('chatable');
});
}
};
+4 -4
View File
@@ -1,5 +1,5 @@
# Stage 1: Build stage # Stage 1: Build stage
FROM php:8.3-fpm AS builder FROM php:8.4-fpm AS builder
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
ENV APP_NAME=Investbrain ENV APP_NAME=Investbrain
@@ -39,7 +39,7 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local
&& rm -rf node_modules && rm -rf node_modules
# Stage 2: Production stage # Stage 2: Production stage
FROM php:8.3-fpm-alpine FROM php:8.4-fpm-alpine
# Set the working directory # Set the working directory
WORKDIR /var/app WORKDIR /var/app
@@ -71,8 +71,8 @@ RUN apk add --no-cache \
RUN rm -rf /var/www/html \ RUN rm -rf /var/www/html \
&& ln -s /var/app /var/www/app && ln -s /var/app /var/www/app
# Create required directories for supervisord # Create required directories
RUN mkdir -p /var/log/supervisor /var/run/supervisor RUN mkdir -p /var/log/supervisor /var/run/supervisor /var/run/nginx
# Copy over configs # Copy over configs
COPY ./docker/nginx.conf /etc/nginx/http.d/default.conf COPY ./docker/nginx.conf /etc/nginx/http.d/default.conf
+3
View File
@@ -15,6 +15,9 @@ mkdir -p storage/framework/cache \
storage/app \ storage/app \
storage/logs storage/logs
timestamp=$(date -u "+[%Y-%m-%d %H:%M:%S]")
echo "$timestamp Investbrain starting ($VERSION)..." >> storage/logs/laravel.log
echo -e "\n > Storage directory scaffolding is OK... " echo -e "\n > Storage directory scaffolding is OK... "
# Ensure storage directory is permissioned for www-data # Ensure storage directory is permissioned for www-data
+12 -15
View File
@@ -15,6 +15,7 @@
"Cancel": "Cancel", "Cancel": "Cancel",
"Save": "Save", "Save": "Save",
"Close": "Close", "Close": "Close",
"Dismiss": "Dismiss",
"or": "or", "or": "or",
"and": "and", "and": "and",
"Yes": "Yes", "Yes": "Yes",
@@ -28,21 +29,14 @@
"Permanently delete your account.": "Permanently delete your account.", "Permanently delete your account.": "Permanently delete your account.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.", "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
"Browser Sessions": "Browser Sessions",
"Manage and log out your active sessions on other browsers and devices.": "Manage and log out your active sessions on other browsers and devices.",
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.",
"This device": "This device",
"Last active": "Last active",
"Log Out Other Browser Sessions": "Log Out Other Browser Sessions",
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.",
"Two Factor Authentication": "Two Factor Authentication", "Two Factor Authentication": "Two Factor Authentication",
"Add additional security to your account using two factor authentication.": "Add additional security to your account using two factor authentication.", "Add additional security to your account using two factor authentication.": "Add additional security to your account using two factor authentication.",
"Finish enabling two factor authentication.": "Finish enabling two factor authentication.", "Finish enabling two factor authentication.": "Finish enabling two factor authentication.",
"You have enabled two factor authentication.": "You have enabled two factor authentication.", "You have enabled two factor authentication.": "You have enabled two factor authentication.",
"You have not enabled two factor authentication.": "You have not enabled two factor authentication.", "You have not enabled two factor authentication.": "You have not enabled two factor authentication.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.", "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.",
"To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.", "To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.",
"Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.", "Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.",
"Setup Key": "Setup Key", "Setup Key": "Setup Key",
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.", "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.",
"Regenerate Recovery Codes": "Regenerate Recovery Codes", "Regenerate Recovery Codes": "Regenerate Recovery Codes",
@@ -57,7 +51,7 @@
"Your :provider account has been connected.": "Your :provider account has been connected.", "Your :provider account has been connected.": "Your :provider account has been connected.",
"Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.", "Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.",
"Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.", "Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.",
"Update your account\\'s profile information and email address.": "Update your account\\'s profile information and email address.", "Update your account's profile information and email address.": "Update your account's profile information and email address.",
"Photo": "Photo", "Photo": "Photo",
"Select A New Photo": "Select A New Photo", "Select A New Photo": "Select A New Photo",
"Remove Photo": "Remove Photo", "Remove Photo": "Remove Photo",
@@ -68,7 +62,9 @@
"Last used": "Last used", "Last used": "Last used",
"Delete": "Delete", "Delete": "Delete",
"API Token": "API Token", "API Token": "API Token",
"Please copy your new API token. For your security, it won\\'t be shown again.": "Please copy your new API token. For your security, it won\\'t be shown again.", "Please copy your new API token. For your security, it won't be shown again.": "Please copy your new API token. For your security, it won't be shown again.",
"Copy to clipboard": "Copy to clipboard",
"Successfully copied!": "Successfully copied!",
"API Token Permissions": "API Token Permissions", "API Token Permissions": "API Token Permissions",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.", "API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
"Delete API Token": "Delete API Token", "Delete API Token": "Delete API Token",
@@ -96,7 +92,7 @@
"Recovery Code": "Recovery Code", "Recovery Code": "Recovery Code",
"Use a recovery code": "Use a recovery code", "Use a recovery code": "Use a recovery code",
"Use an authentication code": "Use an authentication code", "Use an authentication code": "Use an authentication code",
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.", "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.",
"A new verification link has been sent to the email address you provided in your profile settings.": "A new verification link has been sent to the email address you provided in your profile settings.", "A new verification link has been sent to the email address you provided in your profile settings.": "A new verification link has been sent to the email address you provided in your profile settings.",
"Resend Verification Email": "Resend Verification Email", "Resend Verification Email": "Resend Verification Email",
"Edit Profile": "Edit Profile", "Edit Profile": "Edit Profile",
@@ -114,8 +110,9 @@
"The provided password does not match your current password.": "The provided password does not match your current password.", "The provided password does not match your current password.": "The provided password does not match your current password.",
"Documentation": "Documentation", "Documentation": "Documentation",
"We\\'re open source!": "We\\'re open source!", "We're open source!": "We're open source!",
"Toggle Theme": "Toggle Theme", "Toggle Theme": "Toggle Theme",
"Toggle Sidebar": "Toggle Sidebar",
"Dashboard": "Dashboard", "Dashboard": "Dashboard",
"Gain/Loss": "Gain/Loss", "Gain/Loss": "Gain/Loss",
@@ -333,7 +330,7 @@
"passwords.sent": "We have emailed your password reset link.", "passwords.sent": "We have emailed your password reset link.",
"passwords.throttled": "Please wait before retrying.", "passwords.throttled": "Please wait before retrying.",
"passwords.token": "This password reset token is invalid.", "passwords.token": "This password reset token is invalid.",
"passwords.user": "We can\\'t find a user with that email address.", "passwords.user": "We can't find a user with that email address.",
"pagination.previous": "&laquo; Previous", "pagination.previous": "&laquo; Previous",
"pagination.next": "Next &raquo;", "pagination.next": "Next &raquo;",
+4 -7
View File
@@ -15,6 +15,7 @@
"Cancel": "Cancelar", "Cancel": "Cancelar",
"Save": "Guardar", "Save": "Guardar",
"Close": "Cerrar", "Close": "Cerrar",
"Dismiss": "Despedir",
"or": "o", "or": "o",
"and": "y", "and": "y",
"Yes": "Sí", "Yes": "Sí",
@@ -28,13 +29,6 @@
"Permanently delete your account.": "Elimina tu cuenta de forma permanente.", "Permanently delete your account.": "Elimina tu cuenta de forma permanente.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Antes de eliminar tu cuenta, descarga cualquier dato o información que desees conservar.", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Antes de eliminar tu cuenta, descarga cualquier dato o información que desees conservar.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "¿Estás seguro de que deseas eliminar tu cuenta? Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Introduce tu contraseña para confirmar que deseas eliminar tu cuenta de forma permanente.", "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "¿Estás seguro de que deseas eliminar tu cuenta? Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Introduce tu contraseña para confirmar que deseas eliminar tu cuenta de forma permanente.",
"Browser Sessions": "Sesiones del Navegador",
"Manage and log out your active sessions on other browsers and devices.": "Gestiona y cierra sesión en tus sesiones activas en otros navegadores y dispositivos.",
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "Si es necesario, puedes cerrar sesión en todas tus otras sesiones del navegador en todos tus dispositivos. Algunas de tus sesiones recientes se enumeran a continuación; sin embargo, esta lista puede no ser exhaustiva. Si sientes que tu cuenta ha sido comprometida, también deberías actualizar tu contraseña.",
"This device": "Este dispositivo",
"Last active": "Última actividad",
"Log Out Other Browser Sessions": "Cerrar Sesión en Otras Sesiones del Navegador",
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Introduce tu contraseña para confirmar que deseas cerrar sesión en tus otras sesiones del navegador en todos tus dispositivos.",
"Two Factor Authentication": "Autenticación de Dos Factores", "Two Factor Authentication": "Autenticación de Dos Factores",
"Add additional security to your account using two factor authentication.": "Añade seguridad adicional a tu cuenta utilizando autenticación de dos factores.", "Add additional security to your account using two factor authentication.": "Añade seguridad adicional a tu cuenta utilizando autenticación de dos factores.",
"Finish enabling two factor authentication.": "Finaliza la activación de la autenticación de dos factores.", "Finish enabling two factor authentication.": "Finaliza la activación de la autenticación de dos factores.",
@@ -69,6 +63,8 @@
"Delete": "Eliminar", "Delete": "Eliminar",
"API Token": "Token API", "API Token": "Token API",
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.", "Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
"Copy to clipboard": "Copiar al portapapeles",
"Successfully copied!": "Copiado con éxito!",
"API Token Permissions": "Permisos del Token API", "API Token Permissions": "Permisos del Token API",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.", "API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
"Delete API Token": "Eliminar Token API", "Delete API Token": "Eliminar Token API",
@@ -116,6 +112,7 @@
"Documentation": "Documentación", "Documentation": "Documentación",
"We're open source!": "¡Somos de código abierto!", "We're open source!": "¡Somos de código abierto!",
"Toggle Theme": "Cambiar Tema", "Toggle Theme": "Cambiar Tema",
"Toggle Sidebar": "Alternar Navegación Lateral",
"Dashboard": "Tablero", "Dashboard": "Tablero",
"Gain/Loss": "Ganancia/Pérdida", "Gain/Loss": "Ganancia/Pérdida",
+679 -1301
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -8,15 +8,20 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@tailwindcss/vite": "^4.1.13",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"axios": "^1.7.4", "axios": "^1.7.4",
"daisyui": "^4.12.10", "daisyui": "^5.1.14",
"laravel-vite-plugin": "^1.0", "laravel-vite-plugin": "^1.0",
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^4.1.13",
"vite": "^5.4" "vite": "^5.4"
}, },
"dependencies": { "dependencies": {
"@alpinejs/focus": "^3.15.0",
"@alpinejs/persist": "^3.15.4",
"@alpinejs/resize": "^3.14.9",
"alpinejs": "^3.14.9",
"apexcharts": "^3.51.0" "apexcharts": "^3.51.0"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svgjs="http://svgjs.dev/svgjs" viewBox="0 0 700 700" width="700" height="700" opacity="0.25">
<defs>
<filter id="nnnoise-filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox"
primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.2" numOctaves="4" seed="15" stitchTiles="stitch" x="0%"
y="0%" width="100%" height="100%" result="turbulence"></feTurbulence>
<feSpecularLighting surfaceScale="5" specularConstant="1" specularExponent="20" lighting-color="#3939A8"
x="0%" y="0%" width="100%" height="100%" in="turbulence" result="specularLighting">
<feDistantLight azimuth="3" elevation="18"></feDistantLight>
</feSpecularLighting>
<feColorMatrix type="saturate" values="0" x="0%" y="0%" width="100%" height="100%" in="specularLighting"
result="colormatrix"></feColorMatrix>
</filter>
</defs>
<rect width="700" height="700" fill="transparent"></rect>
<rect width="700" height="700" class="fill-current" filter="url(#nnnoise-filter)"></rect>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
@@ -0,0 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};
@@ -0,0 +1 @@
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
@@ -0,0 +1 @@
(()=>{function c(s,t=()=>{}){let i=!1;return function(){i?t.apply(this,arguments):(i=!0,s.apply(this,arguments))}}var d=s=>{s.data("notificationComponent",({notification:t})=>({isShown:!1,computedStyle:null,transitionDuration:null,transitionEasing:null,unsubscribeLivewireHook:null,init(){this.computedStyle=window.getComputedStyle(this.$el),this.transitionDuration=parseFloat(this.computedStyle.transitionDuration)*1e3,this.transitionEasing=this.computedStyle.transitionTimingFunction,this.configureTransitions(),this.configureAnimations(),t.duration&&t.duration!=="persistent"&&setTimeout(()=>{if(!this.$el.matches(":hover")){this.close();return}this.$el.addEventListener("mouseleave",()=>this.close())},t.duration),this.isShown=!0},configureTransitions(){let i=this.computedStyle.display,e=()=>{s.mutateDom(()=>{this.$el.style.setProperty("display",i),this.$el.style.setProperty("visibility","visible")}),this.$el._x_isShown=!0},o=()=>{s.mutateDom(()=>{this.$el._x_isShown?this.$el.style.setProperty("visibility","hidden"):this.$el.style.setProperty("display","none")})},r=c(n=>n?e():o(),n=>{this.$el._x_toggleAndCascadeWithTransitions(this.$el,n,e,o)});s.effect(()=>r(this.isShown))},configureAnimations(){let i;this.unsubscribeLivewireHook=Livewire.interceptMessage(({onFinish:e,onSuccess:o})=>{requestAnimationFrame(()=>{let r=()=>this.$el.getBoundingClientRect().top,n=r();e(()=>{i=()=>{this.isShown&&this.$el.animate([{transform:`translateY(${n-r()}px)`},{transform:"translateY(0px)"}],{duration:this.transitionDuration,easing:this.transitionEasing})},this.$el.getAnimations().forEach(l=>l.finish())}),o(({payload:l})=>{l?.snapshot?.data?.isFilamentNotificationsComponent&&typeof i=="function"&&i()})})})},close(){this.isShown=!1,setTimeout(()=>window.dispatchEvent(new CustomEvent("notificationClosed",{detail:{id:t.id}})),this.transitionDuration)},markAsRead(){window.dispatchEvent(new CustomEvent("markedNotificationAsRead",{detail:{id:t.id}}))},markAsUnread(){window.dispatchEvent(new CustomEvent("markedNotificationAsUnread",{detail:{id:t.id}}))},destroy(){this.unsubscribeLivewireHook?.()}}))};var h=class{constructor(){return this.id(crypto.randomUUID?.()??"10000000-1000-4000-8000-100000000000".replace(/[018]/g,t=>(+t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>+t/4).toString(16))),this}id(t){return this.id=t,this}title(t){return this.title=t,this}body(t){return this.body=t,this}actions(t){return this.actions=t,this}status(t){return this.status=t,this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconColor(t){return this.iconColor=t,this}duration(t){return this.duration=t,this}seconds(t){return this.duration(t*1e3),this}persistent(){return this.duration("persistent"),this}danger(){return this.status("danger"),this}info(){return this.status("info"),this}success(){return this.status("success"),this}warning(){return this.status("warning"),this}view(t){return this.view=t,this}viewData(t){return this.viewData=t,this}send(){return window.dispatchEvent(new CustomEvent("notificationSent",{detail:{notification:this}})),this}},a=class{constructor(t){return this.name(t),this}name(t){return this.name=t,this}color(t){return this.color=t,this}dispatch(t,i){return this.event(t),this.eventData(i),this}dispatchSelf(t,i){return this.dispatch(t,i),this.dispatchDirection="self",this}dispatchTo(t,i,e){return this.dispatch(i,e),this.dispatchDirection="to",this.dispatchToComponent=t,this}emit(t,i){return this.dispatch(t,i),this}emitSelf(t,i){return this.dispatchSelf(t,i),this}emitTo(t,i,e){return this.dispatchTo(t,i,e),this}dispatchDirection(t){return this.dispatchDirection=t,this}dispatchToComponent(t){return this.dispatchToComponent=t,this}event(t){return this.event=t,this}eventData(t){return this.eventData=t,this}extraAttributes(t){return this.extraAttributes=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}outlined(t=!0){return this.isOutlined=t,this}disabled(t=!0){return this.isDisabled=t,this}label(t){return this.label=t,this}close(t=!0){return this.shouldClose=t,this}openUrlInNewTab(t=!0){return this.shouldOpenUrlInNewTab=t,this}size(t){return this.size=t,this}url(t){return this.url=t,this}view(t){return this.view=t,this}button(){return this.view("filament::components.button.index"),this}grouped(){return this.view("filament::components.dropdown.list.item"),this}iconButton(){return this.view("filament::components.icon-button"),this}link(){return this.view("filament::components.link"),this}},u=class{constructor(t){return this.actions(t),this}actions(t){return this.actions=t.map(i=>i.grouped()),this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}label(t){return this.label=t,this}tooltip(t){return this.tooltip=t,this}};window.FilamentNotificationAction=a;window.FilamentNotificationActionGroup=u;window.FilamentNotification=h;document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})();
@@ -0,0 +1 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
@@ -0,0 +1 @@
function v({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:r}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:i,onSuccess:a})=>{a(()=>{this.$nextTick(()=>{if(i.component.id!==g)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,a,l,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let c=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),o=Math.ceil(c.clientWidth),d=s?Math.ceil(s.clientWidth):0;return{label:o,badge:d,total:o+(d>0?a+d:0)}});for(let n=0;n<t.length;n++){let c=u.slice(0,n+1).reduce((p,y)=>p+y,0),s=n*i,o=b.slice(n+1),d=o.length>0,D=d?Math.max(...o.map(p=>p.total)):0,W=d?l+D+a+h+i:0;if(c+s+W>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),a=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),c=this.findOverflowIndex(i,l,h,b,n,u);i.forEach((s,o)=>s.style.display=a[o]),c!==-1&&(this.withinDropdownIndex=c),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{v as default};
@@ -0,0 +1 @@
function p({isSkippable:i,isStepPersistedInQueryString:n,key:r,startStep:o,stepQueryStringKey:h}){return{step:null,init(){this.step=this.getSteps().at(o-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(h,this.step),history.replaceState(null,document.title,t.toString())}}}export{p as default};
+1
View File
@@ -0,0 +1 @@
(()=>{var o=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var a=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},d=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i})=>({handleFormValidationError(e){e.detail.livewireId===i&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let t=n;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(a(e,t,r)),$set:(t,r,s,l=!1)=>n.$set(a(e,t,s),r,l),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",o),Livewire.interceptMessage(({message:i,onSuccess:e})=>{e(({payload:n})=>{n.effects?.dispatches?.forEach(t=>{if(!t.params?.awaitSchemaComponent)return;let r=Array.from(i.component.el.querySelectorAll(`[wire\\:partial="schema-component::${t.params.awaitSchemaComponent}"]`)).filter(s=>d(s)===i.component);if(r.length!==1){if(r.length>1)throw`Multiple schema components found with key [${t.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${component.id}-${t.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(t.name,{detail:t.params}))},{once:!0})}})})})});})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||this.getNormalizedState()===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
@@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};
File diff suppressed because one or more lines are too long
+186 -5
View File
@@ -1,11 +1,192 @@
@tailwind base; @import url("https://fonts.bunny.net/css?family=Inter:400,500,600&display=swap");
@tailwind components;
@tailwind utilities;
[x-cloak] { @import 'tailwindcss';
display: none;
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Inter", sans-serif;
} }
@plugin "daisyui" {
themes: light, dark --default --prefersdark;
}
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@plugin "daisyui/theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: dark;
--animation-input: 0.15s;
--radius-selector: 0.15rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--color-primary: "#78716c";
--color-primary-content: "#e3e1e0";
--color-secondary: "#7a7a7a";
--color-secondary-content: "#d1d1d5";
--color-accent: "#8c9ae3";
--color-accent-content: "#d3d4dd";
--color-neutral: "#302f3c";
--color-neutral-content: "#d1d1d5";
--color-base-100: "#20202A";
--color-base-200: "#1a1a23";
--color-base-300: "#15151c";
--color-base-content: "#cecdd0";
--color-info: "#1e40af";
--color-info-content: "#ced9f2";
--color-success: "#166534";
--color-success-content: "#d1dfd3";
--color-warning: "#a16207";
--color-warning-content: "#eddfd1";
--color-error: "#991b1b";
--color-error-content: "#efd3cf";
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "light";
prefersdark: false;
color-scheme: light;
--animation-input: 0.15s;
--radius-selector: 0.15rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--color-primary: "#d6d3d1";
--color-primary-content: "#101010";
--color-secondary: "#9ca3af";
--color-secondary-content: "#090a0b";
--color-accent: "#525783";
--color-accent-content: "#110c16";
--color-neutral: "#6b7280";
--color-neutral-content: "#e0e1e4";
--color-base-100: "oklch(100% 0 0)";
--color-base-200: "oklch(97.466% 0.011 259.822)";
--color-base-300: "oklch(0.95 0.016 244.89)";
--color-base-content: "#161616";
--color-info: "#60a5fa";
--color-info-content: "#030a15";
--color-success: "#10b981";
--color-success-content: "#000d06";
--color-warning: "#fb923c";
--color-warning-content: "#150801";
--color-error: "#ef4444";
--color-error-content: "#140202";
--depth: 0;
--noise: 0;
}
/* Required by all components */
@import '../../vendor/filament/support/resources/css/index.css';
/* Required by actions and tables */
@import '../../vendor/filament/actions/resources/css/index.css';
/* Required by actions, forms and tables */
@import '../../vendor/filament/forms/resources/css/index.css';
/* Required by actions, infolists, forms, schemas and tables */
@import '../../vendor/filament/schemas/resources/css/index.css';
/* Required by tables */
@import '../../vendor/filament/tables/resources/css/index.css';
@source "../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php";
@source "../../storage/framework/views/*.php";
@source "../**/*.blade.php";
/* table overrides */
.fi-ta-ctn {
@apply bg-transparent rounded-none border-none ring-0 shadow-none;
& table.fi-ta-table thead tr {
@apply bg-transparent;
}
& table.fi-ta-table thead tr th.fi-ta-header-cell {
color: color-mix(in oklab,var(--color-base-content)60%,transparent) !important;
text-transform: uppercase;
letter-spacing: .05rem;
font-size: .725rem;
font-weight: normal;
& .fi-icon {
color: color-mix(in oklab,var(--color-base-content)60%,transparent) !important;
}
}
& table.fi-ta-table .fi-clickable:hover {
background-color: color-mix(in oklab,var(--color-base-200)50%,transparent) !important;
}
& [data-theme=dark] table.fi-ta-table .fi-clickable:hover {
background-color: var(--color-base-100);
}
& ol.fi-pagination-items {
& .fi-pagination-item-label {
color: var(--color-base-content);
}
}
& td.fi-ta-cell div.fi-ta-text {
color: var(--color-base-content);
}
& table.fi-ta-table.fi-ta-table-stacked-on-mobile td {
padding-block: 0;
}
& .fi-ta-header-toolbar {
border-bottom-width: 0;
}
}
/* Tool tip for apex charts */
[data-theme=dark] .apexcharts-tooltip-title {
background: #292933 !important;
border-bottom: 1px solid #393939 !important;
}
[data-theme=dark] .apexcharts-tooltip {
background: #20202A !important;
border-color: #393939 !important;
}
/* Wiggle animation */
@keyframes wiggle {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
.wiggle {
animation: wiggle 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
/* AI chat styles */
.ai-chat ul, .ai-chat ol, .ai-chat ol li > ul { .ai-chat ul, .ai-chat ol, .ai-chat ol li > ul {
margin-left: 1.1rem; margin-left: 1.1rem;
} }
+269 -56
View File
@@ -1,5 +1,195 @@
<div> <?php
<!-- Generate API Token -->
use App\Traits\Toast;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Livewire\Volt\Component;
new class extends Component
{
use Toast;
/**
* The create API token form state.
*
* @var array
*/
public $createApiTokenForm = [
'name' => '',
'permissions' => [],
];
/**
* Indicates if the plain text token is being displayed to the user.
*
* @var bool
*/
public $displayingToken = false;
/**
* The plain text token value.
*
* @var string|null
*/
public $plainTextToken;
/**
* Indicates if the user is currently managing an API token's permissions.
*
* @var bool
*/
public $managingApiTokenPermissions = false;
/**
* The token that is currently having its permissions managed.
*
* @var \Laravel\Sanctum\PersonalAccessToken|null
*/
public $managingPermissionsFor;
/**
* The update API token form state.
*
* @var array
*/
public $updateApiTokenForm = [
'permissions' => [],
];
/**
* Indicates if the application is confirming if an API token should be deleted.
*
* @var bool
*/
public $confirmingApiTokenDeletion = false;
/**
* The ID of the API token being deleted.
*
* @var int
*/
public $apiTokenIdBeingDeleted;
/**
* Mount the component.
*
* @return void
*/
public function mount()
{
//
}
/**
* Create a new API token.
*
* @return void
*/
public function createApiToken()
{
$this->resetErrorBag();
Validator::make([
'name' => $this->createApiTokenForm['name'],
], [
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createApiToken');
$this->displayTokenValue($this->user->createToken(
$this->createApiTokenForm['name']
));
$this->createApiTokenForm['name'] = '';
$this->dispatch('created');
}
/**
* Display the token value to the user.
*
* @param \Laravel\Sanctum\NewAccessToken $token
* @return void
*/
protected function displayTokenValue($token)
{
$this->displayingToken = true;
$this->plainTextToken = explode('|', $token->plainTextToken, 2)[1];
}
/**
* Allow the given token's permissions to be managed.
*
* @param int $tokenId
* @return void
*/
public function manageApiTokenPermissions($tokenId)
{
$this->managingApiTokenPermissions = true;
$this->managingPermissionsFor = $this->user->tokens()->where(
'id', $tokenId
)->firstOrFail();
$this->updateApiTokenForm['permissions'] = $this->managingPermissionsFor->abilities;
}
/**
* Update the API token's permissions.
*
* @return void
*/
public function updateApiToken()
{
$this->managingPermissionsFor->forceFill([
'abilities' => [],
])->save();
$this->managingApiTokenPermissions = false;
}
/**
* Confirm that the given API token should be deleted.
*
* @param int $tokenId
* @return void
*/
public function confirmApiTokenDeletion($tokenId)
{
$this->confirmingApiTokenDeletion = true;
$this->apiTokenIdBeingDeleted = $tokenId;
}
/**
* Delete the API token.
*
* @return void
*/
public function deleteApiToken()
{
$this->user->tokens()->where('id', $this->apiTokenIdBeingDeleted)->first()->delete();
$this->user->load('tokens');
$this->confirmingApiTokenDeletion = false;
$this->managingPermissionsFor = null;
}
/**
* Get the current user of the application.
*
* @return mixed
*/
public function getUserProperty()
{
return Auth::user();
}
}; ?>
<div x-data>
{{-- Generate API Token --}}
<x-forms.form-section submit="createApiToken"> <x-forms.form-section submit="createApiToken">
<x-slot name="title"> <x-slot name="title">
{{ __('Create API Token') }} {{ __('Create API Token') }}
@@ -10,13 +200,13 @@
</x-slot> </x-slot>
<x-slot name="form"> <x-slot name="form">
<!-- Token Name --> {{-- Token Name --}}
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<x-input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus /> <x-ui.input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus />
</div> </div>
<!-- Token Permissions --> {{-- Token Permissions --}}
@if (Laravel\Jetstream\Jetstream::hasPermissions()) @if (false)
<div class="col-span-6"> <div class="col-span-6">
<label class="pt-0 label label-text font-semibold"> <label class="pt-0 label label-text font-semibold">
<span> <span>
@@ -25,15 +215,63 @@
</span> </span>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission) @foreach ([] as $label => $permission)
<label class="flex items-center"> <label class="flex items-center">
<x-checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/> <x-ui.checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/>
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span> <span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
</label> </label>
@endforeach @endforeach
</div> </div>
</div> </div>
@endif @endif
{{-- Token Value Modal --}}
<x-ui.modal
persistent
key="token-display-modal"
wire:model.live="displayingToken"
title="{{ __('API Token') }}"
>
<div class="mt-2 text-sm text-secondary-content">
<div class="mb-4">
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
</div>
<x-ui.input
x-ref="plaintextToken"
type="text"
readonly
:value="$plainTextToken"
class="font-mono break-all focus:outline-none focus:ring-0"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
<x-slot:suffix>
<x-ui.button
title="{{ __('Copy to clipboard') }}"
class="btn-circle btn-sm btn-ghost me-2"
icon="o-clipboard"
@click="
navigator.clipboard.writeText($wire.plainTextToken);
$wire.$set('displayingToken', false);
$wire.success('{{ __('Successfully copied!') }}')
"
/>
</x-slot:suffix>
</x-ui.input>
</div>
<div class="flex flex-row items-center justify-end mt-8 text-end">
<x-ui.button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
{{ __('Close') }}
</x-ui.button>
</div>
</x-ui.modal>
</x-slot> </x-slot>
<x-slot name="actions"> <x-slot name="actions">
@@ -41,16 +279,16 @@
{{ __('Created.') }} {{ __('Created.') }}
</x-forms.action-message> </x-forms.action-message>
<x-button type="submit"> <x-ui.button type="submit">
{{ __('Create') }} {{ __('Create') }}
</x-button> </x-ui.button>
</x-slot> </x-slot>
</x-forms.form-section> </x-forms.form-section>
@if ($this->user->tokens->isNotEmpty()) @if ($this->user->tokens->isNotEmpty())
<x-section-border hide-on-mobile /> <x-ui.section-border hide-on-mobile />
<!-- Manage API Tokens --> {{-- Manage API Tokens --}}
<div class="mt-10 sm:mt-0"> <div class="mt-10 sm:mt-0">
<x-forms.action-section> <x-forms.action-section>
<x-slot name="title"> <x-slot name="title">
@@ -61,12 +299,12 @@
{{ __('You may delete any of your existing tokens if they are no longer needed.') }} {{ __('You may delete any of your existing tokens if they are no longer needed.') }}
</x-slot> </x-slot>
<!-- API Token List --> {{-- API Token List --}}
<x-slot name="content"> <x-slot name="content">
<div class="space-y-6"> <div class="space-y-6">
@foreach ($this->user->tokens->sortBy('name') as $token) @foreach ($this->user->tokens->sortBy('name') as $token)
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="break-all dark:text-white"> <div class="break-all">
{{ $token->name }} {{ $token->name }}
</div> </div>
@@ -77,7 +315,7 @@
</div> </div>
@endif @endif
@if (Laravel\Jetstream\Jetstream::hasPermissions()) @if (false)
<button class="cursor-pointer ms-6 text-sm text-gray-400 underline" wire:click="manageApiTokenPermissions({{ $token->id }})"> <button class="cursor-pointer ms-6 text-sm text-gray-400 underline" wire:click="manageApiTokenPermissions({{ $token->id }})">
{{ __('Permissions') }} {{ __('Permissions') }}
</button> </button>
@@ -95,42 +333,17 @@
</div> </div>
@endif @endif
<!-- Token Value Modal --> {{-- API Token Permissions Modal --}}
<x-dialog-modal wire:model.live="displayingToken"> <x-ui.dialog-modal key="manage-permission-modal" wire:model.live="managingApiTokenPermissions">
<x-slot name="title">
{{ __('API Token') }}
</x-slot>
<x-slot name="content">
<div>
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
</div>
<x-input x-ref="plaintextToken" type="text" readonly :value="$plainTextToken"
class="mt-4 px-4 py-2 rounded font-mono text-sm w-full break-all"
autofocus autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
@showing-token-modal.window="setTimeout(() => $refs.plaintextToken.select(), 250)"
/>
</x-slot>
<x-slot name="footer">
<x-button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
{{ __('Close') }}
</x-button>
</x-slot>
</x-dialog-modal>
<!-- API Token Permissions Modal -->
<x-dialog-modal wire:model.live="managingApiTokenPermissions">
<x-slot name="title"> <x-slot name="title">
{{ __('API Token Permissions') }} {{ __('API Token Permissions') }}
</x-slot> </x-slot>
<x-slot name="content"> <x-slot name="content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission) @foreach ([] as $label => $permission)
<label class="flex items-center"> <label class="flex items-center">
<x-checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/> <x-ui.checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/>
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span> <span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
</label> </label>
@endforeach @endforeach
@@ -138,18 +351,18 @@
</x-slot> </x-slot>
<x-slot name="footer"> <x-slot name="footer">
<x-button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled"> <x-ui.button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled">
{{ __('Cancel') }} {{ __('Cancel') }}
</x-button> </x-ui.button>
<x-button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled"> <x-ui.button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled">
{{ __('Save') }} {{ __('Save') }}
</x-button> </x-ui.button>
</x-slot> </x-slot>
</x-dialog-modal> </x-ui.dialog-modal>
<!-- Delete Token Confirmation Modal --> {{-- Delete Token Confirmation Modal --}}
<x-confirmation-modal wire:model.live="confirmingApiTokenDeletion"> <x-ui.confirmation-modal key="confirm-deletion-modal" wire:model.live="confirmingApiTokenDeletion">
<x-slot name="title"> <x-slot name="title">
{{ __('Delete API Token') }} {{ __('Delete API Token') }}
</x-slot> </x-slot>
@@ -159,13 +372,13 @@
</x-slot> </x-slot>
<x-slot name="footer"> <x-slot name="footer">
<x-button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled"> <x-ui.button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled">
{{ __('Cancel') }} {{ __('Cancel') }}
</x-button> </x-ui.button>
<x-button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled"> <x-ui.button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled">
{{ __('Delete') }} {{ __('Delete') }}
</x-button> </x-ui.button>
</x-slot> </x-slot>
</x-confirmation-modal> </x-ui.confirmation-modal>
</div> </div>
+4 -10
View File
@@ -1,13 +1,7 @@
<x-app-layout> <x-layouts.app>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('API Tokens') }}
</h2>
</x-slot>
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@livewire('api.api-token-manager') @livewire('api-token-manager')
</div> </div>
</div>
</x-app-layout> </x-layouts.app>
@@ -1,8 +1,8 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
@@ -10,20 +10,20 @@
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }} {{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
</div> </div>
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.confirm') }}"> <form method="POST" action="{{ route('password.confirm') }}">
@csrf @csrf
<div> <div>
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus /> <x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus />
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<x-button type="submit" class="btn-primary ms-4"> <x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Confirm') }} {{ __('Confirm') }}
</x-button> </x-ui.button>
</div> </div>
</form> </form>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
+11 -11
View File
@@ -1,8 +1,8 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
@@ -11,26 +11,26 @@
</div> </div>
@session('status') @session('status')
<x-alert icon="o-envelope" class="alert-success mb-4"> <x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ $value }} {{ $value }}
</x-alert> </x-ui.alert>
@endsession @endsession
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.email') }}"> <form method="POST" action="{{ route('password.email') }}">
@csrf @csrf
<div class="block"> <div class="block">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" /> <x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
</div> </div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-button class="btn-primary" type="submit"> <x-ui.button class="btn-primary" type="submit">
{{ __('Email Password Reset Link') }} {{ __('Email Password Reset Link') }}
</x-button> </x-ui.button>
</div> </div>
</form> </form>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
@@ -6,10 +6,11 @@ use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Rule; use Livewire\Attributes\Rule;
use Livewire\Volt\Component; use Livewire\Volt\Component;
new class extends Component { new class extends Component
{
// props // props
public Portfolio $portfolio; public Portfolio $portfolio;
public User $user; public User $user;
#[Rule('required|string')] #[Rule('required|string')]
@@ -41,30 +42,29 @@ new class extends Component {
return redirect(route('portfolio.show', ['portfolio' => $this->portfolio->id])); return redirect(route('portfolio.show', ['portfolio' => $this->portfolio->id]));
} }
}; ?> }; ?>
<x-form wire:submit="updateUserInformation" class=""> <x-ui.form wire:submit="updateUserInformation" class="">
<div class="mt-2"> <div class="mt-2">
<x-input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus /> <x-ui.input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus />
</div> </div>
<div class="mt-2"> <div class="mt-2">
<x-input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" /> <x-ui.input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
</div> </div>
<div class="mt-2"> <div class="mt-2">
<x-input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" /> <x-ui.input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
</div> </div>
<div class="flex items-center justify-end mt-2"> <div class="flex items-center justify-end mt-2">
<x-button class="btn-primary" type="submit"> <x-ui.button class="btn-primary" type="submit">
{{ __('Get Started') }} {{ __('Get Started') }}
</x-button> </x-ui.button>
</div> </div>
</x-form> </x-ui.form>
@@ -1,8 +1,8 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot:logo> <x-slot:logo>
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot:logo> </x-slot:logo>
@@ -14,5 +14,5 @@
'user' => $user, 'user' => $user,
]) ])
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
+17 -17
View File
@@ -1,17 +1,17 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
@session('status') @session('status')
<x-alert icon="o-envelope" class="alert-success mb-4"> <x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ $value }} {{ $value }}
</x-alert> </x-ui.alert>
@endsession @endsession
<form method="POST" action="{{ route('login') }}"> <form method="POST" action="{{ route('login') }}">
@@ -19,16 +19,16 @@
<div> <div>
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" /> <x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" /> <x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
</div> </div>
<div class="block mt-4"> <div class="block mt-4">
<x-checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" /> <x-ui.checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
</div> </div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
@@ -38,26 +38,26 @@
</a> </a>
@endif @endif
<x-button type="submit" class="btn-primary ms-4" > <x-ui.button type="submit" class="btn-primary ms-4" >
{{ __('Log in') }} {{ __('Log in') }}
</x-button> </x-ui.button>
</div> </div>
@if (\Laravel\Fortify\Features::enabled('registration')) @if (\Laravel\Fortify\Features::enabled('registration'))
<x-section-border /> <x-ui.section-border />
<x-connected-accounts-login /> <x-social.connected-accounts-login />
<x-button <x-ui.button
link="{{ route('register') }}" link="{{ route('register') }}"
class="btn-sm btn-block btn-outline btn-secondary my-1" class="btn-sm btn-block btn-outline btn-secondary my-1"
> >
{{ __('Sign up with email') }} {{ __('Sign up with email') }}
</x-button> </x-ui.button>
@endif @endif
</form> </form>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
+13 -13
View File
@@ -1,41 +1,41 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('register') }}"> <form method="POST" action="{{ route('register') }}">
@csrf @csrf
<div> <div>
<x-input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" /> <x-ui.input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" /> <x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" /> <x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" /> <x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div> </div>
@if (! config('investbrain.self_hosted')) @if (! config('investbrain.self_hosted'))
<div class="mt-4"> <div class="mt-4">
<label> <label>
<div class="flex items-center"> <div class="flex items-center">
<x-checkbox name="terms" id="terms" required /> <x-ui.checkbox name="terms" id="terms" required />
<div class="ms-2 text-sm"> <div class="ms-2 text-sm">
{!! __('I agree to the :terms_of_service and :privacy_policy', [ {!! __('I agree to the :terms_of_service and :privacy_policy', [
@@ -53,10 +53,10 @@
{{ __('Already registered?') }} {{ __('Already registered?') }}
</a> </a>
<x-button type="submit" class="btn-primary ms-4"> <x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Register') }} {{ __('Register') }}
</x-button> </x-ui.button>
</div> </div>
</form> </form>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
+11 -11
View File
@@ -1,12 +1,12 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('password.update') }}"> <form method="POST" action="{{ route('password.update') }}">
@csrf @csrf
@@ -15,24 +15,24 @@
<div class="block"> <div class="block">
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" /> <x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" /> <x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" /> <x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
</div> </div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-button class="btn-primary" type="submit"> <x-ui.button class="btn-primary" type="submit">
{{ __('Reset Password') }} {{ __('Reset Password') }}
</x-button> </x-ui.button>
</div> </div>
</form> </form>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
@@ -1,8 +1,8 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
@@ -15,19 +15,19 @@
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }} {{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
</div> </div>
<x-errors class="mb-4" /> <x-ui.errors class="mb-4" />
<form method="POST" action="{{ route('two-factor.login') }}"> <form method="POST" action="{{ route('two-factor.login') }}">
@csrf @csrf
<div class="mt-4" x-show="! recovery"> <div class="mt-4" x-show="! recovery">
<x-input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" /> <x-ui.input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" />
</div> </div>
<div class="mt-4" x-cloak x-show="recovery"> <div class="mt-4" x-cloak x-show="recovery">
<x-input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" /> <x-ui.input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" />
</div> </div>
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
@@ -50,11 +50,11 @@
{{ __('Use an authentication code') }} {{ __('Use an authentication code') }}
</button> </button>
<x-button type="submit" class="btn-primary ms-4"> <x-ui.button type="submit" class="btn-primary ms-4">
{{ __('Log in') }} {{ __('Log in') }}
</x-button> </x-ui.button>
</div> </div>
</form> </form>
</div> </div>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
+10 -10
View File
@@ -1,8 +1,8 @@
<x-guest-layout> <x-layouts.guest>
<x-authentication-card> <x-ui.authentication-card>
<x-slot name="logo"> <x-slot name="logo">
<div class="w-24 mb-10"> <div class="w-24 mb-10">
<x-glyph-only-logo /> <x-ui.logo />
</div> </div>
</x-slot> </x-slot>
@@ -11,9 +11,9 @@
</div> </div>
@if (session('status') == 'verification-link-sent') @if (session('status') == 'verification-link-sent')
<x-alert icon="o-envelope" class="alert-success mb-4"> <x-ui.alert icon="o-envelope" class="alert-success mb-4">
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }} {{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
</x-alert> </x-ui.alert>
@endif @endif
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
@@ -21,9 +21,9 @@
@csrf @csrf
<div> <div>
<x-button type="submit" type="submit" class="btn-primary"> <x-ui.button label="{{ __('Resend Verification Email') }}" type="submit" class="bg-primary hover:bg-secondary focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900 focus:shadow-outline focus:outline-none" />
{{ __('Resend Verification Email') }}
</x-button>
</div> </div>
</form> </form>
@@ -43,5 +43,5 @@
</form> </form>
</div> </div>
</div> </div>
</x-authentication-card> </x-ui.authentication-card>
</x-guest-layout> </x-layouts.guest>
@@ -1,395 +0,0 @@
<?php
use Mary\Traits\Toast;
use App\Models\AiChat;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Model;
use Livewire\Volt\Component;
use OpenAI\Factory;
use OpenAI\Responses\StreamResponse;
new class extends Component {
use Toast;
// 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 = [];
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')->limit(25)->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.generateCompletion()');
}
public function generateCompletion(): void
{
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()]);
$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();
$this->js('$wire.generateSuggestedPrompts()');
}
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) {
$this->suggested_prompts = [];
$this->error($e->getMessage());
return;
}
}
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 createOpenAiClient()
{
$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();
}
}; ?>
<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-button
x-show="!open"
@click="$dispatch('toggle-ai-chat')"
class="flex btn btn-circle md:btn-lg btn-primary"
>
<x-slot:label>
<x-icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-icon>
</x-slot:label>
</x-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-100 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">{{ __('AI Chat') }}</h2>
<x-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-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>
<div class="leading-relaxed" >
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
</div>
</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 --}}
<div class="mt-3 grow-0">
<form submit="startCompletion" >
<div class="">
@foreach($suggested_prompts as $prompt)
<x-button
class="btn-xs btn-primary btn-outline mr-1 mb-2"
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
>{{ $prompt['text'] }}</x-button>
@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"
autofocus
></x-textarea>
</div>
<x-button
spinner="generateCompletion"
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>
</div>
</div>
</div>
</div>
File diff suppressed because one or more lines are too long
@@ -1,9 +0,0 @@
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<div>
{{ $logo }}
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More