Compare commits
2 Commits
main
...
zachgoll/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9858a67f | ||
|
|
d1b83541c1 |
1
Gemfile
1
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
17
app/controllers/chats_controller.rb
Normal file
17
app/controllers/chats_controller.rb
Normal file
@@ -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
|
||||
23
app/controllers/messages_controller.rb
Normal file
23
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class MessagesController < ApplicationController
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = @chat.messages.create!(message_params.merge(role: "user"))
|
||||
|
||||
AiResponseJob.perform_later(@message)
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to chat_path(@chat) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content)
|
||||
end
|
||||
end
|
||||
32
app/javascript/controllers/chat_controller.js
Normal file
32
app/javascript/controllers/chat_controller.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="chat-scroll"
|
||||
export default class extends Controller {
|
||||
static targets = ["form", "messages"];
|
||||
|
||||
connect() {
|
||||
this.scrollToBottom();
|
||||
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.scrollToBottom();
|
||||
this.clearInput();
|
||||
});
|
||||
|
||||
this.observer.observe(this.messagesTarget, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
this.formTarget.querySelector("textarea").value = "";
|
||||
}
|
||||
}
|
||||
7
app/jobs/ai_response_job.rb
Normal file
7
app/jobs/ai_response_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class AiResponseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(message)
|
||||
message.chat.generate_next_ai_response
|
||||
end
|
||||
end
|
||||
61
app/models/chat.rb
Normal file
61
app/models/chat.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
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
|
||||
|
||||
def generate_next_ai_response
|
||||
if messages.conversation.ordered.last&.role == "assistant"
|
||||
Rails.logger.info("Skipping response because last message was an assistant message")
|
||||
return
|
||||
end
|
||||
|
||||
openai.chat(
|
||||
parameters: {
|
||||
model: "gpt-4o-mini",
|
||||
stream: streamer,
|
||||
n: 1,
|
||||
messages: messages.conversation.order(:created_at).map do |message|
|
||||
{
|
||||
role: message.role,
|
||||
content: message.content
|
||||
}
|
||||
end
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def openai
|
||||
OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"])
|
||||
end
|
||||
|
||||
def streamer
|
||||
message = messages.create!(
|
||||
role: "assistant",
|
||||
content: ""
|
||||
)
|
||||
|
||||
proc do |chunk, _bytesize|
|
||||
new_content = chunk.dig("choices", 0, "delta", "content")
|
||||
message.update(content: message.content + new_content) if new_content
|
||||
end
|
||||
end
|
||||
end
|
||||
33
app/models/message.rb
Normal file
33
app/models/message.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class Message < ApplicationRecord
|
||||
belongs_to :chat
|
||||
|
||||
enum :role, { user: "user", assistant: "assistant", system: "system" }
|
||||
|
||||
validates :content, presence: true, allow_blank: true
|
||||
validates :role, presence: true
|
||||
|
||||
scope :conversation, -> { where(debug_mode: false, role: [ :user, :assistant ]) }
|
||||
scope :ordered, -> { order(created_at: :asc) }
|
||||
|
||||
after_create_commit :broadcast_to_chat
|
||||
after_update_commit :broadcast_update_to_chat
|
||||
|
||||
private
|
||||
def broadcast_to_chat
|
||||
broadcast_append_to(
|
||||
chat,
|
||||
partial: "messages/message",
|
||||
locals: { message: self },
|
||||
target: "chat_#{chat.id}_messages"
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_update_to_chat
|
||||
broadcast_update_to(
|
||||
chat,
|
||||
partial: "messages/message",
|
||||
locals: { message: self },
|
||||
target: "message_#{self.id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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 }
|
||||
|
||||
3
app/views/chats/_chat.html.erb
Normal file
3
app/views/chats/_chat.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= link_to chat_path(chat) do %>
|
||||
<%= chat.title %>
|
||||
<% end %>
|
||||
6
app/views/chats/_chat_nav.html.erb
Normal file
6
app/views/chats/_chat_nav.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<div>
|
||||
<p>Chat nav</p>
|
||||
|
||||
<%= 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" } %>
|
||||
</div>
|
||||
9
app/views/chats/index.html.erb
Normal file
9
app/views/chats/index.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%= turbo_frame_tag "chat_content" do %>
|
||||
<% if @chats.empty? %>
|
||||
<p>No chats</p>
|
||||
<% else %>
|
||||
<div class="flex flex-col gap-2">
|
||||
<%= render @chats %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
17
app/views/chats/show.html.erb
Normal file
17
app/views/chats/show.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<%= turbo_frame_tag "chat_content" do %>
|
||||
<div class="flex flex-col gap-4 justify-between h-full" data-controller="chat">
|
||||
<div class="grow overflow-y-auto" data-chat-target="messages">
|
||||
<h2 class="text-2xl font-bold mb-6"><%= @chat.title %></h2>
|
||||
|
||||
<%= turbo_stream_from @chat %>
|
||||
|
||||
<div id="<%= dom_id(@chat) %>_messages" class="space-y-6 py-8">
|
||||
<%= render @chat.messages.conversation.ordered %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-chat-target="form">
|
||||
<%= render "messages/form", chat: @chat %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -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 transition-all duration-300 w-[375px]") do %>
|
||||
<div class="bg-white p-4">
|
||||
<%= render "chats/chat_nav" %>
|
||||
</div>
|
||||
|
||||
<div id="chat-content" class="grow p-4 bg-white overflow-y-auto" data-turbo-permanent>
|
||||
<% if Current.user.current_chat.present? %>
|
||||
<%= turbo_frame_tag "chat_content", src: chat_path(Current.user.current_chat), loading: "lazy" do %>
|
||||
<p>Loading chat...</p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= turbo_frame_tag "chat_content", src: chats_path, loading: "lazy" do %>
|
||||
<p>Loading chats...</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
6
app/views/messages/_form.html.erb
Normal file
6
app/views/messages/_form.html.erb
Normal file
@@ -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 %>
|
||||
6
app/views/messages/_message.html.erb
Normal file
6
app/views/messages/_message.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<div id="<%= dom_id(message) %>">
|
||||
<p class="text-sm text-gray-500">
|
||||
role: <%= message.role %>
|
||||
</p>
|
||||
<p class="text-primary whitespace-pre-wrap"><%= message.content %></p>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
10
db/migrate/20250312152948_create_chats.rb
Normal file
10
db/migrate/20250312152948_create_chats.rb
Normal file
@@ -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
|
||||
12
db/migrate/20250312153208_create_messages.rb
Normal file
12
db/migrate/20250312153208_create_messages.rb
Normal file
@@ -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
|
||||
5
db/migrate/20250312160915_user_latest_chat.rb
Normal file
5
db/migrate/20250312160915_user_latest_chat.rb
Normal file
@@ -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
|
||||
25
db/schema.rb
generated
25
db/schema.rb
generated
@@ -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
|
||||
|
||||
11
test/fixtures/chats.yml
vendored
Normal file
11
test/fixtures/chats.yml
vendored
Normal file
@@ -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
|
||||
11
test/fixtures/messages.yml
vendored
Normal file
11
test/fixtures/messages.yml
vendored
Normal file
@@ -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
|
||||
7
test/jobs/ai_response_job_test.rb
Normal file
7
test/jobs/ai_response_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class AiResponseJobTest < ActiveJob::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
7
test/models/chat_test.rb
Normal file
7
test/models/chat_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class ChatTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
7
test/models/message_test.rb
Normal file
7
test/models/message_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class MessageTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
Reference in New Issue
Block a user