Compare commits
45 Commits
zachgoll/a
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa3698823 | ||
|
|
88a6373e84 | ||
|
|
52d170e36c | ||
|
|
2bc3887262 | ||
|
|
0da057b792 | ||
|
|
0e1c902b63 | ||
|
|
bb0f0239fb | ||
|
|
e6c1c5f368 | ||
|
|
02bbeeaec5 | ||
|
|
1649e991b4 | ||
|
|
7096eefa2b | ||
|
|
4c72231312 | ||
|
|
d86ccd36b6 | ||
|
|
02bfa9f251 | ||
|
|
f2020a816a | ||
|
|
5f2a031d4c | ||
|
|
939244bd3e | ||
|
|
0a17b84566 | ||
|
|
5cf758bd03 | ||
|
|
6331788b33 | ||
|
|
83bee295ca | ||
|
|
dc17a0a298 | ||
|
|
29f445d75e | ||
|
|
9fadfe074b | ||
|
|
2a505b000c | ||
|
|
36a66baf00 | ||
|
|
67716f3006 | ||
|
|
1061aacb0f | ||
|
|
2f6b11c18f | ||
|
|
8e6b81af77 | ||
|
|
9f062de6b4 | ||
|
|
3dfdd0aea5 | ||
|
|
86431e79a3 | ||
|
|
54f5a44a60 | ||
|
|
b41897b5e5 | ||
|
|
f8d64561cf | ||
|
|
5a8074c7ee | ||
|
|
9122eafd31 | ||
|
|
19cc63c8f4 | ||
|
|
a7db914005 | ||
|
|
06468a05b1 | ||
|
|
087dd720c1 | ||
|
|
78baf2b327 | ||
|
|
56203b04d3 | ||
|
|
f65b93a352 |
23
.cursor/rules/general-rules.mdc
Normal file
23
.cursor/rules/general-rules.mdc
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Miscellaneous rules to get the AI to behave
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
# General rules for AI
|
||||
|
||||
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||
|
||||
## Prohibited actions
|
||||
|
||||
Do not under any circumstance do the following:
|
||||
|
||||
- Do not run `rails server` in your responses.
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||
@@ -1,14 +1,9 @@
|
||||
---
|
||||
description: This rule explains the project's tech stack and code conventions
|
||||
globs: *
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
This rule serves as high-level documentation for how the Maybe codebase is structured.
|
||||
|
||||
## Rules for AI
|
||||
|
||||
- Use this file to understand how the codebase works
|
||||
- Treat this rule/file as your "source of truth" when making code recommendations
|
||||
- When creating migrations, always use `rails g migration` instead of creating the file yourself
|
||||
This rule serves as high-level documentation for how you should write code for the Maybe codebase.
|
||||
|
||||
## Project Tech Stack
|
||||
|
||||
@@ -18,8 +13,9 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
|
||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||
- TailwindCSS for styles
|
||||
- Lucide Icons for icons
|
||||
- OpenAI for AI chat
|
||||
- Database: PostgreSQL
|
||||
- Jobs: GoodJob
|
||||
- Jobs: Sidekiq + Redis
|
||||
- External
|
||||
- Payments: Stripe
|
||||
- User bank data syncing: Plaid
|
||||
@@ -46,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
|
||||
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
|
||||
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
|
||||
|
||||
### Convention 3: Prefer server-side solutions over client-side solutions
|
||||
### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions
|
||||
|
||||
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
|
||||
- When writing a client-side solution, use Stimulus controllers and keep it simple!
|
||||
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
|
||||
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
|
||||
|
||||
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
|
||||
|
||||
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
|
||||
|
||||
- Focus on good OOP design first, performance second
|
||||
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
|
||||
|
||||
### Convention 5: Prefer semantic, native HTML features
|
||||
|
||||
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
|
||||
|
||||
- Using the `dialog` element for modals
|
||||
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
|
||||
- Native HTML is always preferred over JS-based components
|
||||
- Example 1: Use `<dialog>` element for modals instead of creating a custom component
|
||||
- Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components
|
||||
- Leverage Turbo frames to break up the page over JS-driven client-side solutions
|
||||
- Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout
|
||||
- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state.
|
||||
- Use Turbo streams to enhance functionality, but do not solely depend on it
|
||||
- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only
|
||||
- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
|
||||
|
||||
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
|
||||
|
||||
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
### Convention 4: Optimize for simplicitly and clarity
|
||||
|
||||
All code should maximize readability and simplicity.
|
||||
|
||||
- Prioritize good OOP domain design over performance
|
||||
- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.
|
||||
- Example 1: be mindful of loading large data payloads in global layouts
|
||||
- Example 2: Avoid N+1 queries
|
||||
|
||||
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
|
||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
|
||||
- Use system tests sparingly as they increase the time to complete the test suite
|
||||
- Only write tests for critical and important code paths
|
||||
- Write tests as you go, when required
|
||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||
|
||||
Below are examples of necessary vs. unnecessary tests:
|
||||
|
||||
```rb
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Account::Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
|
||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
description: This rule explains the system architecture and data flow of the Rails app
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
This file outlines how the codebase is structured and how data flows through the app.
|
||||
@@ -110,12 +111,12 @@ Below are brief descriptions of each type of sync in more detail.
|
||||
|
||||
### Account Syncs
|
||||
|
||||
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
|
||||
The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks:
|
||||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates holdings and balances for the account
|
||||
- Enriches transaction data
|
||||
- Converts account balances that are not in the family's preferred currency to the preferred currency
|
||||
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Enriches transaction data if enabled by user
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
|
||||
@@ -131,4 +132,125 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
|
||||
|
||||
## Data Providers
|
||||
|
||||
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
|
||||
|
||||
Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
|
||||
|
||||
There are two types of 3rd party data in the Maybe app:
|
||||
|
||||
1. "Concept" data
|
||||
2. One-off data
|
||||
|
||||
### "Concept" data
|
||||
|
||||
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
|
||||
|
||||
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
|
||||
|
||||
```
|
||||
app/models/
|
||||
exchange_rate/
|
||||
provided.rb # <- Responsible for selecting the concept provider from the registry
|
||||
provider.rb # <- Base provider class
|
||||
provider/
|
||||
registry.rb <- Defines available providers by concept
|
||||
concepts/
|
||||
exchange_rate.rb <- defines the interface required for the exchange rate concept
|
||||
synth.rb # <- Concrete provider implementation
|
||||
```
|
||||
|
||||
### One-off data
|
||||
|
||||
For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
|
||||
|
||||
```rb
|
||||
class SomeModel < Application
|
||||
def synth_usage
|
||||
Provider::Registry.get_provider(:synth)&.usage
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## "Provided" Concerns
|
||||
|
||||
In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
|
||||
|
||||
- Choosing the provider to use for this "concept"
|
||||
- Providing convenience methods on the model for accessing data
|
||||
|
||||
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
|
||||
|
||||
```rb
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||
# Implementation
|
||||
end
|
||||
|
||||
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||
# Implementation
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
|
||||
|
||||
```rb
|
||||
def access_patterns_example
|
||||
# Call exchange rate provider directly
|
||||
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
|
||||
|
||||
# Call convenience method
|
||||
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
|
||||
end
|
||||
```
|
||||
|
||||
## Concrete provider implementations
|
||||
|
||||
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
ExampleData.new(
|
||||
example: "data"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
data = nil
|
||||
|
||||
# Raise an error if data cannot be returned
|
||||
raise ProviderError.new("Could not find the data you need") if data.nil?
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
---
|
||||
description: This file describes Maybe's design system and how views should be styled
|
||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||
alwaysApply: true
|
||||
---
|
||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
||||
Use the rules below when:
|
||||
|
||||
- You are writing HTML
|
||||
- You are writing CSS
|
||||
- You are writing styles in a JavaScript Stimulus controller
|
||||
|
||||
## Rules for AI (mandatory)
|
||||
|
||||
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
|
||||
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
|
||||
- Always generate semantic HTML
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
|
||||
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
|
||||
- Example 1: use `text-primary` rather than `text-primary`
|
||||
- Example 2: use `bg-container` rather than `bg-white`
|
||||
- Example 3: use `border border-primary` rather than `border border-gray-200`
|
||||
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
|
||||
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
|
||||
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.
|
||||
- Always generate semantic HTML
|
||||
@@ -1,4 +1,15 @@
|
||||
version: "3"
|
||||
x-db-env: &db_env
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
x-rails-env: &rails_env
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -16,32 +27,41 @@ services:
|
||||
command: sleep infinity
|
||||
|
||||
environment:
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
<<: *rails_env
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: bundle exec sidekiq
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *rails_env
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
<<: *db_env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
bundle_cache:
|
||||
|
||||
96
.env.example
96
.env.example
@@ -1,20 +1,31 @@
|
||||
# ================================ PLEASE READ ==========================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app.
|
||||
#
|
||||
# This includes several features that are for our "hosted" version of Maybe, which most
|
||||
# open-source contributors won't need.
|
||||
# ================================ PLEASE READ ===========================================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
|
||||
#
|
||||
# If you are developing locally, you should be referencing `.env.local.example` instead.
|
||||
# =======================================================================================
|
||||
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
|
||||
# ========================================================================================================
|
||||
|
||||
# Required self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Enables self hosting features (should be set to true unless you know what you're doing)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Optional self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Optional: Synth API Key for exchange rates + stock prices
|
||||
# (you can also set this in your self-hosted settings page)
|
||||
# Get it here: https://synthfinance.com/
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# Exchange Rate & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
@@ -37,60 +48,20 @@ POSTGRES_USER=postgres
|
||||
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
|
||||
APP_DOMAIN=
|
||||
|
||||
## Error and Performance Monitoring
|
||||
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
|
||||
SENTRY_DSN=
|
||||
|
||||
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
|
||||
# This is useful for controlling who can sign up for your Maybe instance.
|
||||
REQUIRE_INVITE_CODE=false
|
||||
|
||||
# Enables self hosting features (should be set to true for most folks)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# The hosting platform used to deploy the app (e.g. "render")
|
||||
# `localhost` (or unset) is used for local development and testing
|
||||
HOSTING_PLATFORM=localhost
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
||||
# ======================================================================================================
|
||||
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
|
||||
# ======================================================================================================
|
||||
#
|
||||
# UPGRADES_ENABLED: Enables Upgrader class functionality.
|
||||
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
|
||||
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
|
||||
#
|
||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
||||
UPGRADES_MODE=manual # `manual` or `auto`
|
||||
UPGRADES_TARGET=release # `release` or `commit`
|
||||
|
||||
|
||||
# ======================================================================================================
|
||||
# Git Repository Module - responsible for fetching latest commit data for upgrades
|
||||
# ======================================================================================================
|
||||
#
|
||||
GITHUB_REPO_OWNER=maybe-finance
|
||||
GITHUB_REPO_NAME=maybe
|
||||
GITHUB_REPO_BRANCH=main
|
||||
|
||||
# ======================================================================================================
|
||||
# Active Storage Configuration - responsible for storing file uploads
|
||||
# ======================================================================================================
|
||||
#
|
||||
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
||||
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
|
||||
# * Set the appropriate environment variables to use these services.
|
||||
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||
#
|
||||
# Amazon S3
|
||||
# ==========
|
||||
# ACTIVE_STORAGE_SERVICE=amazon
|
||||
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_REGION= # defaults to `us-east-1` if not set
|
||||
@@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main
|
||||
#
|
||||
# Cloudflare R2
|
||||
# =============
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
# CLOUDFLARE_ACCESS_KEY_ID=
|
||||
# CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||
# CLOUDFLARE_BUCKET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Billing Module - responsible for handling billing
|
||||
# ======================================================================================================
|
||||
#
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Plaid Configuration
|
||||
# ======================================================================================================
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
PLAID_EU_CLIENT_ID=
|
||||
PLAID_EU_SECRET=
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
SELF_HOSTED=false
|
||||
SYNTH_API_KEY=fookey
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
@@ -1,3 +1,5 @@
|
||||
SELF_HOSTED=false
|
||||
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/other.md
vendored
9
.github/ISSUE_TEMPLATE/other.md
vendored
@@ -21,9 +21,7 @@ If you are a _paying_ Maybe user, you can open a support request in Intercom.
|
||||
|
||||
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
|
||||
|
||||
All feature requests should be opened as Discussions here:
|
||||
|
||||
https://github.com/maybe-finance/maybe/discussions/categories/feature-requests
|
||||
All feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests).
|
||||
|
||||
Be sure to search existing discussions prior to opening a new feature request.
|
||||
|
||||
@@ -31,9 +29,8 @@ Be sure to search existing discussions prior to opening a new feature request.
|
||||
|
||||
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
|
||||
|
||||
- **First**: Read our [self hosting guides](https://github.com/maybe-finance/maybe/tree/main/docs/hosting) and follow them step-by-step
|
||||
- Open a [General Discussion](https://github.com/maybe-finance/maybe/discussions/categories/general)
|
||||
- Make a post in the "Self hosted" channel in our [Discord](https://link.maybe.co/discord)
|
||||
- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step
|
||||
- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)
|
||||
|
||||
---
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -11,7 +11,6 @@
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.test
|
||||
!.env*.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
@@ -63,6 +62,10 @@ gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
.cursorrules
|
||||
.cursor/rules/structure.mdc
|
||||
.cursor/rules/agent.mdc
|
||||
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
compose.yml
|
||||
11
Gemfile
11
Gemfile
@@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", ">= 5.0"
|
||||
@@ -22,17 +23,17 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
|
||||
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
|
||||
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
gem "sidekiq"
|
||||
|
||||
# Error logging
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "sentry-sidekiq"
|
||||
gem "logtail-rails"
|
||||
|
||||
# Active Storage
|
||||
@@ -57,6 +58,10 @@ gem "intercom-rails"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
gem "activerecord-import"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
|
||||
162
Gemfile.lock
162
Gemfile.lock
@@ -1,14 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/josefarias/hotwire_combobox.git
|
||||
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
specs:
|
||||
hotwire_combobox (0.3.2)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
||||
@@ -72,6 +61,8 @@ GEM
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
@@ -92,15 +83,17 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1043.0)
|
||||
aws-sdk-core (3.217.0)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1073.0)
|
||||
aws-sdk-core (3.221.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.97.0)
|
||||
logger
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
@@ -124,7 +117,7 @@ GEM
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -146,7 +139,7 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.2)
|
||||
csv (3.3.3)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
@@ -165,8 +158,7 @@ GEM
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
@@ -177,7 +169,7 @@ GEM
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday-retry (2.3.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
@@ -187,18 +179,8 @@ GEM
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.9.3)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.2)
|
||||
highline (3.1.2)
|
||||
reline
|
||||
@@ -206,6 +188,11 @@ GEM
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.4.0)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.15)
|
||||
@@ -238,11 +225,11 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.10.1)
|
||||
json (2.10.2)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
@@ -252,8 +239,8 @@ GEM
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.6)
|
||||
logtail (0.1.15)
|
||||
logger (1.7.0)
|
||||
logtail (0.1.17)
|
||||
msgpack (~> 1.0)
|
||||
logtail-rack (0.2.6)
|
||||
logtail (~> 0.1)
|
||||
@@ -274,49 +261,49 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (5.1.2)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
minitest (5.25.5)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.5)
|
||||
net-imap (0.5.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.3-aarch64-linux-gnu)
|
||||
nokogiri (1.18.6-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-aarch64-linux-musl)
|
||||
nokogiri (1.18.6-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-gnu)
|
||||
nokogiri (1.18.6-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm-linux-musl)
|
||||
nokogiri (1.18.6-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm64-darwin)
|
||||
nokogiri (1.18.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-darwin)
|
||||
nokogiri (1.18.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
nokogiri (1.18.6-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-musl)
|
||||
nokogiri (1.18.6-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
pagy (9.3.4)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.1)
|
||||
parser (3.3.7.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
@@ -329,7 +316,7 @@ GEM
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.3.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -341,9 +328,8 @@ GEM
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.11)
|
||||
rack (3.1.12)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
@@ -393,11 +379,15 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
rbs (3.9.1)
|
||||
logger
|
||||
rdoc (6.12.0)
|
||||
rdoc (6.13.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
@@ -407,7 +397,7 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.73.2)
|
||||
rubocop (1.74.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -418,8 +408,8 @@ GEM
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.1)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-ast (1.41.0)
|
||||
parser (>= 3.3.7.2)
|
||||
rubocop-performance (1.24.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
@@ -434,13 +424,17 @@ GEM
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.23.9)
|
||||
ruby-lsp (0.23.12)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-openai (8.1.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
@@ -451,18 +445,27 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.22.4)
|
||||
sentry-rails (5.23.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sentry-ruby (5.23.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.23.0)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.2)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
@@ -470,21 +473,21 @@ GEM
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11813)
|
||||
sorbet-runtime (0.5.11953)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.5)
|
||||
stripe (13.5.0)
|
||||
tailwindcss-rails (4.0.0)
|
||||
stripe (14.0.0)
|
||||
tailwindcss-rails (4.2.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.0.6)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.6-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.0.15)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.15-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
@@ -497,11 +500,11 @@ GEM
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.5.0)
|
||||
vernier (1.7.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
@@ -521,19 +524,17 @@ GEM
|
||||
zeitwerk (2.7.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
activerecord-import
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
@@ -549,9 +550,8 @@ DEPENDENCIES
|
||||
faraday
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
hotwire-livereload
|
||||
hotwire_combobox!
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
@@ -572,13 +572,17 @@ DEPENDENCIES
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
redis (~> 5.4)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sidekiq
|
||||
simplecov
|
||||
stimulus-rails
|
||||
stripe
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bundle exec bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
worker: bundle exec sidekiq
|
||||
|
||||
43
README.md
43
README.md
@@ -6,9 +6,6 @@
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
_If you're looking for the previous React codebase, you can find it
|
||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||
@@ -29,9 +26,8 @@ and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
There are 3 primary ways to use the Maybe app:
|
||||
|
||||
1. Managed (easiest) - _coming soon..._
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
### Setup Guides
|
||||
|
||||
#### Dev Container (optional)
|
||||
|
||||
This is 100% optional and meant for devs who don't want to worry about
|
||||
installing requirements manually for their platform. You can
|
||||
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
|
||||
to learn more about Dev Containers.
|
||||
|
||||
If you run into `could not connect to server` errors, you may need to change
|
||||
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
|
||||
Postgres container.
|
||||
|
||||
#### Mac
|
||||
|
||||
Please visit
|
||||
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
||||
|
||||
#### Linux
|
||||
|
||||
Please visit
|
||||
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
||||
|
||||
#### Windows
|
||||
|
||||
Please visit
|
||||
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
||||
|
||||
### Testing Emails
|
||||
|
||||
In development, we use `letter_opener` to automatically open emails in your
|
||||
browser. When an email sends locally, a new browser tab will open with a
|
||||
preview.
|
||||
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
|
||||
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
|
||||
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
|
||||
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
|
||||
|
||||
## Repo Activity
|
||||
|
||||
|
||||
85
app/assets/images/ai.svg
Normal file
85
app/assets/images/ai.svg
Normal file
@@ -0,0 +1,85 @@
|
||||
<svg width="62" height="68" viewBox="0 0 62 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_7620_90382)">
|
||||
<path d="M15.0109 27.3668C14.8138 11.2848 17.2087 15.4884 28.5797 15.5133L32.8675 15.5228C44.2383 15.5478 46.7179 11.3549 46.9149 27.4368L46.9891 33.5015C47.1861 49.5834 44.7913 53.0249 33.4205 52.9999L29.1325 52.9906C17.7617 52.9656 15.2823 49.5134 15.0852 33.4315L15.0109 27.3668Z" fill="url(#paint0_linear_7620_90382)" fill-opacity="0.15"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_i_7620_90382)">
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="url(#paint1_linear_7620_90382)"/>
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_ii_7620_90382)">
|
||||
<rect x="16.7773" y="14.7778" width="28.4444" height="28.4444" rx="8.88889" fill="url(#paint2_linear_7620_90382)"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="#141414"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="url(#paint3_linear_7620_90382)"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="#141414"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="url(#paint4_linear_7620_90382)"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="#141414"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="url(#paint5_linear_7620_90382)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_7620_90382" x="0.937778" y="0.937778" width="60.1244" height="66.1244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7.03111" result="effect1_foregroundBlur_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter1_i_7620_90382" x="15" y="13" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter2_ii_7620_90382" x="16.7773" y="13.8889" width="28.4453" height="30.2222" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_7620_90382" result="effect2_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_7620_90382" x1="30.1185" y1="16.1417" x2="33.7041" y2="53.1754" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_7620_90382" x1="31" y1="13" x2="31" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_7620_90382" x1="30.9996" y1="23.6667" x2="30.9996" y2="43.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -1 +0,0 @@
|
||||
/* Application styles */
|
||||
@@ -8,7 +8,7 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "../stylesheets/simonweb_pickr.css";
|
||||
@import "./simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
@@ -112,6 +112,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prose--ai-chat {
|
||||
@apply break-words;
|
||||
|
||||
p, li {
|
||||
@apply text-sm text-primary;
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
@@ -141,4 +165,5 @@
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* The following Markdown CSS has been removed as requested */
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
||||
*/
|
||||
|
||||
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
@@ -241,83 +243,240 @@
|
||||
/* Design system color utilities */
|
||||
@utility text-primary {
|
||||
@apply text-gray-900;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-secondary {
|
||||
@apply text-gray-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-subdued {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-link {
|
||||
@apply text-blue-600;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-black;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-inset {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-inset-hover {
|
||||
@apply bg-gray-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container {
|
||||
@apply bg-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-hover {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-inset {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-inset-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-inverse {
|
||||
@apply bg-gray-800;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-inverse-hover {
|
||||
@apply bg-gray-700;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
@apply bg-alpha-black-200;
|
||||
background-color: rgba(var(--color-gray-100), 0.5);
|
||||
|
||||
@variant theme-dark {
|
||||
background-color: var(--color-alpha-black-900);
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-primary {
|
||||
@apply border-alpha-black-300;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-secondary {
|
||||
@apply border-alpha-black-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-300;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-tertiary {
|
||||
@apply border-alpha-black-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-200;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-subdued {
|
||||
@apply border-alpha-black-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-solid {
|
||||
@apply border-black;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-destructive {
|
||||
@apply border-red-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Foreground Colors */
|
||||
@utility fg-gray {
|
||||
@apply text-gray-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-contrast {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-inverse {
|
||||
@apply text-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-primary {
|
||||
@apply text-gray-900;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-primary-variant {
|
||||
@apply text-gray-800;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-secondary {
|
||||
@apply text-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-secondary-variant {
|
||||
@apply text-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-subdued {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -331,6 +490,15 @@
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600; /* Default light mode */
|
||||
|
||||
@variant theme-dark {
|
||||
/* Dark mode radio button base and checked styles */
|
||||
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -341,31 +509,63 @@
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
@apply button-bg-primary text-white disabled:text-gray-400;
|
||||
@apply hover:button-bg-primary-hover;
|
||||
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-primary fg-primary;
|
||||
@apply hover:button-bg-primary-hover;
|
||||
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
@apply button-bg-secondary text-primary;
|
||||
@apply hover:button-bg-secondary-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-secondary text-white;
|
||||
@apply hover:button-bg-secondary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
@apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-100;
|
||||
@apply border border-transparent text-primary hover:button-bg-ghost-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply fg-primary hover:button-bg-ghost-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--destructive {
|
||||
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
|
||||
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
|
||||
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply focus-within:ring-alpha-white-300;
|
||||
}
|
||||
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
@@ -375,25 +575,25 @@
|
||||
}
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-white;
|
||||
@apply bg-container-inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply disabled:text-subdued;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
@apply transition-opacity duration-300;
|
||||
|
||||
@@ -403,11 +603,11 @@
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
@@ -455,4 +655,109 @@
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Specific override for strong tags in prose under dark mode */
|
||||
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
|
||||
color: theme(colors.white) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Backgrounds */
|
||||
@utility button-bg-primary {
|
||||
@apply bg-gray-900; /* Maps to fg-primary light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-white; /* Maps to fg-primary dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-primary-hover {
|
||||
@apply bg-gray-800; /* Maps to fg-primary-variant light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-50; /* Maps to fg-primary-variant dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-secondary {
|
||||
@apply bg-gray-50; /* Maps to fg-secondary light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700; /* Maps to fg-secondary dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-secondary-hover {
|
||||
@apply bg-gray-100; /* Maps to fg-secondary-variant light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-600; /* Maps to fg-secondary-variant dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-disabled {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-destructive {
|
||||
@apply bg-red-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-destructive-hover {
|
||||
@apply bg-red-600;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-ghost-hover {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-outline-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Styles */
|
||||
@utility tab-item-active {
|
||||
@apply bg-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tab-item-hover {
|
||||
@apply bg-gray-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tab-bg-group {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-alpha-black-700;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
rescue_from StandardError, with: :report_error
|
||||
|
||||
private
|
||||
def report_error(e)
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
@@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base
|
||||
else ""
|
||||
end
|
||||
end
|
||||
|
||||
# By default, we show the user the last chat they interacted with
|
||||
def set_default_chat
|
||||
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||
@chat = @last_viewed_chat
|
||||
end
|
||||
end
|
||||
|
||||
67
app/controllers/chats_controller.rb
Normal file
67
app/controllers/chats_controller.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
class ChatsController < ApplicationController
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
|
||||
@chats = Current.user.chats.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
set_last_viewed_chat(@chat)
|
||||
end
|
||||
|
||||
def new
|
||||
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
|
||||
set_last_viewed_chat(@chat)
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@chat.update!(chat_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@chat.destroy
|
||||
clear_last_viewed_chat
|
||||
|
||||
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||
end
|
||||
|
||||
def retry
|
||||
@chat.retry_last_message!
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
end
|
||||
|
||||
def set_last_viewed_chat(chat)
|
||||
Current.user.update!(last_viewed_chat: chat)
|
||||
end
|
||||
|
||||
def clear_last_viewed_chat
|
||||
Current.user.update!(last_viewed_chat: nil)
|
||||
end
|
||||
|
||||
def chat_params
|
||||
params.require(:chat).permit(:title, :content, :ai_model)
|
||||
end
|
||||
end
|
||||
23
app/controllers/concerns/feature_guardable.rb
Normal file
23
app/controllers/concerns/feature_guardable.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# Simple feature guard that renders a 403 Forbidden status with a message
|
||||
# when the feature is disabled.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class MessagesController < ApplicationController
|
||||
# guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
# end
|
||||
#
|
||||
module FeatureGuardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def guard_feature(**options)
|
||||
before_action :guard_feature, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def guard_feature
|
||||
render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden
|
||||
end
|
||||
end
|
||||
@@ -1,20 +0,0 @@
|
||||
class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
before_action :set_issue, only: :update
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
account = @issue.issuable
|
||||
account.sync_later
|
||||
redirect_back_or_to account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
|
||||
def exchange_rate_params
|
||||
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class IssuesController < ApplicationController
|
||||
before_action :set_issue, only: :show
|
||||
|
||||
def show
|
||||
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
end
|
||||
24
app/controllers/messages_controller.rb
Normal file
24
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class MessagesController < ApplicationController
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = UserMessage.create!(
|
||||
chat: @chat,
|
||||
content: message_params[:content],
|
||||
ai_model: message_params[:ai_model]
|
||||
)
|
||||
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content, :ai_model)
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ class PagesController < ApplicationController
|
||||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
@release_notes = github_provider.fetch_latest_release_notes
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
@@ -26,4 +26,9 @@ class PagesController < ApplicationController
|
||||
@invite_code = InviteCode.order("RANDOM()").limit(1).first
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def index
|
||||
@securities = Security.search_provider({
|
||||
search: params[:q],
|
||||
country: params[:country_code] == "US" ? "US" : nil
|
||||
})
|
||||
@securities = Security.search_provider(
|
||||
params[:q],
|
||||
country_code: params[:country_code] == "US" ? "US" : nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
guard_feature unless: -> { self_hosted? }
|
||||
|
||||
before_action :ensure_admin, only: :clear_cache
|
||||
|
||||
def show
|
||||
@synth_usage = Current.family.synth_usage
|
||||
synth_provider = Provider::Registry.get_provider(:synth)
|
||||
@synth_usage = synth_provider&.usage
|
||||
end
|
||||
|
||||
def update
|
||||
if hosting_params[:upgrades_setting].present?
|
||||
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
|
||||
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
|
||||
|
||||
Setting.upgrades_mode = mode
|
||||
Setting.upgrades_target = target
|
||||
end
|
||||
|
||||
if hosting_params.key?(:render_deploy_hook)
|
||||
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:require_invite_for_signup)
|
||||
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
|
||||
end
|
||||
@@ -46,11 +36,7 @@ class Settings::HostingsController < ApplicationController
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
end
|
||||
|
||||
def raise_if_not_self_hosted
|
||||
raise "Settings not available on non-self-hosted instance" unless self_hosted?
|
||||
params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
|
||||
@@ -36,14 +36,11 @@ class TransfersController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
Transfer.transaction do
|
||||
update_transfer_status
|
||||
update_transfer_details
|
||||
end
|
||||
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||
format.turbo_stream
|
||||
@@ -69,4 +66,17 @@ class TransfersController < ApplicationController
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status, :category_id)
|
||||
end
|
||||
|
||||
def update_transfer_status
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
end
|
||||
end
|
||||
|
||||
def update_transfer_details
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
@transfer.update!(notes: transfer_update_params[:notes])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
class UpgradesController < ApplicationController
|
||||
before_action :verify_upgrades_enabled
|
||||
|
||||
def acknowledge
|
||||
commit_sha = params[:id]
|
||||
upgrade = Upgrader.find_upgrade(commit_sha)
|
||||
|
||||
if upgrade
|
||||
if upgrade.available?
|
||||
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
|
||||
flash[:notice] = t(".upgrade_dismissed")
|
||||
elsif upgrade.complete?
|
||||
Current.user.acknowledge_upgrade_alert(upgrade.commit_sha)
|
||||
flash[:notice] = t(".upgrade_complete_dismiss")
|
||||
else
|
||||
flash[:alert] = t(".upgrade_not_available")
|
||||
end
|
||||
else
|
||||
flash[:alert] = t(".upgrade_not_found")
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
def deploy
|
||||
commit_sha = params[:id]
|
||||
upgrade = Upgrader.find_upgrade(commit_sha)
|
||||
|
||||
unless upgrade
|
||||
flash[:alert] = t(".upgrade_not_found")
|
||||
return redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha
|
||||
|
||||
# Optimistically acknowledge the upgrade prompt
|
||||
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
|
||||
|
||||
upgrade_result = Upgrader.upgrade_to(upgrade)
|
||||
|
||||
if upgrade_result[:success]
|
||||
flash[:notice] = upgrade_result[:message]
|
||||
else
|
||||
# If the upgrade fails, revert to the prior acknowledged upgrade
|
||||
Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha)
|
||||
flash[:alert] = upgrade_result[:message]
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
private
|
||||
def verify_upgrades_enabled
|
||||
head :not_found unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
end
|
||||
end
|
||||
@@ -17,11 +17,19 @@ class UsersController < ApplicationController
|
||||
redirect_to settings_profile_path, alert: error_message
|
||||
end
|
||||
else
|
||||
was_ai_enabled = @user.ai_enabled
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
|
||||
# Add a special notice if AI was just enabled
|
||||
notice = if !was_ai_enabled && @user.ai_enabled
|
||||
"AI Assistant has been enabled successfully."
|
||||
else
|
||||
t(".success")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { handle_redirect(t(".success")) }
|
||||
format.html { handle_redirect(notice) }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
@@ -66,7 +74,7 @@ class UsersController < ApplicationController
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period,
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@ module Account::EntriesHelper
|
||||
end
|
||||
end
|
||||
|
||||
deduped_entries.group_by(&:date).map do |date, grouped_entries|
|
||||
deduped_entries.group_by(&:date).sort.reverse_each.map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
@@ -129,6 +129,74 @@ module ApplicationHelper
|
||||
cookies[:admin] == "true"
|
||||
end
|
||||
|
||||
# Renders Markdown text using Redcarpet
|
||||
def markdown(text)
|
||||
return "" if text.blank?
|
||||
|
||||
renderer = Redcarpet::Render::HTML.new(
|
||||
hard_wrap: true,
|
||||
link_attributes: { target: "_blank", rel: "noopener noreferrer" }
|
||||
)
|
||||
|
||||
markdown = Redcarpet::Markdown.new(
|
||||
renderer,
|
||||
autolink: true,
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
strikethrough: true,
|
||||
superscript: true,
|
||||
underline: true,
|
||||
highlight: true,
|
||||
quote: true,
|
||||
footnotes: true
|
||||
)
|
||||
|
||||
markdown.render(text).html_safe
|
||||
end
|
||||
|
||||
# Determines the starting widths of each panel depending on the user's sidebar preferences
|
||||
def app_sidebar_config(user)
|
||||
left_sidebar_showing = user.show_sidebar?
|
||||
right_sidebar_showing = user.show_ai_sidebar?
|
||||
|
||||
content_max_width = if !left_sidebar_showing && !right_sidebar_showing
|
||||
1024 # 5xl
|
||||
elsif left_sidebar_showing && !right_sidebar_showing
|
||||
896 # 4xl
|
||||
else
|
||||
768 # 3xl
|
||||
end
|
||||
|
||||
left_panel_min_width = 320
|
||||
left_panel_max_width = 320
|
||||
right_panel_min_width = 400
|
||||
right_panel_max_width = 550
|
||||
|
||||
left_panel_width = left_sidebar_showing ? left_panel_min_width : 0
|
||||
right_panel_width = if right_sidebar_showing
|
||||
left_sidebar_showing ? right_panel_min_width : right_panel_max_width
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
left_panel: {
|
||||
is_open: left_sidebar_showing,
|
||||
initial_width: left_panel_width,
|
||||
min_width: left_panel_min_width,
|
||||
max_width: left_panel_max_width
|
||||
},
|
||||
right_panel: {
|
||||
is_open: right_sidebar_showing,
|
||||
initial_width: right_panel_width,
|
||||
min_width: right_panel_min_width,
|
||||
max_width: right_panel_max_width,
|
||||
overflow: right_sidebar_showing ? "auto" : "hidden"
|
||||
},
|
||||
content_max_width: content_max_width
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
|
||||
12
app/helpers/chats_helper.rb
Normal file
12
app/helpers/chats_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module ChatsHelper
|
||||
def chat_frame
|
||||
:sidebar_chat
|
||||
end
|
||||
|
||||
def chat_view_path(chat)
|
||||
return new_chat_path if params[:chat_view] == "new"
|
||||
return chats_path if chat.nil? || params[:chat_view] == "all"
|
||||
|
||||
chat.persisted? ? chat_path(chat) : new_chat_path
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
def period_select(form:, selected:, classes: "border border-secondary bg-container-inset rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
|
||||
|
||||
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
@@ -30,7 +30,7 @@ end
|
||||
|
||||
private
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-white group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
concat tag.span(label, class: "group-has-checked:font-semibold")
|
||||
end
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
module MenusHelper
|
||||
def contextual_menu(&block)
|
||||
tag.div data: { controller: "menu" } do
|
||||
concat contextual_menu_icon
|
||||
def contextual_menu(icon: "more-horizontal", id: nil, &block)
|
||||
tag.div id: id, data: { controller: "menu" } do
|
||||
concat contextual_menu_icon(icon)
|
||||
concat contextual_menu_content(&block)
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
|
||||
link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
|
||||
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
@@ -16,7 +23,7 @@ module MenusHelper
|
||||
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
|
||||
button_to url,
|
||||
method: :delete,
|
||||
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
|
||||
class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2",
|
||||
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
|
||||
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
@@ -24,14 +31,14 @@ module MenusHelper
|
||||
end
|
||||
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary"
|
||||
def contextual_menu_icon(icon)
|
||||
tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
|
||||
lucide_icon icon, class: "w-5 h-5 text-secondary"
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_content(&block)
|
||||
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
|
||||
tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden",
|
||||
data: { menu_target: "content" } do
|
||||
capture(&block)
|
||||
end
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
module UpgradesHelper
|
||||
def get_upgrade_for_notification(user, upgrades_mode)
|
||||
return nil unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
return nil unless user.present?
|
||||
|
||||
completed_upgrade = Upgrader.completed_upgrade
|
||||
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
|
||||
|
||||
available_upgrade = Upgrader.available_upgrade
|
||||
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
|
||||
available_upgrade
|
||||
end
|
||||
end
|
||||
end
|
||||
60
app/javascript/controllers/chat_controller.js
Normal file
60
app/javascript/controllers/chat_controller.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["messages", "form", "input"];
|
||||
|
||||
connect() {
|
||||
this.#configureAutoScroll();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.messagesObserver) {
|
||||
this.messagesObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const input = this.inputTarget;
|
||||
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
|
||||
const maxLines = 3; // 3 lines = 60px total
|
||||
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
|
||||
input.style.overflowY =
|
||||
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
submitSampleQuestion(e) {
|
||||
this.inputTarget.value = e.target.dataset.chatQuestionParam;
|
||||
|
||||
setTimeout(() => {
|
||||
this.formTarget.requestSubmit();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
|
||||
handleInputKeyDown(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.formTarget.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
#configureAutoScroll() {
|
||||
this.messagesObserver = new MutationObserver((_mutations) => {
|
||||
if (this.hasMessagesTarget) {
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to entire sidebar for changes, always try to scroll to the bottom
|
||||
this.messagesObserver.observe(this.element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
#scrollToBottom = () => {
|
||||
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
|
||||
};
|
||||
}
|
||||
@@ -2,17 +2,75 @@ import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="sidebar"
|
||||
export default class extends Controller {
|
||||
static values = { userId: String };
|
||||
static targets = ["panel", "content"];
|
||||
static values = {
|
||||
userId: String,
|
||||
config: Object,
|
||||
};
|
||||
|
||||
toggle() {
|
||||
this.panelTarget.classList.toggle("w-0");
|
||||
this.panelTarget.classList.toggle("opacity-0");
|
||||
this.panelTarget.classList.toggle("w-80");
|
||||
this.panelTarget.classList.toggle("opacity-100");
|
||||
this.contentTarget.classList.toggle("max-w-4xl");
|
||||
this.contentTarget.classList.toggle("max-w-5xl");
|
||||
static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"];
|
||||
|
||||
initialize() {
|
||||
this.leftPanelOpen = this.configValue.left_panel.is_open;
|
||||
this.rightPanelOpen = this.configValue.right_panel.is_open;
|
||||
}
|
||||
|
||||
toggleLeftPanel() {
|
||||
this.leftPanelOpen = !this.leftPanelOpen;
|
||||
this.#updatePanelWidths();
|
||||
this.#persistPreference("show_sidebar", this.leftPanelOpen);
|
||||
}
|
||||
|
||||
toggleLeftPanelMobile() {
|
||||
if (this.leftPanelOpen) {
|
||||
this.leftPanelMobileTarget.classList.remove("hidden");
|
||||
this.leftPanelOpen = false;
|
||||
} else {
|
||||
this.leftPanelMobileTarget.classList.add("hidden");
|
||||
this.leftPanelOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
toggleRightPanel() {
|
||||
this.rightPanelOpen = !this.rightPanelOpen;
|
||||
this.#updatePanelWidths();
|
||||
this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
|
||||
}
|
||||
|
||||
#updatePanelWidths() {
|
||||
this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
|
||||
this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
|
||||
this.rightPanelTarget.style.overflow = this.#rightPanelOverflow();
|
||||
}
|
||||
|
||||
#leftPanelWidth() {
|
||||
if (this.leftPanelOpen) {
|
||||
return this.configValue.left_panel.min_width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#rightPanelWidth() {
|
||||
if (this.rightPanelOpen) {
|
||||
if (this.leftPanelOpen) {
|
||||
return this.configValue.right_panel.min_width;
|
||||
}
|
||||
|
||||
return this.configValue.right_panel.max_width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#rightPanelOverflow() {
|
||||
if (this.rightPanelOpen) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
#persistPreference(field, value) {
|
||||
fetch(`/users/${this.userIdValue}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -21,7 +79,7 @@ export default class extends Controller {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"user[show_sidebar]": !this.panelTarget.classList.contains("w-0"),
|
||||
[`user[${field}]`]: value,
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
73
app/javascript/controllers/theme_controller.js
Normal file
73
app/javascript/controllers/theme_controller.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { userPreference: String }
|
||||
|
||||
connect() {
|
||||
this.applyTheme()
|
||||
this.startSystemThemeListener()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopSystemThemeListener()
|
||||
}
|
||||
|
||||
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
|
||||
userPreferenceValueChanged() {
|
||||
this.applyTheme()
|
||||
}
|
||||
|
||||
// Called when a theme radio button is clicked
|
||||
updateTheme(event) {
|
||||
const selectedTheme = event.currentTarget.value
|
||||
if (selectedTheme === "system") {
|
||||
this.setTheme(this.systemPrefersDark())
|
||||
} else if (selectedTheme === "dark") {
|
||||
this.setTheme(true)
|
||||
} else {
|
||||
this.setTheme(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Applies theme based on the userPreferenceValue (from server)
|
||||
applyTheme() {
|
||||
if (this.userPreferenceValue === "system") {
|
||||
this.setTheme(this.systemPrefersDark())
|
||||
} else if (this.userPreferenceValue === "dark") {
|
||||
this.setTheme(true)
|
||||
} else {
|
||||
this.setTheme(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sets or removes the data-theme attribute
|
||||
setTheme(isDark) {
|
||||
if (isDark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
}
|
||||
}
|
||||
|
||||
systemPrefersDark() {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
handleSystemThemeChange = (event) => {
|
||||
// Only apply system theme changes if the user preference is currently 'system'
|
||||
if (this.userPreferenceValue === "system") {
|
||||
this.setTheme(event.matches)
|
||||
}
|
||||
}
|
||||
|
||||
startSystemThemeListener() {
|
||||
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
|
||||
}
|
||||
|
||||
stopSystemThemeListener() {
|
||||
if (this.darkMediaQuery) {
|
||||
this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
retry_on ActiveRecord::Deadlocked
|
||||
discard_on ActiveJob::DeserializationError
|
||||
queue_as :low_priority # default queue
|
||||
end
|
||||
|
||||
7
app/jobs/assistant_response_job.rb
Normal file
7
app/jobs/assistant_response_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class AssistantResponseJob < ApplicationJob
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(message)
|
||||
message.request_response
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
class AutoUpgradeJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(*args)
|
||||
raise_if_disabled
|
||||
|
||||
return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual"
|
||||
|
||||
Rails.logger.info "Searching for available auto-upgrades..."
|
||||
|
||||
candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target)
|
||||
|
||||
if candidate
|
||||
if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha
|
||||
Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress"
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..."
|
||||
Upgrader.upgrade_to(candidate)
|
||||
Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day)
|
||||
else
|
||||
Rails.logger.info "No auto upgrade available at this time"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def raise_if_disabled
|
||||
raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class DataCacheClearJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(family)
|
||||
ActiveRecord::Base.transaction do
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class DestroyJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(model)
|
||||
model.destroy
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class EnrichTransactionBatchJob < ApplicationJob
|
||||
queue_as :latency_high
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(account, batch_size = 100, offset = 0)
|
||||
enricher = Account::DataEnricher.new(account)
|
||||
enricher.enrich_transaction_batch(batch_size, offset)
|
||||
account.enrich_transaction_batch(batch_size, offset)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class FamilyResetJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(family)
|
||||
# Delete all family data except users
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.provider.present?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ImportJob < ApplicationJob
|
||||
queue_as :latency_medium
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(import)
|
||||
import.publish
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class RevertImportJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :medium_priority
|
||||
|
||||
def perform(import)
|
||||
import.revert
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class SyncJob < ApplicationJob
|
||||
queue_as :latency_medium
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(sync)
|
||||
sleep 1 # simulate work for faster jobs
|
||||
sync.perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class UserPurgeJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(user)
|
||||
user.purge
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable, Convertible
|
||||
include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
@@ -13,7 +13,6 @@ class Account < ApplicationRecord
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
@@ -88,7 +87,6 @@ class Account < ApplicationRecord
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(family, target: "syncing-notice")
|
||||
resolve_stale_issues
|
||||
accountable.post_sync
|
||||
end
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@ module Account::Chartable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
|
||||
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||
|
||||
series_interval = interval || period.interval
|
||||
|
||||
balances = Account::Balance.find_by_sql([
|
||||
balance_series_query,
|
||||
{
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
target_currency: currency
|
||||
}
|
||||
])
|
||||
@@ -33,7 +35,7 @@ module Account::Chartable
|
||||
Series.new(
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
trend: Trend.new(
|
||||
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
|
||||
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
|
||||
@@ -124,11 +126,12 @@ module Account::Chartable
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def balance_series(period: Period.last_30_days, view: :balance)
|
||||
def balance_series(period: Period.last_30_days, view: :balance, interval: nil)
|
||||
self.class.where(id: self.id).balance_series(
|
||||
currency: currency,
|
||||
period: period,
|
||||
view: view,
|
||||
interval: interval,
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
@@ -7,14 +7,13 @@ module Account::Convertible
|
||||
return
|
||||
end
|
||||
|
||||
rates = ExchangeRate.find_rates(
|
||||
affected_row_count = ExchangeRate.sync_provider_rates(
|
||||
from: currency,
|
||||
to: target_currency,
|
||||
start_date: start_date,
|
||||
cache: true # caches from provider to DB
|
||||
)
|
||||
|
||||
Rails.logger.info("Synced #{rates.count} exchange rates for account #{id}")
|
||||
Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
class Account::DataEnricher
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def run
|
||||
total_unenriched = account.entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
batch_size = 50
|
||||
batches = (total_unenriched.to_f / batch_size).ceil
|
||||
|
||||
batches.times do |batch|
|
||||
EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_transaction_batch(batch_size = 50, offset = 0)
|
||||
candidates = account.entries.account_transactions
|
||||
.includes(entryable: [ :merchant, :category ])
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.offset(offset)
|
||||
.limit(batch_size)
|
||||
|
||||
Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})")
|
||||
|
||||
merchants = {}
|
||||
|
||||
candidates.each do |entry|
|
||||
begin
|
||||
info = entry.fetch_enrichment_info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
entryable_attributes = { id: entry.entryable_id }
|
||||
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
entryable_attributes: entryable_attributes
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,70 @@ module Account::Enrichable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def enrich_data
|
||||
DataEnricher.new(self).run
|
||||
total_unenriched = entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
batch_size = 50
|
||||
batches = (total_unenriched.to_f / batch_size).ceil
|
||||
|
||||
batches.times do |batch|
|
||||
EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size)
|
||||
# EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_transaction_batch(batch_size = 50, offset = 0)
|
||||
transactions_batch = enrichable_transactions.offset(offset).limit(batch_size)
|
||||
|
||||
Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})")
|
||||
|
||||
merchants = {}
|
||||
|
||||
transactions_batch.each do |transaction|
|
||||
begin
|
||||
info = transaction.fetch_enrichment_info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil?
|
||||
|
||||
transaction.entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def enrichable?
|
||||
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
|
||||
end
|
||||
|
||||
def enrichable_transactions
|
||||
transactions.active
|
||||
.includes(:merchant, :category)
|
||||
.where(
|
||||
"account_entries.enriched_at IS NULL",
|
||||
"OR merchant_id IS NULL",
|
||||
"OR category_id IS NULL"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable, Provided
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module Account::Entry::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
def fetch_enrichment_info
|
||||
return nil unless synth_client.present?
|
||||
|
||||
synth_client.enrich_transaction(name).info
|
||||
end
|
||||
end
|
||||
@@ -79,12 +79,11 @@ class Account::Holding::PortfolioCache
|
||||
securities.each do |security|
|
||||
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
||||
|
||||
# Highest priority prices
|
||||
db_or_provider_prices = Security::Price.find_prices(
|
||||
security: security,
|
||||
start_date: account.start_date,
|
||||
end_date: Date.current
|
||||
).map do |price|
|
||||
# Load prices from provider to DB
|
||||
security.sync_provider_prices(start_date: account.start_date)
|
||||
|
||||
# High priority prices from DB (synced from provider)
|
||||
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
|
||||
PriceWithPriority.new(
|
||||
price: price,
|
||||
priority: 1
|
||||
@@ -125,7 +124,7 @@ class Account::Holding::PortfolioCache
|
||||
|
||||
@security_cache[security.id] = {
|
||||
security: security,
|
||||
prices: db_or_provider_prices + trade_prices + holding_prices
|
||||
prices: db_prices + trade_prices + holding_prices
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Transaction < ApplicationRecord
|
||||
include Account::Entryable, Transferable
|
||||
include Account::Entryable, Transferable, Provided
|
||||
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
|
||||
20
app/models/account/transaction/provided.rb
Normal file
20
app/models/account/transaction/provided.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Account::Transaction::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def fetch_enrichment_info
|
||||
return nil unless provider
|
||||
|
||||
response = provider.enrich_transaction(
|
||||
entry.name,
|
||||
amount: entry.amount,
|
||||
date: entry.date
|
||||
)
|
||||
|
||||
response.data
|
||||
end
|
||||
|
||||
private
|
||||
def provider
|
||||
Provider::Registry.get_provider(:synth)
|
||||
end
|
||||
end
|
||||
75
app/models/assistant.rb
Normal file
75
app/models/assistant.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class Assistant
|
||||
include Provided, Configurable, Broadcastable
|
||||
|
||||
attr_reader :chat, :instructions
|
||||
|
||||
class << self
|
||||
def for_chat(chat)
|
||||
config = config_for(chat)
|
||||
new(chat, instructions: config[:instructions], functions: config[:functions])
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(chat, instructions: nil, functions: [])
|
||||
@chat = chat
|
||||
@instructions = instructions
|
||||
@functions = functions
|
||||
end
|
||||
|
||||
def respond_to(message)
|
||||
assistant_message = AssistantMessage.new(
|
||||
chat: chat,
|
||||
content: "",
|
||||
ai_model: message.ai_model
|
||||
)
|
||||
|
||||
responder = Assistant::Responder.new(
|
||||
message: message,
|
||||
instructions: instructions,
|
||||
function_tool_caller: function_tool_caller,
|
||||
llm: get_model_provider(message.ai_model)
|
||||
)
|
||||
|
||||
latest_response_id = chat.latest_assistant_response_id
|
||||
|
||||
responder.on(:output_text) do |text|
|
||||
if assistant_message.content.blank?
|
||||
stop_thinking
|
||||
|
||||
Chat.transaction do
|
||||
assistant_message.append_text!(text)
|
||||
chat.update_latest_response!(latest_response_id)
|
||||
end
|
||||
else
|
||||
assistant_message.append_text!(text)
|
||||
end
|
||||
end
|
||||
|
||||
responder.on(:response) do |data|
|
||||
update_thinking("Analyzing your data...")
|
||||
|
||||
if data[:function_tool_calls].present?
|
||||
assistant_message.tool_calls = data[:function_tool_calls]
|
||||
latest_response_id = data[:id]
|
||||
else
|
||||
chat.update_latest_response!(data[:id])
|
||||
end
|
||||
end
|
||||
|
||||
responder.respond(previous_response_id: latest_response_id)
|
||||
rescue => e
|
||||
stop_thinking
|
||||
chat.add_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :functions
|
||||
|
||||
def function_tool_caller
|
||||
function_instances = functions.map do |fn|
|
||||
fn.new(chat.user)
|
||||
end
|
||||
|
||||
@function_tool_caller ||= FunctionToolCaller.new(function_instances)
|
||||
end
|
||||
end
|
||||
12
app/models/assistant/broadcastable.rb
Normal file
12
app/models/assistant/broadcastable.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Assistant::Broadcastable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
def update_thinking(thought)
|
||||
chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought }
|
||||
end
|
||||
|
||||
def stop_thinking
|
||||
chat.broadcast_remove target: "thinking-indicator"
|
||||
end
|
||||
end
|
||||
85
app/models/assistant/configurable.rb
Normal file
85
app/models/assistant/configurable.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
module Assistant::Configurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def config_for(chat)
|
||||
preferred_currency = Money::Currency.new(chat.user.family.currency)
|
||||
preferred_date_format = chat.user.family.date_format
|
||||
|
||||
{
|
||||
instructions: default_instructions(preferred_currency, preferred_date_format),
|
||||
functions: default_functions
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def default_functions
|
||||
[
|
||||
Assistant::Function::GetTransactions,
|
||||
Assistant::Function::GetAccounts,
|
||||
Assistant::Function::GetBalanceSheet,
|
||||
Assistant::Function::GetIncomeStatement
|
||||
]
|
||||
end
|
||||
|
||||
def default_instructions(preferred_currency, preferred_date_format)
|
||||
<<~PROMPT
|
||||
## Your identity
|
||||
|
||||
You are a friendly financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance".
|
||||
|
||||
## Your purpose
|
||||
|
||||
You help users understand their financial data by answering questions about their accounts,
|
||||
transactions, income, expenses, net worth, and more.
|
||||
|
||||
## Your rules
|
||||
|
||||
Follow all rules below at all times.
|
||||
|
||||
### General rules
|
||||
|
||||
- Provide ONLY the most important numbers and insights
|
||||
- Eliminate all unnecessary words and context
|
||||
- Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.
|
||||
- Do NOT add introductions or conclusions
|
||||
- Do NOT apologize or explain limitations
|
||||
|
||||
### Formatting rules
|
||||
|
||||
- Format all responses in markdown
|
||||
- Format all monetary values according to the user's preferred currency
|
||||
- Format dates in the user's preferred format: #{preferred_date_format}
|
||||
|
||||
#### User's preferred currency
|
||||
|
||||
Maybe is a multi-currency app where each user has a "preferred currency" setting.
|
||||
|
||||
When no currency is specified, use the user's preferred currency for formatting and displaying monetary values.
|
||||
|
||||
- Symbol: #{preferred_currency.symbol}
|
||||
- ISO code: #{preferred_currency.iso_code}
|
||||
- Default precision: #{preferred_currency.default_precision}
|
||||
- Default format: #{preferred_currency.default_format}
|
||||
- Separator: #{preferred_currency.separator}
|
||||
- Delimiter: #{preferred_currency.delimiter}
|
||||
|
||||
### Rules about financial advice
|
||||
|
||||
You are NOT a licensed financial advisor and therefore, you should not provide any specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.).
|
||||
|
||||
Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions.
|
||||
|
||||
- Do not suggest investments or financial products
|
||||
- Do not make assumptions about the user's financial situation. Use the functions available to get the data you need.
|
||||
|
||||
### Function calling rules
|
||||
|
||||
- Use the functions available to you to get user financial data and enhance your responses
|
||||
- For functions that require dates, use the current date as your reference point: #{Date.current}
|
||||
- If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what
|
||||
the data you're presenting represents and what context it is in (i.e. date range, account, etc.)
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
end
|
||||
92
app/models/assistant/function.rb
Normal file
92
app/models/assistant/function.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
class Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
raise NotImplementedError, "Subclasses must implement the name class method"
|
||||
end
|
||||
|
||||
def description
|
||||
raise NotImplementedError, "Subclasses must implement the description class method"
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
raise NotImplementedError, "Subclasses must implement the call method"
|
||||
end
|
||||
|
||||
def name
|
||||
self.class.name
|
||||
end
|
||||
|
||||
def description
|
||||
self.class.description
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema
|
||||
end
|
||||
|
||||
# (preferred) when in strict mode, the schema needs to include all properties in required array
|
||||
def strict_mode?
|
||||
true
|
||||
end
|
||||
|
||||
def to_definition
|
||||
{
|
||||
name: name,
|
||||
description: description,
|
||||
params_schema: params_schema,
|
||||
strict: strict_mode?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :user
|
||||
|
||||
def build_schema(properties: {}, required: [])
|
||||
{
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required,
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def family_account_names
|
||||
@family_account_names ||= family.accounts.active.pluck(:name)
|
||||
end
|
||||
|
||||
def family_category_names
|
||||
@family_category_names ||= begin
|
||||
names = family.categories.pluck(:name)
|
||||
names << "Uncategorized"
|
||||
names
|
||||
end
|
||||
end
|
||||
|
||||
def family_merchant_names
|
||||
@family_merchant_names ||= family.merchants.pluck(:name)
|
||||
end
|
||||
|
||||
def family_tag_names
|
||||
@family_tag_names ||= family.tags.pluck(:name)
|
||||
end
|
||||
|
||||
def family
|
||||
user.family
|
||||
end
|
||||
|
||||
# To save tokens, we provide the AI metadata about the series and a flat array of
|
||||
# raw, formatted values which it can infer dates from
|
||||
def to_ai_time_series(series)
|
||||
{
|
||||
start_date: series.start_date,
|
||||
end_date: series.end_date,
|
||||
interval: series.interval,
|
||||
values: series.values.map { |v| v.trend.current.format }
|
||||
}
|
||||
end
|
||||
end
|
||||
40
app/models/assistant/function/get_accounts.rb
Normal file
40
app/models/assistant/function/get_accounts.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class Assistant::Function::GetAccounts < Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
"get_accounts"
|
||||
end
|
||||
|
||||
def description
|
||||
"Use this to see what accounts the user has along with their current and historical balances"
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
accounts: family.accounts.includes(:balances).map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
balance_formatted: account.balance_money.format,
|
||||
classification: account.classification,
|
||||
type: account.accountable_type,
|
||||
start_date: account.start_date,
|
||||
is_plaid_linked: account.plaid_account_id.present?,
|
||||
is_active: account.is_active,
|
||||
historical_balances: historical_balances(account)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_balances(account)
|
||||
start_date = [ account.start_date, 5.years.ago.to_date ].max
|
||||
period = Period.custom(start_date: start_date, end_date: Date.current)
|
||||
balance_series = account.balance_series(period: period, interval: "1 month")
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
||||
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_balance_sheet"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get the user's balance sheet with varying amounts of historical data.
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net worth? What is it composed of?
|
||||
- How has the user's wealth changed over time?
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
observation_start_date = [ 5.years.ago.to_date, family.oldest_entry_date ].max
|
||||
|
||||
period = Period.custom(start_date: observation_start_date, end_date: Date.current)
|
||||
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
oldest_account_start_date: family.oldest_entry_date,
|
||||
currency: family.currency,
|
||||
net_worth: {
|
||||
current: family.balance_sheet.net_worth_money.format,
|
||||
monthly_history: historical_data(period)
|
||||
},
|
||||
assets: {
|
||||
current: family.balance_sheet.total_assets_money.format,
|
||||
monthly_history: historical_data(period, classification: "asset")
|
||||
},
|
||||
liabilities: {
|
||||
current: family.balance_sheet.total_liabilities_money.format,
|
||||
monthly_history: historical_data(period, classification: "liability")
|
||||
},
|
||||
insights: insights_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_data(period, classification: nil)
|
||||
scope = family.accounts.active
|
||||
scope = scope.where(classification: classification) if classification.present?
|
||||
|
||||
if period.start_date == Date.current
|
||||
[]
|
||||
else
|
||||
balance_series = scope.balance_series(
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
interval: "1 month",
|
||||
favorable_direction: "up",
|
||||
)
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
||||
|
||||
def insights_data
|
||||
assets = family.balance_sheet.total_assets
|
||||
liabilities = family.balance_sheet.total_liabilities
|
||||
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
|
||||
|
||||
{
|
||||
debt_to_asset_ratio: number_to_percentage(ratio * 100, precision: 0)
|
||||
}
|
||||
end
|
||||
end
|
||||
125
app/models/assistant/function/get_income_statement.rb
Normal file
125
app/models/assistant/function/get_income_statement.rb
Normal file
@@ -0,0 +1,125 @@
|
||||
class Assistant::Function::GetIncomeStatement < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_income_statement"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get income and expense insights by category, for a specific time period
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net income for the current month?
|
||||
- What are the user's spending habits?
|
||||
- How much income or spending did the user have over a specific time period?
|
||||
|
||||
Simple example:
|
||||
|
||||
```
|
||||
get_income_statement({
|
||||
start_date: "2024-01-01",
|
||||
end_date: "2024-12-31"
|
||||
})
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
period = Period.custom(start_date: Date.parse(params["start_date"]), end_date: Date.parse(params["end_date"]))
|
||||
income_data = family.income_statement.income_totals(period: period)
|
||||
expense_data = family.income_statement.expense_totals(period: period)
|
||||
|
||||
{
|
||||
currency: family.currency,
|
||||
period: {
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date
|
||||
},
|
||||
income: {
|
||||
total: format_money(income_data.total),
|
||||
by_category: to_ai_category_totals(income_data.category_totals)
|
||||
},
|
||||
expense: {
|
||||
total: format_money(expense_data.total),
|
||||
by_category: to_ai_category_totals(expense_data.category_totals)
|
||||
},
|
||||
insights: get_insights(income_data, expense_data)
|
||||
}
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: [ "start_date", "end_date" ],
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
description: "Start date for aggregation period in YYYY-MM-DD format"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
description: "End date for aggregation period in YYYY-MM-DD format"
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def format_money(value)
|
||||
Money.new(value, family.currency).format
|
||||
end
|
||||
|
||||
def calculate_savings_rate(total_income, total_expenses)
|
||||
return 0 if total_income.zero?
|
||||
savings = total_income - total_expenses
|
||||
rate = (savings / total_income.to_f) * 100
|
||||
rate.round(2)
|
||||
end
|
||||
|
||||
def to_ai_category_totals(category_totals)
|
||||
hierarchical_groups = category_totals.group_by { |ct| ct.category.parent_id }.then do |grouped|
|
||||
root_category_totals = grouped[nil] || []
|
||||
|
||||
root_category_totals.each_with_object({}) do |ct, hash|
|
||||
subcategory_totals = ct.category.name == "Uncategorized" ? [] : (grouped[ct.category.id] || [])
|
||||
hash[ct.category.name] = {
|
||||
category_total: ct,
|
||||
subcategory_totals: subcategory_totals
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
hierarchical_groups.sort_by { |name, data| -data.dig(:category_total).total }.map do |name, data|
|
||||
{
|
||||
name: name,
|
||||
total: format_money(data.dig(:category_total).total),
|
||||
percentage_of_total: number_to_percentage(data.dig(:category_total).weight, precision: 1),
|
||||
subcategory_totals: data.dig(:subcategory_totals).map do |st|
|
||||
{
|
||||
name: st.category.name,
|
||||
total: format_money(st.total),
|
||||
percentage_of_total: number_to_percentage(st.weight, precision: 1)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_insights(income_data, expense_data)
|
||||
net_income = income_data.total - expense_data.total
|
||||
savings_rate = calculate_savings_rate(income_data.total, expense_data.total)
|
||||
median_monthly_income = family.income_statement.median_income
|
||||
median_monthly_expenses = family.income_statement.median_expense
|
||||
avg_monthly_expenses = family.income_statement.avg_expense
|
||||
|
||||
{
|
||||
net_income: format_money(net_income),
|
||||
savings_rate: number_to_percentage(savings_rate),
|
||||
median_monthly_income: format_money(median_monthly_income),
|
||||
median_monthly_expenses: format_money(median_monthly_expenses),
|
||||
avg_monthly_expenses: format_money(avg_monthly_expenses)
|
||||
}
|
||||
end
|
||||
end
|
||||
185
app/models/assistant/function/get_transactions.rb
Normal file
185
app/models/assistant/function/get_transactions.rb
Normal file
@@ -0,0 +1,185 @@
|
||||
class Assistant::Function::GetTransactions < Assistant::Function
|
||||
include Pagy::Backend
|
||||
|
||||
class << self
|
||||
def default_page_size
|
||||
50
|
||||
end
|
||||
|
||||
def name
|
||||
"get_transactions"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to search user's transactions by using various optional filters.
|
||||
|
||||
This function is great for things like:
|
||||
- Finding specific transactions
|
||||
- Getting basic stats about a small group of transactions
|
||||
|
||||
This function is not great for:
|
||||
- Large time periods (use the get_income_statement function for this)
|
||||
|
||||
Note on pagination:
|
||||
|
||||
This function can be paginated. You can expect the following properties in the response:
|
||||
|
||||
- `total_pages`: The total number of pages of results
|
||||
- `page`: The current page of results
|
||||
- `page_size`: The number of results per page (this will always be #{default_page_size})
|
||||
- `total_results`: The total number of results for the given filters
|
||||
- `total_income`: The total income for the given filters
|
||||
- `total_expenses`: The total expenses for the given filters
|
||||
|
||||
Simple example (transactions from the last 30 days):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
start_date: "#{30.days.ago.to_date}",
|
||||
end_date: "#{Date.current}"
|
||||
})
|
||||
```
|
||||
|
||||
More complex example (various filters):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
search: "mcdonalds",
|
||||
accounts: ["Checking", "Savings"],
|
||||
start_date: "#{30.days.ago.to_date}",
|
||||
end_date: "#{Date.current}",
|
||||
categories: ["Restaurants"],
|
||||
merchants: ["McDonald's"],
|
||||
tags: ["Food"],
|
||||
amount: "100",
|
||||
amount_operator: "less"
|
||||
})
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def strict_mode?
|
||||
false
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: [ "order", "page", "page_size" ],
|
||||
properties: {
|
||||
page: {
|
||||
type: "integer",
|
||||
description: "Page number"
|
||||
},
|
||||
order: {
|
||||
enum: [ "asc", "desc" ],
|
||||
description: "Order of the transactions by date"
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Search for transactions by name"
|
||||
},
|
||||
amount: {
|
||||
type: "string",
|
||||
description: "Amount for transactions (must be used with amount_operator)"
|
||||
},
|
||||
amount_operator: {
|
||||
type: "string",
|
||||
description: "Operator for amount (must be used with amount)",
|
||||
enum: [ "equal", "less", "greater" ]
|
||||
},
|
||||
start_date: {
|
||||
type: "string",
|
||||
description: "Start date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
description: "End date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
accounts: {
|
||||
type: "array",
|
||||
description: "Filter transactions by account name",
|
||||
items: { enum: family_account_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
categories: {
|
||||
type: "array",
|
||||
description: "Filter transactions by category name",
|
||||
items: { enum: family_category_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
merchants: {
|
||||
type: "array",
|
||||
description: "Filter transactions by merchant name",
|
||||
items: { enum: family_merchant_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
description: "Filter transactions by tag name",
|
||||
items: { enum: family_tag_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
search_params = params.except("order", "page")
|
||||
|
||||
transactions_query = family.transactions.active.search(search_params)
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
pagy, paginated_transactions = pagy(
|
||||
pagy_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
),
|
||||
page: params["page"] || 1,
|
||||
limit: default_page_size
|
||||
)
|
||||
|
||||
totals = family.income_statement.totals(transactions_scope: transactions_query)
|
||||
|
||||
normalized_transactions = paginated_transactions.map do |txn|
|
||||
entry = txn.entry
|
||||
{
|
||||
date: entry.date,
|
||||
amount: entry.amount.abs,
|
||||
currency: entry.currency,
|
||||
formatted_amount: entry.amount_money.abs.format,
|
||||
classification: entry.amount < 0 ? "income" : "expense",
|
||||
account: entry.account.name,
|
||||
category: txn.category&.name,
|
||||
merchant: txn.merchant&.name,
|
||||
tags: txn.tags.map(&:name),
|
||||
is_transfer: txn.transfer.present?
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
transactions: normalized_transactions,
|
||||
total_results: pagy.count,
|
||||
page: pagy.page,
|
||||
page_size: default_page_size,
|
||||
total_pages: pagy.pages,
|
||||
total_income: totals.income_money.format,
|
||||
total_expenses: totals.expense_money.format
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def default_page_size
|
||||
self.class.default_page_size
|
||||
end
|
||||
end
|
||||
37
app/models/assistant/function_tool_caller.rb
Normal file
37
app/models/assistant/function_tool_caller.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class Assistant::FunctionToolCaller
|
||||
Error = Class.new(StandardError)
|
||||
FunctionExecutionError = Class.new(Error)
|
||||
|
||||
attr_reader :functions
|
||||
|
||||
def initialize(functions = [])
|
||||
@functions = functions
|
||||
end
|
||||
|
||||
def fulfill_requests(function_requests)
|
||||
function_requests.map do |function_request|
|
||||
result = execute(function_request)
|
||||
|
||||
ToolCall::Function.from_function_request(function_request, result)
|
||||
end
|
||||
end
|
||||
|
||||
def function_definitions
|
||||
functions.map(&:to_definition)
|
||||
end
|
||||
|
||||
private
|
||||
def execute(function_request)
|
||||
fn = find_function(function_request)
|
||||
fn_args = JSON.parse(function_request.function_args)
|
||||
fn.call(fn_args)
|
||||
rescue => e
|
||||
raise FunctionExecutionError.new(
|
||||
"Error calling function #{fn.name} with arguments #{fn_args}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def find_function(function_request)
|
||||
functions.find { |f| f.name == function_request.function_name }
|
||||
end
|
||||
end
|
||||
12
app/models/assistant/provided.rb
Normal file
12
app/models/assistant/provided.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Assistant::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def get_model_provider(ai_model)
|
||||
registry.providers.find { |provider| provider.supports_model?(ai_model) }
|
||||
end
|
||||
|
||||
private
|
||||
def registry
|
||||
@registry ||= Provider::Registry.for_concept(:llm)
|
||||
end
|
||||
end
|
||||
87
app/models/assistant/responder.rb
Normal file
87
app/models/assistant/responder.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class Assistant::Responder
|
||||
def initialize(message:, instructions:, function_tool_caller:, llm:)
|
||||
@message = message
|
||||
@instructions = instructions
|
||||
@function_tool_caller = function_tool_caller
|
||||
@llm = llm
|
||||
end
|
||||
|
||||
def on(event_name, &block)
|
||||
listeners[event_name.to_sym] << block
|
||||
end
|
||||
|
||||
def respond(previous_response_id: nil)
|
||||
# For the first response
|
||||
streamer = proc do |chunk|
|
||||
case chunk.type
|
||||
when "output_text"
|
||||
emit(:output_text, chunk.data)
|
||||
when "response"
|
||||
response = chunk.data
|
||||
|
||||
if response.function_requests.any?
|
||||
handle_follow_up_response(response)
|
||||
else
|
||||
emit(:response, { id: response.id })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
get_llm_response(streamer: streamer, previous_response_id: previous_response_id)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :message, :instructions, :function_tool_caller, :llm
|
||||
|
||||
def handle_follow_up_response(response)
|
||||
streamer = proc do |chunk|
|
||||
case chunk.type
|
||||
when "output_text"
|
||||
emit(:output_text, chunk.data)
|
||||
when "response"
|
||||
# We do not currently support function executions for a follow-up response (avoid recursive LLM calls that could lead to high spend)
|
||||
emit(:response, { id: chunk.data.id })
|
||||
end
|
||||
end
|
||||
|
||||
function_tool_calls = function_tool_caller.fulfill_requests(response.function_requests)
|
||||
|
||||
emit(:response, {
|
||||
id: response.id,
|
||||
function_tool_calls: function_tool_calls
|
||||
})
|
||||
|
||||
# Get follow-up response with tool call results
|
||||
get_llm_response(
|
||||
streamer: streamer,
|
||||
function_results: function_tool_calls.map(&:to_result),
|
||||
previous_response_id: response.id
|
||||
)
|
||||
end
|
||||
|
||||
def get_llm_response(streamer:, function_results: [], previous_response_id: nil)
|
||||
response = llm.chat_response(
|
||||
message.content,
|
||||
model: message.ai_model,
|
||||
instructions: instructions,
|
||||
functions: function_tool_caller.function_definitions,
|
||||
function_results: function_results,
|
||||
streamer: streamer,
|
||||
previous_response_id: previous_response_id
|
||||
)
|
||||
|
||||
unless response.success?
|
||||
raise response.error
|
||||
end
|
||||
|
||||
response.data
|
||||
end
|
||||
|
||||
def emit(event_name, payload = nil)
|
||||
listeners[event_name.to_sym].each { |block| block.call(payload) }
|
||||
end
|
||||
|
||||
def listeners
|
||||
@listeners ||= Hash.new { |h, k| h[k] = [] }
|
||||
end
|
||||
end
|
||||
12
app/models/assistant_message.rb
Normal file
12
app/models/assistant_message.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class AssistantMessage < Message
|
||||
validates :ai_model, presence: true
|
||||
|
||||
def role
|
||||
"assistant"
|
||||
end
|
||||
|
||||
def append_text!(text)
|
||||
self.content += text
|
||||
save!
|
||||
end
|
||||
end
|
||||
75
app/models/chat.rb
Normal file
75
app/models/chat.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class Chat < ApplicationRecord
|
||||
include Debuggable
|
||||
|
||||
belongs_to :user
|
||||
|
||||
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
|
||||
has_many :messages, dependent: :destroy
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
class << self
|
||||
def start!(prompt, model:)
|
||||
create!(
|
||||
title: generate_title(prompt),
|
||||
messages: [ UserMessage.new(content: prompt, ai_model: model) ]
|
||||
)
|
||||
end
|
||||
|
||||
def generate_title(prompt)
|
||||
prompt.first(80)
|
||||
end
|
||||
end
|
||||
|
||||
def needs_assistant_response?
|
||||
conversation_messages.ordered.last.role != "assistant"
|
||||
end
|
||||
|
||||
def retry_last_message!
|
||||
update!(error: nil)
|
||||
|
||||
last_message = conversation_messages.ordered.last
|
||||
|
||||
if last_message.present? && last_message.role == "user"
|
||||
|
||||
ask_assistant_later(last_message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_latest_response!(provider_response_id)
|
||||
update!(latest_assistant_response_id: provider_response_id)
|
||||
end
|
||||
|
||||
def add_error(e)
|
||||
update! error: e.to_json
|
||||
broadcast_append target: "messages", partial: "chats/error", locals: { chat: self }
|
||||
end
|
||||
|
||||
def clear_error
|
||||
update! error: nil
|
||||
broadcast_remove target: "chat-error"
|
||||
end
|
||||
|
||||
def assistant
|
||||
@assistant ||= Assistant.for_chat(self)
|
||||
end
|
||||
|
||||
def ask_assistant_later(message)
|
||||
clear_error
|
||||
AssistantResponseJob.perform_later(message)
|
||||
end
|
||||
|
||||
def ask_assistant(message)
|
||||
assistant.respond_to(message)
|
||||
end
|
||||
|
||||
def conversation_messages
|
||||
if debug_mode?
|
||||
messages
|
||||
else
|
||||
messages.where(type: [ "UserMessage", "AssistantMessage" ])
|
||||
end
|
||||
end
|
||||
end
|
||||
7
app/models/chat/debuggable.rb
Normal file
7
app/models/chat/debuggable.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module Chat::Debuggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def debug_mode?
|
||||
ENV["AI_DEBUG_MODE"] == "true"
|
||||
end
|
||||
end
|
||||
@@ -1,52 +0,0 @@
|
||||
module Issuable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :issues, dependent: :destroy, as: :issuable
|
||||
end
|
||||
|
||||
def has_issues?
|
||||
issues.active.any?
|
||||
end
|
||||
|
||||
def resolve_stale_issues
|
||||
issues.active.each do |issue|
|
||||
issue.resolve! if issue.stale?
|
||||
end
|
||||
end
|
||||
|
||||
def observe_unknown_issue(error)
|
||||
observe_issue(
|
||||
Issue::Unknown.new(data: { error: error.message })
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_exchange_rates(from:, to:, dates:)
|
||||
observe_issue(
|
||||
Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates })
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_exchange_rate_provider
|
||||
observe_issue(
|
||||
Issue::ExchangeRateProviderMissing.new
|
||||
)
|
||||
end
|
||||
|
||||
def highest_priority_issue
|
||||
issues.active.ordered.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def observe_issue(new_issue)
|
||||
existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil)
|
||||
|
||||
if existing_issue
|
||||
existing_issue.update!(last_observed_at: Time.current, data: new_issue.data)
|
||||
else
|
||||
new_issue.issuable = self
|
||||
new_issue.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
module Synthable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def synth_usage
|
||||
synth_client&.usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
synth_usage&.usage&.utilization.to_i >= 100
|
||||
end
|
||||
|
||||
def synth_healthy?
|
||||
synth_client&.healthy?
|
||||
end
|
||||
|
||||
def synth_client
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
Provider::Synth.new(api_key)
|
||||
end
|
||||
end
|
||||
|
||||
def synth_client
|
||||
self.class.synth_client
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_usage
|
||||
end
|
||||
|
||||
def synth_overage?
|
||||
self.class.synth_overage?
|
||||
end
|
||||
end
|
||||
@@ -374,7 +374,7 @@ class Demo::Generator
|
||||
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||
security = trade[:security]
|
||||
qty = trade[:qty]
|
||||
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
|
||||
price = Security::Price.find_by(security: security, date: date)&.price || 1
|
||||
name_prefix = qty < 0 ? "Sell " : "Buy "
|
||||
|
||||
account.entries.create! \
|
||||
|
||||
10
app/models/developer_message.rb
Normal file
10
app/models/developer_message.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class DeveloperMessage < Message
|
||||
def role
|
||||
"developer"
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast?
|
||||
chat.debug_mode?
|
||||
end
|
||||
end
|
||||
@@ -2,27 +2,5 @@ class ExchangeRate < ApplicationRecord
|
||||
include Provided
|
||||
|
||||
validates :from_currency, :to_currency, :date, :rate, presence: true
|
||||
|
||||
class << self
|
||||
def find_rate(from:, to:, date:, cache: true)
|
||||
result = find_by \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: date
|
||||
|
||||
result || fetch_rate_from_provider(from:, to:, date:, cache:)
|
||||
end
|
||||
|
||||
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
|
||||
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a
|
||||
existing_dates = rates.map(&:date)
|
||||
missing_dates = all_dates - existing_dates
|
||||
if missing_dates.any?
|
||||
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
rates
|
||||
end
|
||||
end
|
||||
validates :date, uniqueness: { scope: %i[from_currency to_currency] }
|
||||
end
|
||||
|
||||
@@ -1,63 +1,55 @@
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Synthable
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
synth_client
|
||||
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
|
||||
return [] unless provider.present?
|
||||
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||
rate = find_by(from_currency: from, to_currency: to, date: date)
|
||||
return rate if rate.present?
|
||||
|
||||
response = provider.fetch_exchange_rates \
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
return nil unless provider.present? # No provider configured (some self-hosted apps)
|
||||
|
||||
if response.success?
|
||||
response.rates.map do |exchange_rate|
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: exchange_rate.dig(:date).to_date,
|
||||
rate: exchange_rate.dig(:rate)
|
||||
response = provider.fetch_exchange_rate(from: from, to: to, date: date)
|
||||
|
||||
rate.save! if cache
|
||||
rate
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
return nil unless response.success? # Provider error
|
||||
|
||||
rate = response.data
|
||||
ExchangeRate.find_or_create_by!(
|
||||
from_currency: rate.from,
|
||||
to_currency: rate.to,
|
||||
date: rate.date,
|
||||
rate: rate.rate
|
||||
) if cache
|
||||
rate
|
||||
end
|
||||
|
||||
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||
unless provider.present?
|
||||
Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates")
|
||||
return 0
|
||||
end
|
||||
|
||||
def fetch_rate_from_provider(from:, to:, date:, cache: false)
|
||||
return nil unless provider.present?
|
||||
fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date)
|
||||
|
||||
response = provider.fetch_exchange_rate \
|
||||
from: from,
|
||||
to: to,
|
||||
date: date
|
||||
|
||||
if response.success?
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
rate: response.rate,
|
||||
date: date
|
||||
|
||||
if cache
|
||||
rate.save! rescue ActiveRecord::RecordNotUnique
|
||||
end
|
||||
rate
|
||||
else
|
||||
nil
|
||||
end
|
||||
unless fetched_rates.success?
|
||||
Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}")
|
||||
return 0
|
||||
end
|
||||
|
||||
rates_data = fetched_rates.data.map do |rate|
|
||||
{
|
||||
from_currency: rate.from,
|
||||
to_currency: rate.to,
|
||||
date: rate.date,
|
||||
rate: rate.rate
|
||||
}
|
||||
end
|
||||
|
||||
ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Family < ApplicationRecord
|
||||
include Synthable, Plaidable, Syncable, AutoTransferMatchable
|
||||
include Syncable, AutoTransferMatchable
|
||||
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
@@ -19,7 +19,6 @@ class Family < ApplicationRecord
|
||||
has_many :invitations, dependent: :destroy
|
||||
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
|
||||
has_many :entries, through: :accounts
|
||||
has_many :transactions, through: :accounts
|
||||
@@ -75,9 +74,9 @@ class Family < ApplicationRecord
|
||||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
||||
provider = if region.to_sym == :eu
|
||||
self.class.plaid_eu_provider
|
||||
Provider::Registry.get_provider(:plaid_eu)
|
||||
else
|
||||
self.class.plaid_us_provider
|
||||
Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
# early return when no provider
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
class Issue < ApplicationRecord
|
||||
belongs_to :issuable, polymorphic: true
|
||||
|
||||
after_initialize :set_default_severity
|
||||
|
||||
enum :severity, { critical: 1, error: 2, warning: 3, info: 4 }
|
||||
|
||||
validates :severity, presence: true
|
||||
|
||||
scope :active, -> { where(resolved_at: nil) }
|
||||
scope :ordered, -> { order(:severity) }
|
||||
|
||||
def title
|
||||
model_name.human
|
||||
end
|
||||
|
||||
# The conditions that must be met for an issue to be fixed
|
||||
def stale?
|
||||
raise NotImplementedError, "#{self.class} must implement #{__method__}"
|
||||
end
|
||||
|
||||
def resolve!
|
||||
update!(resolved_at: Time.current)
|
||||
end
|
||||
|
||||
def default_severity
|
||||
:warning
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_severity
|
||||
self.severity ||= default_severity
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
class Issue::ExchangeRateProviderMissing < Issue
|
||||
def default_severity
|
||||
:error
|
||||
end
|
||||
|
||||
def stale?
|
||||
ExchangeRate.provider_healthy?
|
||||
end
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
class Issue::ExchangeRatesMissing < Issue
|
||||
store_accessor :data, :from_currency, :to_currency, :dates
|
||||
|
||||
validates :from_currency, :to_currency, :dates, presence: true
|
||||
|
||||
def stale?
|
||||
if dates.length == 1
|
||||
ExchangeRate.find_rate(from: from_currency, to: to_currency, date: dates.first).present?
|
||||
else
|
||||
sorted_dates = dates.sort
|
||||
rates = ExchangeRate.find_rates(from: from_currency, to: to_currency, start_date: sorted_dates.first, end_date: sorted_dates.last)
|
||||
rates.length == dates.length
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,11 +0,0 @@
|
||||
class Issue::Unknown < Issue
|
||||
def default_severity
|
||||
:warning
|
||||
end
|
||||
|
||||
# Unknown issues are always stale because we only want to show them
|
||||
# to the user once. If the same error occurs again, we'll create a new instance.
|
||||
def stale?
|
||||
true
|
||||
end
|
||||
end
|
||||
22
app/models/message.rb
Normal file
22
app/models/message.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Message < ApplicationRecord
|
||||
belongs_to :chat
|
||||
has_many :tool_calls, dependent: :destroy
|
||||
|
||||
enum :status, {
|
||||
pending: "pending",
|
||||
complete: "complete",
|
||||
failed: "failed"
|
||||
}
|
||||
|
||||
validates :content, presence: true
|
||||
|
||||
after_create_commit -> { broadcast_append_to chat, target: "messages" }, if: :broadcast?
|
||||
after_update_commit -> { broadcast_update_to chat }, if: :broadcast?
|
||||
|
||||
scope :ordered, -> { order(created_at: :asc) }
|
||||
|
||||
private
|
||||
def broadcast?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -156,8 +156,8 @@ class Period
|
||||
def must_be_valid_date_range
|
||||
return if start_date.nil? || end_date.nil?
|
||||
unless start_date.is_a?(Date) && end_date.is_a?(Date)
|
||||
errors.add(:start_date, "must be a valid date")
|
||||
errors.add(:end_date, "must be a valid date")
|
||||
errors.add(:start_date, "must be a valid date, got #{start_date.inspect}")
|
||||
errors.add(:end_date, "must be a valid date, got #{end_date.inspect}")
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class PlaidAccount < ApplicationRecord
|
||||
include Plaidable
|
||||
|
||||
TYPE_MAPPING = {
|
||||
"depository" => Depository,
|
||||
"credit" => CreditCard,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class PlaidItem < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
include Provided, Syncable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
module Plaidable
|
||||
module PlaidItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def plaid_us_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid
|
||||
Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
def plaid_eu_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu
|
||||
Provider::Registry.get_provider(:plaid_eu)
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
59
app/models/provider.rb
Normal file
59
app/models/provider.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class Provider
|
||||
Response = Data.define(:success?, :data, :error)
|
||||
|
||||
class Error < StandardError
|
||||
attr_reader :details
|
||||
|
||||
def initialize(message, details: nil)
|
||||
super(message)
|
||||
@details = details
|
||||
end
|
||||
|
||||
def as_json
|
||||
{
|
||||
message: message,
|
||||
details: details
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
|
||||
UsageData = Data.define(:used, :limit, :utilization, :plan)
|
||||
|
||||
def with_provider_response(error_transformer: nil, &block)
|
||||
data = yield
|
||||
|
||||
Response.new(
|
||||
success?: true,
|
||||
data: data,
|
||||
error: nil,
|
||||
)
|
||||
rescue => error
|
||||
transformed_error = if error_transformer
|
||||
error_transformer.call(error)
|
||||
else
|
||||
default_error_transformer(error)
|
||||
end
|
||||
|
||||
Sentry.capture_exception(transformed_error)
|
||||
|
||||
Response.new(
|
||||
success?: false,
|
||||
data: nil,
|
||||
error: transformed_error
|
||||
)
|
||||
end
|
||||
|
||||
# Override to set class-level error transformation for methods using `with_provider_response`
|
||||
def default_error_transformer(error)
|
||||
if error.is_a?(Faraday::Error)
|
||||
self.class::Error.new(
|
||||
error.message,
|
||||
details: error.response&.dig(:body),
|
||||
)
|
||||
else
|
||||
self.class::Error.new(error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
class Provider::Base
|
||||
ProviderError = Class.new(StandardError)
|
||||
|
||||
TRANSIENT_NETWORK_ERRORS = [
|
||||
Faraday::TimeoutError,
|
||||
Faraday::ConnectionFailed,
|
||||
Faraday::SSLError,
|
||||
Faraday::ClientError,
|
||||
Faraday::ServerError
|
||||
]
|
||||
|
||||
class << self
|
||||
def known_transient_errors
|
||||
TRANSIENT_NETWORK_ERRORS + [ ProviderError ]
|
||||
end
|
||||
end
|
||||
end
|
||||
13
app/models/provider/exchange_rate_concept.rb
Normal file
13
app/models/provider/exchange_rate_concept.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Provider::ExchangeRateConcept
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
Rate = Data.define(:date, :from, :to, :rate)
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
|
||||
end
|
||||
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
|
||||
end
|
||||
end
|
||||
@@ -1,43 +1,10 @@
|
||||
class Provider::Github
|
||||
attr_reader :name, :owner, :branch
|
||||
|
||||
def initialize(config = {})
|
||||
@name = config[:name] || ENV.fetch("GITHUB_REPO_NAME", "maybe")
|
||||
@owner = config[:owner] || ENV.fetch("GITHUB_REPO_OWNER", "maybe-finance")
|
||||
@branch = config[:branch] || ENV.fetch("GITHUB_REPO_BRANCH", "main")
|
||||
end
|
||||
|
||||
def fetch_latest_upgrade_candidates
|
||||
Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 30.minutes) do
|
||||
Rails.logger.info "Fetching latest GitHub upgrade candidates from #{repo} on branch #{branch}..."
|
||||
begin
|
||||
latest_release = Octokit.releases(repo).first
|
||||
latest_version = latest_release ? Semver.from_release_tag(latest_release.tag_name) : Semver.new(Maybe.version)
|
||||
latest_commit = Octokit.branch(repo, branch)
|
||||
|
||||
release_info = if latest_release
|
||||
{
|
||||
version: latest_version,
|
||||
url: latest_release.html_url,
|
||||
commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
|
||||
}
|
||||
end
|
||||
|
||||
commit_info = {
|
||||
version: latest_version,
|
||||
commit_sha: latest_commit.commit.sha,
|
||||
url: latest_commit.commit.html_url
|
||||
}
|
||||
|
||||
{
|
||||
release: release_info,
|
||||
commit: commit_info
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to fetch latest GitHub commits: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
def initialize
|
||||
@name = "maybe"
|
||||
@owner = "maybe-finance"
|
||||
@branch = "main"
|
||||
end
|
||||
|
||||
def fetch_latest_release_notes
|
||||
|
||||
12
app/models/provider/llm_concept.rb
Normal file
12
app/models/provider/llm_concept.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Provider::LlmConcept
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ChatMessage = Data.define(:id, :output_text)
|
||||
ChatStreamChunk = Data.define(:type, :data)
|
||||
ChatResponse = Data.define(:id, :model, :messages, :function_requests)
|
||||
ChatFunctionRequest = Data.define(:id, :call_id, :function_name, :function_args)
|
||||
|
||||
def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #chat_response"
|
||||
end
|
||||
end
|
||||
62
app/models/provider/openai.rb
Normal file
62
app/models/provider/openai.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
class Provider::Openai < Provider
|
||||
include LlmConcept
|
||||
|
||||
# Subclass so errors caught in this provider are raised as Provider::Openai::Error
|
||||
Error = Class.new(Provider::Error)
|
||||
|
||||
MODELS = %w[gpt-4o]
|
||||
|
||||
def initialize(access_token)
|
||||
@client = ::OpenAI::Client.new(access_token: access_token)
|
||||
end
|
||||
|
||||
def supports_model?(model)
|
||||
MODELS.include?(model)
|
||||
end
|
||||
|
||||
def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil)
|
||||
with_provider_response do
|
||||
chat_config = ChatConfig.new(
|
||||
functions: functions,
|
||||
function_results: function_results
|
||||
)
|
||||
|
||||
collected_chunks = []
|
||||
|
||||
# Proxy that converts raw stream to "LLM Provider concept" stream
|
||||
stream_proxy = if streamer.present?
|
||||
proc do |chunk|
|
||||
parsed_chunk = ChatStreamParser.new(chunk).parsed
|
||||
|
||||
unless parsed_chunk.nil?
|
||||
streamer.call(parsed_chunk)
|
||||
collected_chunks << parsed_chunk
|
||||
end
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
raw_response = client.responses.create(parameters: {
|
||||
model: model,
|
||||
input: chat_config.build_input(prompt),
|
||||
instructions: instructions,
|
||||
tools: chat_config.tools,
|
||||
previous_response_id: previous_response_id,
|
||||
stream: stream_proxy
|
||||
})
|
||||
|
||||
# If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search
|
||||
# for the "response chunk" in the stream and return it (it is already parsed)
|
||||
if stream_proxy.present?
|
||||
response_chunk = collected_chunks.find { |chunk| chunk.type == "response" }
|
||||
response_chunk.data
|
||||
else
|
||||
ChatParser.new(raw_response).parsed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :client
|
||||
end
|
||||
36
app/models/provider/openai/chat_config.rb
Normal file
36
app/models/provider/openai/chat_config.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class Provider::Openai::ChatConfig
|
||||
def initialize(functions: [], function_results: [])
|
||||
@functions = functions
|
||||
@function_results = function_results
|
||||
end
|
||||
|
||||
def tools
|
||||
functions.map do |fn|
|
||||
{
|
||||
type: "function",
|
||||
name: fn[:name],
|
||||
description: fn[:description],
|
||||
parameters: fn[:params_schema],
|
||||
strict: fn[:strict]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_input(prompt)
|
||||
results = function_results.map do |fn_result|
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: fn_result[:call_id],
|
||||
output: fn_result[:output].to_json
|
||||
}
|
||||
end
|
||||
|
||||
[
|
||||
{ role: "user", content: prompt },
|
||||
*results
|
||||
]
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :functions, :function_results
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user