1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 12:49:38 +02:00

Personal finance AI (v1) (#2022)

* AI sidebar

* Add chat and message models with associations

* Implement AI chat functionality with sidebar and messaging system

- Add chat and messages controllers
- Create chat and message views
- Implement chat-related routes
- Add message broadcasting and user interactions
- Update application layout to support chat sidebar
- Enhance user model with initials method

* Refactor AI sidebar with enhanced chat menu and interactions

- Update sidebar layout with dynamic width and improved responsiveness
- Add new chat menu Stimulus controller for toggling between chat and chat list views
- Improve chat list display with recent chats and empty state
- Extract AI avatar to a partial for reusability
- Enhance message display and interaction styling
- Add more contextual buttons and interaction hints

* Improve chat scroll behavior and message styling

- Refactor chat scroll functionality with Stimulus controller
- Optimize message scrolling in chat views
- Update message styling for better visual hierarchy
- Enhance chat container layout with flex and auto-scroll
- Simplify message rendering across different chat views

* Extract AI avatar to a shared partial for consistent styling

- Refactor AI avatar rendering across chat views
- Replace hardcoded avatar markup with a reusable partial
- Simplify avatar display in chats and messages views

* Update sidebar controller to handle right panel width dynamically

- Add conditional width class for right sidebar panel
- Ensure consistent sidebar toggle behavior for both left and right panels
- Use specific width class for right panel (w-[375px])

* Refactor chat form and AI greeting with flexible partials

- Extract message form to a reusable partial with dynamic context support
- Create flexible AI greeting partial for consistent welcome messages
- Simplify chat and sidebar views by leveraging new partials
- Add support for different form scenarios (chat, new chat, sidebar)
- Improve code modularity and reduce duplication

* Add chat clearing functionality with dynamic menu options

- Implement clear chat action in ChatsController
- Add clear chat route to support clearing messages
- Update AI sidebar with dropdown menu for chat actions
- Preserve system message when clearing chat
- Enhance chat interaction with new menu options

* Add frontmatter to project structure documentation

- Create initial frontmatter for structure.mdc file
- Include description and configuration options
- Prepare for potential dynamic documentation rendering

* Update general project rules with additional guidelines

- Add rule for using `Current.family` instead of `current_family`
- Include new guidelines for testing, API routes, and solution approach
- Expand project-specific rules for more consistent development practices

* Add OpenAI gem and AI-friendly data representations

- Add `ruby-openai` gem for AI integration
- Implement `to_ai_readable_hash` methods in BalanceSheet and IncomeStatement
- Include Promptable module in both models
- Add savings rate calculation method in IncomeStatement
- Prepare financial models for AI-powered insights and interactions

* Enhance AI Financial Assistant with Advanced Querying and Debugging Capabilities

- Implement comprehensive AI financial query system with function-based interactions
- Add detailed debug logging for AI responses and function calls
- Extend BalanceSheet and IncomeStatement models with AI-friendly methods
- Create robust error handling and fallback mechanisms for AI queries
- Update chat and message views to support debug mode and enhanced rendering
- Add AI query routes and initial test coverage for financial assistant

* Refactor AI sidebar and chat layout with improved structure and comments

- Remove inline AI chat from application layout
- Enhance AI sidebar with more semantic HTML structure
- Add descriptive comments to clarify different sections of chat view
- Improve flex layout and scrolling behavior in chat messages container
- Optimize message rendering with more explicit class names and structure

* Add Markdown rendering support for AI chat messages

- Implement `markdown` helper method in ApplicationHelper using Redcarpet
- Update message view to render AI messages with Markdown formatting
- Add comprehensive Markdown rendering options (tables, code blocks, links)
- Enhance AI Financial Assistant prompt to encourage Markdown usage
- Remove commented Markdown CSS in Tailwind application stylesheet

* Missing comma

* Enhance AI response processing with chat history context

* Improve AI debug logging with payload size limits and internal message flag

* Enhance AI chat interaction with improved thinking indicator and scrolling behavior

* Add AI consent and enable/disable functionality for AI chat

* Upgrade Biome and refactor JavaScript template literals

- Update @biomejs/biome to latest version with caret (^) notation
- Refactor AI query and chat controllers to use template literals
- Standardize npm scripts formatting in package.json

* Add beta testing usage note to AI consent modal

* Update test fixtures and configurations for AI chat functionality

- Add family association to chat fixtures and tests
- Set consistent password digest for test users
- Enable AI for test users
- Add OpenAI access token for test environment
- Update chat and user model tests to include family context

* Simplify data model and get tests passing

* Remove structure.mdc from version control

* Integrate AI chat styles into existing prose pattern

* Match Figma design spec, implement Turbo frames and actions for chats controller

* AI rules refresh

* Consolidate Stimulus controllers, thinking state, controllers, and views

* Naming, domain alignment

* Reset migrations

* Improve data model to support tool calls and message types

* Tool calling tests and fixtures

* Tool call implementation and test

* Get assistant test working again

* Test updates

* Process tool calls within provider

* Chat UI back to working state again

* Remove stale code

* Tests passing

* Update openai class naming to avoid conflicts

* Reconfigure test env

* Rebuild gemfile

* Fix naming conflicts for ChatResponse

* Message styles

* Use OpenAI conversation state management

* Assistant function base implementation

* Add back thinking messages, clean up error handling for chat

* Fix sync error when security price has bad data from provider

* Add balance sheet function to assistant

* Add better function calling error visibility

* Add income statement function

* Simplify and clean up "thinking" interactions with Turbo frames

* Remove stale data definitions from functions

* Ensure VCR fixtures working with latest code

* basic stream implementation

* Get streaming working

* Make AI sidebar wider when left sidebar is collapsed

* Get tests working with streaming responses

* Centralize provider error handling

* Provider data boundaries

---------

Co-authored-by: Josh Pigford <josh@joshpigford.com>
This commit is contained in:
Zach Gollwitzer 2025-03-28 13:08:22 -04:00 committed by GitHub
parent 8e6b81af77
commit 2f6b11c18f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 3576 additions and 462 deletions

View 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.

View file

@ -3,13 +3,7 @@ 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
@ -19,6 +13,7 @@ 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: Sidekiq + Redis
- External
@ -47,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

View file

@ -1,7 +1,7 @@
---
description: This rule explains the system architecture and data flow of the Rails app
globs: *
alwaysApply: false
alwaysApply: true
---
This file outlines how the codebase is structured and how data flows through the app.
@ -111,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.
@ -136,21 +136,7 @@ A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns
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 the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
```rb
module Providers
module_function
def synth
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
end
```
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:
@ -161,74 +147,35 @@ There are two types of 3rd party data in the Maybe app:
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" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this:
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
```
app/models/
exchange_rate.rb # <- ActiveRecord model and "concept"
exchange_rate/
provided.rb # <- Chooses the provider for this concept based on user settings / config
provideable.rb # <- Defines interface for providing exchange rates
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
```
Where the `Provideable` and concrete provider implementations would be something like:
```rb
# Defines the interface an exchange rate provider must implement
module ExchangeRate::Provideable
extend ActiveSupport::Concern
FetchRateData = Data.define(:rate)
FetchRatesData = Data.define(:rates)
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
```
Any provider that is a valid exchange rate provider must implement this interface:
```rb
class ConcreteProvider < Provider
include ExchangeRate::Provideable
def fetch_exchange_rate(from:, to:, date:)
provider_response do
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new # build response
)
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
# Implementation
end
end
```
### One-off data
For data that does not fit neatly into a "concept", a `Provideable` 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:
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
Providers.synth.usage
Provider::Registry.get_provider(:synth)&.usage
end
end
```
## "Provided" Concerns
In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) 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:
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
@ -241,7 +188,8 @@ module ExchangeRate::Provided
class_methods do
def provider
Providers.synth
registry = Provider::Registry.for_concept(:exchange_rates)
registry.get_provider(:synth)
end
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
@ -269,12 +217,12 @@ end
## Concrete provider implementations
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object:
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
provider_response do
with_provider_response do
ExampleData.new(
example: "data"
)
@ -283,12 +231,12 @@ class ConcreteProvider < Provider
end
```
The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
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
provider_response do
with_provider_response do
data = nil
# Raise an error if data cannot be returned

View file

@ -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-gray-900`
- 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

2
.gitignore vendored
View file

@ -62,6 +62,8 @@ gcp-storage-keyfile.json
coverage
.cursorrules
.cursor/rules/structure.mdc
.cursor/rules/agent.mdc
# Ignore node related files
node_modules

View file

@ -60,6 +60,9 @@ 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]
gem "brakeman", require: false

View file

@ -83,15 +83,16 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
ast (2.4.3)
aws-eventstream (1.3.2)
aws-partitions (1.1067.0)
aws-sdk-core (3.220.1)
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)
logger
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
@ -157,6 +158,7 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
event_stream_parser (1.0.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.2)
@ -280,28 +282,28 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.4-aarch64-linux-gnu)
nokogiri (1.18.6-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.4-aarch64-linux-musl)
nokogiri (1.18.6-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.4-arm-linux-gnu)
nokogiri (1.18.6-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.4-arm-linux-musl)
nokogiri (1.18.6-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.4-arm64-darwin)
nokogiri (1.18.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-darwin)
nokogiri (1.18.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-linux-gnu)
nokogiri (1.18.6-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.4-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.4)
parallel (1.26.3)
parser (3.3.7.1)
parser (3.3.7.2)
ast (~> 2.4.1)
racc
pg (1.5.9)
@ -314,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)
@ -377,9 +379,9 @@ 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)
@ -406,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.39.0)
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)
@ -422,13 +424,17 @@ GEM
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-lsp (0.23.11)
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.0.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)
@ -467,21 +473,21 @@ GEM
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11934)
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.2.0)
tailwindcss-rails (4.2.1)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.0.14)
tailwindcss-ruby (4.0.14-aarch64-linux-gnu)
tailwindcss-ruby (4.0.14-aarch64-linux-musl)
tailwindcss-ruby (4.0.14-arm64-darwin)
tailwindcss-ruby (4.0.14-x86_64-darwin)
tailwindcss-ruby (4.0.14-x86_64-linux-gnu)
tailwindcss-ruby (4.0.14-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)
@ -518,15 +524,12 @@ 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
@ -574,6 +577,7 @@ DEPENDENCIES
rqrcode (~> 2.2)
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
selenium-webdriver
sentry-rails
sentry-ruby

85
app/assets/images/ai.svg Normal file
View 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

View file

@ -1 +0,0 @@
/* Application styles */

View file

@ -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 */

View file

@ -316,8 +316,8 @@
}
@layer base {
form>button {
@apply cursor-pointer;
button {
@apply cursor-pointer focus-visible:outline-gray-900;
}
hr {

View file

@ -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

View 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

View 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

View 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

View file

@ -10,7 +10,7 @@ class PagesController < ApplicationController
end
def changelog
@release_notes = Providers.github.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

View file

@ -1,11 +1,13 @@
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 = Providers.synth&.usage
synth_provider = Provider::Registry.get_provider(:synth)
@synth_usage = synth_provider&.usage
end
def update
@ -37,10 +39,6 @@ class Settings::HostingsController < ApplicationController
params.require(:setting).permit(: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?
end
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end

View file

@ -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,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end

View file

@ -129,6 +129,73 @@ 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
},
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? }

View 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

View file

@ -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-white rounded-lg hidden",
data: { menu_target: "content" } do
capture(&block)
end

View 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;
};
}

View file

@ -2,17 +2,69 @@ 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", "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);
}
toggleRightPanel() {
this.rightPanelOpen = !this.rightPanelOpen;
this.#updatePanelWidths();
this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
}
#updatePanelWidths() {
this.contentTarget.style.maxWidth = `${this.#contentMaxWidth()}px`;
this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
}
#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;
}
#contentMaxWidth() {
if (!this.leftPanelOpen && !this.rightPanelOpen) {
return 1024;
}
if (this.leftPanelOpen && !this.rightPanelOpen) {
return 896;
}
return 768;
}
#persistPreference(field, value) {
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",
headers: {
@ -21,7 +73,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(),
});
}

