diff --git a/.cursor/rules/general-rules.mdc b/.cursor/rules/general-rules.mdc index ea5fb59b..c0f68e48 100644 --- a/.cursor/rules/general-rules.mdc +++ b/.cursor/rules/general-rules.mdc @@ -11,8 +11,4 @@ alwaysApply: true - Do not run `touch tmp/restart.txt` - Do not run `rails credentials` - Do not automatically run migrations -- Do not create or use i18n methods or language files -- Do not create new API routes -- Write and run tests as you go to verify functionality in small pieces -- Focus on simple solutions -- Use `gpt-4o` model from OpenAI unless otherwise noted. \ No newline at end of file +- Do not create or use i18n methods or language files \ No newline at end of file diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index d2d00dcc..f575f27a 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -19,6 +19,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: GoodJob - External @@ -50,6 +51,7 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore, ### Convention 3: Prefer server-side solutions over client-side solutions - When possible, leverage Turbo frames over complex, JS-driven client-side solutions +- Prefer query params for state over JS based 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. @@ -77,9 +79,10 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture - 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) +- 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 - 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 \ No newline at end of file +- Complex validations and business logic should remain in ActiveRecord diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 8957f7a6..da2b8154 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -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. diff --git a/.cursor/rules/ui-ux-design-guidelines.mdc b/.cursor/rules/ui-ux-design-guidelines.mdc index 7d5f7099..430959d6 100644 --- a/.cursor/rules/ui-ux-design-guidelines.mdc +++ b/.cursor/rules/ui-ux-design-guidelines.mdc @@ -3,12 +3,20 @@ description: This file describes Maybe's design system and how views should be s 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`. \ No newline at end of file +- Always generate semantic HTML \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d0ad6828..1f51c6e9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base helper_method :require_upgrade?, :subscription_pending? before_action :detect_os - before_action :set_chat_for_sidebar + before_action :set_default_chat private def require_upgrade? @@ -35,14 +35,9 @@ class ApplicationController < ActionController::Base end end - def set_chat_for_sidebar - return unless Current.user - return unless params[:chat_id].present? - - @chat = Current.user.chats.find_by(id: params[:chat_id]) - if @chat - @messages = @chat.messages.conversation.ordered - @message = Message.new - 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 diff --git a/app/controllers/chat_controller.js b/app/controllers/chat_controller.js deleted file mode 100644 index 8dc2ced3..00000000 --- a/app/controllers/chat_controller.js +++ /dev/null @@ -1,87 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["messages", "form", "input"] - - connect() { - this.scrollToBottom() - this.setupAutoResize() - this.setupMessageObserver() - } - - scrollToBottom() { - if (this.hasMessagesTarget) { - this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight - } - } - - setupAutoResize() { - if (this.hasInputTarget) { - this.inputTarget.addEventListener('input', this.autoResize.bind(this)) - // Initialize height - this.autoResize() - } - } - - setupMessageObserver() { - if (this.hasMessagesTarget) { - // Create a mutation observer to watch for new messages - this.observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.addedNodes.length) { - this.scrollToBottom() - } - }) - }) - - // Start observing - this.observer.observe(this.messagesTarget, { - childList: true, - subtree: true - }) - } - } - - disconnect() { - // Clean up observer when controller is disconnected - if (this.observer) { - this.observer.disconnect() - } - } - - autoResize() { - const input = this.inputTarget - // Reset height to calculate proper scrollHeight - input.style.height = 'auto' - // Set new height based on content - input.style.height = (input.scrollHeight) + 'px' - // Cap at 150px max height - if (input.scrollHeight > 150) { - input.style.height = '150px' - input.style.overflowY = 'auto' - } else { - input.style.overflowY = 'hidden' - } - } - - submit(event) { - // Let the form submit normally, but prepare for the response - this.startLoadingState() - } - - startLoadingState() { - if (this.hasFormTarget) { - this.formTarget.classList.add('opacity-50') - this.formTarget.querySelector('button[type="submit"]').disabled = true - } - } - - endLoadingState() { - if (this.hasFormTarget) { - this.formTarget.classList.remove('opacity-50') - this.formTarget.querySelector('button[type="submit"]').disabled = false - this.formTarget.reset() - this.autoResize() - } - } -} \ No newline at end of file diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index e91e79ef..f63de195 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -1,12 +1,16 @@ class ChatsController < ApplicationController - before_action :set_chat, only: [ :show, :destroy, :clear ] - before_action :ensure_ai_enabled, only: [ :create, :show ] + include ActionView::RecordIdentifier + + before_action :set_chat, only: [ :show, :edit, :update, :destroy ] + before_action :ensure_ai_enabled, only: [ :edit, :update, :create, :show ] 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) @messages = @chat.messages.conversation.ordered @message = Message.new @@ -16,10 +20,16 @@ class ChatsController < ApplicationController end 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.new(title: "New Chat", user: Current.user) + @chat = Current.user.chats.new(chat_params.merge(title: "New Chat")) if @chat.save + set_last_viewed_chat(@chat) + # Create initial system message with enhanced financial assistant context @chat.messages.create( content: "You are a helpful financial assistant for Maybe. You can answer questions about the user's finances including net worth, account balances, income, expenses, spending patterns, budgets, and financial goals. You have access to the user's financial data and can provide insights based on their transactions and accounts. Be conversational, helpful, and provide specific financial insights tailored to the user's question.", @@ -31,7 +41,6 @@ class ChatsController < ApplicationController @user_message = @chat.messages.create( content: params[:content], role: "user", - user: Current.user ) end @@ -52,17 +61,41 @@ class ChatsController < ApplicationController end 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 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) + end + def ensure_ai_enabled unless Current.user.ai_enabled? redirect_to root_path, alert: "AI chat is not enabled. Please enable it in your settings." diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4b114375..8688ee61 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -154,6 +154,13 @@ module ApplicationHelper markdown.render(text).html_safe 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 + private def calculate_total(item, money_method, negate) items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? } diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb index 55ad55a3..7f12b76e 100644 --- a/app/helpers/menus_helper.rb +++ b/app/helpers/menus_helper.rb @@ -1,13 +1,13 @@ module MenusHelper - def contextual_menu(&block) + def contextual_menu(icon: "more-horizontal", &block) tag.div data: { controller: "menu" } do - concat contextual_menu_icon + 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 @@ -16,7 +16,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 +24,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 diff --git a/app/jobs/process_ai_response_job.rb b/app/jobs/process_ai_response_job.rb index f50b0045..5a0d7640 100644 --- a/app/jobs/process_ai_response_job.rb +++ b/app/jobs/process_ai_response_job.rb @@ -96,7 +96,7 @@ class ProcessAiResponseJob < ApplicationJob target: "thinking", html: <<~HTML
- #{ApplicationController.render(partial: "layouts/shared/ai_avatar")} + #{ApplicationController.render(partial: "chats/ai_avatar")}
diff --git a/app/models/concerns/openai.rb b/app/models/concerns/openai.rb deleted file mode 100644 index 0f4ed339..00000000 --- a/app/models/concerns/openai.rb +++ /dev/null @@ -1,17 +0,0 @@ -module OpenAI - extend ActiveSupport::Concern - - class_methods do - def openai_client - api_key = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token) - - return nil unless api_key.present? - - OpenAI::Client.new(access_token: api_key) - end - end - - def openai_client - self.class.openai_client - end -end diff --git a/app/models/concerns/promptable.rb b/app/models/concerns/promptable.rb index 3b8ba0a2..d1e43d60 100644 --- a/app/models/concerns/promptable.rb +++ b/app/models/concerns/promptable.rb @@ -1,6 +1,24 @@ module Promptable extend ActiveSupport::Concern + # The openai ruby gem hasn't yet added support for the responses endpoint. + # TODO: Remove this once the gem implements it. + class CustomOpenAI < OpenAI::Client + def responses(parameters: {}) + json_post(path: "/responses", parameters: parameters) + end + end + + class_methods do + def openai_client + api_key = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token) + + return nil unless api_key.present? + + CustomOpenAI.new(access_token: api_key) + end + end + # Convert model data to a format that's readable by AI def to_ai_readable_hash raise NotImplementedError, "#{self.class} must implement to_ai_readable_hash" @@ -29,6 +47,10 @@ module Promptable private + def openai_client + self.class.openai_client + end + # Format currency values consistently for AI display def format_currency(amount, currency = family.currency) Money.new(amount, currency).format diff --git a/app/models/message.rb b/app/models/message.rb index 87d1ea8e..93bb1954 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -1,5 +1,5 @@ class Message < ApplicationRecord - include OpenAI + include Promptable belongs_to :chat diff --git a/app/models/setting.rb b/app/models/setting.rb index 41355fee..992717ba 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -18,6 +18,7 @@ class Setting < RailsSettings::Base validates: { inclusion: { in: %w[release commit] } } 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 diff --git a/app/models/user.rb b/app/models/user.rb index b1668054..d1aa9da2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,7 @@ 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 diff --git a/app/views/layouts/shared/_ai_avatar.html.erb b/app/views/chats/_ai_avatar.html.erb similarity index 100% rename from app/views/layouts/shared/_ai_avatar.html.erb rename to app/views/chats/_ai_avatar.html.erb diff --git a/app/views/chats/_ai_consent.html.erb b/app/views/chats/_ai_consent.html.erb new file mode 100644 index 00000000..37e913d8 --- /dev/null +++ b/app/views/chats/_ai_consent.html.erb @@ -0,0 +1,34 @@ +
+
+
+ <%= icon("sparkles") %> +
+ +

