From 2d51740e2f4339cbc9427a1102b9956a4f41e344 Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Sat, 9 Nov 2024 22:12:47 -0800 Subject: [PATCH 01/14] convert to hotwire --- lib/phlex/chatbot/chat/input.rb | 32 ++++++++++++++++-------------- lib/phlex/chatbot/chat/message.rb | 13 ++++++++---- lib/phlex/chatbot/chat/messages.rb | 1 + lib/phlex/chatbot/chat/source.rb | 12 +++++++---- lib/phlex/chatbot/source_modal.rb | 10 +++++----- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index ea48608..7272975 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -4,22 +4,24 @@ module Phlex module Chatbot module Chat class Input < Phlex::HTML + include Phlex::Rails::Helpers::TurboFrameTag + include Phlex::Rails::Helpers::FormWith + + def initialize(chat_thread:) + @chat_message = chat_thread.chat_messages.new + end + def view_template - div(class: "pcb__chat-input") do - form(data: { action: "submit->pcb-chat-form#submit" }) do - textarea( - placeholder: "Type your message...", - rows: "1", - data: { - pcb_chat_form_target: "input", - action: <<~ACTIONS.squish, - keydown.ctrl+enter->pcb-chat-form#handleKeyboardSubmit:prevent - keydown.meta+enter->pcb-chat-form#handleKeyboardSubmit:prevent - input->pcb-chat-form#resetTextareaHeight - ACTIONS - }, - ) - button(type: "submit") { "Send" } + turbo_frame_tag("pcci-form") do + div(class: "pcb__chat-input") do + form_with(model: @chat_message, remote: true) do |form| + form.text_area( + :user_input, + placeholder: "Type your message...", + rows: "1", + ) + form.submit("Send", data: { disable_with: "Sending..." }) + end end end end diff --git a/lib/phlex/chatbot/chat/message.rb b/lib/phlex/chatbot/chat/message.rb index af86c43..54defe1 100644 --- a/lib/phlex/chatbot/chat/message.rb +++ b/lib/phlex/chatbot/chat/message.rb @@ -17,6 +17,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists sources: nil, status_message: nil, user_name: nil, + current_status: nil, **_others ) @additional_message_actions = additional_message_actions @@ -26,6 +27,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists @sources = sources @status_message = status_message @user_name = user_name + @current_status = current_status || !!status_message end def view_template @@ -34,15 +36,18 @@ def view_template message_class += " pcb__message__bot-loading" if status_message div( - id: status_message ? "current_status" : nil, + id: @current_status ? "current_status" : nil, class: "pcb__message #{message_class}", data_chat_messages_target: user_target_data, + data_chatbot_target: "message", + data_controller: "chatbot-message", + data_chatbot_message_type_value: from_user ? "user" : "bot", ) do div(class: "pcb__status-indicator") { status_message } if status_message render UserIdentifier.new(avatar: avatar, from_system: !from_user, user_name: user_name) - div class: "pcb__message__content" do + div class: "pcb__message__content", data_chatbot_message_target: "content" do if block_given? yield else @@ -69,7 +74,7 @@ def message_with_embedded_sources end def render_sources - div class: "pcb__message__footnotes" do + div class: "pcb__message__footnotes", data_chatbot_message_target: "sources" do sources.each.with_index(1) do |source, index| render Source.new(index: index, source: source) end @@ -78,7 +83,7 @@ def render_sources def render_actions div class: "pcb__message__actions" do - button(data: { action: "click->pcb-chat-messages#copyMessage" }) { "Copy" } + button(data_chatbot_message_target: "copyButton") { "Copy" } button(data: { action: "click->pcb-chat-messages#regenerateResponse" }) { "Regenerate" } @additional_message_actions&.each do |component_callback| render component_callback.call(self) diff --git a/lib/phlex/chatbot/chat/messages.rb b/lib/phlex/chatbot/chat/messages.rb index 7d14582..757a976 100644 --- a/lib/phlex/chatbot/chat/messages.rb +++ b/lib/phlex/chatbot/chat/messages.rb @@ -12,6 +12,7 @@ def initialize(messages:) def view_template div( + id: "pcb-chat-messages", class: "pcb__chat-messages", data: { pcb_chat_form_target: "messagesContainer", diff --git a/lib/phlex/chatbot/chat/source.rb b/lib/phlex/chatbot/chat/source.rb index 7ab0a3f..9dd7fa9 100644 --- a/lib/phlex/chatbot/chat/source.rb +++ b/lib/phlex/chatbot/chat/source.rb @@ -17,10 +17,14 @@ def view_template(&block) href: "#", class: @classes, data: { - action: "click->pcb-chat-messages#showSource:prevent", - pcb_chat_messages_source_title_param: source[:title], - pcb_chat_messages_source_description_param: source[:description], - pcb_chat_messages_source_url_param: source[:url], + # action: "click->pcb-chat-messages#showSource:prevent", + # pcb_chat_messages_source_title_param: source[:title], + # pcb_chat_messages_source_description_param: source[:description], + # pcb_chat_messages_source_url_param: source[:url], + action: "chatbot-modal#show", + chatbot_modal_title_param: source[:title], + chatbot_modal_content_param: source[:description], + chatbot_modal_link_param: source[:url], }, ) { "[#{index}]" } end diff --git a/lib/phlex/chatbot/source_modal.rb b/lib/phlex/chatbot/source_modal.rb index 40c5e31..d2aa291 100644 --- a/lib/phlex/chatbot/source_modal.rb +++ b/lib/phlex/chatbot/source_modal.rb @@ -6,16 +6,16 @@ class SourceModal < Phlex::HTML def view_template div( class: "pcb__source-modal hide-modal", - data: { controller: "pcb-source-modal", pcb_source_modal_target: "modal" }, + data: { controller: "pcb-source-modal", pcb_source_modal_target: "modal", chatbot_modal_target: "modal" }, ) do div(class: "pcb__source-modal-content") do - h3(class: "pcb__source-modal-title") - div(class: "pcb__source-modal-description") do + h3(class: "pcb__source-modal-title", data_chatbot_modal_target: "title") + div(class: "pcb__source-modal-description", data_chatbot_modal_target: "content") do blockquote(class: "pcb__source-modal-quote") end div(class: "pcb__source-modal-actions") do - a(href: "", target: "_blank", class: "pcb__source-modal-link") { "Visit source" } - button(data: { action: "pcb-source-modal#closeModal" }, class: "pcb__source-modal-close") { "Close" } + a(href: "", target: "_blank", class: "pcb__source-modal-link", data_chatbot_modal_target: "link") { "Visit source" } + button(data: { action: "chatbot-modal#hide" }, class: "pcb__source-modal-close") { "Close" } end end end From c7c286b21fa540d52055546290486924c1cff373 Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Sun, 10 Nov 2024 17:26:53 -0800 Subject: [PATCH 02/14] remove wsc --- Gemfile.lock | 82 ----- lib/phlex/chatbot.rb | 50 ---- lib/phlex/chatbot/channel.rb | 108 ------- lib/phlex/chatbot/chat/component.rb | 6 - lib/phlex/chatbot/chat/input.rb | 4 +- lib/phlex/chatbot/chat/message.rb | 3 +- lib/phlex/chatbot/chat/messages.rb | 4 - lib/phlex/chatbot/chat/source.rb | 4 - .../chatbot/client/server_sent_events.rb | 34 --- lib/phlex/chatbot/client/web_socket.rb | 57 ---- lib/phlex/chatbot/null_logger.rb | 13 - lib/phlex/chatbot/source_modal.rb | 2 +- lib/phlex/chatbot/switchboard/base.rb | 52 ---- lib/phlex/chatbot/switchboard/in_memory.rb | 29 -- lib/phlex/chatbot/switchboard/redis.rb | 107 ------- lib/phlex/chatbot/web.rb | 108 ------- phlex-chatbot.gemspec | 4 - .../controllers/chat_form_controller.js | 279 ------------------ src/javascript/index.js | 1 - 19 files changed, 5 insertions(+), 942 deletions(-) delete mode 100644 lib/phlex/chatbot/channel.rb delete mode 100644 lib/phlex/chatbot/client/server_sent_events.rb delete mode 100644 lib/phlex/chatbot/client/web_socket.rb delete mode 100644 lib/phlex/chatbot/null_logger.rb delete mode 100644 lib/phlex/chatbot/switchboard/base.rb delete mode 100644 lib/phlex/chatbot/switchboard/in_memory.rb delete mode 100644 lib/phlex/chatbot/switchboard/redis.rb delete mode 100644 lib/phlex/chatbot/web.rb delete mode 100644 src/javascript/controllers/chat_form_controller.js diff --git a/Gemfile.lock b/Gemfile.lock index 94df8e0..2463c8f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,96 +2,22 @@ PATH remote: . specs: phlex-chatbot (0.3.3) - actioncable (~> 7.1) - concurrent-ruby (~> 1.3) - concurrent-ruby-edge (~> 0.7.1) phlex (~> 1.10) - rack (~> 3.1) GEM remote: https://rubygems.org/ specs: - actioncable (7.2.0) - actionpack (= 7.2.0) - activesupport (= 7.2.0) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - zeitwerk (~> 2.6) - actionpack (7.2.0) - actionview (= 7.2.0) - activesupport (= 7.2.0) - nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.2) - rack-session (>= 1.0.1) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - useragent (~> 0.16) - actionview (7.2.0) - activesupport (= 7.2.0) - builder (~> 3.1) - erubi (~> 1.11) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - activesupport (7.2.0) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) ast (2.4.2) - base64 (0.2.0) - bigdecimal (3.1.8) - builder (3.3.0) - concurrent-ruby (1.3.4) - concurrent-ruby-edge (0.7.1) - concurrent-ruby (~> 1.3) - connection_pool (2.4.1) - crass (1.0.6) diff-lcs (1.5.1) - drb (2.2.1) - erubi (1.13.0) foreman (0.88.1) - i18n (1.14.5) - concurrent-ruby (~> 1.0) json (2.7.2) language_server-protocol (3.17.0.3) - logger (1.6.0) - loofah (2.22.0) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - minitest (5.25.1) - nio4r (2.7.3) - nokogiri (1.16.7-arm64-darwin) - racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) - racc (~> 1.4) parallel (1.26.3) parser (3.3.4.2) ast (~> 2.4.1) racc phlex (1.11.0) racc (1.8.1) - rack (3.1.7) - rack-session (2.0.0) - rack (>= 3.0.0) - rack-test (2.1.0) - rack (>= 1.3) - rails-dom-testing (2.2.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) rainbow (3.1.1) rake (13.2.1) regexp_parser (2.9.2) @@ -126,16 +52,8 @@ GEM rubocop-rspec (3.0.4) rubocop (~> 1.61) ruby-progressbar (1.13.0) - securerandom (0.3.1) strscan (3.1.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) - useragent (0.16.10) - websocket-driver (0.7.6) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - zeitwerk (2.6.17) PLATFORMS arm64-darwin-22 diff --git a/lib/phlex/chatbot.rb b/lib/phlex/chatbot.rb index 8ce8eee..bb1317f 100644 --- a/lib/phlex/chatbot.rb +++ b/lib/phlex/chatbot.rb @@ -1,61 +1,11 @@ # frozen_string_literal: true -require "concurrent" require "phlex" -require "rack" -require_relative "chatbot/channel" require_relative "chatbot/chat" -require_relative "chatbot/conversator" -require_relative "chatbot/null_logger" -require_relative "chatbot/status_component" -require_relative "chatbot/switchboard/base" require_relative "chatbot/version" -require_relative "chatbot/web" module Phlex module Chatbot - class Error < StandardError; end - ROOT_DIR = Pathname.new(__dir__).join("../..").expand_path - - def self.allow_error_messages? - @allow_error_messages - end - - def self.allow_error_messages! - @allow_error_messages = true - end - - def self.conversator(channel_id:) - @conversator.create(channel_id) - end - - def self.conversator=(conversator) - @conversator = conversator - end - self.conversator = Phlex::Chatbot::Conversator - - def self.disallow_error_messages! - @allow_error_messages = false - end - - def self.logger - @logger ||= NullLogger.new(nil) - end - - def self.logger=(logger) - @logger = logger - end - - def self.switchboard - @switchboard ||= "in_memory" - require_relative "chatbot/switchboard/#{@switchboard}" - cls_name = @switchboard.to_s.split("_").map(&:capitalize).join - Switchboard.const_get(cls_name).instance - end - - def self.switchboard=(name) - @switchboard = name - end end end diff --git a/lib/phlex/chatbot/channel.rb b/lib/phlex/chatbot/channel.rb deleted file mode 100644 index 33aff31..0000000 --- a/lib/phlex/chatbot/channel.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -require_relative "client/server_sent_events" -require_relative "client/web_socket" - -module Phlex - module Chatbot - class Channel - attr_reader :conversator, :clients, :channel_id - - def initialize(channel_id) - @conversator = Phlex::Chatbot.conversator(channel_id: channel_id) - @channel_id = channel_id - @clients = Concurrent::Set.new - end - - def send_ack!(message:) - args = conversator.contextualize(message: message, from_user: true) - send_event(:resp, data: [{ cmd: "append", element: Chat::Message.new(**args).call }]) - end - - def send_failure!(error, uid: nil) - Chatbot.logger.error error - message = if error.respond_to?(:message) && Phlex::Chatbot.allow_error_messages? - error.message - elsif error.is_a?(String) - error - else - "An error occurred" - end - args = conversator.contextualize(message: message, from_user: false, uid: uid) - send_event( - :resp, - data: [ - { cmd: "delete", selector: "#current_status" }, - { cmd: "append", element: Chat::Message.new(**args).call }, - ], - ) - end - - def send_partial_response!(message:, status_message:, format: nil, uid: nil) - args = conversator.contextualize( - message: message, - from_user: false, - status_message: status_message, - format: format, - uid: uid, - ) - send_event( - :resp, - data: [ - { cmd: "delete", selector: "#current_status" }, - { cmd: "append", element: Chat::Message.new(**args).call }, - ], - ) - end - - def send_response!(message:, sources: nil, format: nil, uid: nil) - args = conversator.contextualize(message: message, format: format, from_user: false, sources: sources, uid: uid) - send_event( - :resp, - data: [ - { cmd: "delete", selector: "#current_status" }, - { cmd: "append", element: Chat::Message.new(**args).call }, - ], - ) - end - - def send_status!(message:) - send_event( - :resp, - data: [ - { cmd: "delete", selector: "#current_status" }, - { cmd: "append", element: ChatbotThinking.new(message).call }, - ], - ) - end - - def start_conversation!(data) - conversator.call(self, data, channel_id) - rescue StandardError => e - send_failure!(e) - end - - def subscribe(client) - @clients << client - send_event(:joined, data: []) - end - - protected - - def send_event(event, data:) - removals = Set.new - clients.each do |client| - # one of ServerSentEvents or WebSocket - client.send_event(event, data) - rescue Errno::EPIPE => _e - removals << client - end - removals.each do |e| - e.close rescue nil # rubocop:disable Style/RescueModifier - clients.delete(e) - end - Switchboard.destroy(channel_id) if clients.empty? - end - end - end -end diff --git a/lib/phlex/chatbot/chat/component.rb b/lib/phlex/chatbot/chat/component.rb index 62cc797..4812558 100644 --- a/lib/phlex/chatbot/chat/component.rb +++ b/lib/phlex/chatbot/chat/component.rb @@ -22,12 +22,6 @@ def initialize(conversation_token:, driver:, endpoint:, messages:, full_page: fa def view_template div( **classes("pcb pcb__chat-container", full_page?: "full_page"), - data: { - controller: "pcb-chat-form pcb-chat-messages", - pcb_chat_form_conversation_token_value: @conversation_token, - pcb_chat_form_driver_value: @driver, - pcb_chat_form_endpoint_value: @endpoint, - }, ) { chat_content! } templates! diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index 7272975..aacf450 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -4,6 +4,8 @@ module Phlex module Chatbot module Chat class Input < Phlex::HTML + SELECTOR = "chatbot-input" + include Phlex::Rails::Helpers::TurboFrameTag include Phlex::Rails::Helpers::FormWith @@ -12,7 +14,7 @@ def initialize(chat_thread:) end def view_template - turbo_frame_tag("pcci-form") do + turbo_frame_tag(SELECTOR) do div(class: "pcb__chat-input") do form_with(model: @chat_message, remote: true) do |form| form.text_area( diff --git a/lib/phlex/chatbot/chat/message.rb b/lib/phlex/chatbot/chat/message.rb index 54defe1..dd1d937 100644 --- a/lib/phlex/chatbot/chat/message.rb +++ b/lib/phlex/chatbot/chat/message.rb @@ -38,7 +38,6 @@ def view_template div( id: @current_status ? "current_status" : nil, class: "pcb__message #{message_class}", - data_chat_messages_target: user_target_data, data_chatbot_target: "message", data_controller: "chatbot-message", data_chatbot_message_type_value: from_user ? "user" : "bot", @@ -84,7 +83,7 @@ def render_sources def render_actions div class: "pcb__message__actions" do button(data_chatbot_message_target: "copyButton") { "Copy" } - button(data: { action: "click->pcb-chat-messages#regenerateResponse" }) { "Regenerate" } + button { "Regenerate" } @additional_message_actions&.each do |component_callback| render component_callback.call(self) end diff --git a/lib/phlex/chatbot/chat/messages.rb b/lib/phlex/chatbot/chat/messages.rb index 757a976..f55162d 100644 --- a/lib/phlex/chatbot/chat/messages.rb +++ b/lib/phlex/chatbot/chat/messages.rb @@ -14,10 +14,6 @@ def view_template div( id: "pcb-chat-messages", class: "pcb__chat-messages", - data: { - pcb_chat_form_target: "messagesContainer", - pcb_chat_messages_target: "messagesContainer", - }, ) do @messages.each { |message| render Message.new(**message) } end diff --git a/lib/phlex/chatbot/chat/source.rb b/lib/phlex/chatbot/chat/source.rb index 9dd7fa9..11e284e 100644 --- a/lib/phlex/chatbot/chat/source.rb +++ b/lib/phlex/chatbot/chat/source.rb @@ -17,10 +17,6 @@ def view_template(&block) href: "#", class: @classes, data: { - # action: "click->pcb-chat-messages#showSource:prevent", - # pcb_chat_messages_source_title_param: source[:title], - # pcb_chat_messages_source_description_param: source[:description], - # pcb_chat_messages_source_url_param: source[:url], action: "chatbot-modal#show", chatbot_modal_title_param: source[:title], chatbot_modal_content_param: source[:description], diff --git a/lib/phlex/chatbot/client/server_sent_events.rb b/lib/phlex/chatbot/client/server_sent_events.rb deleted file mode 100644 index f32e349..0000000 --- a/lib/phlex/chatbot/client/server_sent_events.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Phlex - module Chatbot - module Client - class ServerSentEvents - def initialize(io, env) - @io = io - @remote_ip = env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"] - - Chatbot.logger.debug "[SSE] Connection opened from #{@remote_ip}" - end - - def close - Chatbot.logger.debug "[SSE] Connection to #{@remote_ip} closed: #{code} - #{reason}" - @io.close rescue nil # rubocop:disable Style/RescueModifier - end - - def send_event(event, data) - Chatbot.logger.debug "[SSE] Sending event: #{event} to #{@remote_ip}" - @io.write("event: #{event}\n") - @io.write(prefix_data(JSON.pretty_generate(data: data))) - @io.write("\n\n") # required by the SSE protocol - end - - private - - def prefix_data(data) - data.split("\n").map { |line| "data: #{line}" }.join("\n") - end - end - end - end -end diff --git a/lib/phlex/chatbot/client/web_socket.rb b/lib/phlex/chatbot/client/web_socket.rb deleted file mode 100644 index 6a280c2..0000000 --- a/lib/phlex/chatbot/client/web_socket.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require "English" -require "websocket/driver" - -module Phlex - module Chatbot - module Client - class WebSocket - EVENT_LOOP = ::ActionCable::Connection::StreamEventLoop.new - - def initialize(env, token) - @remote_ip = env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"] - @token = token - @client_socket = ActionCable::Connection::WebSocket.new( - env, - self, - EVENT_LOOP, - ) - @client_socket.rack_response - end - - def on_open - Chatbot.logger.debug "[WS] Connection opened from #{@remote_ip}" - end - - def on_message(message) - if message == "ping" - Switchboard.extend_ttl(@token) - @client_socket.transmit("pong") - return - end - Chatbot.logger.debug "[WS] Received message: #{message}" - Switchboard.converse(@token, message) - end - - def on_close(reason, code) - if (reason.nil? || reason.empty?) && $ERROR_INFO.nil? - reason = "No reason given" - elsif $ERROR_INFO - reason = $ERROR_INFO.message - end - - Chatbot.logger.debug "[WS] Connection to #{@remote_ip} closed: #{code} - #{reason}" - end - - def on_error(message) - Chatbot.logger.error "[WS] Error: #{message}" - end - - def send_event(event, data) - @client_socket.transmit(JSON.generate(event: event, data: data)) - end - end - end - end -end diff --git a/lib/phlex/chatbot/null_logger.rb b/lib/phlex/chatbot/null_logger.rb deleted file mode 100644 index 48d89e3..0000000 --- a/lib/phlex/chatbot/null_logger.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require "logger" - -module Phlex - module Chatbot - class NullLogger < Logger - def write(...) - # Do nothing - end - end - end -end diff --git a/lib/phlex/chatbot/source_modal.rb b/lib/phlex/chatbot/source_modal.rb index d2aa291..dae559b 100644 --- a/lib/phlex/chatbot/source_modal.rb +++ b/lib/phlex/chatbot/source_modal.rb @@ -6,7 +6,7 @@ class SourceModal < Phlex::HTML def view_template div( class: "pcb__source-modal hide-modal", - data: { controller: "pcb-source-modal", pcb_source_modal_target: "modal", chatbot_modal_target: "modal" }, + data: { chatbot_modal_target: "modal" }, ) do div(class: "pcb__source-modal-content") do h3(class: "pcb__source-modal-title", data_chatbot_modal_target: "title") diff --git a/lib/phlex/chatbot/switchboard/base.rb b/lib/phlex/chatbot/switchboard/base.rb deleted file mode 100644 index b6bfb2a..0000000 --- a/lib/phlex/chatbot/switchboard/base.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module Phlex - module Chatbot - module Switchboard - def self.converse(channel_id, message) - Phlex::Chatbot.switchboard.converse(channel_id, message) - end - - def self.create(id) - Phlex::Chatbot.switchboard.create(id.to_s) - end - - def self.destroy(channel_id) - Phlex::Chatbot.switchboard.destroy(channel_id) - end - - def self.extend_ttl(channel_id) - Phlex::Chatbot.switchboard.extend_ttl(channel_id) - end - - def self.find(channel_id) - Phlex::Chatbot.switchboard.find(channel_id) - end - - class Base - attr_reader :channels - - def converse(channel_id, message) - the_channel = find(channel_id) - return false unless the_channel - - the_channel.send_ack!(message: message) - - future = Concurrent::Promises.future_on(:io, the_channel, message) do |channel, data| - channel.start_conversation!(data) - rescue StandardError => e - channel.send_failure!(e) - end - - future.on_rejection { |error| Chatbot.logger.error error } - - true - end - - def destroy(channel_id) - channels.delete(channel_id) - end - end - end - end -end diff --git a/lib/phlex/chatbot/switchboard/in_memory.rb b/lib/phlex/chatbot/switchboard/in_memory.rb deleted file mode 100644 index 48a6354..0000000 --- a/lib/phlex/chatbot/switchboard/in_memory.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative "base" - -module Phlex - module Chatbot - module Switchboard - class InMemory < Base - include Singleton - - def initialize - @channels = Concurrent::Hash.new - end - - def create(channel_id) - (channels[channel_id] ||= Channel.new(channel_id)).channel_id - end - - def extend_ttl(_channel_id) - nil - end - - def find(channel_id) - channels[channel_id] - end - end - end - end -end diff --git a/lib/phlex/chatbot/switchboard/redis.rb b/lib/phlex/chatbot/switchboard/redis.rb deleted file mode 100644 index 5c5d75d..0000000 --- a/lib/phlex/chatbot/switchboard/redis.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require "concurrent" -require "concurrent-edge" -require_relative "base" - -module Phlex - module Chatbot - module Switchboard - class Redis < Base - include Singleton - - TEN_MINUTES = 10 * 60 # seconds - - def self.new_redis_connection - ::Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) - end - - def initialize - @channels = Concurrent::Hash.new - @redis_db = self.class.new_redis_connection - @subscriber = RedisSubscriber.spawn(name: :redis_subscriber) - end - - def create(channel_id) - extend_ttl(channel_id) || @redis_db.setex(channel_id, TEN_MINUTES, true) - (channels[channel_id] ||= ChannelWrapper.new(channel_id, @subscriber)).channel_id - end - - def extend_ttl(channel_id) - @redis_db.getex(channel_id, ex: TEN_MINUTES) - end - - def find(channel_id) - unless extend_ttl(channel_id) - channels.delete(channel_id) - return - end - - channels[channel_id] ||= ChannelWrapper.new(channel_id, @subscriber) - end - - class ChannelWrapper < Channel - def initialize(channel_id, subscriber) - super(channel_id) - - @redis_subscriber = subscriber - end - - def broadcast_event(event, data:) - send_event(event, data: data, as_broadcast: true) - end - - protected - - def send_event(event, data:, as_broadcast: false) - return super(event, data: data) if as_broadcast - - @redis_subscriber.tell([event, @channel_id, data]) - end - end - - class RedisSubscriber < Concurrent::Actor::RestartingContext - CHANNEL_NAME = "phlex:chatbot:switchboard:redis" - - def initialize - @redis_db = Switchboard::Redis.new_redis_connection - executor = Concurrent::SingleThreadExecutor.new(auto_terminate: true) - Concurrent::Promises.future_on(executor, @redis_db) do |redis| - Phlex::Chatbot.logger.info "[Redis] Starting up subscriber" - redis.subscribe(CHANNEL_NAME) do |on| - on.message do |_, msg| - Phlex::Chatbot.logger.debug "[Redis] Received msg: #{msg}" - decoded_msg = JSON.parse(msg, symbolize_names: true) - channel_id = decoded_msg[:channel_id] - channel = Switchboard::Redis.instance.find(channel_id) - - Phlex::Chatbot.logger.warn("[Redis] Channel not found: #{channel_id}") unless channel - channel&.broadcast_event(decoded_msg[:event], data: decoded_msg[:data]) - end - end - Phlex::Chatbot.logger.info "[Redis] Shutting down subscriber" - end - end - - def on_message(message) - # TODO: replace the case!! - case message - in :terminate, reason - terminate(reason) - in :joined, channel_id, data - @redis_db.publish(CHANNEL_NAME, { channel_id: channel_id, event: :joined, data: data }.to_json) - in :resp, channel_id, data - @redis_db.publish(CHANNEL_NAME, { channel_id: channel_id, event: :resp, data: data }.to_json) - else - raise "unsupported message: #{message}" - end - rescue StandardError => e - Phlex::Chatbot.logger.error e - # pass to ErrorsOnUnknownMessage behaviour, which will just fail - pass - end - end - end - end - end -end diff --git a/lib/phlex/chatbot/web.rb b/lib/phlex/chatbot/web.rb deleted file mode 100644 index 26b9e14..0000000 --- a/lib/phlex/chatbot/web.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -require_relative "client/web_socket" -require_relative "client/server_sent_events" - -module Phlex - module Chatbot - # Rack app to serve the chatbot assets - - class Web - def self.call(env) - new(env).call - end - - def initialize(env) - @env = env - end - - def call - case request_method - when "GET" then on_get - when "POST" then on_post - else raise "Unsupported request method: #{request_method}" - end - end - - private - - def css - File.read(File.join(ROOT_DIR, "dist", "bot.css")) - end - - def js - File.read(File.join(ROOT_DIR, "dist", "bot.js")) - end - - def js_map - File.read(File.join(ROOT_DIR, "dist", "bot.js.map")) - end - - def on_get # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength - case path - when "/bot.css" - [200, { "content-type" => "text/css" }, [css]] - when "/bot.js" - [200, { "content-type" => "application/javascript" }, [js]] - when "/bot.js.map" - [200, { "content-type" => "application/javascript" }, [js_map]] - when %r{/join/(.+$)} - channel = Switchboard.find(URI.decode_uri_component(Regexp.last_match(1))) - return respond_not_found! unless channel - - if @env["HTTP_UPGRADE"]&.starts_with?("websocket") - return respond_not_found! unless valid_origin? - - channel.subscribe(Client::WebSocket.new(@env, channel.channel_id)) - [-1, {}, []] - elsif @env["HTTP_ACCEPT"]&.include?("text/event-stream") - [200, sse_headers, ->(io) { channel.subscribe(Client::ServerSentEvents.new(io, @env)) }] - else - respond_not_found! - end - else - respond_not_found! - end - end - - def on_post - case path - when %r{/ask/(.+$)} - return respond_not_found! unless valid_origin? - - conversable = Switchboard.converse( - URI.decode_www_form_component(Regexp.last_match(1)), - JSON.parse(@env["rack.input"].read)["message"], - ) - if conversable - [200, { "content-type" => "text/plain" }, ["ok"]] - else - respond_not_found! - end - else - respond_not_found! - end - end - - def path = @env["PATH_INFO"] - - def request_method = @env["REQUEST_METHOD"].upcase - - def respond_not_found! - [404, { "content-type" => "text/plain" }, ["not found"]] - end - - def sse_headers - { - "content-type" => "text/event-stream", - "x-accel-buffering" => "no", - "last-modified" => Time.now.httpdate, - } - end - - def valid_origin? - @env["HTTP_ORIGIN"].include?(@env["HTTP_HOST"]) - end - end - end -end diff --git a/phlex-chatbot.gemspec b/phlex-chatbot.gemspec index a5d8174..4d28f7d 100644 --- a/phlex-chatbot.gemspec +++ b/phlex-chatbot.gemspec @@ -25,11 +25,7 @@ Gem::Specification.new do |spec| end spec.require_paths = ["lib"] - spec.add_dependency "actioncable", "~> 7.1" - spec.add_dependency "concurrent-ruby", "~> 1.3" - spec.add_dependency "concurrent-ruby-edge", "~> 0.7.1" spec.add_dependency "phlex", "~> 1.10" - spec.add_dependency "rack", "~> 3.1" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js deleted file mode 100644 index 3fa9161..0000000 --- a/src/javascript/controllers/chat_form_controller.js +++ /dev/null @@ -1,279 +0,0 @@ -// app/javascript/controllers/chat_form_controller.js -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ "input", "messagesContainer", "statusIndicator" ] - static values = { - conversationToken: String, - driver: { type: String, default: "websocket" }, - endpoint: String, - pingMs: { type: Number, default: 17000 }, - } - - connect() { - console.debug("stimulus connect") - this.reconnectAttempts = 0; - - console.debug("ChatFormController connected") - this.registerEventListeners(); - - if (this.driverValue === "sse") { - this.setup = this.setupSseConversation; - this.submit = this.submitSse; - } else if (this.driverValue === "websocket") { - this.setup = this.setupWebSocketConversation; - this.submit = this.submitWebSocket; - } - this.setup(); - this.resetTextarea() - this.scrollToBottom() - } - - disconnect() { - console.debug("stimulus disconnect"); - this.unregisterEventListeners(); - - if (this.driverValue === "sse") { - this.teardownSseConversation(); - } else { - this.teardownWebSocketConversation(); - } - } - - alterUI(commands) { - if (!this.hasMessagesContainerTarget) { - console.error("Messages container not found"); - return; - } - - commands?.forEach((obj) => { - const { cmd, element, selector } = obj; - if (cmd === "append") { - this.messagesContainerTarget.insertAdjacentHTML("beforeEnd", element); - this.scrollToBottom(); - } else if (cmd === "delete") { - this.messagesContainerTarget.querySelector(selector)?.remove(); - this.scrollToBottom(); - } - }); - } - - disableInput() { - this.disabled = true; - this.inputTarget.parentElement.querySelector('button').disabled = true; - } - - dispatchClose(message, event) { - document.dispatchEvent( - new CustomEvent("phlex-chatbot:close", { - bubbles: true, - detail: { message, event }, - }) - ); - } - - dispatchError(message, event) { - document.dispatchEvent( - new CustomEvent("phlex-chatbot:error", { - bubbles: true, - detail: { message, event }, - }) - ); - } - - dispatchOpen(message, event) { - document.dispatchEvent( - new CustomEvent("phlex-chatbot:open", { - bubbles: true, - detail: { message, event }, - }) - ); - } - - dispatchResp(data) { - const parsed = JSON.parse(data); - document.dispatchEvent( - new CustomEvent("phlex-chatbot:resp", { - bubbles: true, - detail: { commands: parsed.data }, - }) - ); - } - - enableInput() { - this.disabled = false; - this.inputTarget.parentElement.querySelector('button').disabled = false; - } - - handleKeyboardSubmit(event) { - if (!this.disabled) { - this.submit(event); - } - } - - reconnect() { - if (this.reconnecting) { - return; - } - - this.reconnecting = true; - this.reconnectAttempts += 1; - const timeout = Math.min(5, ((this.reconnectAttempts - 1) ** 1.3)); - console.debug(`Reconnecting in ${timeout}s (attempt ${this.reconnectAttempts})`); - setTimeout(() => { - this.setup(); - this.reconnecting = false; - }, timeout * 1000); - } - - registerEventListeners() { - this.disconnectCallback = (e) => { - console.debug(e); - this.reconnect(); - setTimeout(() => { - if (this.conversation.readyState !== EventSource.OPEN || this.conversation.readyState !== WebSocket.OPEN) { - this.disableInput(); - this.messagesContainerTarget.classList.add('pcb__connection-error'); - } - }, 100); - }; - - this.openCallback = (e) => { - console.debug(e); - this.reconnectAttempts = 0; - this.enableInput(); - this.messagesContainerTarget.classList.remove('pcb__connection-error'); - } - - this.responseCallback = (e) => { - console.debug("Received resp:", event.detail); - this.alterUI(event.detail.commands); - } - - document.addEventListener("phlex-chatbot:close", this.disconnectCallback); - document.addEventListener("phlex-chatbot:error", this.disconnectCallback); - document.addEventListener("phlex-chatbot:open", this.openCallback); - document.addEventListener("phlex-chatbot:resp", this.responseCallback); - } - - resetTextarea() { - this.inputTarget.value = "" - this.resetTextareaHeight(); - } - - resetTextareaHeight() { - this.inputTarget.style.height = 'auto' - this.inputTarget.style.height = this.inputTarget.scrollHeight + 'px' - } - - scrollToBottom() { - if (this.hasMessagesContainerTarget) { - this.messagesContainerTarget.scrollTop = this.messagesContainerTarget.scrollHeight - } - } - - setupSseConversation() { - if (this.conversation?.readyState === EventSource.OPEN) { - console.debug("sse already connected"); - return; - } - - this.conversation = new EventSource(this.url("join")); - this.conversation.onerror = event => this.dispatchError("Connection error", event); - this.conversation.onopen = event => this.dispatchOpen("Connected", event); - this.conversation.onclose = event => this.dispatchClose("Closed", event); - this.conversation.addEventListener("resp", event => this.dispatchResp(event.data)); - } - - setupWebSocketConversation() { - if (this.conversation?.readyState === WebSocket.OPEN) { - console.debug("websocket already connected"); - return; - } - - this.conversation = new WebSocket(this.url("join")); - this.conversation.onerror = event => this.dispatchError("Connection error", event); - this.conversation.onopen = event => this.dispatchOpen("Connected", event); - this.conversation.onclose = event => this.dispatchClose("Closed", event); - this.conversation.onmessage = event => { - if (event.data === "pong") { return; } - this.dispatchResp(event.data); - } - - if (!this.pingTask) { - this.pingTask = setInterval(() => { - if (this.conversation.readyState !== WebSocket.OPEN) { - return; - } - - console.debug("sending ping"); - this.conversation.send("ping"); - }, this.pingMsValue); - } - } - - submitWebSocket(event) { - event.preventDefault(); - const message = this.inputTarget.value.trim(); - this.resetTextarea(); - - if (message) { - console.debug("Sending message:", message); - this.conversation.send(message); - } - } - - submitSse(event) { - event.preventDefault(); - const message = this.inputTarget.value.trim(); - this.resetTextarea(); - - if (message) { - console.debug("Sending message:", message) - - fetch(this.url("ask"), { - method: 'POST', - body: JSON.stringify({ message }), - headers: { "Content-Type": "application/json" }, - }).then(response => { - if (response.ok) { - return null; - } else { - throw new Error('Failed to send message') - } - }); - } - } - - teardownSseConversation() { - if (this.conversation) { - console.debug("tearing down SSE conversation"); - this.conversation.close(); - } - } - - teardownWebSocketConversation() { - if (this.conversation) { - console.debug("tearing down websocket conversation"); - this.conversation.onclose = () => { console.debug("websocket closed") }; - this.conversation.close(); - if (this.pingTask) { - console.debug("shutting down websocket keep-alive pinger"); - clearInterval(this.pingTask); - } - } - } - - unregisterEventListeners() { - document.removeEventListener("phlex-chatbot:close", this.disconnectCallback); - document.removeEventListener("phlex-chatbot:error", this.disconnectCallback); - document.removeEventListener("phlex-chatbot:open", this.openCallback); - document.removeEventListener("phlex-chatbot:resp", this.responseCallback); - console.debug("unregistered event listeners"); - } - - url(action) { - const encodedToken = encodeURIComponent(this.conversationTokenValue); - return `${this.endpointValue}/${action}/${encodedToken}` - } -} diff --git a/src/javascript/index.js b/src/javascript/index.js index a4b8d81..73a4914 100644 --- a/src/javascript/index.js +++ b/src/javascript/index.js @@ -9,7 +9,6 @@ import SourceModalController from "./controllers/source_modal_controller" const application = Application.start() // Manually register each controller -application.register("pcb-chat-form", ChatFormController) application.register("pcb-chat-messages", ChatMessagesController) application.register("pcb-sidebar", SidebarController) application.register("pcb-source-modal", SourceModalController) From 2ecbea3f2b440c0d5c9a616a1558feaf321e13ca Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Sun, 10 Nov 2024 17:27:06 -0800 Subject: [PATCH 03/14] add optional rails hook --- lib/phlex/chatbot/rails.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 lib/phlex/chatbot/rails.rb diff --git a/lib/phlex/chatbot/rails.rb b/lib/phlex/chatbot/rails.rb new file mode 100644 index 0000000..8b47699 --- /dev/null +++ b/lib/phlex/chatbot/rails.rb @@ -0,0 +1,21 @@ +require_relative "../chatbot" + +module Phlex + module Chatbot + # class Railtie < ::Rails::Engine + # end + + class Engine < ::Rails::Engine + config.autoload_paths << "#{__dir__}/../" + initializer "phlex-chatbot.assets" do + if ::Rails.application.config.respond_to?(:assets) + ::Rails.application.config.assets.paths << File.expand_path("../../../dist", __dir__) + ::Rails.application.config.assets.precompile += %w[ + bot.js + bot.css + ] + end + end + end + end +end From 9670d76be3173bb5942103b486d92a7477dce0ea Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Tue, 12 Nov 2024 13:54:12 -0800 Subject: [PATCH 04/14] javascript --- lib/phlex/chatbot/chat/input.rb | 12 +- lib/phlex/chatbot/chat/message.rb | 9 +- lib/phlex/chatbot/chat/messages.rb | 3 +- lib/phlex/chatbot/chat/source.rb | 20 +-- lib/phlex/chatbot/modals/reference.rb | 29 +++++ lib/phlex/chatbot/rails.rb | 16 ++- lib/phlex/chatbot/source_modal.rb | 4 + .../controllers/chat_form_controller.js | 24 ++++ .../controllers/chat_messages_controller.js | 122 ++---------------- .../controllers/sidebar_controller.js | 1 + .../controllers/source_modal_controller.js | 1 + src/javascript/index.js | 1 + 12 files changed, 111 insertions(+), 131 deletions(-) create mode 100644 lib/phlex/chatbot/modals/reference.rb create mode 100644 src/javascript/controllers/chat_form_controller.js diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index aacf450..112bd50 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -16,13 +16,21 @@ def initialize(chat_thread:) def view_template turbo_frame_tag(SELECTOR) do div(class: "pcb__chat-input") do - form_with(model: @chat_message, remote: true) do |form| + form_with( + model: @chat_message, + remote: true, + data: { controller: "pcb-chat-form" } + ) do |form| form.text_area( :user_input, placeholder: "Type your message...", rows: "1", ) - form.submit("Send", data: { disable_with: "Sending..." }) + form.submit( + "Send", + class: "px-4 py-2 rounded bg-blue-500 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700", + + ) end end end diff --git a/lib/phlex/chatbot/chat/message.rb b/lib/phlex/chatbot/chat/message.rb index dd1d937..9ddd5ff 100644 --- a/lib/phlex/chatbot/chat/message.rb +++ b/lib/phlex/chatbot/chat/message.rb @@ -18,6 +18,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists status_message: nil, user_name: nil, current_status: nil, + controlled: true, **_others ) @additional_message_actions = additional_message_actions @@ -28,6 +29,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists @status_message = status_message @user_name = user_name @current_status = current_status || !!status_message + @controlled = controlled end def view_template @@ -38,9 +40,10 @@ def view_template div( id: @current_status ? "current_status" : nil, class: "pcb__message #{message_class}", - data_chatbot_target: "message", - data_controller: "chatbot-message", - data_chatbot_message_type_value: from_user ? "user" : "bot", + data: { + pcb_chat_messages_target: ("message" if @controlled), + pcb_chat_message_type: from_user ? "user" : "bot", + } ) do div(class: "pcb__status-indicator") { status_message } if status_message diff --git a/lib/phlex/chatbot/chat/messages.rb b/lib/phlex/chatbot/chat/messages.rb index f55162d..279c41e 100644 --- a/lib/phlex/chatbot/chat/messages.rb +++ b/lib/phlex/chatbot/chat/messages.rb @@ -14,8 +14,9 @@ def view_template div( id: "pcb-chat-messages", class: "pcb__chat-messages", + data_controller: "pcb-chat-messages", ) do - @messages.each { |message| render Message.new(**message) } + @messages.each { |message| render Message.new(**message.merge(controlled: false)) } end end end diff --git a/lib/phlex/chatbot/chat/source.rb b/lib/phlex/chatbot/chat/source.rb index 11e284e..788f608 100644 --- a/lib/phlex/chatbot/chat/source.rb +++ b/lib/phlex/chatbot/chat/source.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../modals/reference" + module Phlex module Chatbot module Chat @@ -12,16 +14,14 @@ def initialize(index:, source:, classes: "pcb__footnote") @classes = classes end - def view_template(&block) - a( - href: "#", - class: @classes, - data: { - action: "chatbot-modal#show", - chatbot_modal_title_param: source[:title], - chatbot_modal_content_param: source[:description], - chatbot_modal_link_param: source[:url], - }, + # Add "Link" action to the modal + + def view_template + render Phlex::Chatbot::Modals::Reference.new( + title: source[:title], + content: source[:description], + link: source[:url], + classes: @classes, ) { "[#{index}]" } end end diff --git a/lib/phlex/chatbot/modals/reference.rb b/lib/phlex/chatbot/modals/reference.rb new file mode 100644 index 0000000..d9fe526 --- /dev/null +++ b/lib/phlex/chatbot/modals/reference.rb @@ -0,0 +1,29 @@ +module Phlex + module Chatbot + module Modals + class Reference < Phlex::HTML + def initialize(title: nil, content:, link: nil, classes: nil) + @title = title + @content = content + @link = link + @classes = classes + end + + # Add slot for multiple actions + + def view_template + a( + href: "#", + class: @classes, + data: { + action: "chatbot-modal#show", + chatbot_modal_title_param: @title, + chatbot_modal_content_param: @content, + chatbot_modal_link_param: @link, + }, + ) { yield } + end + end + end + end +end diff --git a/lib/phlex/chatbot/rails.rb b/lib/phlex/chatbot/rails.rb index 8b47699..d957355 100644 --- a/lib/phlex/chatbot/rails.rb +++ b/lib/phlex/chatbot/rails.rb @@ -2,20 +2,22 @@ module Phlex module Chatbot - # class Railtie < ::Rails::Engine - # end - class Engine < ::Rails::Engine config.autoload_paths << "#{__dir__}/../" - initializer "phlex-chatbot.assets" do - if ::Rails.application.config.respond_to?(:assets) - ::Rails.application.config.assets.paths << File.expand_path("../../../dist", __dir__) - ::Rails.application.config.assets.precompile += %w[ + initializer "phlex-chatbot.assets" do |app| + if app.config.respond_to?(:assets) + app.config.assets.paths << File.expand_path("../../../dist", __dir__) + app.config.assets.precompile += %w[ bot.js bot.css ] end end + initializer "phlex-chatbot.importmap", before: "importmap" do |app| + if app.config.respond_to?(:importmap) + app.config.importmap.cache_sweepers << File.expand_path("../../../dist", __dir__) + end + end end end end diff --git a/lib/phlex/chatbot/source_modal.rb b/lib/phlex/chatbot/source_modal.rb index dae559b..18cd7c9 100644 --- a/lib/phlex/chatbot/source_modal.rb +++ b/lib/phlex/chatbot/source_modal.rb @@ -14,7 +14,11 @@ def view_template blockquote(class: "pcb__source-modal-quote") end div(class: "pcb__source-modal-actions") do + # This is a slot for actions. "Close" is a default action. We can add more using Ref. a(href: "", target: "_blank", class: "pcb__source-modal-link", data_chatbot_modal_target: "link") { "Visit source" } + + + button(data: { action: "chatbot-modal#hide" }, class: "pcb__source-modal-close") { "Close" } end end diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js new file mode 100644 index 0000000..45238f9 --- /dev/null +++ b/src/javascript/controllers/chat_form_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="pcb-chat-form" +export default class extends Controller { + + connect() { + // console.log("pcb ChatFormController connected") + // clear form + + this.element.reset() + } + + // Add behavior to submitting the form: + // 1. Change the text + // 2. Prevent the default form submission + // 3. disable the form + // 4. Send the form + // submitForm(event) { + // this.submitButtonTarget.textContent = "Sending..." + // event.preventDefault() + // this.formTarget.disabled = true + // this.formTarget.requestSubmit() + // } +} diff --git a/src/javascript/controllers/chat_messages_controller.js b/src/javascript/controllers/chat_messages_controller.js index b622b18..6f00e7d 100644 --- a/src/javascript/controllers/chat_messages_controller.js +++ b/src/javascript/controllers/chat_messages_controller.js @@ -1,121 +1,27 @@ import { Controller } from "@hotwired/stimulus" +// Connects to data-controller="pcb-chat-messages" export default class extends Controller { - static targets = ["message", "typingIndicator", "sourceModal", "messagesContainer"] + static targets = ["message"] connect() { - console.debug("ChatMessagesController connected") - this.observeMessageAddition() + // console.log("pcb ChatMessagesController connected") + this.element.lastElementChild.scrollIntoView({ block: "end", inline: "nearest" }) } - observeMessageAddition() { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('pcb__message')) { - this.animateMessage(node) - } - }) - } - }) - }) + messageTargetConnected(element) { + // console.log("Message connected") + element.scrollIntoView({ block: "end", inline: "nearest", behavior: "smooth" }) // - observer.observe(this.element, { childList: true, subtree: true }) - } - - animateMessage(message) { - console.debug("Animating new message") - if (message.classList.contains('pcb__message__user')) { - message.classList.add('slide-in') - setTimeout(() => { - message.classList.remove('slide-in') - }, 300) - } else { - - message.classList.add('fade-in') - } - } - - messageTargetConnected(message) { - this.animateMessage(message) - } - - showTypingIndicator() { - this.typingIndicatorTarget.classList.remove('hidden') - } - - hideTypingIndicator() { - this.typingIndicatorTarget.classList.add('hidden') - } - - clearChat() { - console.log("Clearing chat") - if (this.hasMessagesContainerTarget) { - this.messagesContainerTarget.innerHTML = '' + if (element.dataset.pcbChatMessageType === 'user') { + this.animateNewMessage(element) } } - toggleDarkMode() { - document.body.classList.toggle('dark') - } - - copyMessage(event) { - const messageElement = event.target.closest('.pcb__message') - const messageContent = messageElement.querySelector('.pcb__message__content').textContent - const footnotes = Array.from(messageElement.querySelectorAll('.pcb__message__footnotes > a')) - .map((a, i) => `(${i+1}) ${a.dataset.pcbChatMessagesSourceTitleParam} - ${a.dataset.pcbChatMessagesSourceUrlParam}`) - .join("\n") - - const copyToClipboard = (text) => { - if (navigator.clipboard && window.isSecureContext) { - // Use the Clipboard API when available which it isnt usually in non https environments - return navigator.clipboard.writeText(text) - } else { - // Fallback method using a temporary textarea element - let textArea = document.createElement("textarea") - textArea.value = text - textArea.style.position = "fixed" - textArea.style.left = "-999999px" - textArea.style.top = "-999999px" - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - return new Promise((resolve, reject) => { - document.execCommand('copy') ? resolve() : reject() - textArea.remove() - }) - } - } - - copyToClipboard(`${messageContent}\n\nSources:\n${footnotes}`) - .then(() => { - const originalText = event.target.textContent - event.target.textContent = "Copied!" - setTimeout(() => { - event.target.textContent = originalText - }, 2000) - }) - .catch(err => { - console.error('Failed to copy text: ', err) - }) - } - - regenerateResponse(event) { - console.log("Regenerate response") - } - - - showSource(event) { - const title = event.params.sourceTitle, - description = event.params.sourceDescription, - url = event.params.sourceUrl; - - const customEvent = new CustomEvent('showSourceModal', { - bubbles: true, - detail: { title, description, url } - }) - console.log("Dispatching showSourceModal event") - this.element.dispatchEvent(customEvent) + animateNewMessage(element) { + element.classList.add('slide-in') + setTimeout(() => { + element.classList.remove('slide-in') + }, 300) } } diff --git a/src/javascript/controllers/sidebar_controller.js b/src/javascript/controllers/sidebar_controller.js index 4d8bf33..5158811 100644 --- a/src/javascript/controllers/sidebar_controller.js +++ b/src/javascript/controllers/sidebar_controller.js @@ -1,6 +1,7 @@ // sidebar_controller.js import { Controller } from "@hotwired/stimulus" +// Connects to data-controller="pcb-sidebar" export default class extends Controller { static targets = ["sidebar", "main", "toggleButton"] static classes = ['active']; diff --git a/src/javascript/controllers/source_modal_controller.js b/src/javascript/controllers/source_modal_controller.js index bbec42c..1b054c1 100644 --- a/src/javascript/controllers/source_modal_controller.js +++ b/src/javascript/controllers/source_modal_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" +// Connects to data-controller="pcb-source-modal" export default class extends Controller { static targets = ["modal"] diff --git a/src/javascript/index.js b/src/javascript/index.js index 73a4914..a4b8d81 100644 --- a/src/javascript/index.js +++ b/src/javascript/index.js @@ -9,6 +9,7 @@ import SourceModalController from "./controllers/source_modal_controller" const application = Application.start() // Manually register each controller +application.register("pcb-chat-form", ChatFormController) application.register("pcb-chat-messages", ChatMessagesController) application.register("pcb-sidebar", SidebarController) application.register("pcb-source-modal", SourceModalController) From bd08c5099ca285e9f9c78c2d2d06c043b8a160ae Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Tue, 12 Nov 2024 14:47:38 -0800 Subject: [PATCH 05/14] Extract Input component --- lib/phlex/chatbot/chat/input.rb | 32 +- spec/spec_helper.rb | 1 - src/main.css | 535 ++++++++++++++++---------------- 3 files changed, 278 insertions(+), 290 deletions(-) diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index 112bd50..418e638 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -4,33 +4,17 @@ module Phlex module Chatbot module Chat class Input < Phlex::HTML - SELECTOR = "chatbot-input" - - include Phlex::Rails::Helpers::TurboFrameTag - include Phlex::Rails::Helpers::FormWith - - def initialize(chat_thread:) - @chat_message = chat_thread.chat_messages.new + def initialize end def view_template - turbo_frame_tag(SELECTOR) do - div(class: "pcb__chat-input") do - form_with( - model: @chat_message, - remote: true, - data: { controller: "pcb-chat-form" } - ) do |form| - form.text_area( - :user_input, - placeholder: "Type your message...", - rows: "1", - ) - form.submit( - "Send", - class: "px-4 py-2 rounded bg-blue-500 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700", - - ) + div(class: "pcb__chat-input") do + form do + textarea(placeholder: "Type your message...") + submit( + class: "px-4 py-2 rounded bg-blue-500 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700" + ) do + "Send" end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e99d2b0..c15cdd1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "action_cable" require "phlex/chatbot" # RSpec.configure do |config| diff --git a/src/main.css b/src/main.css index dbabb05..948b716 100644 --- a/src/main.css +++ b/src/main.css @@ -2,287 +2,290 @@ @tailwind components; @tailwind utilities; -.pcb { - @apply bg-white text-black dark:bg-gray-900 dark:text-white; -} +@layer components { + .pcb { + @apply bg-white text-black dark:bg-gray-900 dark:text-white; + } -.pcb__chat-container { - @apply flex flex-col h-full; - @apply dark:bg-gray-800; -} + .pcb__chat-container { + @apply flex flex-col h-full; + @apply dark:bg-gray-800; + } -.pcb__chat-input { - @apply mt-2 p-4 bg-gray-100 border-t border-gray-200; - @apply dark:bg-gray-800 dark:border-gray-700; + .pcb__chat-input { + @apply mt-2 p-4 bg-gray-100 border-t border-gray-200; + @apply dark:bg-gray-800 dark:border-gray-700; - button { - @apply px-4 py-2 rounded bg-blue-500 text-white; - @apply focus:outline-none focus:ring-2 focus:ring-blue-500; - @apply dark:bg-blue-600 dark:hover:bg-blue-700; + button { + @apply px-4 py-2 rounded bg-blue-500 text-white; + @apply focus:outline-none focus:ring-2 focus:ring-blue-500; + @apply dark:bg-blue-600 dark:hover:bg-blue-700; + } + + form { + @apply flex items-center space-x-2; + } + + textarea { + @apply flex-grow mr-2 p-2 border rounded; + @apply resize-none focus:outline-none focus:ring-2 focus:ring-blue-500; + @apply dark:bg-gray-700 dark:text-white dark:border-gray-600; + } } - form { - @apply flex items-center space-x-2; + .transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; } - textarea { - @apply flex-grow mr-2 p-2 border rounded; - @apply resize-none focus:outline-none focus:ring-2 focus:ring-blue-500; - @apply dark:bg-gray-700 dark:text-white dark:border-gray-600; + .transform.translate-x-full { + transform: translateX(100%); } -} -.transition-all { - transition-property: all; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 300ms; -} + button[data-sidebar-target="toggleButton"] { + transition: all 0.3s ease-in-out; + } -.transform.translate-x-full { - transform: translateX(100%); -} + @media (min-width: 768px) { + main[data-sidebar-target="main"] { + margin-right: 40%; + } + } -button[data-sidebar-target="toggleButton"] { - transition: all 0.3s ease-in-out; -} + @media (min-width: 1024px) { + main[data-sidebar-target="main"] { + margin-right: 33.333333%; + } + } -@media (min-width: 768px) { - main[data-sidebar-target="main"] { - margin-right: 40%; + @media (max-width: 767px) { + [data-sidebar-target="sidebar"].w-full { + width: 100% !important; + } } -} -@media (min-width: 1024px) { - main[data-sidebar-target="main"] { - margin-right: 33.333333%; + .pcb__chat-messages { + @apply flex flex-col space-y-4 p-4; + @apply max-h-[100vh] overflow-y-auto; + @apply dark:bg-gray-800; + @apply flex-grow overflow-y-auto p-4; + @apply dark:bg-gray-800; } -} -@media (max-width: 767px) { - [data-sidebar-target="sidebar"].w-full { - width: 100% !important; + .pcb__connection-error { + @apply ring-red-600 ring-4; } -} -.pcb__chat-messages { - @apply flex flex-col space-y-4 p-4; - @apply max-h-[100vh] overflow-y-auto; - @apply dark:bg-gray-800; - @apply flex-grow overflow-y-auto p-4; - @apply dark:bg-gray-800; -} + .pcb__footnote { + @apply cursor-pointer text-blue-500 hover:text-blue-700 mr-2; + @apply dark:text-blue-400 dark:hover:text-blue-300; + } -.pcb__connection-error { - @apply ring-red-600 ring-4; -} + .pcb__header { + @apply bg-white border-b border-gray-200 p-4 flex justify-between items-center; + @apply dark:bg-gray-900 dark:border-gray-700; -.pcb__footnote { - @apply cursor-pointer text-blue-500 hover:text-blue-700 mr-2; - @apply dark:text-blue-400 dark:hover:text-blue-300; -} + > h1 { + @apply text-xl font-bold; + @apply dark:text-white; + } -.pcb__header { - @apply bg-white border-b border-gray-200 p-4 flex justify-between items-center; - @apply dark:bg-gray-900 dark:border-gray-700; + > div { + @apply flex space-x-2; - > h1 { - @apply text-xl font-bold; - @apply dark:text-white; + > button { + @apply px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm; + @apply dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white; + } + } } - > div { - @apply flex space-x-2; + .pcb__loading-line { + @apply mb-2 h-3 bg-gray-400 rounded; + @apply dark:bg-gray-600; + animation: pulse 1.5s infinite; - > button { - @apply px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm; - @apply dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white; + &:nth-child(2) { + @apply w-4/5; } - } -} - -.pcb__loading-line { - @apply mb-2 h-3 bg-gray-400 rounded; - @apply dark:bg-gray-600; - animation: pulse 1.5s infinite; - &:nth-child(2) { - @apply w-4/5; - } + &:nth-child(3) { + @apply w-3/5; + } - &:nth-child(3) { - @apply w-3/5; + &:last-child { + @apply mb-0; + } } - &:last-child { - @apply mb-0; + .pcb__message { + @apply mb-4 p-3 rounded-lg inline-block min-w-[200px] max-w-[70%] transition-opacity duration-500 ease-in-out; + @apply dark:text-white; } -} -.pcb__message { - @apply mb-4 p-3 rounded-lg inline-block min-w-[200px] max-w-[70%] transition-opacity duration-500 ease-in-out; - @apply dark:text-white; -} - -.pcb__message__actions { - @apply mt-2 flex space-x-2; + .pcb__message__actions { + @apply mt-2 flex space-x-2; - a, button { - @apply text-xs text-gray-500 hover:text-gray-700; - @apply dark:text-gray-400 dark:hover:text-gray-200; + a, button { + @apply text-xs text-gray-500 hover:text-gray-700; + @apply dark:text-gray-400 dark:hover:text-gray-200; + } } -} - -.pcb__message__bot { - @apply bg-gray-100 self-start; - @apply dark:bg-gray-700; -} - -.pcb__message__bot-loading { - @apply bg-gray-100; - @apply dark:bg-gray-700; -} - -.pcb__message__bot-appear { - animation: fadeIn 0.5s; -} - -.pcb__message__content { - @apply text-sm; - @apply dark:text-white; -} -.pcb__message__footnotes { - @apply mt-2 text-sm text-gray-500; - @apply dark:text-gray-400; -} + .pcb__message__bot { + @apply bg-gray-100 self-start; + @apply dark:bg-gray-700; + } -.pcb__message__user { - @apply bg-green-100 self-end; - @apply dark:bg-green-800; -} + .pcb__message__bot-loading { + @apply bg-gray-100; + @apply dark:bg-gray-700; + } -.pcb__messages { - @apply flex-grow overflow-y-auto p-4; - @apply dark:bg-gray-800; -} + .pcb__message__bot-appear { + animation: fadeIn 0.5s; + } -.pcb__sidebar { - @apply w-full md:w-2/5 lg:w-1/3 border-l border-gray-200; - @apply transition-all duration-300 ease-in-out; - @apply fixed inset-y-0 right-0 transform; -} + .pcb__message__content { + @apply text-sm; + @apply dark:text-white; + } -.pcb__sidebar-activator { - @apply fixed z-10 bottom-20 right-4 bg-blue-500 text-white p-2 rounded-full shadow-lg; - @apply transition-all duration-300 ease-in-out; -} + .pcb__message__footnotes { + @apply mt-2 text-sm text-gray-500; + @apply dark:text-gray-400; + } -.pcb__sidebar-activator__deactivate { - @apply rotate-180 right-2; -} + .pcb__message__user { + @apply bg-green-100 self-end; + @apply dark:bg-green-800; + } -.pcb__source-modal { - @apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4; -} + .pcb__messages { + @apply flex-grow overflow-y-auto p-4; + @apply dark:bg-gray-800; + } -.pcb__source-modal-content { - @apply bg-white dark:bg-gray-800 text-gray-800 dark:text-white p-6 rounded-lg; - @apply max-w-2xl w-full max-h-[80%]; - @apply shadow-xl; -} + .pcb__sidebar { + @apply w-full md:w-2/5 lg:w-1/3 border-l border-gray-200; + @apply transition-all duration-300 ease-in-out; + @apply fixed inset-y-0 right-0 transform; + } -.pcb__source-modal-title { - @apply text-2xl font-bold mb-4 text-center; -} + .pcb__sidebar-activator { + @apply fixed z-10 bottom-20 right-4 bg-blue-500 text-white p-2 rounded-full shadow-lg; + @apply transition-all duration-300 ease-in-out; + } -.pcb__source-modal-description { - @apply mb-6; -} + .pcb__sidebar-activator__deactivate { + @apply rotate-180 right-2; + } -.pcb__source-modal-quote { - @apply border-l-4 border-blue-500 dark:border-blue-400 pl-4 py-2 mb-6; - @apply bg-gray-100 dark:bg-gray-700 rounded; - @apply font-serif text-lg leading-relaxed; /* Updated: serif font and larger text */ -} + .pcb__source-modal { + @apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4; + } -.pcb__source-modal-actions { - @apply flex justify-between items-center; -} + .pcb__source-modal-content { + @apply bg-white dark:bg-gray-800 text-gray-800 dark:text-white p-6 rounded-lg; + @apply max-w-2xl w-full max-h-[80%]; + @apply shadow-xl; + } -.pcb__source-modal-link { - @apply px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors; - @apply dark:bg-blue-600 dark:hover:bg-blue-700; -} + .pcb__source-modal-title { + @apply text-2xl font-bold mb-4 text-center; + } -.pcb__source-modal-close { - @apply px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors; - @apply dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600; -} + .pcb__source-modal-description { + @apply mb-6; + } -.pcb__status-indicator { - @apply text-xs text-gray-500 mb-1; - @apply dark:text-gray-400; - transition: opacity 0.3s ease-in-out; -} + .pcb__source-modal-quote { + @apply border-l-4 border-blue-500 dark:border-blue-400 pl-4 py-2 mb-6; + @apply bg-gray-100 dark:bg-gray-700 rounded; + @apply font-serif text-lg leading-relaxed; + /* Updated: serif font and larger text */ + } -.pcb__user-identifier { - @apply flex items-center mb-2; -} + .pcb__source-modal-actions { + @apply flex justify-between items-center; + } -.pcb__user-identifier-avatar { - @apply w-8 h-8 rounded-full flex items-center justify-center text-white font-bold mr-2; -} + .pcb__source-modal-link { + @apply px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors; + @apply dark:bg-blue-600 dark:hover:bg-blue-700; + } -.pcb__user-identifier-avatar__bot { - @apply bg-blue-500; - @apply dark:bg-blue-600; -} + .pcb__source-modal-close { + @apply px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors; + @apply dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600; + } -.pcb__user-identifier-avatar__user { - @apply bg-green-500; - @apply dark:bg-green-600; -} + .pcb__status-indicator { + @apply text-xs text-gray-500 mb-1; + @apply dark:text-gray-400; + transition: opacity 0.3s ease-in-out; + } -.pcb__user-identifier-name { - @apply font-semibold; - @apply dark:text-white; -} + .pcb__user-identifier { + @apply flex items-center mb-2; + } -@keyframes fadeIn { - from { - @apply opacity-0; + .pcb__user-identifier-avatar { + @apply w-8 h-8 rounded-full flex items-center justify-center text-white font-bold mr-2; } - to { - @apply opacity-100; + + .pcb__user-identifier-avatar__bot { + @apply bg-blue-500; + @apply dark:bg-blue-600; } -} -@keyframes loadingPulse { - 0%, 100% { - @apply opacity-40; + .pcb__user-identifier-avatar__user { + @apply bg-green-500; + @apply dark:bg-green-600; } - 50% { - @apply opacity-80; + + .pcb__user-identifier-name { + @apply font-semibold; + @apply dark:text-white; } -} -@keyframes pulse { - 0%, 100% { - @apply opacity-50 scale-[0.98]; + @keyframes fadeIn { + from { + @apply opacity-0; + } + to { + @apply opacity-100; + } } - 50% { - @apply opacity-100 scale-100; + + @keyframes loadingPulse { + 0%, 100% { + @apply opacity-40; + } + 50% { + @apply opacity-80; + } } -} -@keyframes slideInFromRight { - 0% { - transform: translateX(100%); - opacity: 0; + @keyframes pulse { + 0%, 100% { + @apply opacity-50 scale-[0.98]; + } + 50% { + @apply opacity-100 scale-100; + } } - 100% { - transform: translateX(0); - opacity: 1; + + @keyframes slideInFromRight { + 0% { + transform: translateX(100%); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } } } @@ -295,63 +298,65 @@ button[data-sidebar-target="toggleButton"] { } /* Full page style messing around */ +@layer components { + + .pcb__chat-container.full-page { + @apply flex flex-col; + @apply bg-gray-100 dark:bg-gray-900 text-black dark:text-white; + @apply rounded-lg shadow-xl; + @apply my-8 mx-auto; + max-width: 900px; + height: 85vh; + } -.pcb__chat-container.full-page { - @apply flex flex-col; - @apply bg-gray-100 dark:bg-gray-900 text-black dark:text-white; - @apply rounded-lg shadow-xl; - @apply my-8 mx-auto; - max-width: 900px; - height: 85vh; -} - -.pcb__chat-container.full-page .pcb__messages { - @apply flex-grow overflow-y-auto p-6; - @apply space-y-4; -} + .pcb__chat-container.full-page .pcb__messages { + @apply flex-grow overflow-y-auto p-6; + @apply space-y-4; + } -.pcb__chat-container.full-page .pcb__header { - @apply bg-white dark:bg-gray-800 text-black dark:text-white p-4 rounded-t-lg; - @apply flex justify-between items-center; -} + .pcb__chat-container.full-page .pcb__header { + @apply bg-white dark:bg-gray-800 text-black dark:text-white p-4 rounded-t-lg; + @apply flex justify-between items-center; + } -.pcb__chat-container.full-page .pcb__chat-input { - @apply p-4 bg-white dark:bg-gray-800 rounded-b-lg; - @apply border-t border-gray-200 dark:border-gray-700; -} + .pcb__chat-container.full-page .pcb__chat-input { + @apply p-4 bg-white dark:bg-gray-800 rounded-b-lg; + @apply border-t border-gray-200 dark:border-gray-700; + } -.pcb__chat-container.full-page .pcb__chat-input textarea { - @apply w-full p-2 rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; - @apply focus:outline-none focus:ring-2 focus:ring-blue-500; -} + .pcb__chat-container.full-page .pcb__chat-input textarea { + @apply w-full p-2 rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; + @apply focus:outline-none focus:ring-2 focus:ring-blue-500; + } -.pcb__chat-container.full-page .pcb__chat-input button { - @apply mt-0 px-4 py-2 bg-blue-500 text-white rounded; - @apply hover:bg-blue-600 transition-colors; -} + .pcb__chat-container.full-page .pcb__chat-input button { + @apply mt-0 px-4 py-2 bg-blue-500 text-white rounded; + @apply hover:bg-blue-600 transition-colors; + } -.pcb__chat-container.full-page .pcb__message { - @apply p-3 rounded-lg max-w-[70%]; - @apply transition-opacity duration-500 ease-in-out; -} + .pcb__chat-container.full-page .pcb__message { + @apply p-3 rounded-lg max-w-[70%]; + @apply transition-opacity duration-500 ease-in-out; + } -.pcb__chat-container.full-page .pcb__message__bot { - @apply bg-gray-200 dark:bg-gray-700 self-start; -} + .pcb__chat-container.full-page .pcb__message__bot { + @apply bg-gray-200 dark:bg-gray-700 self-start; + } -.pcb__chat-container.full-page .pcb__message__user { - @apply bg-blue-100 dark:bg-blue-600 self-end; -} + .pcb__chat-container.full-page .pcb__message__user { + @apply bg-blue-100 dark:bg-blue-600 self-end; + } -.pcb__chat-container.full-page .pcb__message__content { - @apply text-sm text-black dark:text-white; -} + .pcb__chat-container.full-page .pcb__message__content { + @apply text-sm text-black dark:text-white; + } -.pcb__chat-container.full-page .pcb__message__actions { - @apply mt-2 flex space-x-2; -} + .pcb__chat-container.full-page .pcb__message__actions { + @apply mt-2 flex space-x-2; + } -.pcb__chat-container.full-page .pcb__message__actions button, -.pcb__chat-container.full-page .pcb__message__actions a, { - @apply text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200; -} + .pcb__chat-container.full-page .pcb__message__actions button, + .pcb__chat-container.full-page .pcb__message__actions a, { + @apply text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200; + } +} \ No newline at end of file From 9f6dbd537bd8627e2d7c94d406d336ac12fe8a06 Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Tue, 12 Nov 2024 14:53:27 -0800 Subject: [PATCH 06/14] Remove empty initializer --- lib/phlex/chatbot/chat/input.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index 418e638..e78b16a 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -4,9 +4,6 @@ module Phlex module Chatbot module Chat class Input < Phlex::HTML - def initialize - end - def view_template div(class: "pcb__chat-input") do form do From 578edd6660d3bee93508e1220b64d89d0e67b981 Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Thu, 14 Nov 2024 10:54:27 -0800 Subject: [PATCH 07/14] refactor message. extract source --- lib/phlex/chatbot/chat/input.rb | 2 +- lib/phlex/chatbot/chat/message.rb | 91 +++++++------------ lib/phlex/chatbot/chat/messages.rb | 10 +- lib/phlex/chatbot/chat/source.rb | 30 ------ .../chatbot/modals/{reference.rb => link.rb} | 12 +-- lib/phlex/chatbot/rails.rb | 7 +- 6 files changed, 53 insertions(+), 99 deletions(-) delete mode 100644 lib/phlex/chatbot/chat/source.rb rename lib/phlex/chatbot/modals/{reference.rb => link.rb} (70%) diff --git a/lib/phlex/chatbot/chat/input.rb b/lib/phlex/chatbot/chat/input.rb index e78b16a..b8a2c29 100644 --- a/lib/phlex/chatbot/chat/input.rb +++ b/lib/phlex/chatbot/chat/input.rb @@ -9,7 +9,7 @@ def view_template form do textarea(placeholder: "Type your message...") submit( - class: "px-4 py-2 rounded bg-blue-500 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700" + class: "px-4 py-2 rounded bg-blue-500 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-600 dark:hover:bg-blue-700", ) do "Send" end diff --git a/lib/phlex/chatbot/chat/message.rb b/lib/phlex/chatbot/chat/message.rb index 9ddd5ff..be97f90 100644 --- a/lib/phlex/chatbot/chat/message.rb +++ b/lib/phlex/chatbot/chat/message.rb @@ -1,96 +1,73 @@ # frozen_string_literal: true -require_relative "source" require_relative "user_identifier" module Phlex module Chatbot module Chat class Message < Phlex::HTML - attr_reader :avatar, :from_user, :message, :sources, :status_message, :user_name + include Phlex::DeferredRender - def initialize( # rubocop:disable Metrics/ParameterLists + attr_reader :avatar, :from_user, :message, :user_name + + def initialize( message:, - additional_message_actions: nil, avatar: nil, from_user: false, - sources: nil, - status_message: nil, user_name: nil, - current_status: nil, - controlled: true, - **_others + id: nil, + **html_attrs ) - @additional_message_actions = additional_message_actions @avatar = avatar @from_user = from_user @message = message - @sources = sources - @status_message = status_message @user_name = user_name - @current_status = current_status || !!status_message - @controlled = controlled + @id = id + @other_attrs = html_attrs end def view_template - message_class = from_user ? "pcb__message__user" : "pcb__message__bot" - user_target_data = from_user ? "message" : "" - message_class += " pcb__message__bot-loading" if status_message + classes = tokens( + "pcb__message", + from_user ? "pcb__message__user" : "pcb__message__bot", + @other_attrs.delete(:class), + ) div( - id: @current_status ? "current_status" : nil, - class: "pcb__message #{message_class}", - data: { - pcb_chat_messages_target: ("message" if @controlled), - pcb_chat_message_type: from_user ? "user" : "bot", - } + id: @id, + class: classes, + **@other_attrs, ) do - div(class: "pcb__status-indicator") { status_message } if status_message + render @header if @header render UserIdentifier.new(avatar: avatar, from_system: !from_user, user_name: user_name) - div class: "pcb__message__content", data_chatbot_message_target: "content" do - if block_given? - yield - else - unsafe_raw message_with_embedded_sources - end - end - - render_sources if sources + content class: "pcb__message__content" - render_actions unless from_user + render @footer if @footer end end - private - - def message_with_embedded_sources - @message.gsub(/\[(\d+)\]/) do |ref| - index = ref.scan(/\d+/).first&.to_i - source = sources[index - 1] rescue nil - next ref.to_s unless source - - Source.new(index: index, source: source, classes: "pcb__footnote !mr-0").call + def content(**attrs) + div(**attrs) do + if @body + render @body + else + unsafe_raw message + end end end - def render_sources - div class: "pcb__message__footnotes", data_chatbot_message_target: "sources" do - sources.each.with_index(1) do |source, index| - render Source.new(index: index, source: source) - end - end + def header(&block) + @header = block end - def render_actions - div class: "pcb__message__actions" do - button(data_chatbot_message_target: "copyButton") { "Copy" } - button { "Regenerate" } - @additional_message_actions&.each do |component_callback| - render component_callback.call(self) - end - end + def body(&block) + @body = block + end + + def footer(&block) + @footer = block end end end diff --git a/lib/phlex/chatbot/chat/messages.rb b/lib/phlex/chatbot/chat/messages.rb index 279c41e..190b92f 100644 --- a/lib/phlex/chatbot/chat/messages.rb +++ b/lib/phlex/chatbot/chat/messages.rb @@ -6,7 +6,7 @@ module Phlex module Chatbot module Chat class Messages < Phlex::HTML - def initialize(messages:) + def initialize(messages: []) @messages = messages end @@ -16,7 +16,13 @@ def view_template class: "pcb__chat-messages", data_controller: "pcb-chat-messages", ) do - @messages.each { |message| render Message.new(**message.merge(controlled: false)) } + if block_given? + yield + else + @messages.each do |message| + render Message.new(**message) + end + end end end end diff --git a/lib/phlex/chatbot/chat/source.rb b/lib/phlex/chatbot/chat/source.rb deleted file mode 100644 index 788f608..0000000 --- a/lib/phlex/chatbot/chat/source.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require_relative "../modals/reference" - -module Phlex - module Chatbot - module Chat - class Source < Phlex::HTML - attr_reader :index, :source - - def initialize(index:, source:, classes: "pcb__footnote") - @index = index - @source = source - @classes = classes - end - - # Add "Link" action to the modal - - def view_template - render Phlex::Chatbot::Modals::Reference.new( - title: source[:title], - content: source[:description], - link: source[:url], - classes: @classes, - ) { "[#{index}]" } - end - end - end - end -end diff --git a/lib/phlex/chatbot/modals/reference.rb b/lib/phlex/chatbot/modals/link.rb similarity index 70% rename from lib/phlex/chatbot/modals/reference.rb rename to lib/phlex/chatbot/modals/link.rb index d9fe526..55d7614 100644 --- a/lib/phlex/chatbot/modals/reference.rb +++ b/lib/phlex/chatbot/modals/link.rb @@ -1,17 +1,15 @@ module Phlex module Chatbot module Modals - class Reference < Phlex::HTML - def initialize(title: nil, content:, link: nil, classes: nil) + class Link < Phlex::HTML + def initialize(content:, title: nil, link: nil, classes: nil) @title = title @content = content @link = link @classes = classes end - # Add slot for multiple actions - - def view_template + def view_template(&) a( href: "#", class: @classes, @@ -20,8 +18,8 @@ def view_template chatbot_modal_title_param: @title, chatbot_modal_content_param: @content, chatbot_modal_link_param: @link, - }, - ) { yield } + }, & + ) end end end diff --git a/lib/phlex/chatbot/rails.rb b/lib/phlex/chatbot/rails.rb index d957355..1a5d4d3 100644 --- a/lib/phlex/chatbot/rails.rb +++ b/lib/phlex/chatbot/rails.rb @@ -1,9 +1,10 @@ -require_relative "../chatbot" +# require_relative "../chatbot" module Phlex module Chatbot class Engine < ::Rails::Engine - config.autoload_paths << "#{__dir__}/../" + config.autoload_paths << "#{__dir__}/../../" + initializer "phlex-chatbot.assets" do |app| if app.config.respond_to?(:assets) app.config.assets.paths << File.expand_path("../../../dist", __dir__) @@ -13,6 +14,8 @@ class Engine < ::Rails::Engine ] end end + + # TODO: Test if this works without importmap gem initializer "phlex-chatbot.importmap", before: "importmap" do |app| if app.config.respond_to?(:importmap) app.config.importmap.cache_sweepers << File.expand_path("../../../dist", __dir__) From 4d85643e67de334df2d9b77c3781c60744431b49 Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Mon, 18 Nov 2024 17:00:37 -0800 Subject: [PATCH 08/14] [#188571675] fix: Prevent harmless null error --- src/javascript/controllers/chat_messages_controller.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/javascript/controllers/chat_messages_controller.js b/src/javascript/controllers/chat_messages_controller.js index 6f00e7d..c2da674 100644 --- a/src/javascript/controllers/chat_messages_controller.js +++ b/src/javascript/controllers/chat_messages_controller.js @@ -5,8 +5,9 @@ export default class extends Controller { static targets = ["message"] connect() { - // console.log("pcb ChatMessagesController connected") - this.element.lastElementChild.scrollIntoView({ block: "end", inline: "nearest" }) + if (this.element.lastElementChild) { + this.element.lastElementChild.scrollIntoView({ block: "end", inline: "nearest" }) + } } messageTargetConnected(element) { From 000b3bbaca2d633d1b69c459a533ae1a127ce73f Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Mon, 18 Nov 2024 20:06:31 -0800 Subject: [PATCH 09/14] [#188571675] feat: Restore textarea auto-resizing --- .../controllers/chat_form_controller.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js index 45238f9..ab798e5 100644 --- a/src/javascript/controllers/chat_form_controller.js +++ b/src/javascript/controllers/chat_form_controller.js @@ -2,23 +2,15 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="pcb-chat-form" export default class extends Controller { + static targets = ["input"]; connect() { - // console.log("pcb ChatFormController connected") // clear form - this.element.reset() } - // Add behavior to submitting the form: - // 1. Change the text - // 2. Prevent the default form submission - // 3. disable the form - // 4. Send the form - // submitForm(event) { - // this.submitButtonTarget.textContent = "Sending..." - // event.preventDefault() - // this.formTarget.disabled = true - // this.formTarget.requestSubmit() - // } + resize() { + this.inputTarget.style.height = 'auto' + this.inputTarget.style.height = this.inputTarget.scrollHeight + 'px' + } } From f0403a2bc58fdcd94fae819462c222fe183fa59d Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Mon, 18 Nov 2024 20:15:33 -0800 Subject: [PATCH 10/14] [#188571675] feat: Restore newline creation on cmd/ctrl+enter --- src/javascript/controllers/chat_form_controller.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js index ab798e5..0bc0821 100644 --- a/src/javascript/controllers/chat_form_controller.js +++ b/src/javascript/controllers/chat_form_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" +// FIXME(Chris): Do we want to refactor out a controller specifically for the input? // Connects to data-controller="pcb-chat-form" export default class extends Controller { static targets = ["input"]; @@ -13,4 +14,9 @@ export default class extends Controller { this.inputTarget.style.height = 'auto' this.inputTarget.style.height = this.inputTarget.scrollHeight + 'px' } + + createNewline() { + this.inputTarget.value += '\n'; + this.resize(); + } } From 28d316242324b8a7745318866d4cf6a580771ad6 Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Mon, 18 Nov 2024 22:53:32 -0800 Subject: [PATCH 11/14] [#188571675] fix: Submit form with cmd/ctrl+enter --- src/javascript/controllers/chat_form_controller.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js index 0bc0821..da87413 100644 --- a/src/javascript/controllers/chat_form_controller.js +++ b/src/javascript/controllers/chat_form_controller.js @@ -15,8 +15,7 @@ export default class extends Controller { this.inputTarget.style.height = this.inputTarget.scrollHeight + 'px' } - createNewline() { - this.inputTarget.value += '\n'; - this.resize(); + submit() { + this.element.requestSubmit(); } } From 0b00dab95dea7882d298555f63ab50cf7779b351 Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Tue, 19 Nov 2024 11:21:12 -0800 Subject: [PATCH 12/14] [#188571675] docs: Remove FIXME comment --- src/javascript/controllers/chat_form_controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/javascript/controllers/chat_form_controller.js b/src/javascript/controllers/chat_form_controller.js index da87413..8dd3f2c 100644 --- a/src/javascript/controllers/chat_form_controller.js +++ b/src/javascript/controllers/chat_form_controller.js @@ -1,6 +1,5 @@ import { Controller } from "@hotwired/stimulus" -// FIXME(Chris): Do we want to refactor out a controller specifically for the input? // Connects to data-controller="pcb-chat-form" export default class extends Controller { static targets = ["input"]; From 57444510cd04df31164b7a8b362265e8096e8d0c Mon Sep 17 00:00:00 2001 From: Christopher Chang Date: Tue, 19 Nov 2024 11:29:07 -0800 Subject: [PATCH 13/14] [#188571675] docs Make comment a TODO --- lib/phlex/chatbot/source_modal.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phlex/chatbot/source_modal.rb b/lib/phlex/chatbot/source_modal.rb index 18cd7c9..59f8de0 100644 --- a/lib/phlex/chatbot/source_modal.rb +++ b/lib/phlex/chatbot/source_modal.rb @@ -14,7 +14,7 @@ def view_template blockquote(class: "pcb__source-modal-quote") end div(class: "pcb__source-modal-actions") do - # This is a slot for actions. "Close" is a default action. We can add more using Ref. + # TODO(Chris): Make this a slot for actions. "Close" is a default action. We can add more using Ref. a(href: "", target: "_blank", class: "pcb__source-modal-link", data_chatbot_modal_target: "link") { "Visit source" } From 061745367f6eaee6a0f5e81c1754a7482053898f Mon Sep 17 00:00:00 2001 From: Brandon Gastelo Date: Tue, 19 Nov 2024 12:02:41 -0800 Subject: [PATCH 14/14] Refactor source modal --- lib/phlex/chatbot/modals/link.rb | 8 ++--- lib/phlex/chatbot/source_modal.rb | 14 ++++---- .../controllers/source_modal_controller.js | 36 +++++++++---------- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/lib/phlex/chatbot/modals/link.rb b/lib/phlex/chatbot/modals/link.rb index 55d7614..34f801e 100644 --- a/lib/phlex/chatbot/modals/link.rb +++ b/lib/phlex/chatbot/modals/link.rb @@ -14,10 +14,10 @@ def view_template(&) href: "#", class: @classes, data: { - action: "chatbot-modal#show", - chatbot_modal_title_param: @title, - chatbot_modal_content_param: @content, - chatbot_modal_link_param: @link, + action: "pcb-source-modal#show", + pcb_source_modal_title_param: @title, + pcb_source_modal_content_param: @content, + pcb_source_modal_link_param: @link, }, & ) end diff --git a/lib/phlex/chatbot/source_modal.rb b/lib/phlex/chatbot/source_modal.rb index 59f8de0..550c43b 100644 --- a/lib/phlex/chatbot/source_modal.rb +++ b/lib/phlex/chatbot/source_modal.rb @@ -6,20 +6,20 @@ class SourceModal < Phlex::HTML def view_template div( class: "pcb__source-modal hide-modal", - data: { chatbot_modal_target: "modal" }, + data: { pcb_source_modal_target: "modal" }, ) do div(class: "pcb__source-modal-content") do - h3(class: "pcb__source-modal-title", data_chatbot_modal_target: "title") - div(class: "pcb__source-modal-description", data_chatbot_modal_target: "content") do + h3(class: "pcb__source-modal-title", data_pcb_source_modal_target: "title") + div(class: "pcb__source-modal-description", data_pcb_source_modal_target: "content") do blockquote(class: "pcb__source-modal-quote") end div(class: "pcb__source-modal-actions") do # TODO(Chris): Make this a slot for actions. "Close" is a default action. We can add more using Ref. - a(href: "", target: "_blank", class: "pcb__source-modal-link", data_chatbot_modal_target: "link") { "Visit source" } + a(href: "", target: "_blank", class: "pcb__source-modal-link", data_pcb_source_modal_target: "link") do + "Visit source" + end - - - button(data: { action: "chatbot-modal#hide" }, class: "pcb__source-modal-close") { "Close" } + button(data: { action: "pcb-source-modal#hide" }, class: "pcb__source-modal-close") { "Close" } end end end diff --git a/src/javascript/controllers/source_modal_controller.js b/src/javascript/controllers/source_modal_controller.js index 1b054c1..7330504 100644 --- a/src/javascript/controllers/source_modal_controller.js +++ b/src/javascript/controllers/source_modal_controller.js @@ -2,29 +2,27 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="pcb-source-modal" export default class extends Controller { - static targets = ["modal"] + static targets = [ + "modal", + "title", + "content", + "link" + ] - connect() { - document.addEventListener('showSourceModal', this.showModal.bind(this)) - } - - disconnect() { - document.removeEventListener('showSourceModal', this.showModal.bind(this)) - } - - showModal(event) { - const { title, description, url } = event.detail - - this.setTextOrHtml(this.modalTarget.querySelector('.pcb__source-modal-title'), title); - this.setTextOrHtml(this.modalTarget.querySelector('.pcb__source-modal-description'), description); - this.modalTarget.querySelector('.pcb__source-modal-link').href = url + show(event) { + event.preventDefault() + const { title, content, link } = event.params + this.setTextOrHtml(this.titleTarget, title) + this.setTextOrHtml(this.contentTarget, content) + this.linkTarget.href = link this.modalTarget.classList.remove('hide-modal') } - closeModal(event) { - this.modalTarget.querySelector('.pcb__source-modal-title').innerText = "" - this.modalTarget.querySelector('.pcb__source-modal-description').innerText = "" - this.modalTarget.querySelector('.pcb__source-modal-link').href = "" + hide(event) { + event.preventDefault() + this.titleTarget.innerText = "" + this.contentTarget.innerText = "" + this.linkTarget.href = "" this.modalTarget.classList.add('hide-modal') }