Match Figma design spec, implement Turbo frames and actions for chats controller
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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? }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Message < ApplicationRecord
|
||||
include OpenAI
|
||||
include Promptable
|
||||
|
||||
belongs_to :chat
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
34
app/views/chats/_ai_consent.html.erb
Normal file
34
app/views/chats/_ai_consent.html.erb
Normal 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>
|
||||
|
||||
29
app/views/chats/_ai_greeting.html.erb
Normal file
29
app/views/chats/_ai_greeting.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
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 %>
|
||||
|
||||
<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 %>
|
||||
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 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>
|
||||
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: "sidebar-chat-content" } do %>
|
||||
<h3 class="text-sm font-medium text-primary"><%= chat.title %></h3>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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 %>
|
||||
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 %>
|
||||
@@ -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 %>
|
||||
9
app/views/chats/new.html.erb
Normal file
9
app/views/chats/new.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
6
db/schema.rb
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user