Enable Personal Finance AI

+ +

+ <% 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 OPENAI_ACCESS_TOKEN + environment variable in your self-hosted instance. + <% end %> +

+ + <% unless self_hosted? %> +

+ NOTE: During beta testing, we'll be monitoring usage and AI conversations to improve the assistant. +

+ <% end %> + + <% if Current.user.ai_available? %> + <%= form_with url: user_path(Current.user), method: :patch, class: "w-full" 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 %> +
+
+ \ No newline at end of file diff --git a/app/views/chats/_ai_greeting.html.erb b/app/views/chats/_ai_greeting.html.erb new file mode 100644 index 00000000..ec210b07 --- /dev/null +++ b/app/views/chats/_ai_greeting.html.erb @@ -0,0 +1,29 @@ +
+ <%= render "chats/ai_avatar" %> + +
+

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.

+ +
+ You can use / to access commands +
+ +
+

Here's a few questions you can ask:

+ +
+ + + + + +
+
+
+
\ No newline at end of file diff --git a/app/views/layouts/shared/_ai_sidebar.html.erb b/app/views/chats/_ai_sidebar.html.erb similarity index 96% rename from app/views/layouts/shared/_ai_sidebar.html.erb rename to app/views/chats/_ai_sidebar.html.erb index 85ea8e7b..e9ec2d3e 100644 --- a/app/views/layouts/shared/_ai_sidebar.html.erb +++ b/app/views/chats/_ai_sidebar.html.erb @@ -47,7 +47,7 @@ <% end %> <% if !Current.user.ai_enabled? %> - <%= render "layouts/shared/ai_consent" %> + <%= render "chats/ai_consent" %> <% else %>
<%# Chat List Menu (hidden by default) %> @@ -110,13 +110,13 @@ <%= render "messages/message", message: message %> <% end %> <% else %> - <%= render "layouts/shared/ai_greeting", context: 'chat' %> + <%= render "chats/ai_greeting", context: 'chat' %> <% end %>
<%# Thinking indicator - moved outside of messages div to prevent it from being replaced %>