mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Add streaming chat
This commit is contained in:
parent
d1b83541c1
commit
3f9858a67f
9 changed files with 127 additions and 13 deletions
|
@ -4,7 +4,12 @@ class MessagesController < ApplicationController
|
||||||
def create
|
def create
|
||||||
@message = @chat.messages.create!(message_params.merge(role: "user"))
|
@message = @chat.messages.create!(message_params.merge(role: "user"))
|
||||||
|
|
||||||
redirect_to chat_path(@chat)
|
AiResponseJob.perform_later(@message)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream
|
||||||
|
format.html { redirect_to chat_path(@chat) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
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
|
|
@ -20,4 +20,42 @@ class Chat < ApplicationRecord
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -3,8 +3,31 @@ class Message < ApplicationRecord
|
||||||
|
|
||||||
enum :role, { user: "user", assistant: "assistant", system: "system" }
|
enum :role, { user: "user", assistant: "assistant", system: "system" }
|
||||||
|
|
||||||
validates :content, presence: true
|
validates :content, presence: true, allow_blank: true
|
||||||
validates :role, presence: true
|
validates :role, presence: true
|
||||||
|
|
||||||
scope :conversation, -> { where(debug_mode: false, role: [ :user, :assistant ]) }
|
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
|
end
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
<%= turbo_frame_tag "chat_content" do %>
|
<%= turbo_frame_tag "chat_content" do %>
|
||||||
<div class="flex flex-col gap-4 justify-between h-full">
|
<div class="flex flex-col gap-4 justify-between h-full" data-controller="chat">
|
||||||
<div>
|
<div class="grow overflow-y-auto" data-chat-target="messages">
|
||||||
<h2 class="text-2xl font-bold mb-6"><%= @chat.title %></h2>
|
<h2 class="text-2xl font-bold mb-6"><%= @chat.title %></h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<%= turbo_stream_from @chat %>
|
||||||
<%= render @chat.messages.conversation %>
|
|
||||||
|
<div id="<%= dom_id(@chat) %>_messages" class="space-y-6 py-8">
|
||||||
|
<%= render @chat.messages.conversation.ordered %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render "messages/form", chat: @chat %>
|
<div data-chat-target="form">
|
||||||
|
<%= render "messages/form", chat: @chat %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
|
@ -58,12 +58,12 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% 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 %>
|
<%= 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">
|
<div class="bg-white p-4">
|
||||||
<%= render "chats/chat_nav" %>
|
<%= render "chats/chat_nav" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chat-content" class="grow p-4 bg-white" data-turbo-permanent>
|
<div id="chat-content" class="grow p-4 bg-white overflow-y-auto" data-turbo-permanent>
|
||||||
<% if Current.user.current_chat.present? %>
|
<% if Current.user.current_chat.present? %>
|
||||||
<%= turbo_frame_tag "chat_content", src: chat_path(Current.user.current_chat), loading: "lazy" do %>
|
<%= turbo_frame_tag "chat_content", src: chat_path(Current.user.current_chat), loading: "lazy" do %>
|
||||||
<p>Loading chat...</p>
|
<p>Loading chat...</p>
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
<div>
|
<div id="<%= dom_id(message) %>">
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
role: <%= message.role %>
|
role: <%= message.role %>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-gray-900">
|
<p class="text-primary whitespace-pre-wrap"><%= message.content %></p>
|
||||||
<%= message.content %>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
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
|
Loading…
Add table
Add a link
Reference in a new issue