View file

@ -0,0 +1,7 @@
class AssistantResponseJob < ApplicationJob
queue_as :default
def perform(message)
message.request_response
end
end

View file

@ -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

View file

@ -2,9 +2,9 @@ module Account::Transaction::Provided
extend ActiveSupport::Concern
def fetch_enrichment_info
return nil unless Providers.synth # Only Synth can provide this data
return nil unless provider
response = Providers.synth.enrich_transaction(
response = provider.enrich_transaction(
entry.name,
amount: entry.amount,
date: entry.date
@ -12,4 +12,9 @@ module Account::Transaction::Provided
response.data
end
private
def provider
Provider::Registry.get_provider(:synth)
end
end

178
app/models/assistant.rb Normal file
View file

@ -0,0 +1,178 @@
# Orchestrates LLM interactions for chat conversations by:
# - Streaming generic provider responses
# - Persisting messages and tool calls
# - Broadcasting updates to chat UI
# - Handling provider errors
class Assistant
include Provided
attr_reader :chat
class << self
def for_chat(chat)
new(chat)
end
end
def initialize(chat)
@chat = chat
end
def streamer(model)
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: model
)
proc do |chunk|
case chunk.type
when "output_text"
stop_thinking
assistant_message.content += chunk.data
assistant_message.save!
when "function_request"
update_thinking("Analyzing your data to assist you with your question...")
when "response"
stop_thinking
assistant_message.ai_model = chunk.data.model
combined_tool_calls = chunk.data.functions.map do |tc|
ToolCall::Function.new(
provider_id: tc.id,
provider_call_id: tc.call_id,
function_name: tc.name,
function_arguments: tc.arguments,
function_result: tc.result
)
end
assistant_message.tool_calls = combined_tool_calls
assistant_message.save!
chat.update!(latest_assistant_response_id: chunk.data.id)
end
end
end
def respond_to(message)
chat.clear_error
sleep artificial_thinking_delay
provider = get_model_provider(message.ai_model)
provider.chat_response(
message,
instructions: instructions,
available_functions: functions,
streamer: streamer(message.ai_model)
)
rescue => e
chat.add_error(e)
end
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
def process_response_artifacts(data)
messages = data.messages.map do |message|
AssistantMessage.new(
chat: chat,
content: message.content,
provider_id: message.id,
ai_model: data.model,
tool_calls: data.functions.map do |fn|
ToolCall::Function.new(
provider_id: fn.id,
provider_call_id: fn.call_id,
function_name: fn.name,
function_arguments: fn.arguments,
function_result: fn.result
)
end
)
end
messages.each(&:save!)
end
def instructions
<<~PROMPT
## Your identity
You are a 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
#### 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 financial advice. Instead,
you should focus on educating the user about personal finance and their own data so they can make informed decisions.
- Do not provide financial and/or investment advice
- 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
def functions
[
Assistant::Function::GetTransactions.new(chat.user),
Assistant::Function::GetAccounts.new(chat.user),
Assistant::Function::GetBalanceSheet.new(chat.user),
Assistant::Function::GetIncomeStatement.new(chat.user)
]
end
def preferred_currency
Money::Currency.new(chat.user.family.currency)
end
def artificial_thinking_delay
1
end
end

View file

@ -0,0 +1,83 @@
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
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

View 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

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,11 @@
class AssistantMessage < Message
validates :ai_model, presence: true
def role
"assistant"
end
def broadcast?
true
end
end

64
app/models/chat.rb Normal file
View file

@ -0,0 +1,64 @@
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 retry_last_message!
last_message = conversation_messages.ordered.last
if last_message.present? && last_message.role == "user"
update!(error: nil)
ask_assistant_later(last_message)
end
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)
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

