mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +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:
parent
8e6b81af77
commit
2f6b11c18f
126 changed files with 3576 additions and 462 deletions
23
.cursor/rules/general-rules.mdc
Normal file
23
.cursor/rules/general-rules.mdc
Normal file
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
description: Miscellaneous rules to get the AI to behave
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
# General rules for AI
|
||||
|
||||
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||
|
||||
## Prohibited actions
|
||||
|
||||
Do not under any circumstance do the following:
|
||||
|
||||
- Do not run `rails server` in your responses.
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
2
.gitignore
vendored
|
@ -62,6 +62,8 @@ gcp-storage-keyfile.json
|
|||
|
||||
coverage
|
||||
.cursorrules
|
||||
.cursor/rules/structure.mdc
|
||||
.cursor/rules/agent.mdc
|
||||
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
|
|
3
Gemfile
3
Gemfile
|
@ -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
|
||||
|
|
64
Gemfile.lock
64
Gemfile.lock
|
@ -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
85
app/assets/images/ai.svg
Normal file
|
@ -0,0 +1,85 @@
|
|||
<svg width="62" height="68" viewBox="0 0 62 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_7620_90382)">
|
||||
<path d="M15.0109 27.3668C14.8138 11.2848 17.2087 15.4884 28.5797 15.5133L32.8675 15.5228C44.2383 15.5478 46.7179 11.3549 46.9149 27.4368L46.9891 33.5015C47.1861 49.5834 44.7913 53.0249 33.4205 52.9999L29.1325 52.9906C17.7617 52.9656 15.2823 49.5134 15.0852 33.4315L15.0109 27.3668Z" fill="url(#paint0_linear_7620_90382)" fill-opacity="0.15"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_i_7620_90382)">
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="url(#paint1_linear_7620_90382)"/>
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_ii_7620_90382)">
|
||||
<rect x="16.7773" y="14.7778" width="28.4444" height="28.4444" rx="8.88889" fill="url(#paint2_linear_7620_90382)"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="#141414"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="url(#paint3_linear_7620_90382)"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="#141414"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="url(#paint4_linear_7620_90382)"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="#141414"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="url(#paint5_linear_7620_90382)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_7620_90382" x="0.937778" y="0.937778" width="60.1244" height="66.1244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7.03111" result="effect1_foregroundBlur_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter1_i_7620_90382" x="15" y="13" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter2_ii_7620_90382" x="16.7773" y="13.8889" width="28.4453" height="30.2222" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_7620_90382" result="effect2_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_7620_90382" x1="30.1185" y1="16.1417" x2="33.7041" y2="53.1754" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_7620_90382" x1="31" y1="13" x2="31" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_7620_90382" x1="30.9996" y1="23.6667" x2="30.9996" y2="43.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 9.5 KiB |
|
@ -1 +0,0 @@
|
|||
/* Application styles */
|
|
@ -8,7 +8,7 @@
|
|||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "../stylesheets/simonweb_pickr.css";
|
||||
@import "./simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
|
@ -112,6 +112,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
.prose--ai-chat {
|
||||
@apply break-words;
|
||||
|
||||
p, li {
|
||||
@apply text-sm text-primary;
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
|
@ -142,3 +166,4 @@
|
|||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
/* The following Markdown CSS has been removed as requested */
|
||||
|
|
|
@ -316,8 +316,8 @@
|
|||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
||||
hr {
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
|
@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base
|
|||
else ""
|
||||
end
|
||||
end
|
||||
|
||||
# By default, we show the user the last chat they interacted with
|
||||
def set_default_chat
|
||||
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||
@chat = @last_viewed_chat
|
||||
end
|
||||
end
|
||||
|
|
67
app/controllers/chats_controller.rb
Normal file
67
app/controllers/chats_controller.rb
Normal file
|
@ -0,0 +1,67 @@
|
|||
class ChatsController < ApplicationController
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
|
||||
@chats = Current.user.chats.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
set_last_viewed_chat(@chat)
|
||||
end
|
||||
|
||||
def new
|
||||
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
|
||||
set_last_viewed_chat(@chat)
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@chat.update!(chat_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@chat.destroy
|
||||
clear_last_viewed_chat
|
||||
|
||||
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||
end
|
||||
|
||||
def retry
|
||||
@chat.retry_last_message!
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
end
|
||||
|
||||
def set_last_viewed_chat(chat)
|
||||
Current.user.update!(last_viewed_chat: chat)
|
||||
end
|
||||
|
||||
def clear_last_viewed_chat
|
||||
Current.user.update!(last_viewed_chat: nil)
|
||||
end
|
||||
|
||||
def chat_params
|
||||
params.require(:chat).permit(:title, :content, :ai_model)
|
||||
end
|
||||
end
|
23
app/controllers/concerns/feature_guardable.rb
Normal file
23
app/controllers/concerns/feature_guardable.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Simple feature guard that renders a 403 Forbidden status with a message
|
||||
# when the feature is disabled.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class MessagesController < ApplicationController
|
||||
# guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
# end
|
||||
#
|
||||
module FeatureGuardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def guard_feature(**options)
|
||||
before_action :guard_feature, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def guard_feature
|
||||
render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden
|
||||
end
|
||||
end
|
24
app/controllers/messages_controller.rb
Normal file
24
app/controllers/messages_controller.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
class MessagesController < ApplicationController
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = UserMessage.create!(
|
||||
chat: @chat,
|
||||
content: message_params[:content],
|
||||
ai_model: message_params[:ai_model]
|
||||
)
|
||||
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content, :ai_model)
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@ class PagesController < ApplicationController
|
|||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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? }
|
||||
|
|
12
app/helpers/chats_helper.rb
Normal file
12
app/helpers/chats_helper.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
module ChatsHelper
|
||||
def chat_frame
|
||||
:sidebar_chat
|
||||
end
|
||||
|
||||
def chat_view_path(chat)
|
||||
return new_chat_path if params[:chat_view] == "new"
|
||||
return chats_path if chat.nil? || params[:chat_view] == "all"
|
||||
|
||||
chat.persisted? ? chat_path(chat) : new_chat_path
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
60
app/javascript/controllers/chat_controller.js
Normal file
60
app/javascript/controllers/chat_controller.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["messages", "form", "input"];
|
||||
|
||||
connect() {
|
||||
this.#configureAutoScroll();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.messagesObserver) {
|
||||
this.messagesObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const input = this.inputTarget;
|
||||
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
|
||||
const maxLines = 3; // 3 lines = 60px total
|
||||
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
|
||||
input.style.overflowY =
|
||||
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
submitSampleQuestion(e) {
|
||||
this.inputTarget.value = e.target.dataset.chatQuestionParam;
|
||||
|
||||
setTimeout(() => {
|
||||
this.formTarget.requestSubmit();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
|
||||
handleInputKeyDown(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.formTarget.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
#configureAutoScroll() {
|
||||
this.messagesObserver = new MutationObserver((_mutations) => {
|
||||
if (this.hasMessagesTarget) {
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to entire sidebar for changes, always try to scroll to the bottom
|
||||
this.messagesObserver.observe(this.element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
#scrollToBottom = () => {
|
||||
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
|
||||
};
|
||||
}
|
|
@ -2,17 +2,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(),
|
||||
});
|
||||
}
|
||||
|
|
7
app/jobs/assistant_response_job.rb
Normal file
7
app/jobs/assistant_response_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
class AssistantResponseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(message)
|
||||
message.request_response
|
||||
end
|
||||
end
|
|
@ -2,15 +2,17 @@ module Account::Chartable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
|
||||
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||
|
||||
series_interval = interval || period.interval
|
||||
|
||||
balances = Account::Balance.find_by_sql([
|
||||
balance_series_query,
|
||||
{
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
target_currency: currency
|
||||
}
|
||||
])
|
||||
|
@ -33,7 +35,7 @@ module Account::Chartable
|
|||
Series.new(
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
trend: Trend.new(
|
||||
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
|
||||
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
|
||||
|
@ -124,11 +126,12 @@ module Account::Chartable
|
|||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def balance_series(period: Period.last_30_days, view: :balance)
|
||||
def balance_series(period: Period.last_30_days, view: :balance, interval: nil)
|
||||
self.class.where(id: self.id).balance_series(
|
||||
currency: currency,
|
||||
period: period,
|
||||
view: view,
|
||||
interval: interval,
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
|
|
@ -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
178
app/models/assistant.rb
Normal 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
|
83
app/models/assistant/function.rb
Normal file
83
app/models/assistant/function.rb
Normal 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
|
40
app/models/assistant/function/get_accounts.rb
Normal file
40
app/models/assistant/function/get_accounts.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
class Assistant::Function::GetAccounts < Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
"get_accounts"
|
||||
end
|
||||
|
||||
def description
|
||||
"Use this to see what accounts the user has along with their current and historical balances"
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
accounts: family.accounts.includes(:balances).map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
balance_formatted: account.balance_money.format,
|
||||
classification: account.classification,
|
||||
type: account.accountable_type,
|
||||
start_date: account.start_date,
|
||||
is_plaid_linked: account.plaid_account_id.present?,
|
||||
is_active: account.is_active,
|
||||
historical_balances: historical_balances(account)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_balances(account)
|
||||
start_date = [ account.start_date, 5.years.ago.to_date ].max
|
||||
period = Period.custom(start_date: start_date, end_date: Date.current)
|
||||
balance_series = account.balance_series(period: period, interval: "1 month")
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_balance_sheet"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get the user's balance sheet with varying amounts of historical data.
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net worth? What is it composed of?
|
||||
- How has the user's wealth changed over time?
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
observation_start_date = [ 5.years.ago.to_date, family.oldest_entry_date ].max
|
||||
|
||||
period = Period.custom(start_date: observation_start_date, end_date: Date.current)
|
||||
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
oldest_account_start_date: family.oldest_entry_date,
|
||||
currency: family.currency,
|
||||
net_worth: {
|
||||
current: family.balance_sheet.net_worth_money.format,
|
||||
monthly_history: historical_data(period)
|
||||
},
|
||||
assets: {
|
||||
current: family.balance_sheet.total_assets_money.format,
|
||||
monthly_history: historical_data(period, classification: "asset")
|
||||
},
|
||||
liabilities: {
|
||||
current: family.balance_sheet.total_liabilities_money.format,
|
||||
monthly_history: historical_data(period, classification: "liability")
|
||||
},
|
||||
insights: insights_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_data(period, classification: nil)
|
||||
scope = family.accounts.active
|
||||
scope = scope.where(classification: classification) if classification.present?
|
||||
|
||||
if period.start_date == Date.current
|
||||
[]
|
||||
else
|
||||
balance_series = scope.balance_series(
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
interval: "1 month",
|
||||
favorable_direction: "up",
|
||||
)
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
||||
|
||||
def insights_data
|
||||
assets = family.balance_sheet.total_assets
|
||||
liabilities = family.balance_sheet.total_liabilities
|
||||
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
|
||||
|
||||
{
|
||||
debt_to_asset_ratio: number_to_percentage(ratio * 100, precision: 0)
|
||||
}
|
||||
end
|
||||
end
|
125
app/models/assistant/function/get_income_statement.rb
Normal file
125
app/models/assistant/function/get_income_statement.rb
Normal file
|
@ -0,0 +1,125 @@
|
|||
class Assistant::Function::GetIncomeStatement < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_income_statement"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get income and expense insights by category, for a specific time period
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net income for the current month?
|
||||
- What are the user's spending habits?
|
||||
- How much income or spending did the user have over a specific time period?
|
||||
|
||||
Simple example:
|
||||
|
||||
```
|
||||
get_income_statement({
|
||||
start_date: "2024-01-01",
|
||||
end_date: "2024-12-31"
|
||||
})
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
period = Period.custom(start_date: Date.parse(params["start_date"]), end_date: Date.parse(params["end_date"]))
|
||||
income_data = family.income_statement.income_totals(period: period)
|
||||
expense_data = family.income_statement.expense_totals(period: period)
|
||||
|
||||
{
|
||||
currency: family.currency,
|
||||
period: {
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date
|
||||
},
|
||||
income: {
|
||||
total: format_money(income_data.total),
|
||||
by_category: to_ai_category_totals(income_data.category_totals)
|
||||
},
|
||||
expense: {
|
||||
total: format_money(expense_data.total),
|
||||
by_category: to_ai_category_totals(expense_data.category_totals)
|
||||
},
|
||||
insights: get_insights(income_data, expense_data)
|
||||
}
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: [ "start_date", "end_date" ],
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
description: "Start date for aggregation period in YYYY-MM-DD format"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
description: "End date for aggregation period in YYYY-MM-DD format"
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def format_money(value)
|
||||
Money.new(value, family.currency).format
|
||||
end
|
||||
|
||||
def calculate_savings_rate(total_income, total_expenses)
|
||||
return 0 if total_income.zero?
|
||||
savings = total_income - total_expenses
|
||||
rate = (savings / total_income.to_f) * 100
|
||||
rate.round(2)
|
||||
end
|
||||
|
||||
def to_ai_category_totals(category_totals)
|
||||
hierarchical_groups = category_totals.group_by { |ct| ct.category.parent_id }.then do |grouped|
|
||||
root_category_totals = grouped[nil] || []
|
||||
|
||||
root_category_totals.each_with_object({}) do |ct, hash|
|
||||
subcategory_totals = ct.category.name == "Uncategorized" ? [] : (grouped[ct.category.id] || [])
|
||||
hash[ct.category.name] = {
|
||||
category_total: ct,
|
||||
subcategory_totals: subcategory_totals
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
hierarchical_groups.sort_by { |name, data| -data.dig(:category_total).total }.map do |name, data|
|
||||
{
|
||||
name: name,
|
||||
total: format_money(data.dig(:category_total).total),
|
||||
percentage_of_total: number_to_percentage(data.dig(:category_total).weight, precision: 1),
|
||||
subcategory_totals: data.dig(:subcategory_totals).map do |st|
|
||||
{
|
||||
name: st.category.name,
|
||||
total: format_money(st.total),
|
||||
percentage_of_total: number_to_percentage(st.weight, precision: 1)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_insights(income_data, expense_data)
|
||||
net_income = income_data.total - expense_data.total
|
||||
savings_rate = calculate_savings_rate(income_data.total, expense_data.total)
|
||||
median_monthly_income = family.income_statement.median_income
|
||||
median_monthly_expenses = family.income_statement.median_expense
|
||||
avg_monthly_expenses = family.income_statement.avg_expense
|
||||
|
||||
{
|
||||
net_income: format_money(net_income),
|
||||
savings_rate: number_to_percentage(savings_rate),
|
||||
median_monthly_income: format_money(median_monthly_income),
|
||||
median_monthly_expenses: format_money(median_monthly_expenses),
|
||||
avg_monthly_expenses: format_money(avg_monthly_expenses)
|
||||
}
|
||||
end
|
||||
end
|
185
app/models/assistant/function/get_transactions.rb
Normal file
185
app/models/assistant/function/get_transactions.rb
Normal file
|
@ -0,0 +1,185 @@
|
|||
class Assistant::Function::GetTransactions < Assistant::Function
|
||||
include Pagy::Backend
|
||||
|
||||
class << self
|
||||
def default_page_size
|
||||
50
|
||||
end
|
||||
|
||||
def name
|
||||
"get_transactions"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to search user's transactions by using various optional filters.
|
||||
|
||||
This function is great for things like:
|
||||
- Finding specific transactions
|
||||
- Getting basic stats about a small group of transactions
|
||||
|
||||
This function is not great for:
|
||||
- Large time periods (use the get_income_statement function for this)
|
||||
|
||||
Note on pagination:
|
||||
|
||||
This function can be paginated. You can expect the following properties in the response:
|
||||
|
||||
- `total_pages`: The total number of pages of results
|
||||
- `page`: The current page of results
|
||||
- `page_size`: The number of results per page (this will always be #{default_page_size})
|
||||
- `total_results`: The total number of results for the given filters
|
||||
- `total_income`: The total income for the given filters
|
||||
- `total_expenses`: The total expenses for the given filters
|
||||
|
||||
Simple example (transactions from the last 30 days):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
start_date: "#{30.days.ago.to_date}",
|
||||
end_date: "#{Date.current}"
|
||||
})
|
||||
```
|
||||
|
||||
More complex example (various filters):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
search: "mcdonalds",
|
||||
accounts: ["Checking", "Savings"],
|
||||
start_date: "#{30.days.ago.to_date}",
|
||||
end_date: "#{Date.current}",
|
||||
categories: ["Restaurants"],
|
||||
merchants: ["McDonald's"],
|
||||
tags: ["Food"],
|
||||
amount: "100",
|
||||
amount_operator: "less"
|
||||
})
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def strict_mode?
|
||||
false
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: [ "order", "page", "page_size" ],
|
||||
properties: {
|
||||
page: {
|
||||
type: "integer",
|
||||
description: "Page number"
|
||||
},
|
||||
order: {
|
||||
enum: [ "asc", "desc" ],
|
||||
description: "Order of the transactions by date"
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Search for transactions by name"
|
||||
},
|
||||
amount: {
|
||||
type: "string",
|
||||
description: "Amount for transactions (must be used with amount_operator)"
|
||||
},
|
||||
amount_operator: {
|
||||
type: "string",
|
||||
description: "Operator for amount (must be used with amount)",
|
||||
enum: [ "equal", "less", "greater" ]
|
||||
},
|
||||
start_date: {
|
||||
type: "string",
|
||||
description: "Start date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
description: "End date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
accounts: {
|
||||
type: "array",
|
||||
description: "Filter transactions by account name",
|
||||
items: { enum: family_account_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
categories: {
|
||||
type: "array",
|
||||
description: "Filter transactions by category name",
|
||||
items: { enum: family_category_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
merchants: {
|
||||
type: "array",
|
||||
description: "Filter transactions by merchant name",
|
||||
items: { enum: family_merchant_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
description: "Filter transactions by tag name",
|
||||
items: { enum: family_tag_names },
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
search_params = params.except("order", "page")
|
||||
|
||||
transactions_query = family.transactions.active.search(search_params)
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
pagy, paginated_transactions = pagy(
|
||||
pagy_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
),
|
||||
page: params["page"] || 1,
|
||||
limit: default_page_size
|
||||
)
|
||||
|
||||
totals = family.income_statement.totals(transactions_scope: transactions_query)
|
||||
|
||||
normalized_transactions = paginated_transactions.map do |txn|
|
||||
entry = txn.entry
|
||||
{
|
||||
date: entry.date,
|
||||
amount: entry.amount.abs,
|
||||
currency: entry.currency,
|
||||
formatted_amount: entry.amount_money.abs.format,
|
||||
classification: entry.amount < 0 ? "income" : "expense",
|
||||
account: entry.account.name,
|
||||
category: txn.category&.name,
|
||||
merchant: txn.merchant&.name,
|
||||
tags: txn.tags.map(&:name),
|
||||
is_transfer: txn.transfer.present?
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
transactions: normalized_transactions,
|
||||
total_results: pagy.count,
|
||||
page: pagy.page,
|
||||
page_size: default_page_size,
|
||||
total_pages: pagy.pages,
|
||||
total_income: totals.income_money.format,
|
||||
total_expenses: totals.expense_money.format
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def default_page_size
|
||||
self.class.default_page_size
|
||||
end
|
||||
end
|
12
app/models/assistant/provided.rb
Normal file
12
app/models/assistant/provided.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
module Assistant::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def get_model_provider(ai_model)
|
||||
registry.providers.find { |provider| provider.supports_model?(ai_model) }
|
||||
end
|
||||
|
||||
private
|
||||
def registry
|
||||
@registry ||= Provider::Registry.for_concept(:llm)
|
||||
end
|
||||
end
|
11
app/models/assistant_message.rb
Normal file
11
app/models/assistant_message.rb
Normal 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
64
app/models/chat.rb
Normal 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
|
7
app/models/chat/debuggable.rb
Normal file
7
app/models/chat/debuggable.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
module Chat::Debuggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def debug_mode?
|
||||
ENV["AI_DEBUG_MODE"] == "true"
|
||||
end
|
||||
end
|
9
app/models/developer_message.rb
Normal file
9
app/models/developer_message.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class DeveloperMessage < Message
|
||||
def role
|
||||
"developer"
|
||||
end
|
||||
|
||||
def broadcast?
|
||||
chat.debug_mode?
|
||||
end
|
||||
end
|
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
22
app/models/message.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
class Message < ApplicationRecord
|
||||
belongs_to :chat
|
||||
has_many :tool_calls, dependent: :destroy
|
||||
|
||||
enum :status, {
|
||||
pending: "pending",
|
||||
complete: "complete",
|
||||
failed: "failed"
|
||||
}
|
||||
|
||||
validates :content, presence: true, 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
13
app/models/provider/llm_provider.rb
Normal file
13
app/models/provider/llm_provider.rb
Normal 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
|
30
app/models/provider/openai.rb
Normal file
30
app/models/provider/openai.rb
Normal 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
|
188
app/models/provider/openai/chat_response_processor.rb
Normal file
188
app/models/provider/openai/chat_response_processor.rb
Normal 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
|
13
app/models/provider/openai/chat_streamer.rb
Normal file
13
app/models/provider/openai/chat_streamer.rb
Normal 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
|
91
app/models/provider/registry.rb
Normal file
91
app/models/provider/registry.rb
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
)
|
||||
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|
|
||||
parsed.dig("data").map do |security|
|
||||
Security.new(
|
||||
ticker: security.dig("symbol"),
|
||||
symbol: 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
|
||||
)
|
||||
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(
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
3
app/models/tool_call.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class ToolCall < ApplicationRecord
|
||||
belongs_to :message
|
||||
end
|
4
app/models/tool_call/function.rb
Normal file
4
app/models/tool_call/function.rb
Normal 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
|
|
@ -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) }
|
||||
|
|
22
app/models/user_message.rb
Normal file
22
app/models/user_message.rb
Normal 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
|
|
@ -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">
|
||||
|
|
23
app/views/assistant_messages/_assistant_message.html.erb
Normal file
23
app/views/assistant_messages/_assistant_message.html.erb
Normal 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>
|
19
app/views/assistant_messages/_tool_calls.html.erb
Normal file
19
app/views/assistant_messages/_tool_calls.html.erb
Normal 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>
|
3
app/views/chats/_ai_avatar.html.erb
Normal file
3
app/views/chats/_ai_avatar.html.erb
Normal 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>
|
33
app/views/chats/_ai_consent.html.erb
Normal file
33
app/views/chats/_ai_consent.html.erb
Normal 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>
|
40
app/views/chats/_ai_greeting.html.erb
Normal file
40
app/views/chats/_ai_greeting.html.erb
Normal 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>
|
16
app/views/chats/_chat.html.erb
Normal file
16
app/views/chats/_chat.html.erb
Normal 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 %>
|
24
app/views/chats/_chat_nav.html.erb
Normal file
24
app/views/chats/_chat_nav.html.erb
Normal 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>
|
11
app/views/chats/_chat_title.html.erb
Normal file
11
app/views/chats/_chat_title.html.erb
Normal 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 %>
|
17
app/views/chats/_error.html.erb
Normal file
17
app/views/chats/_error.html.erb
Normal 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>
|
6
app/views/chats/_thinking_indicator.html.erb
Normal file
6
app/views/chats/_thinking_indicator.html.erb
Normal 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>
|
8
app/views/chats/edit.html.erb
Normal file
8
app/views/chats/edit.html.erb
Normal 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 %>
|
31
app/views/chats/index.html.erb
Normal file
31
app/views/chats/index.html.erb
Normal 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 %>
|
11
app/views/chats/new.html.erb
Normal file
11
app/views/chats/new.html.erb
Normal 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 %>
|
35
app/views/chats/show.html.erb
Normal file
35
app/views/chats/show.html.erb
Normal 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 %>
|
6
app/views/developer_messages/_developer_message.html.erb
Normal file
6
app/views/developer_messages/_developer_message.html.erb
Normal 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>
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
35
app/views/messages/_chat_form.html.erb
Normal file
35
app/views/messages/_chat_form.html.erb
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||
|
|
5
app/views/user_messages/_user_message.html.erb
Normal file
5
app/views/user_messages/_user_message.html.erb
Normal 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
142
bin/update_structure.sh
Executable 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"
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
46
db/migrate/20250319212839_create_ai_chats.rb
Normal file
46
db/migrate/20250319212839_create_ai_chats.rb
Normal 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
48
db/schema.rb
generated
|
@ -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
2
package-lock.json
generated
|
@ -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": {
|
||||
|
|
10
package.json
10
package.json
|
@ -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",
|
||||
"style:fix": "biome check --write",
|
||||
"lint": "biome lint",
|
||||
"lint:fix" : "biome lint --write",
|
||||
"format:check" : "biome format",
|
||||
"format" : "biome format --write"
|
||||
"lint:fix": "biome lint --write",
|
||||
"format:check": "biome format",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
|
|
|
@ -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"
|
||||
|
|
52
test/controllers/chats_controller_test.rb
Normal file
52
test/controllers/chats_controller_test.rb
Normal 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
|
22
test/controllers/messages_controller_test.rb
Normal file
22
test/controllers/messages_controller_test.rb
Normal 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
|
|
@ -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
7
test/fixtures/chats.yml
vendored
Normal 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
43
test/fixtures/messages.yml
vendored
Normal 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
7
test/fixtures/tool_calls.yml
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue