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

This commit is contained in:
Zach Gollwitzer
2025-03-14 07:55:05 -04:00
parent aeff1f213f
commit 6ce984c6f1
38 changed files with 324 additions and 395 deletions

View File

@@ -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.
- Do not create or use i18n methods or language files

View File

@@ -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
- Complex validations and business logic should remain in ActiveRecord

View File

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

View File

@@ -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`.
- Always generate semantic HTML

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ class ProcessAiResponseJob < ApplicationJob
target: "thinking",
html: <<~HTML
<div id="thinking" class="flex items-start gap-3">
#{ApplicationController.render(partial: "layouts/shared/ai_avatar")}
#{ApplicationController.render(partial: "chats/ai_avatar")}
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class Message < ApplicationRecord
include OpenAI
include Promptable
belongs_to :chat

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<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" do |form| %>
<%= form.hidden_field "user[ai_enabled]", value: true %>
<%= form.hidden_field "user[redirect_to]", value: "home" %>
<%= form.submit "Enable AI Assistant", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
<% end %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<div class="flex items-start gap-1 mt-4 w-full">
<%= render "chats/ai_avatar" %>
<div class="pt-2 pr-1 max-w-[85%] text-gray-800 text-sm">
<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>
<div class="mt-4 text-gray-600">
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
</div>
<div class="mt-4">
<p class="text-gray-600 mb-3">Here's a few questions you can ask:</p>
<div class="space-y-2">
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("bar-chart-2") %> Evaluate investment portfolio
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("credit-card") %> Show spending insights
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("alert-triangle") %> Find unusual patterns
</button>
</div>
</div>
</div>
</div>

View File

@@ -47,7 +47,7 @@
<% end %>
<% if !Current.user.ai_enabled? %>
<%= render "layouts/shared/ai_consent" %>
<%= render "chats/ai_consent" %>
<% else %>
<div id="chat-messages" class="prose prose--ai flex-grow overflow-y-auto px-4 py-3" data-controller="chat-scroll" data-chat-scroll-target="container">
<%# 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 %>
</div>
<%# Thinking indicator - moved outside of messages div to prevent it from being replaced %>
<div id="thinking" class="hidden flex items-start gap-3 mb-4 w-full" data-chat-progress-target="thinking">
<%= render "layouts/shared/ai_avatar" %>
<%= render "chats/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center text-gray-800">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
@@ -129,12 +129,12 @@
<% else %>
<%# New Chat: Show greeting in the top section %>
<div class="flex-grow" data-controller="chat-progress">
<%= render "layouts/shared/ai_greeting", context: 'default' %>
<%= render "chats/ai_greeting", context: 'default' %>
</div>
<%# Thinking indicator - moved outside of messages div to prevent it from being replaced %>
<div id="thinking" class="hidden flex items-start gap-3 mb-4 w-full" data-chat-progress-target="thinking">
<%= render "layouts/shared/ai_avatar" %>
<%= render "chats/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center text-gray-800">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
@@ -154,7 +154,6 @@
<%# For existing chats %>
<%= render "messages/form", chat: @chat, message: @message, scroll_behavior: true %>
<% else %>
<%# For new chats %>
<%= render "messages/form", scroll_behavior: true %>
<% end %>
<p class="text-xs text-gray-500 text-center mt-2">AI may make mistakes. Make sure to double check responses.</p>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<%= turbo_stream.replace "messages" do %>
<div id="messages" data-turbo-cache="false">
<%= render "layouts/shared/ai_greeting", context: 'chat' %>
<%= render "chats/ai_greeting", context: 'chat' %>
</div>
<% end %>

View File

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

View File

@@ -1,34 +1,27 @@
<% content_for :title, "All Chats" %>
<%= turbo_frame_tag "sidebar-chat-content" do %>
<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", data: { turbo_frame: "sidebar-chat-content" } do %>
<%= icon("arrow-left", color: "gray" ) %>
<% end %>
</nav>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold">All Chats</h1>
<h1 class="text-xl font-medium mb-6">Chats</h1>
<%= link_to "New Chat", chats_path, data: { turbo_method: :post }, class: "py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
</div>
<% if @chats.any? %>
<div class="space-y-2">
<% @chats.each do |chat| %>
<%= link_to chat_path(chat), class: "block p-4 bg-white border border-gray-200 rounded-lg hover:bg-gray-50" do %>
<div class="flex items-center justify-between">
<div>
<h3 class="font-medium text-gray-900"><%= chat.title %></h3>
<p class="text-sm text-gray-500">
<%= chat.messages.conversation.order(created_at: :desc).first&.content&.truncate(60) || "No messages" %>
</p>
</div>
<span class="text-xs text-gray-500"><%= time_ago_in_words(chat.updated_at) %> ago</span>
<div>
<% if @chats.any? %>
<div class="space-y-2">
<%= 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>
<% end %>
<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, data: { turbo_frame: "sidebar-chat-content" }, 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>
<% 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", chats_path, data: { turbo_method: :post }, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,9 @@
<%= turbo_frame_tag "sidebar-chat-content" do %>
<div class="flex flex-col h-full">
<%= render "chats/chat_nav", chat: @chat %>
<div class="mt-auto py-8">
<%= render "chats/ai_greeting" %>
</div>
</div>
<% end %>

View File

@@ -1,36 +1,19 @@
<% content_for :title, @chat.title %>
<%= turbo_frame_tag "sidebar-chat-content" do %>
<%= turbo_stream_from @chat %>
<%= turbo_stream_from @chat %>
<div class="flex flex-col h-full">
<%= render "chats/chat_nav", chat: @chat %>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold"><%= @chat.title %></h1>
<div class="flex items-center gap-2">
<%= link_to chats_path, class: "py-2 px-4 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium" do %>
All Chats
<% end %>
<%= button_to chat_path(@chat), method: :delete, class: "p-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200", data: { turbo_confirm: "Are you sure you want to delete this chat?" } do %>
<%= icon("trash-2") %>
<% end %>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden" data-controller="chat-scroll">
<div class="h-[500px] overflow-y-auto flex flex-col" id="chat-container" data-chat-scroll-target="container">
<div id="messages" class="flex flex-col justify-end min-h-full p-4 space-y-4 mt-auto" data-chat-scroll-target="messages">
<div id="messages" class="grow flex flex-col gap-8 py-8" data-chat-scroll-target="messages">
<% if @messages.any? %>
<% @messages.each do |message| %>
<%= render "messages/message", message: message %>
<% end %>
<% else %>
<!-- Show welcome message when chat has no messages -->
<%= render "layouts/shared/ai_greeting", context: 'chat' %>
<div class="mt-auto">
<%= render "chats/ai_greeting", context: 'chat' %>
</div>
<% end %>
</div>
</div>
<div class="border-t border-gray-200 p-4">
<%= render "messages/form", chat: @chat, message: @message, scroll_behavior: true %>
</div>
</div>
<% end %>

View File

@@ -2,13 +2,13 @@
<% @messages.each do |message| %>
<div class="flex items-start gap-3 <%= message.user? ? 'justify-end' : '' %> mb-4">
<% unless message.user? %>
<%= render "layouts/shared/ai_avatar" %>
<%= render "chats/ai_avatar" %>
<% end %>
<div class="<%= message.user? ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-800' %> rounded-lg p-4 max-w-[85%]">
<p><%= message.content %></p>
</div>
<% if message.user? %>
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0 text-gray-700">
<%= Current.user.initials %>

View File

@@ -38,7 +38,7 @@
<%
left_sidebar_open = Current.user.show_sidebar?
right_sidebar_open = (Current.user.respond_to?(:show_ai_sidebar?) && Current.user.show_ai_sidebar?) || @chat.present?
right_sidebar_open = Current.user.show_ai_sidebar?
content_width_class = if left_sidebar_open && right_sidebar_open
"max-w-3xl"
elsif left_sidebar_open || right_sidebar_open
@@ -70,8 +70,20 @@
<% end %>
<% end %>
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", right_sidebar_open ? "w-[375px]" : "w-0"), data: { sidebar_target: "rightPanel" } do %>
<%= render "layouts/shared/ai_sidebar" %>
<%# AI chat sidebar %>
<%= tag.div id: "sidebar-chat-container", class: class_names("flex flex-col p-4 pl-0 justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[375px]" : "w-0"), data: { sidebar_target: "rightPanel", turbo_permanent: true } do %>
<div class="grow overflow-y-auto">
<%= turbo_frame_tag "sidebar-chat-content", 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 %>
</div>
<div class="space-y-2 shrink-0">
<%= render "messages/form" %>
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
</div>
<% end %>
</div>
<% end %>

View File

@@ -1,31 +0,0 @@
<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>
<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>
<% 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 %>
</div>
</div>

View File

@@ -1,75 +0,0 @@
<%#
This partial renders the AI greeting message.
Parameters:
- context: Either 'chat' (for existing chat) or 'default' (for new chat)
%>
<% context ||= 'default' %>
<div class="flex items-start gap-1 mt-4 w-full">
<%= render "layouts/shared/ai_avatar" %>
<div class="pt-2 pr-1 max-w-[85%] text-gray-800 text-sm">
<% if context == 'chat' %>
<p>Hey <%= Current.user&.first_name || 'there' %>! I'm your financial assistant. I can help you understand your finances by answering questions about your accounts, transactions, income, expenses, and net worth.</p>
<div class="mt-4">
<p class="text-gray-600 mb-3">Here are some financial questions you can ask:</p>
<div class="space-y-2">
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50"
data-controller="example-question"
data-action="click->example-question#fillForm"
data-example-question-text-value="What's my current net worth?">
<%= icon("dollar-sign") %> What's my current net worth?
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50"
data-controller="example-question"
data-action="click->example-question#fillForm"
data-example-question-text-value="How much did I spend on groceries last month?">
<%= icon("shopping-bag") %> How much did I spend on groceries last month?
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50"
data-controller="example-question"
data-action="click->example-question#fillForm"
data-example-question-text-value="What's my savings rate this year?">
<%= icon("piggy-bank") %> What's my savings rate this year?
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50"
data-controller="example-question"
data-action="click->example-question#fillForm"
data-example-question-text-value="How has my spending changed compared to last month?">
<%= icon("trending-up") %> How has my spending changed compared to last month?
</button>
</div>
</div>
<% else %>
<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>
<div class="mt-4 text-gray-600">
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
</div>
<div class="mt-4">
<p class="text-gray-600 mb-3">Here's a few questions you can ask:</p>
<div class="space-y-2">
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("bar-chart-2") %> Evaluate investment portfolio
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("credit-card") %> Show spending insights
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("alert-triangle") %> Find unusual patterns
</button>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -1,68 +1,25 @@
<%
# This partial can be used in multiple contexts:
# - In a chat show page: form_with model: [chat, message]
# - In a new chat context: form_with url: chats_path, method: :post
# - In a sidebar: with additional scroll behavior
#
# Parameters:
# - chat: The chat object (optional, if creating a new chat)
# - message: The message object (optional, if creating a new chat)
# - form_options: Additional options for the form (default: {})
# - scroll_behavior: Whether to include scroll behavior (default: false)
%>
<%# locals: (chat: nil) %>
<%
# Set up default values
form_options ||= {}
scroll_behavior ||= false
# Determine form parameters based on context
if defined?(chat) && chat.present? && defined?(message) && message.present?
form_params = { model: [chat, message] }
else
form_params = { url: chats_path, method: :post }
end
# Merge form options
form_params.merge!(form_options)
# Set up controller and actions
controller_data = { controller: "message-form", action: "turbo:submit-end->message-form#reset" }
if scroll_behavior
controller_data[:action] += " turbo:submit-end->chat-scroll#scrollToBottom"
end
%>
<% model = chat ? [chat, Message.new] : Chat.new %>
<%= form_with **form_params, class: "relative", data: controller_data do |f| %>
<div class="bg-white border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 pt-2">
<%= f.text_area :content,
placeholder: "Ask about your finances, net worth, spending, income...",
class: "w-full border-0 focus:ring-0 resize-none text-sm",
rows: 1,
data: {
controller: "textarea-autogrow",
action: "input->textarea-autogrow#resize keydown->message-form#checkSubmit"
} %>
</div>
<div class="flex items-center px-2 pb-2">
<button type="button" class="p-1.5 text-gray-500 hover:text-gray-700 rounded-md">
<%= icon("plus") %>
</button>
<button type="button" class="p-1.5 text-gray-500 hover:text-gray-700 rounded-md">
<%= icon("command") %>
</button>
<button type="button" class="p-1.5 text-gray-500 hover:text-gray-700 rounded-md">
<%= icon("at-sign") %>
</button>
<button type="button" class="p-1.5 text-gray-500 hover:text-gray-700 rounded-md">
<%= icon("sparkles") %>
</button>
<button type="submit" class="ml-auto p-1.5 text-gray-500 hover:text-gray-700 rounded-md" data-message-form-target="submit">
<%= icon("arrow-up") %>
</button>
<%= form_with model: model, class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs" do |f| %>
<%= f.text_area :content,
placeholder: "Ask anything ...",
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
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" class="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 %>

View File

@@ -35,7 +35,7 @@
<p class="whitespace-pre-wrap break-words"><%= message.content %></p>
</div>
<% else %>
<%= render "layouts/shared/ai_avatar" %>
<%= render "chats/ai_avatar" %>
<div class="pr-2 max-w-[85%] text-gray-800">
<div class="prose prose-sm prose-gray break-words"><%= markdown(message.content) %></div>
</div>

View File

@@ -5,7 +5,7 @@
<% if @message.user? %>
<%= turbo_stream.replace "thinking" do %>
<div id="thinking" class="flex items-start gap-3 mb-4">
<%= render "layouts/shared/ai_avatar" %>
<%= render "chats/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
@@ -16,7 +16,7 @@
</div>
</div>
<% end %>
<%= turbo_stream.replace "message_form" do %>
<%= render "messages/form", chat: @chat, message: Message.new, scroll_behavior: true, form_options: { data: { controller: "message-form", action: "turbo:submit-end->chat-scroll#scrollToBottom" } } %>
<% end %>

View File

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

View File

@@ -9,7 +9,7 @@ Rails.application.routes.draw do
mount GoodJob::Engine => "good_job"
# AI Chat routes
resources :chats, only: [ :index, :show, :create, :destroy ] do
resources :chats do
resources :messages, only: [ :create ]
end

View File

@@ -16,7 +16,7 @@ class CreateAiChat < ActiveRecord::Migration[7.2]
t.text :content, null: false
end
add_reference :users, :current_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid
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

6
db/schema.rb generated
View File

@@ -696,12 +696,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_13_112556) 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 "current_chat_id"
t.uuid "last_viewed_chat_id"
t.boolean "show_ai_sidebar", default: true
t.boolean "ai_enabled", default: false, null: false
t.index ["current_chat_id"], name: "index_users_on_current_chat_id"
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
@@ -753,6 +753,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_13_112556) do
add_foreign_key "tags", "families"
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: "current_chat_id"
add_foreign_key "users", "chats", column: "last_viewed_chat_id"
add_foreign_key "users", "families"
end