View file

@ -0,0 +1,7 @@
module Chat::Debuggable
extend ActiveSupport::Concern
def debug_mode?
ENV["AI_DEBUG_MODE"] == "true"
end
end

View file

@ -0,0 +1,9 @@
class DeveloperMessage < Message
def role
"developer"
end
def broadcast?
chat.debug_mode?
end
end

View file

@ -3,7 +3,8 @@ module ExchangeRate::Provided
class_methods do
def provider
Providers.synth
registry = Provider::Registry.for_concept(:exchange_rates)
registry.get_provider(:synth)
end
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
@ -16,8 +17,13 @@ module ExchangeRate::Provided
return nil unless response.success? # Provider error
rate = response.data.rate
rate.save! if cache
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
@ -34,8 +40,13 @@ module ExchangeRate::Provided
return 0
end
rates_data = fetched_rates.data.rates.map do |rate|
rate.attributes.slice("from_currency", "to_currency", "date", "rate")
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])

View file

@ -74,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
Providers.plaid_eu
Provider::Registry.get_provider(:plaid_eu)
else
Providers.plaid_us
Provider::Registry.get_provider(:plaid_us)
end
# early return when no provider

View file

@ -1,11 +0,0 @@
class FinancialAssistant
include Provided
def initialize(chat)
@chat = chat
end
def query(prompt, model_key: "gpt-4o")
llm_provider = self.class.llm_provider_for(model_key)
end
end

View file

@ -1,13 +0,0 @@
module FinancialAssistant::Provided
extend ActiveSupport::Concern
# Placeholder for AI chat PR
def llm_provider_for(model_key)
case model_key
when "gpt-4o"
Providers.openai
else
raise "Unknown LLM model key: #{model_key}"
end
end
end

22
app/models/message.rb Normal file
View 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, allow_blank: 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?
raise NotImplementedError, "subclasses must set #broadcast?"
end
end

View file

@ -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

View file

@ -3,11 +3,11 @@ module PlaidItem::Provided
class_methods do
def plaid_us_provider
Providers.plaid_us
Provider::Registry.get_provider(:plaid_us)
end
def plaid_eu_provider
Providers.plaid_eu
Provider::Registry.get_provider(:plaid_eu)
end
def plaid_provider_for_region(region)

View file

@ -1,8 +1,25 @@
class Provider
include Retryable
ProviderError = Class.new(StandardError)
ProviderResponse = Data.define(:success?, :data, :error)
Response = Data.define(:success?, :data, :error)
class Error < StandardError
attr_reader :details, :provider
def initialize(message, details: nil, provider: nil)
super(message)
@details = details
@provider = provider
end
def as_json
{
provider: provider,
message: message,
details: details
}
end
end
private
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
@ -13,23 +30,49 @@ class Provider
[]
end
def provider_response(retries: nil, &block)
data = if retries
def with_provider_response(retries: default_retries, error_transformer: nil, &block)
data = if retries > 0
retrying(retryable_errors, max_retries: retries) { yield }
else
yield
end
ProviderResponse.new(
Response.new(
success?: true,
data: data,
error: nil,
)
rescue StandardError => error
ProviderResponse.new(
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: error,
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)
Error.new(
error.message,
details: error.response&.dig(:body),
provider: self.class.name
)
else
Error.new(error.message, provider: self.class.name)
end
end
# Override to set class-level number of retries for methods using `with_provider_response`
def default_retries
0
end
end

View file

@ -1,10 +1,6 @@
# Defines the interface an exchange rate provider must implement
module ExchangeRate::Provideable
module Provider::ExchangeRateProvider
extend ActiveSupport::Concern
FetchRateData = Data.define(:rate)
FetchRatesData = Data.define(:rates)
def fetch_exchange_rate(from:, to:, date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
end
@ -12,4 +8,7 @@ module ExchangeRate::Provideable
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
end
private
Rate = Data.define(:date, :from, :to, :rate)
end

View file

@ -0,0 +1,13 @@
module Provider::LlmProvider
extend ActiveSupport::Concern
def chat_response(message, instructions: nil, available_functions: [], streamer: nil)
raise NotImplementedError, "Subclasses must implement #chat_response"
end
private
StreamChunk = Data.define(:type, :data)
ChatResponse = Data.define(:id, :messages, :functions, :model)
Message = Data.define(:id, :content)
FunctionExecution = Data.define(:id, :call_id, :name, :arguments, :result)
end

View file

@ -0,0 +1,30 @@
class Provider::Openai < Provider
include LlmProvider
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(message, instructions: nil, available_functions: [], streamer: nil)
with_provider_response do
processor = ChatResponseProcessor.new(
client: client,
message: message,
instructions: instructions,
available_functions: available_functions,
streamer: streamer
)
processor.process
end
end
private
attr_reader :client
end

View file

@ -0,0 +1,188 @@
class Provider::Openai::ChatResponseProcessor
def initialize(message:, client:, instructions: nil, available_functions: [], streamer: nil)
@client = client
@message = message
@instructions = instructions
@available_functions = available_functions
@streamer = streamer
end
def process
first_response = fetch_response(previous_response_id: previous_openai_response_id)
if first_response.functions.empty?
if streamer.present?
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: first_response))
end
return first_response
end
executed_functions = execute_pending_functions(first_response.functions)
follow_up_response = fetch_response(
executed_functions: executed_functions,
previous_response_id: first_response.id
)
if streamer.present?
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: follow_up_response))
end
follow_up_response
end
private
attr_reader :client, :message, :instructions, :available_functions, :streamer
PendingFunction = Data.define(:id, :call_id, :name, :arguments)
def fetch_response(executed_functions: [], previous_response_id: nil)
function_results = executed_functions.map do |executed_function|
{
type: "function_call_output",
call_id: executed_function.call_id,
output: executed_function.result.to_json
}
end
prepared_input = input + function_results
# No need to pass tools for follow-up messages that provide function results
prepared_tools = executed_functions.empty? ? tools : []
raw_response = nil
internal_streamer = proc do |chunk|
type = chunk.dig("type")
if streamer.present?
case type
when "response.output_text.delta", "response.refusal.delta"
# We don't distinguish between text and refusal yet, so stream both the same
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "output_text", data: chunk.dig("delta")))
when "response.function_call_arguments.done"
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "function_request", data: chunk.dig("arguments")))
end
end
if type == "response.completed"
raw_response = chunk.dig("response")
end
end
client.responses.create(parameters: {
model: model,
input: prepared_input,
instructions: instructions,
tools: prepared_tools,
previous_response_id: previous_response_id,
stream: internal_streamer
})
if raw_response.dig("status") == "failed" || raw_response.dig("status") == "incomplete"
raise Provider::Openai::Error.new("OpenAI returned a failed or incomplete response", { chunk: chunk })
end
response_output = raw_response.dig("output")
functions_output = if executed_functions.any?
executed_functions
else
extract_pending_functions(response_output)
end
Provider::LlmProvider::ChatResponse.new(
id: raw_response.dig("id"),
messages: extract_messages(response_output),
functions: functions_output,
model: raw_response.dig("model")
)
end
def chat
message.chat
end
def model
message.ai_model
end
def previous_openai_response_id
chat.latest_assistant_response_id
end
# Since we're using OpenAI's conversation state management, all we need to pass
# to input is the user message we're currently responding to.
def input
[ { role: "user", content: message.content } ]
end
def extract_messages(response_output)
message_items = response_output.filter { |item| item.dig("type") == "message" }
message_items.map do |item|
output_text = item.dig("content").map do |content|
text = content.dig("text")
refusal = content.dig("refusal")
text || refusal
end.flatten.join("\n")
Provider::LlmProvider::Message.new(
id: item.dig("id"),
content: output_text,
)
end
end
def extract_pending_functions(response_output)
response_output.filter { |item| item.dig("type") == "function_call" }.map do |item|
PendingFunction.new(
id: item.dig("id"),
call_id: item.dig("call_id"),
name: item.dig("name"),
arguments: item.dig("arguments"),
)
end
end
def execute_pending_functions(pending_functions)
pending_functions.map do |pending_function|
execute_function(pending_function)
end
end
def execute_function(fn)
fn_instance = available_functions.find { |f| f.name == fn.name }
parsed_args = JSON.parse(fn.arguments)
result = fn_instance.call(parsed_args)
Provider::LlmProvider::FunctionExecution.new(
id: fn.id,
call_id: fn.call_id,
name: fn.name,
arguments: parsed_args,
result: result
)
rescue => e
fn_execution_details = {
fn_name: fn.name,
fn_args: parsed_args
}
raise Provider::Openai::Error.new(e, fn_execution_details)
end
def tools
available_functions.map do |fn|
{
type: "function",
name: fn.name,
description: fn.description,
parameters: fn.params_schema,
strict: fn.strict_mode?
}
end
end
end

