diff --git a/Gemfile b/Gemfile
index 247a4849..b9f5533e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -57,6 +57,7 @@ gem "intercom-rails"
gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 2.2"
+gem "ruby-openai"
group :development, :test do
gem "debug", platforms: %i[mri windows]
diff --git a/Gemfile.lock b/Gemfile.lock
index 6fe445f8..7a7002d5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -167,6 +167,7 @@ GEM
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
+ event_stream_parser (1.0.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.2)
@@ -441,6 +442,10 @@ GEM
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.4.0)
ruby-lsp (>= 0.23.0, < 0.24.0)
+ ruby-openai (7.4.0)
+ event_stream_parser (>= 0.3.0, < 2.0.0)
+ faraday (>= 1)
+ faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ffi (~> 1.12)
@@ -576,6 +581,7 @@ DEPENDENCIES
rqrcode (~> 2.2)
rubocop-rails-omakase
ruby-lsp-rails
+ ruby-openai
selenium-webdriver
sentry-rails
sentry-ruby
diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb
new file mode 100644
index 00000000..39f8b3ef
--- /dev/null
+++ b/app/controllers/chats_controller.rb
@@ -0,0 +1,17 @@
+class ChatsController < ApplicationController
+ def index
+ Current.user.update!(current_chat: nil)
+ @chats = Current.user.chats.ordered
+ end
+
+ def create
+ @chat = Current.user.chats.create_with_defaults!
+
+ redirect_to chat_path(@chat)
+ end
+
+ def show
+ @chat = Current.user.chats.find(params[:id])
+ Current.user.update!(current_chat: @chat)
+ end
+end
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
new file mode 100644
index 00000000..b0428a58
--- /dev/null
+++ b/app/controllers/messages_controller.rb
@@ -0,0 +1,18 @@
+class MessagesController < ApplicationController
+ before_action :set_chat
+
+ def create
+ @message = @chat.messages.create!(message_params.merge(role: "user"))
+
+ redirect_to chat_path(@chat)
+ end
+
+ private
+ def set_chat
+ @chat = Current.user.chats.find(params[:chat_id])
+ end
+
+ def message_params
+ params.require(:message).permit(:content)
+ end
+end
diff --git a/app/models/chat.rb b/app/models/chat.rb
new file mode 100644
index 00000000..5b3092c0
--- /dev/null
+++ b/app/models/chat.rb
@@ -0,0 +1,23 @@
+class Chat < ApplicationRecord
+ belongs_to :user
+ has_many :messages, dependent: :destroy
+ has_one :user_current_chat, class_name: "User", foreign_key: :current_chat_id, dependent: :nullify
+
+ validates :title, presence: true
+
+ scope :ordered, -> { order(created_at: :desc) }
+
+ class << self
+ def create_with_defaults!
+ create!(
+ title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M:%S")}",
+ messages: [
+ Message.new(
+ role: "system",
+ content: "You are a helpful personal finance assistant.",
+ )
+ ]
+ )
+ end
+ end
+end
diff --git a/app/models/message.rb b/app/models/message.rb
new file mode 100644
index 00000000..933652dd
--- /dev/null
+++ b/app/models/message.rb
@@ -0,0 +1,10 @@
+class Message < ApplicationRecord
+ belongs_to :chat
+
+ enum :role, { user: "user", assistant: "assistant", system: "system" }
+
+ validates :content, presence: true
+ validates :role, presence: true
+
+ scope :conversation, -> { where(debug_mode: false, role: [ :user, :assistant ]) }
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 479ce225..1e3068e8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,10 +1,12 @@
class User < ApplicationRecord
has_secure_password
+ belongs_to :current_chat, class_name: "Chat", optional: true
belongs_to :family
has_many :sessions, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
+ has_many :chats, dependent: :destroy
accepts_nested_attributes_for :family, update_only: true
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb
new file mode 100644
index 00000000..fcd15845
--- /dev/null
+++ b/app/views/chats/_chat.html.erb
@@ -0,0 +1,3 @@
+<%= link_to chat_path(chat) do %>
+ <%= chat.title %>
+<% end %>
diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb
new file mode 100644
index 00000000..e7ea9e66
--- /dev/null
+++ b/app/views/chats/_chat_nav.html.erb
@@ -0,0 +1,6 @@
+
+
Chat nav
+
+ <%= button_to "New chat", chats_path, method: :post, class: "btn btn-primary", data: { turbo_frame: "chat_content" } %>
+ <%= link_to "All chats", chats_path, data: { turbo_frame: "chat_content" } %>
+
\ No newline at end of file
diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb
new file mode 100644
index 00000000..f3621e6f
--- /dev/null
+++ b/app/views/chats/index.html.erb
@@ -0,0 +1,9 @@
+<%= turbo_frame_tag "chat_content" do %>
+ <% if @chats.empty? %>
+ No chats
+ <% else %>
+
+ <%= render @chats %>
+
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb
new file mode 100644
index 00000000..1caa449b
--- /dev/null
+++ b/app/views/chats/show.html.erb
@@ -0,0 +1,13 @@
+<%= turbo_frame_tag "chat_content" do %>
+
+
+
<%= @chat.title %>
+
+
+ <%= render @chat.messages.conversation %>
+
+
+
+ <%= render "messages/form", chat: @chat %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 50388e1e..eb63c56b 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -57,5 +57,23 @@
<%= yield %>
<% end %>
<% end %>
+
+ <%= tag.div class: class_names("py-4 shrink-0 flex flex-col justify-between gap-6 h-full overflow-y-auto transition-all duration-300 w-[375px]") do %>
+
+ <%= render "chats/chat_nav" %>
+
+
+
+ <% if Current.user.current_chat.present? %>
+ <%= turbo_frame_tag "chat_content", src: chat_path(Current.user.current_chat), loading: "lazy" do %>
+
Loading chat...
+ <% end %>
+ <% else %>
+ <%= turbo_frame_tag "chat_content", src: chats_path, loading: "lazy" do %>
+
Loading chats...
+ <% end %>
+ <% end %>
+
+ <% end %>
<% end %>
diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb
new file mode 100644
index 00000000..b7835982
--- /dev/null
+++ b/app/views/messages/_form.html.erb
@@ -0,0 +1,6 @@
+<%# locals: (chat:) %>
+
+<%= styled_form_with model: chat.messages.build, url: chat_messages_path(chat), method: :post, class: "space-y-4" do |f| %>
+ <%= f.text_area :content, size: "50x5" %>
+ <%= f.submit "Send message" %>
+<% end %>
diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb
new file mode 100644
index 00000000..60c4c144
--- /dev/null
+++ b/app/views/messages/_message.html.erb
@@ -0,0 +1,8 @@
+
+
+ role: <%= message.role %>
+
+
+ <%= message.content %>
+
+
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index c714a3ef..2660f858 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,10 @@ Rails.application.routes.draw do
delete :reset, on: :member
end
+ resources :chats, only: %i[index show create destroy] do
+ resources :messages, only: %i[create]
+ end
+
resource :onboarding, only: :show do
collection do
get :profile
diff --git a/db/migrate/20250312152948_create_chats.rb b/db/migrate/20250312152948_create_chats.rb
new file mode 100644
index 00000000..f7b1014f
--- /dev/null
+++ b/db/migrate/20250312152948_create_chats.rb
@@ -0,0 +1,10 @@
+class CreateChats < ActiveRecord::Migration[7.2]
+ def change
+ create_table :chats, id: :uuid do |t|
+ t.timestamps
+
+ t.references :user, null: false, foreign_key: true, type: :uuid
+ t.string :title, null: false
+ end
+ end
+end
diff --git a/db/migrate/20250312153208_create_messages.rb b/db/migrate/20250312153208_create_messages.rb
new file mode 100644
index 00000000..ff33514f
--- /dev/null
+++ b/db/migrate/20250312153208_create_messages.rb
@@ -0,0 +1,12 @@
+class CreateMessages < ActiveRecord::Migration[7.2]
+ def change
+ create_table :messages, id: :uuid do |t|
+ t.timestamps
+
+ t.references :chat, null: false, foreign_key: true, type: :uuid
+ t.string :role, null: false
+ t.text :content, null: false
+ t.boolean :debug_mode, default: false, null: false
+ end
+ end
+end
diff --git a/db/migrate/20250312160915_user_latest_chat.rb b/db/migrate/20250312160915_user_latest_chat.rb
new file mode 100644
index 00000000..f70a46a6
--- /dev/null
+++ b/db/migrate/20250312160915_user_latest_chat.rb
@@ -0,0 +1,5 @@
+class UserLatestChat < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :users, :current_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 59eabedf..b7073a58 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
+ActiveRecord::Schema[7.2].define(version: 2025_03_12_160915) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -196,6 +196,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.index ["family_id"], name: "index_categories_on_family_id"
end
+ create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.uuid "user_id", null: false
+ t.string "title", null: false
+ t.index ["user_id"], name: "index_chats_on_user_id"
+ end
+
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -481,6 +489,16 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
t.index ["family_id"], name: "index_merchants_on_family_id"
end
+ create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.uuid "chat_id", null: false
+ t.string "role", null: false
+ t.text "content", null: false
+ t.boolean "debug_mode", default: false, null: false
+ t.index ["chat_id"], name: "index_messages_on_chat_id"
+ end
+
create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -676,6 +694,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) 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.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 ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
@@ -708,6 +728,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
+ add_foreign_key "chats", "users"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
@@ -716,6 +737,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
add_foreign_key "invitations", "families"
add_foreign_key "invitations", "users", column: "inviter_id"
add_foreign_key "merchants", "families"
+ add_foreign_key "messages", "chats"
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id"
@@ -727,5 +749,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) 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", "families"
end
diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml
new file mode 100644
index 00000000..d7a33292
--- /dev/null
+++ b/test/fixtures/chats.yml
@@ -0,0 +1,11 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# This model initially had no columns defined. If you add columns to the
+# model remove the "{}" from the fixture names and add the columns immediately
+# below each fixture, per the syntax in the comments below
+#
+one: {}
+# column: value
+#
+two: {}
+# column: value
diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml
new file mode 100644
index 00000000..d7a33292
--- /dev/null
+++ b/test/fixtures/messages.yml
@@ -0,0 +1,11 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# This model initially had no columns defined. If you add columns to the
+# model remove the "{}" from the fixture names and add the columns immediately
+# below each fixture, per the syntax in the comments below
+#
+one: {}
+# column: value
+#
+two: {}
+# column: value
diff --git a/test/models/chat_test.rb b/test/models/chat_test.rb
new file mode 100644
index 00000000..69b9d2c1
--- /dev/null
+++ b/test/models/chat_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ChatTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/message_test.rb b/test/models/message_test.rb
new file mode 100644
index 00000000..0e0d35bb
--- /dev/null
+++ b/test/models/message_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class MessageTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end