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