View file

@ -0,0 +1,13 @@
# A stream proxy for OpenAI chat responses
#
# - Consumes an OpenAI chat response stream
# - Outputs a generic "Chat Provider Stream" interface to consumers (e.g. `Assistant`)
class Provider::Openai::ChatStreamer
def initialize(output_stream)
@output_stream = output_stream
end
def call(chunk)
@output_stream.call(chunk)
end
end

View file

@ -0,0 +1,91 @@
class Provider::Registry
include ActiveModel::Validations
Error = Class.new(StandardError)
CONCEPTS = %i[exchange_rates securities llm]
validates :concept, inclusion: { in: CONCEPTS }
class << self
def for_concept(concept)
new(concept.to_sym)
end
def get_provider(name)
send(name)
rescue NoMethodError
raise Error.new("Provider '#{name}' not found in registry")
end
private
def synth
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
def plaid_us
config = Rails.application.config.plaid
return nil unless config.present?
Provider::Plaid.new(config, region: :us)
end
def plaid_eu
config = Rails.application.config.plaid_eu
return nil unless config.present?
Provider::Plaid.new(config, region: :eu)
end
def github
Provider::Github.new
end
def openai
access_token = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token)
return nil unless access_token.present?
Provider::Openai.new(access_token)
end
end
def initialize(concept)
@concept = concept
validate!
end
def providers
available_providers.map { |p| self.class.send(p) }
end
def get_provider(name)
provider_method = available_providers.find { |p| p == name.to_sym }
raise Error.new("Provider '#{name}' not found for concept: #{concept}") unless provider_method.present?
self.class.send(provider_method)
end
private
attr_reader :concept
def available_providers
case concept
when :exchange_rates
%i[synth]
when :securities
%i[synth]
when :llm
%i[openai]
else
%i[synth plaid_us plaid_eu github openai]
end
end
end

View file

@ -1,18 +1,6 @@
module Security::Provideable
module Provider::SecurityProvider
extend ActiveSupport::Concern
Search = Data.define(:securities)
PriceData = Data.define(:price)
PricesData = Data.define(:prices)
SecurityInfo = Data.define(
:ticker,
:name,
:links,
:logo_url,
:description,
:kind,
)
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
raise NotImplementedError, "Subclasses must implement #search_securities"
end
@ -28,4 +16,9 @@ module Security::Provideable
def fetch_security_prices(security, start_date:, end_date:)
raise NotImplementedError, "Subclasses must implement #fetch_security_prices"
end
private
Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic)
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind)
Price = Data.define(:security, :date, :price, :currency)
end

View file

@ -1,20 +1,19 @@
class Provider::Synth < Provider
include ExchangeRate::Provideable
include Security::Provideable
include ExchangeRateProvider, SecurityProvider
def initialize(api_key)
@api_key = api_key
end
def healthy?
provider_response do
with_provider_response do
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
end
def usage
provider_response do
with_provider_response do
response = client.get("#{base_url}/user")
parsed = JSON.parse(response.body)
@ -37,7 +36,7 @@ class Provider::Synth < Provider
# ================================
def fetch_exchange_rate(from:, to:, date:)
provider_response retries: 2 do
with_provider_response retries: 2 do
response = client.get("#{base_url}/rates/historical") do |req|
req.params["date"] = date.to_s
req.params["from"] = from
@ -46,19 +45,12 @@ class Provider::Synth < Provider
rates = JSON.parse(response.body).dig("data", "rates")
ExchangeRate::Provideable::FetchRateData.new(
rate: ExchangeRate.new(
from_currency: from,
to_currency: to,
date: date,
rate: rates.dig(to)
)
)
Rate.new(date:, from:, to:, rate: rates.dig(to))
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
provider_response retries: 1 do
with_provider_response retries: 1 do
data = paginate(
"#{base_url}/rates/historical-range",
from: from,
@ -69,16 +61,9 @@ class Provider::Synth < Provider
body.dig("data")
end
ExchangeRate::Provideable::FetchRatesData.new(
rates: data.paginated.map do |exchange_rate|
ExchangeRate.new(
from_currency: from,
to_currency: to,
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
)
end
)
data.paginated.map do |rate|
Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to))
end
end
end
@ -87,7 +72,7 @@ class Provider::Synth < Provider
# ================================
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
provider_response do
with_provider_response do
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = symbol
req.params["dataset"] = "limited"
@ -98,24 +83,19 @@ class Provider::Synth < Provider
parsed = JSON.parse(response.body)
Security::Provideable::Search.new(
securities: parsed.dig("data").map do |security|
Security.new(
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
country_code: security.dig("exchange", "country_code")
)
end
)
parsed.dig("data").map do |security|
Security.new(
symbol: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
)
end
end
end
def fetch_security_info(security)
provider_response do
with_provider_response do
response = client.get("#{base_url}/tickers/#{security.ticker}") do |req|
req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present?
req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present?
@ -123,8 +103,8 @@ class Provider::Synth < Provider
data = JSON.parse(response.body).dig("data")
Security::Provideable::SecurityInfo.new(
ticker: security.ticker,
SecurityInfo.new(
symbol: data.dig("ticker"),
name: data.dig("name"),
links: data.dig("links"),
logo_url: data.dig("logo_url"),
@ -135,19 +115,17 @@ class Provider::Synth < Provider
end
def fetch_security_price(security, date:)
provider_response do
with_provider_response do
historical_data = fetch_security_prices(security, start_date: date, end_date: date)
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty?
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty?
Security::Provideable::PriceData.new(
price: historical_data.data.prices.first
)
historical_data.data.first
end
end
def fetch_security_prices(security, start_date:, end_date:)
provider_response retries: 1 do
with_provider_response retries: 1 do
params = {
start_date: start_date,
end_date: end_date
@ -167,16 +145,14 @@ class Provider::Synth < Provider
exchange_mic = data.first_page.dig("exchange", "mic_code")
exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code")
Security::Provideable::PricesData.new(
prices: data.paginated.map do |price|
Security::Price.new(
security: security,
date: price.dig("date"),
price: price.dig("close") || price.dig("open"),
currency: currency
)
end
)
data.paginated.map do |price|
Price.new(
security: security,
date: price.dig("date"),
price: price.dig("close") || price.dig("open"),
currency: currency
)
end
end
end
@ -185,7 +161,7 @@ class Provider::Synth < Provider
# ================================
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
provider_response do
with_provider_response do
params = {
description: description,
amount: amount,
@ -216,9 +192,7 @@ class Provider::Synth < Provider
[
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError,
Faraday::ClientError,
Faraday::ServerError
Faraday::SSLError
]
end

View file

@ -1,35 +0,0 @@
module Providers
module_function
def synth
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
return nil unless api_key.present?
Provider::Synth.new(api_key)
end
def plaid_us
config = Rails.application.config.plaid
return nil unless config.present?
Provider::Plaid.new(config, region: :us)
end
def plaid_eu
config = Rails.application.config.plaid_eu
return nil unless config.present?
Provider::Plaid.new(config, region: :eu)
end
def github
Provider::Github.new
end
def openai
# TODO: Placeholder for AI chat PR
end
end

View file

@ -3,7 +3,8 @@ module Security::Provided
class_methods do
def provider
Providers.synth
registry = Provider::Registry.for_concept(:securities)
registry.get_provider(:synth)
end
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
@ -12,7 +13,7 @@ module Security::Provided
response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic)
if response.success?
response.data.securities
response.data
else
[]
end
@ -37,11 +38,24 @@ module Security::Provided
return 0
end
fetched_prices = response.data.prices.map do |price|
price.attributes.slice("security_id", "date", "price", "currency")
fetched_prices = response.data.map do |price|
{
security_id: price.security.id,
date: price.date,
price: price.price,
currency: price.currency
}
end
Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency])
valid_prices = fetched_prices.reject do |price|
is_invalid = price[:date].nil? || price[:price].nil? || price[:currency].nil?
if is_invalid
Rails.logger.warn("Invalid price data for security_id=#{id}: Missing required fields in price record: #{price.inspect}")
end
is_invalid
end
Security::Price.upsert_all(valid_prices, unique_by: %i[security_id date currency])
end
def find_or_fetch_price(date: Date.current, cache: true)
@ -53,8 +67,13 @@ module Security::Provided
return nil unless response.success? # Provider error
price = response.data.price
price.save! if cache
price = response.data
Security::Price.find_or_create_by!(
security_id: price.security.id,
date: price.date,
price: price.price,
currency: price.currency
) if cache
price
end

View file

@ -8,7 +8,6 @@ class Security::SynthComboboxOption
end
def to_combobox_display
display_code = exchange_acronym.presence || exchange_operating_mic
"#{symbol} - #{name} (#{display_code})" # shown in combobox input when selected
"#{symbol} - #{name} (#{exchange_operating_mic})" # shown in combobox input when selected
end
end

View file

@ -3,6 +3,8 @@ class Setting < RailsSettings::Base
cache_prefix { "v1" }
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"]
field :require_invite_for_signup, type: :boolean, default: false
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
end

3
app/models/tool_call.rb Normal file
View file

@ -0,0 +1,3 @@
class ToolCall < ApplicationRecord
belongs_to :message
end

View file

@ -0,0 +1,4 @@
class ToolCall::Function < ToolCall
validates :function_name, :function_result, presence: true
validates :function_arguments, presence: true, allow_blank: true
end

View file

@ -2,7 +2,9 @@ class User < ApplicationRecord
has_secure_password
belongs_to :family
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family, update_only: true
@ -69,6 +71,26 @@ class User < ApplicationRecord
(display_name&.first || email.first).upcase
end
def initials
if first_name.present? && last_name.present?
"#{first_name.first}#{last_name.first}".upcase
else
initial
end
end
def show_ai_sidebar?
show_ai_sidebar
end
def ai_available?
!Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present?
end
def ai_enabled?
ai_enabled && ai_available?
end
# Deactivation
validate :can_deactivate, if: -> { active_changed? && !active }
after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }

View file

@ -0,0 +1,22 @@
class UserMessage < Message
validates :ai_model, presence: true
after_create_commit :request_response_later
def role
"user"
end
def request_response_later
chat.ask_assistant_later(self)
end
def request_response
chat.ask_assistant(self)
end
private
def broadcast?
true
end
end

View file

@ -1,6 +1,6 @@
<%# locals: (family:) %>
<% if family.requires_data_provider? && Providers.synth.nil? %>
<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">

View file

@ -0,0 +1,23 @@
<%# locals: (assistant_message:) %>
<div id="<%= dom_id(assistant_message) %>">
<% if assistant_message.reasoning? %>
<details class="group mb-1">
<summary class="flex items-center gap-2">
<p class="text-secondary text-sm">Assistant reasoning</p>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
</summary>
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>
</details>
<% else %>
<% if assistant_message.chat.debug_mode? && assistant_message.tool_calls.any? %>
<%= render "assistant_messages/tool_calls", message: assistant_message %>
<% end %>
<div class="flex items-start mb-6">
<%= render "chats/ai_avatar" %>
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>
</div>
<% end %>
</div>

View file

@ -0,0 +1,19 @@
<%# locals: (message:) %>
<details class="my-2 group mb-4">
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %>
<p>Tool Calls</p>
</summary>
<div class="mt-2">
<% message.tool_calls.each do |tool_call| %>
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2">
<p class="text-secondary text-xs">Function:</p>
<p class="text-primary text-sm font-mono"><%= tool_call.function_name %></p>
<p class="text-secondary text-xs mt-2">Arguments:</p>
<pre class="text-primary text-sm font-mono whitespace-pre-wrap"><%= tool_call.function_arguments %></pre>
</div>
<% end %>
</div>
</details>

View file

@ -0,0 +1,3 @@
<div class="w-16 h-16 flex-shrink-0 -ml-3 -mt-3">
<%= image_tag "ai.svg", alt: "AI", class: "w-full h-full" %>
</div>

View file

@ -0,0 +1,33 @@
<div class="flex flex-col items-center justify-start h-full p-6 text-center">
<div class="border border-gray-200 rounded-lg p-4 bg-white">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<%= icon("sparkles") %>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Enable Personal Finance AI</h3>
<p class="text-gray-600 mb-6 text-sm">
<% if Current.user.ai_available? %>
Our personal finance AI can help answer questions about your finances and provide insights based on your data.
To use this feature, you'll need to explicitly enable it.
<% else %>
To use the AI assistant, you need to set the <code class="bg-gray-100 px-1 py-0.5 rounded font-mono text-xs">OPENAI_ACCESS_TOKEN</code>
environment variable in your self-hosted instance.
<% end %>
</p>
<% unless self_hosted? %>
<p class="text-gray-600 mb-6 text-sm">
NOTE: During beta testing, we'll be monitoring usage and AI conversations to improve the assistant.
</p>
<% end %>
<% if Current.user.ai_available? %>
<%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %>
<%= form.hidden_field "user[ai_enabled]", value: true %>
<%= form.hidden_field "user[redirect_to]", value: "home" %>
<%= form.submit "Enable AI Assistant", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
<% end %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,40 @@
<div class="flex items-start gap-2 w-full">
<%= render "chats/ai_avatar" %>
<div class="max-w-[85%] text-sm space-y-4 text-primary">
<p>Hey <%= Current.user&.first_name || "there" %>! I'm an AI built by Maybe to help with your finances. I have access to the web and your account data.</p>
<p>
You can use <span class="bg-white border border-gray-200 px-1.5 py-0.5 rounded font-mono text-xs">/</span> to access commands
</p>
<div class="space-y-3">
<p>Here's a few questions you can ask:</p>
<% questions = [
{
icon: "bar-chart-2",
text: "Evaluate investment portfolio"
},
{
icon: "credit-card",
text: "Show spending insights"
},
{
icon: "alert-triangle",
text: "Find unusual patterns"
}
] %>
<div class="space-y-2.5">
<% questions.each do |question| %>
<button data-action="chat#submitSampleQuestion"
data-chat-question-param="<%= question[:text] %>"
class="w-full flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100">
<%= icon(question[:icon]) %> <%= question[:text] %>
</button>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,16 @@
<%# locals: (chat:) %>
<%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %>
<div class="grow">
<%= render "chats/chat_title", chat: chat, ctx: "list" %>
<p class="text-sm text-secondary">
<%= time_ago_in_words(chat.updated_at) %> ago
</p>
</div>
<%= contextual_menu icon: "more-vertical" do %>
<%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %>
<%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %>
<% end %>
<% end %>

View file

@ -0,0 +1,24 @@
<%# locals: (chat:) %>
<nav class="flex items-center justify-between">
<% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>
<div class="flex items-center gap-2 grow">
<%= link_to path, id: "chat-nav-back", class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
<%= icon("menu", color: "gray" ) %>
<% end %>
<div class="grow">
<%= render "chats/chat_title", chat: chat, ctx: "chat" %>
</div>
</div>
<%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
<%= contextual_menu_item "Start new chat", url: new_chat_path, icon: "plus" %>
<% unless chat.new_record? %>
<%= contextual_menu_item "Edit chat title", url: edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
<%= contextual_menu_destructive_item "Delete chat", chat_path(chat), turbo_confirm: "Are you sure you want to delete this chat?" %>
<% end %>
<% end %>
</nav>

View file

@ -0,0 +1,11 @@
<%# locals: (chat:, ctx: "list") %>
<%= turbo_frame_tag dom_id(chat, :title), class: "block" do %>
<% if chat.new_record? || ctx == "chat" %>
<h3 class="text-sm font-medium text-primary"><%= chat.title || "New chat" %></h3>
<% else %>
<%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %>
<h3 class="truncate text-sm font-medium text-primary"><%= chat.title %></h3>
<% end %>
<% end %>
<% end %>

View file

@ -0,0 +1,17 @@
<%# locals: (chat:) %>
<div id="chat-error" class="px-3 py-2 bg-red-100 border border-red-500 rounded-lg">
<% if chat.debug_mode? %>
<div class="overflow-x-auto text-xs p-4 bg-red-200 rounded-md mb-2">
<code><%= chat.error %></code>
</div>
<% end %>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-red-500">Failed to generate response. Please try again.</p>
<%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %>
<span>Retry</span>
<% end %>
</div>
</div>

View file

@ -0,0 +1,6 @@
<%# locals: (chat:, message: "Thinking ...") -%>
<div id="thinking-indicator" class="flex items-start">
<%= render "chats/ai_avatar" %>
<p class="text-sm text-secondary animate-pulse"><%= message %></p>
</div>

View file

@ -0,0 +1,8 @@
<%= turbo_frame_tag dom_id(@chat, :title), class: "block" do %>
<% bg_class = params[:ctx] == "chat" ? "bg-white" : "bg-container-inset" %>
<%= styled_form_with model: @chat,
class: class_names("p-1 rounded-md font-medium text-primary w-full", bg_class),
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %>
<%= f.text_field :title, data: { auto_submit_form_target: "auto" }, inline: true %>
<% end %>
<% end %>

View file

@ -0,0 +1,31 @@
<%= turbo_frame_tag chat_frame do %>
<div class="p-4 flex flex-col h-full">
<nav class="mb-6">
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
<%= icon("arrow-left", color: "gray" ) %>
<% end %>
</nav>
<div class="grow">
<h1 class="text-xl font-medium mb-6">Chats</h1>
<% if @chats.any? %>
<div class="space-y-2 px-0.5">
<%= render @chats %>
</div>
<% else %>
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<%= icon("message-square", size: "lg") %>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-1">No chats yet</h3>
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
<%= link_to "Start a chat", new_chat_path, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
</div>
<% end %>
</div>
<%= render "messages/chat_form" %>
</div>
<% end %>

View file

@ -0,0 +1,11 @@
<%= turbo_frame_tag chat_frame do %>
<div class="p-4 flex flex-col h-full">
<%= render "chats/chat_nav", chat: @chat %>
<div class="mt-auto py-8">
<%= render "chats/ai_greeting" %>
</div>
<%= render "messages/chat_form", chat: @chat %>
</div>
<% end %>

View file

@ -0,0 +1,35 @@
<%= turbo_frame_tag chat_frame do %>
<%= turbo_stream_from @chat %>
<h1 class="sr-only"><%= @chat.title %></h1>
<div class="flex flex-col h-full">
<div class="p-4">
<%= render "chats/chat_nav", chat: @chat %>
</div>
<div id="messages" class="grow overflow-y-auto p-4 space-y-6" data-chat-target="messages">
<% if @chat.conversation_messages.any? %>
<% @chat.conversation_messages.ordered.each do |message| %>
<%= render message %>
<% end %>
<% else %>
<div class="mt-auto">
<%= render "chats/ai_greeting", context: "chat" %>
</div>
<% end %>
<% if params[:thinking].present? %>
<%= render "chats/thinking_indicator", chat: @chat %>
<% end %>
<% if @chat.error.present? %>
<%= render "chats/error", chat: @chat %>
<% end %>
</div>
<div class="p-4">
<%= render "messages/chat_form", chat: @chat %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,6 @@
<%# locals: (developer_message:) %>
<div id="<%= dom_id(developer_message) %>" class="my-2 <%= developer_message.debug? ? "bg-yellow-50 border-yellow-200" : "bg-blue-50 border-blue-200" %> px-3 py-2 rounded-lg max-w-[85%] ml-auto border">
<span class="text-secondary text-xs"><%= developer_message.debug? ? "Debug message (internal only)" : "System instruction (sent to AI)" %></span>
<p class="text-primary text-sm"><%= developer_message.content %></p>
</div>

View file

@ -1,5 +1,10 @@
<%= render "layouts/shared/htmldoc" do %>
<div class="flex h-full bg-gray-50">
<% sidebar_config = app_sidebar_config(Current.user) %>
<div class="flex h-full bg-gray-50"
data-controller="sidebar"
data-sidebar-user-id-value="<%= Current.user.id %>"
data-sidebar-config-value="<%= sidebar_config.to_json %>">
<nav class="flex flex-col shrink-0 w-[84px] py-4 mr-3">
<div class="pl-2 mb-3">
<%= link_to root_path, class: "block" do %>
@ -26,7 +31,9 @@
</div>
</nav>
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-80" : "w-0"), data: { sidebar_target: "panel" } do %>
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300"),
style: "width: #{sidebar_config.dig(:left_panel, :initial_width)}px",
data: { sidebar_target: "leftPanel" } do %>
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
@ -43,7 +50,7 @@
</div>
<% end %>
<%= tag.div class: class_names("mx-auto w-full h-full", Current.user.show_sidebar? ? "max-w-4xl" : "max-w-5xl"), data: { sidebar_target: "content" } do %>
<%= tag.div style: "max-width: #{sidebar_config.dig(:content_max_width)}px", class: class_names("mx-auto w-full h-full"), data: { sidebar_target: "content" } do %>
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
@ -57,5 +64,22 @@
<%= yield %>
<% end %>
<% end %>
<%# AI chat sidebar %>
<%= tag.div id: "chat-container",
style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px",
class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300"),
data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %>
<% if Current.user.ai_enabled? %>
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
<div class="flex justify-center items-center h-full">
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
</div>
<% end %>
<% else %>
<%= render "chats/ai_consent" %>
<% end %>
<% end %>
</div>
<% end %>

View file

@ -2,7 +2,7 @@
<nav class="flex items-center gap-2 mb-6">
<% if sidebar_toggle_enabled %>
<button data-action="sidebar#toggle" class="p-2 inline-flex rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
<button data-action="sidebar#toggleLeftPanel" class="p-2 inline-flex rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer">
<%= icon("panel-left", color: "gray") %>
</button>
<% end %>
@ -22,4 +22,12 @@
<% end %>
<% end %>
</div>
<% if sidebar_toggle_enabled %>
<div class="ml-auto">
<button data-action="sidebar#toggleRightPanel" class="p-2 inline-flex rounded-lg items-center justify-center hover:bg-gray-100 cursor-pointer" title="Toggle AI Assistant">
<%= icon("panel-right", color: "gray") %>
</button>
</div>
<% end %>
</nav>

View file

@ -5,7 +5,6 @@
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %>
<%= combobox_style_tag %>

View file

@ -5,7 +5,7 @@
<%= yield :head %>
</head>
<body class="h-full antialiased" data-controller="sidebar" data-sidebar-user-id-value="<%= Current.user&.id %>">
<body class="h-full antialiased">
<div class="fixed z-50 bottom-6 left-24 w-80">
<div id="notification-tray" class="space-y-1 w-full">
<%= render_flash_notifications %>

View file

@ -0,0 +1,35 @@
<%# locals: (chat: nil, message_hint: nil) %>
<div id="chat-form" class="space-y-2">
<% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>
<%= form_with model: model,
class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs",
data: { chat_target: "form" } do |f| %>
<%# In the future, this will be a dropdown with different AI models %>
<%= f.hidden_field :ai_model, value: "gpt-4o" %>
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" },
rows: 1 %>
<div class="flex items-center justify-between gap-1">
<div class="flex items-center gap-1">
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
<button type="button" title="Coming soon" class="cursor-not-allowed w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
<%= icon(icon, color: "gray") %>
</button>
<% end %>
</div>
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
<%= icon("arrow-up") %>
</button>
</div>
<% end %>
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
</div>

View file

@ -5,7 +5,7 @@
<%= combobox_security.name.presence || combobox_security.symbol %>
</span>
<span class="text-xs text-secondary">
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym.presence || combobox_security.exchange_operating_mic})" %>
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %>
</span>
</div>
</div>

View file

@ -3,12 +3,10 @@
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
</div>
<%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %>
<%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
<% end %>
<%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %>

View file

@ -0,0 +1,5 @@
<%# locals: (user_message:) %>
<div id="<%= dom_id(user_message) %>" class="bg-gray-100 px-3 py-2 rounded-lg max-w-[85%] w-fit ml-auto mb-6">
<div class="prose prose--ai-chat"><%= markdown(user_message.content) %></div>
</div>

142
bin/update_structure.sh Executable file
View file

@ -0,0 +1,142 @@
#!/bin/bash
# save to .scripts/update_structure.sh
# best way to use is with tree: `brew install tree`
# Create the output file with header
echo "---" > .cursor/rules/structure.mdc
echo "description: Project structure" >> .cursor/rules/structure.mdc
echo "globs: *" >> .cursor/rules/structure.mdc
echo "alwaysApply: true" >> .cursor/structure/structure.mdc
echo "---" >> .cursor/rules/structure.mdc
echo "" >> .cursor/rules/structure.mdc
echo "# Project Structure" > .cursor/rules/structure.mdc
echo "" >> .cursor/rules/structure.mdc
echo "\`\`\`" >> .cursor/rules/structure.mdc
# Check if tree command is available
if command -v tree &> /dev/null; then
# Use tree command for better visualization
git ls-files --others --exclude-standard --cached | tree --fromfile -a >> .cursor/rules/structure.mdc
echo "Using tree command for structure visualization."
else
# Fallback to the alternative approach if tree is not available
echo "Tree command not found. Using fallback approach."
# Get all files from git (respecting .gitignore)
git ls-files --others --exclude-standard --cached | sort > /tmp/files_list.txt
# Create a simple tree structure
echo "." > /tmp/tree_items.txt
# Process each file to build the tree
while read -r file; do
# Skip directories
if [[ -d "$file" ]]; then continue; fi
# Add the file to the tree
echo "$file" >> /tmp/tree_items.txt
# Add all parent directories
dir="$file"
while [[ "$dir" != "." ]]; do
dir=$(dirname "$dir")
echo "$dir" >> /tmp/tree_items.txt
done
done < /tmp/files_list.txt
# Sort and remove duplicates
sort -u /tmp/tree_items.txt > /tmp/tree_sorted.txt
mv /tmp/tree_sorted.txt /tmp/tree_items.txt
# Simple tree drawing approach
prev_dirs=()
while read -r item; do
# Skip the root
if [[ "$item" == "." ]]; then
continue
fi
# Determine if it's a file or directory
if [[ -f "$item" ]]; then
is_dir=0
name=$(basename "$item")
else
is_dir=1
name="$(basename "$item")/"
fi
# Split path into components
IFS='/' read -ra path_parts <<< "$item"
# Calculate depth (number of path components minus 1)
depth=$((${#path_parts[@]} - 1))
# Find common prefix with previous path
common=0
if [[ ${#prev_dirs[@]} -gt 0 ]]; then
for ((i=0; i<depth && i<${#prev_dirs[@]}; i++)); do
if [[ "${path_parts[$i]}" == "${prev_dirs[$i]}" ]]; then
((common++))
else
break
fi
done
fi
# Build the prefix
prefix=""
for ((i=0; i<depth; i++)); do
if [[ $i -lt $common ]]; then
# Check if this component has more siblings
has_more=0
for next in $(grep "^$(dirname "$item")/" /tmp/tree_items.txt); do
if [[ "$next" > "$item" ]]; then
has_more=1
break
fi
done
if [[ $has_more -eq 1 ]]; then
prefix="${prefix}"
else
prefix="${prefix} "
fi
else
prefix="${prefix} "
fi
done
# Determine if this is the last item in its directory
is_last=1
dir=$(dirname "$item")
for next in $(grep "^$dir/" /tmp/tree_items.txt); do
if [[ "$next" > "$item" ]]; then
is_last=0
break
fi
done
# Choose the connector
if [[ $is_last -eq 1 ]]; then
connector="└── "
else
connector="├── "
fi
# Output the item
echo "${prefix}${connector}${name}" >> .cursor/rules/structure.mdc
# Save current path for next iteration
prev_dirs=("${path_parts[@]}")
done < /tmp/tree_items.txt
# Clean up
rm -f /tmp/files_list.txt /tmp/tree_items.txt
fi
# Close the code block
echo "\`\`\`" >> .cursor/rules/structure.mdc
echo "Project structure has been updated in .cursor/rules/structure.mdc"

View file

@ -16,7 +16,7 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"].
# == Enabled Environments
# Which environments is auto inclusion of the Javascript enabled for
#
config.enabled_environments = [ "development", "production" ]
config.enabled_environments = [ "production" ]
# == Current user method/variable
# The method/variable that contains the logged in user in your controllers.

View file

@ -11,6 +11,15 @@ Rails.application.routes.draw do
# Uses basic auth - see config/initializers/sidekiq.rb
mount Sidekiq::Web => "/sidekiq"
# AI chats
resources :chats do
resources :messages, only: :create
member do
post :retry
end
end
get "changelog", to: "pages#changelog"
get "feedback", to: "pages#feedback"
get "early-access", to: "pages#early_access"

View file

@ -0,0 +1,46 @@
class CreateAiChats < ActiveRecord::Migration[7.2]
def change
create_table :chats, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.string :title, null: false
t.string :instructions
t.jsonb :error
t.string :latest_assistant_response_id
t.timestamps
end
create_table :messages, id: :uuid do |t|
t.references :chat, null: false, foreign_key: true, type: :uuid
t.string :type, null: false
t.string :status, null: false, default: "complete"
t.text :content
t.string :ai_model
t.timestamps
# Developer message fields
t.boolean :debug, default: false
# Assistant message fields
t.string :provider_id
t.boolean :reasoning, default: false
end
create_table :tool_calls, id: :uuid do |t|
t.references :message, null: false, foreign_key: true, type: :uuid
t.string :provider_id, null: false
t.string :provider_call_id
t.string :type, null: false
# Function specific fields
t.string :function_name
t.jsonb :function_arguments
t.jsonb :function_result
t.timestamps
end
add_reference :users, :last_viewed_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid
add_column :users, :show_ai_sidebar, :boolean, default: true
add_column :users, :ai_enabled, :boolean, default: false, null: false
end
end

48
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -196,6 +196,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
t.index ["family_id"], name: "index_categories_on_family_id"
end
create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.string "title", null: false
t.string "instructions"
t.jsonb "error"
t.string "latest_assistant_response_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_chats_on_user_id"
end
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -380,6 +391,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
t.index ["family_id"], name: "index_merchants_on_family_id"
end
create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "chat_id", null: false
t.string "type", null: false
t.string "status", default: "complete", null: false
t.text "content"
t.string "ai_model"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "debug", default: false
t.string "provider_id"
t.boolean "reasoning", default: false
t.index ["chat_id"], name: "index_messages_on_chat_id"
end
create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -544,6 +569,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
t.index ["family_id"], name: "index_tags_on_family_id"
end
create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "message_id", null: false
t.string "provider_id", null: false
t.string "provider_call_id"
t.string "type", null: false
t.string "function_name"
t.jsonb "function_arguments"
t.jsonb "function_result"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["message_id"], name: "index_tool_calls_on_message_id"
end
create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "inflow_transaction_id", null: false
t.uuid "outflow_transaction_id", null: false
@ -573,8 +611,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
t.string "otp_backup_codes", default: [], array: true
t.boolean "show_sidebar", default: true
t.string "default_period", default: "last_30_days", null: false
t.uuid "last_viewed_chat_id"
t.boolean "show_ai_sidebar", default: true
t.boolean "ai_enabled", default: false, null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
end
@ -605,6 +647,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
@ -613,6 +656,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
add_foreign_key "invitations", "families"
add_foreign_key "invitations", "users", column: "inviter_id"
add_foreign_key "merchants", "families"
add_foreign_key "messages", "chats"
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id"
@ -622,7 +666,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do
add_foreign_key "sessions", "users"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"
add_foreign_key "tool_calls", "messages"
add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id", on_delete: :cascade
add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id", on_delete: :cascade
add_foreign_key "users", "chats", column: "last_viewed_chat_id"
add_foreign_key "users", "families"
end

2
package-lock.json generated
View file

@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@biomejs/biome": "1.9.3"
"@biomejs/biome": "^1.9.3"
}
},
"node_modules/@biomejs/biome": {

View file

@ -1,17 +1,17 @@
{
"devDependencies": {
"@biomejs/biome": "1.9.3"
"@biomejs/biome": "^1.9.3"
},
"name": "maybe",
"version": "1.0.0",
"description": "The OS for your personal finances",
"scripts": {
"style:check": "biome check",
"style:fix":"biome check --write",
"lint": "biome lint",
"lint:fix" : "biome lint --write",
"format:check" : "biome format",
"format" : "biome format --write"
"style:fix": "biome check --write",
"lint": "biome lint",
"lint:fix": "biome lint --write",
"format:check": "biome format",
"format": "biome format --write"
},
"author": "",
"license": "ISC"

View file

@ -21,6 +21,10 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
find("h1", text: "Welcome back, #{user.first_name}")
end
def login_as(user)
sign_in(user)
end
def sign_out
find("#user-menu").click
click_button "Logout"

View file

@ -0,0 +1,52 @@
require "test_helper"
class ChatsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = families(:dylan_family)
sign_in @user
end
test "cannot create a chat if AI is disabled" do
@user.update!(ai_enabled: false)
post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } }
assert_response :forbidden
end
test "gets index" do
get chats_url
assert_response :success
end
test "creates chat" do
assert_difference("Chat.count") do
post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } }
end
assert_redirected_to chat_path(Chat.order(created_at: :desc).first, thinking: true)
end
test "shows chat" do
get chat_url(chats(:one))
assert_response :success
end
test "destroys chat" do
assert_difference("Chat.count", -1) do
delete chat_url(chats(:one))
end
assert_redirected_to chats_url
end
test "should not allow access to other user's chats" do
other_user = users(:family_member)
other_chat = Chat.create!(user: other_user, title: "Other User's Chat")
get chat_url(other_chat)
assert_response :not_found
delete chat_url(other_chat)
assert_response :not_found
end
end

View file

@ -0,0 +1,22 @@
require "test_helper"
class MessagesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@chat = @user.chats.first
end
test "can create a message" do
post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } }
assert_redirected_to chat_path(@chat, thinking: true)
end
test "cannot create a message if AI is disabled" do
@user.update!(ai_enabled: false)
post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } }
assert_response :forbidden
end
end

View file

@ -8,7 +8,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
sign_in users(:family_admin)
@provider = mock
Providers.stubs(:synth).returns(@provider)
Provider::Registry.stubs(:get_provider).with(:synth).returns(@provider)
@usage_response = provider_success_response(
OpenStruct.new(
used: 10,
@ -20,12 +20,12 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
test "cannot edit when self hosting is disabled" do
assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do
with_env_overrides SELF_HOSTED: "false" do
get settings_hosting_url
end
assert_response :forbidden
assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do
patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } }
assert_response :forbidden
end
end
@ -40,8 +40,6 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
test "can update settings when self hosting is enabled" do
with_self_hosting do
assert_nil Setting.synth_api_key
patch settings_hosting_url, params: { setting: { synth_api_key: "1234567890" } }
assert_equal "1234567890", Setting.synth_api_key

7
test/fixtures/chats.yml vendored Normal file
View file

@ -0,0 +1,7 @@
one:
title: First Chat
user: family_admin
two:
title: Second Chat
user: family_member

43
test/fixtures/messages.yml vendored Normal file
View file

@ -0,0 +1,43 @@
chat1_developer:
type: DeveloperMessage
content: You are a personal finance assistant. Be concise and helpful.
chat: one
created_at: 2025-03-20 12:00:00
debug: false
chat1_developer_debug:
type: DeveloperMessage
content: An internal debug message
chat: one
created_at: 2025-03-20 12:00:02
debug: true
chat1_user:
type: UserMessage
content: Can you help me understand my spending habits?
chat: one
ai_model: gpt-4o
created_at: 2025-03-20 12:00:01
chat2_user:
type: UserMessage
content: Can you help me understand my spending habits?
ai_model: gpt-4o
chat: two
created_at: 2025-03-20 12:00:01
chat1_assistant_reasoning:
type: AssistantMessage
content: I'm thinking...
ai_model: gpt-4o
chat: one
created_at: 2025-03-20 12:01:00
reasoning: true
chat1_assistant_response:
type: AssistantMessage
content: Hello! I can help you understand your spending habits.
ai_model: gpt-4o
chat: one
created_at: 2025-03-20 12:02:00
reasoning: false

7
test/fixtures/tool_calls.yml vendored Normal file
View file

@ -0,0 +1,7 @@
one:
type: ToolCall::Function
function_name: get_user_info
provider_id: fc_12345xyz
provider_call_id: call_12345xyz
function_arguments: {}
message: chat1_assistant_response

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