diff --git a/app/assets/stylesheets/conversation.css b/app/assets/stylesheets/conversation.css index f6574ce1e..75bb1fe7d 100644 --- a/app/assets/stylesheets/conversation.css +++ b/app/assets/stylesheets/conversation.css @@ -5,16 +5,19 @@ display: flex; flex-direction: column; height: 100%; - margin-bottom: 0; .conversation__messages { + display: flex; + flex-direction: column; + overflow-y: auto; + } + + .conversation__messages__trascript { display: flex; flex-direction: column; gap: 1.6rem; height: 100%; margin: 1rem; - overflow-x: hidden; - overflow-y: auto; } .conversation__composer { @@ -46,44 +49,59 @@ } .conversation__message { + display: flex; + flex-direction: row; + + .action-text-content { + border-radius: 0.5rem; + padding: 1rem; + width: fit-content; + } } .conversation__message--user { + justify-content: flex-end; + .action-text-content { - border-left: 1px solid var(--color-border); - border-radius: 0.5rem; - border-top: 1px solid var(--color-border); + border-bottom-right-radius: 0; + border: 1px solid var(--color-border); margin-left: 15%; - padding: 1rem; text-align: right; } - - details { - margin-top: 0.5rem; - - summary { - text-align: right; - } - } } .conversation__message--assistant { + justify-content: flex-start; + .action-text-content { - padding: 1rem; - text-align: left; margin-right: 15%; - border-right: 1px solid var(--color-border); - border-top: 1px solid var(--color-border); - border-radius: 0.5rem; + text-align: left; } + } - details { - margin-top: 0.5rem; + .conversation__message--failed { + --color-border: #ff0000; + color: var(--color-border); + } - summary { - text-align: left; - } + .conversation__message--emoji { + font-size: var(--text-xx-large); + + .action-text-content { + border: 0; } } + + .conversation__thinking-indicator { + padding: 1rem; + } + + .conversation__thinking-indicator--thinking { + display: block; + } + + .conversation__thinking-indicator--ready { + display: none; + } } } diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb index ee0a6ce4d..701e8de0f 100644 --- a/app/controllers/conversations/messages_controller.rb +++ b/app/controllers/conversations/messages_controller.rb @@ -1,11 +1,36 @@ class Conversations::MessagesController < ApplicationController + before_action :set_conversation + + def index + @messages = paginated_messages(@conversation.messages) + end + def create - Current.user.resume_or_start_conversation(message_params[:content]) - head :no_content + if @conversation.ask(question, **message_params) + head :ok + else + head :unprocessable_entity + end end private + def set_conversation + @conversation = Current.user.conversation + end + + def paginated_messages(messages) + if params[:before] + messages.page_before(messages.find(params[:before])) + else + messages.last_page + end + end + + def question + params.dig(:conversation_message, :content) + end + def message_params - params.require(:conversation_message).permit(:content) + params.require(:conversation_message).permit(:client_message_id) end end diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb index 09a7d5264..f76bbc824 100644 --- a/app/controllers/conversations_controller.rb +++ b/app/controllers/conversations_controller.rb @@ -6,6 +6,12 @@ class ConversationsController < ApplicationController def show @conversation = Current.user.conversation + + @messages = if params[:before] + @conversation.messages.page_before(params[:before]) + else + @conversation.messages.last_page + end end private diff --git a/app/helpers/conversations_helper.rb b/app/helpers/conversations_helper.rb new file mode 100644 index 000000000..b9c0738fd --- /dev/null +++ b/app/helpers/conversations_helper.rb @@ -0,0 +1,15 @@ +module ConversationsHelper + def conversation_previous_page_link(message, **options) + url = conversation_messages_path(before: message) + + options[:id] ||= dom_id(message.conversation, :load_more) + options[:data] ||= {} + options[:data] = options[:data].reverse_merge( + turbo_stream: true, + controller: "fetch-on-visible", + fetch_on_visible_url_value: url + ) + + link_to "Load more", url, **options + end +end diff --git a/app/javascript/controllers/conversation/composer_controller.js b/app/javascript/controllers/conversation/composer_controller.js new file mode 100644 index 000000000..0716d7be4 --- /dev/null +++ b/app/javascript/controllers/conversation/composer_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" +import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers" + +export default class extends Controller { + static targets = [ "textInput", "submitButton", "clientMessageIdInput" ] + static outlets = [ "conversation--messages" ] + + #submittingMessage + + connect() { + this.#submittingMessage = false + onNextEventLoopTick(() => this.textInputTarget.focus()) + } + + submit(event) { + event.preventDefault() + + if (!this.#submittingMessage) { + this.#submittingMessage = true + this.#submitMessage() + } + } + + submitEnd(even) { + this.#submittingMessage = false + + if (event.detail.success) { + this.#reset() + } else { + this.conversationMessagesOutlet.failPendingMessage(this.clientMessageIdInputTarget.value) + } + } + + async #submitMessage() { + if (this.#validInput()) { + const messageId = this.#generateMessageId() + + await this.conversationMessagesOutlet.insertPendingMessage(messageId, this.#messageText) + await nextFrame() + this.conversationMessagesOutlet.scrollToBottom() + + this.clientMessageIdInputTarget.value = messageId + this.element.requestSubmit() + } + } + + get #messageText() { + return this.textInputTarget.value + } + + #validInput() { + return this.#messageText.trim().length > 0 + } + + #generateMessageId() { + return Math.random().toString(36).slice(2) + } + + #reset() { + this.textInputTarget.value = "" + this.clientMessageIdInputTarget.value = "" + } +} diff --git a/app/javascript/controllers/conversation/messages_controller.js b/app/javascript/controllers/conversation/messages_controller.js new file mode 100644 index 000000000..4d484ee2d --- /dev/null +++ b/app/javascript/controllers/conversation/messages_controller.js @@ -0,0 +1,108 @@ +import { Controller } from "@hotwired/stimulus" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends Controller { + static SCROLL_TRESHOLD = 100 + static EMOJI_MATCHER = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+$/gu + + static targets = [ "messages", "messageTemplate" ] + static classes = [ "failedMessage", "emojiMessage" ] + + connect() { + this.scrollToBottom() + } + + failPendingMessage(clientMessageId) { + const message = this.element.querySelector(`#message_${clientMessageId}`) + + if (message) { + message.classList.add(this.failedMessageClass) + } + } + + async insertPendingMessage(clientMessageId, content) { + const html = this.#pendingMessageHtml({ + clientMessageId: clientMessageId, + content: content, + extraClasses: this.#allEmoji(content) ? this.emojiMessageClass : "" + }) + + this.messagesTarget.insertAdjacentHTML("beforeend", html) + + await nextFrame() + this.scrollToBottom() + } + + scrollToBottom() { + this.element.scrollTop = this.element.scrollHeight + } + + beforeStreamRender(event) { + const target = event.detail.newStream.getAttribute("target") + + if (target === this.messagesTarget.id) { + this.#handleScrollPositionOnTurboStreamRender(event) + } + } + + #pendingMessageHtml(data) { + let html = this.messageTemplateTarget.innerHTML + + for (const key in data) { + html = html.replaceAll(`$${key}$`, data[key]) + } + + return html + } + + #allEmoji(content) { + return content.match(this.constructor.EMOJI_MATCHER) + } + + #handleScrollPositionOnTurboStreamRender(event) { + if (event.detail.newStream.action === "append") { + this.#scrollToBottomOnTurboStreamRender(event) + } else { + this.#preserveScrollPositionOnTurboStreamRender(event) + } + } + + #scrollToBottomOnTurboStreamRender(event) { + const render = event.detail.render + + event.detail.render = async (streamElement) => { + await render(streamElement) + await nextFrame() + this.scrollToBottom() + } + } + + #preserveScrollPositionOnTurboStreamRender(event) { + const render = event.detail.render + + event.detail.render = async (streamElement) => { + const previousScrollHeight = this.element.scrollHeight + const previousScrollTop = this.element.scrollTop + const previouslyWasAtBottom = this.#scrolledToBottom + + await render(streamElement) + + if (previouslyWasAtBottom) { + this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight + } else { + const newScrollHeight = this.element.scrollHeight + const scrollDelta = newScrollHeight - previousScrollHeight + this.element.scrollTop = previousScrollTop + scrollDelta + } + + await nextFrame() + } + } + + get #scrolledToBottom() { + const scrollBottom = this.element.scrollTop + this.element.clientHeight + const contentBottom = this.element.scrollHeight - this.constructor.SCROLL_THRESHOLD + + return scrollBottom >= contentBottom + } +} diff --git a/app/jobs/conversation/message/response_generator_job.rb b/app/jobs/conversation/message/response_generator_job.rb new file mode 100644 index 000000000..40ecfa408 --- /dev/null +++ b/app/jobs/conversation/message/response_generator_job.rb @@ -0,0 +1,7 @@ +class Conversation::Message::ResponseGeneratorJob < ApplicationJob + retry_on RubyLLM::RateLimitError, wait: 2.second, attempts: 3 + + def perform(message) + message.generate_response + end +end diff --git a/app/jobs/conversation/response_generator_job.rb b/app/jobs/conversation/response_generator_job.rb deleted file mode 100644 index 5f2ce7f7d..000000000 --- a/app/jobs/conversation/response_generator_job.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Conversation::ResponseGeneratorJob < ApplicationJob - def perform(message) - message.generate_response - end -end diff --git a/app/models/ai/tool.rb b/app/models/ai/tool.rb new file mode 100644 index 000000000..995393dc2 --- /dev/null +++ b/app/models/ai/tool.rb @@ -0,0 +1,8 @@ +class Ai::Tool < RubyLLM::Tool + include Rails.application.routes.url_helpers + + private + def default_url_options + Rails.application.default_url_options + end +end diff --git a/app/models/ai/tool/list_cards.rb b/app/models/ai/tool/list_cards.rb index cae800912..9dc81984c 100644 --- a/app/models/ai/tool/list_cards.rb +++ b/app/models/ai/tool/list_cards.rb @@ -1,4 +1,4 @@ -class Ai::Tool::ListCards < RubyLLM::Tool +class Ai::Tool::ListCards < Ai::Tool description <<-MD Lists all cards accessible by the current user. The response is paginated so you may need to iterate through multiple pages to get the full list. @@ -32,6 +32,10 @@ class Ai::Tool::ListCards < RubyLLM::Tool - name [String, not null] MD + param :ids, + type: :string, + desc: "If provided, will return only the cards with the given IDs (comma-separated)", + required: false param :query, type: :string, desc: "If provided, will perform a semantinc search by embeddings and return only matching cards", @@ -71,28 +75,29 @@ class Ai::Tool::ListCards < RubyLLM::Tool cards = cards.search(params[:query]) if params[:query].present? cards = cards.golden if params[:golden].present? cards = cards.where(collection_id: params[:collection_id]) if params[:collection_id].present? + cards = cards.where(id: params[:ids]&.split(",")&.map(&:to_i)) if params[:ids].present? if params[:last_active_at_gte].present? - timestamp = Time.iso8601(params[:last_active_at_gte]) + timestamp = DateTime.parse(params[:last_active_at_gte]) cards = cards.where(last_active_at: timestamp..) end if params[:last_active_at_lte].present? - timestamp = Time.iso8601(params[:last_active_at_lte]) + timestamp = DateTime.parse(params[:last_active_at_lte]) cards = cards.where(last_active_at: ..timestamp) end if params[:created_at_gte].present? - timestamp = Time.iso8601(params[:created_at_gte]) + timestamp = DateTime.parse(params[:created_at_gte]) cards = cards.where(created_at: timestamp..) end if params[:created_at_lte].present? - timestamp = Time.iso8601(params[:created_at_lte]) + timestamp = DateTime.parse(params[:created_at_lte]) cards = cards.where(created_at: ..timestamp) end - page = GearedPagination::Recordset.new(cards, ordered_by: { id: :desc }).page(page) + page = GearedPagination::Recordset.new(cards, ordered_by: { id: :desc }).page(params[:page]) { cards: page.records.map do |card| @@ -106,7 +111,8 @@ class Ai::Tool::ListCards < RubyLLM::Tool stage: card.stage.as_json(only: [ :id, :name ]), creator: card.creator.as_json(only: [ :id, :name ]), assignees: card.assignees.as_json(only: [ :id, :name ]), - description: card.description.to_plain_text.truncate(1000) + description: card.description.to_plain_text.truncate(1000), + url: collection_card_path(card.collection, card) } end, pagination: { diff --git a/app/models/ai/tool/list_collections.rb b/app/models/ai/tool/list_collections.rb index 73fff94d7..14fc8e647 100644 --- a/app/models/ai/tool/list_collections.rb +++ b/app/models/ai/tool/list_collections.rb @@ -1,4 +1,4 @@ -class Ai::Tool::ListCollections < RubyLLM::Tool +class Ai::Tool::ListCollections < Ai::Tool description <<-MD Lists all collections accessible by the current user. The response is paginated so you may need to iterate through multiple pages to get the full list. @@ -24,19 +24,20 @@ class Ai::Tool::ListCollections < RubyLLM::Tool desc: "Which page to return. Leave balnk to get the first page", required: false - def execute(page: nil) - puts "= TOOL CALL: ListCollections" + def execute(**params) + scope = Collection.all page = GearedPagination::Recordset.new( - Collection.all, + scope, ordered_by: { name: :asc, id: :desc } - ).page(page) + ).page(params[:page]) { collections: page.records.map do |collection| { id: collection.id, - name: collection.name + name: collection.name, + url: collection_path(collection) } end, pagination: { diff --git a/app/models/ai/tool/list_comments.rb b/app/models/ai/tool/list_comments.rb index 04c703e3b..28bcd89b0 100644 --- a/app/models/ai/tool/list_comments.rb +++ b/app/models/ai/tool/list_comments.rb @@ -1,4 +1,4 @@ -class Ai::Tool::ListComments < RubyLLM::Tool +class Ai::Tool::ListComments < Ai::Tool description <<-MD Lists all comments accessible by the current user. The response is paginated so you may need to iterate through multiple pages to get the full list. @@ -30,12 +30,43 @@ class Ai::Tool::ListComments < RubyLLM::Tool type: :string, desc: "Which page to return. Leave balnk to get the first page", required: false + param :query, + type: :string, + desc: "If provided, will perform a semantinc search by embeddings and return only matching comments", + required: false + param :card_id, + type: :integer, + desc: "If provided, will return only status changes for the specified card", + required: false + param :created_at_gte, + type: :string, + desc: "If provided, will return only comments created on or after after the given ISO timestamp", + required: false + param :created_at_lte, + type: :string, + desc: "If provided, will return only comments created on or before the given ISO timestamp", + required: false + + def execute(**params) + scope = Comment.all.includes(:card, :creator, reactions: [ :reacter ]).where.not(creator: { role: "system" }) + + scope = scope.search(params[:query]) if params[:query].present? + scope = scope.where(card_id: params[:card_id].to_i) if params[:card_id].present? + + if params[:created_at_gte].present? + timestamp = Time.iso8601(params[:created_at_gte]) + scope = scope.where(created_at: timestamp..) + end + + if params[:created_at_lte].present? + timestamp = Time.iso8601(params[:created_at_lte]) + scope = scope.where(created_at: ..timestamp) + end - def execute(page: nil) page = GearedPagination::Recordset.new( - Comment.all.preload(:card, :creator, reactions: [ :reacter ]), + scope, ordered_by: { created_at: :asc, id: :desc } - ).page(page) + ).page(params[:page]) { collections: page.records.map do |comment| diff --git a/app/models/ai/tool/list_status_changes.rb b/app/models/ai/tool/list_status_changes.rb new file mode 100644 index 000000000..c7d9b1bfb --- /dev/null +++ b/app/models/ai/tool/list_status_changes.rb @@ -0,0 +1,84 @@ +class Ai::Tool::ListStatusChanges < Ai::Tool + description <<-MD + Lists all status changes accessible by the current user. + The response is paginated so you may need to iterate through multiple pages to get the full list. + Responses are JSON objects that look like this: + ``` + { + "collections": [ + { + "id": 3, + "card_id": 5, + "body": "Jane Doe moved this to Done", + "created_at": "2023-10-01T12:00:00Z", + "creator": { "id": 2, "name": "Jane Doe" } + } + ], + "pagination": { + "next_page": "e3c2gh75e4..." + } + } + ``` + Each collection object has the following fields: + - id [Integer, not null] + - name [String, not null] + MD + + param :page, + type: :string, + desc: "Which page to return. Leave balnk to get the first page", + required: false + param :query, + type: :string, + desc: "If provided, will perform a semantinc search by embeddings and return only matching status changes", + required: false + param :card_id, + type: :integer, + desc: "If provided, will return only status changes for the specified card", + required: false + param :created_at_gte, + type: :string, + desc: "If provided, will return only comments created on or after after the given ISO timestamp", + required: false + param :created_at_lte, + type: :string, + desc: "If provided, will return only comments created on or before the given ISO timestamp", + required: false + + def execute(**params) + scope = Comment.all.includes(:card, :creator).where(creator: { role: "system" }) + + scope = scope.search(params[:query]) if params[:query].present? + scope = scope.where(card_id: params[:card_id].to_i) if params[:card_id].present? + + if params[:created_at_gte].present? + timestamp = Time.iso8601(params[:created_at_gte]) + scope = scope.where(created_at: timestamp..) + end + + if params[:created_at_lte].present? + timestamp = Time.iso8601(params[:created_at_lte]) + scope = scope.where(created_at: ..timestamp) + end + + page = GearedPagination::Recordset.new( + scope, + ordered_by: { created_at: :asc, id: :desc } + ).page(params[:page]) + + { + collections: page.records.map do |comment| + { + id: comment.id, + card_id: comment.card_id, + body: comment.body.to_plain_text, + created_at: comment.created_at.iso8601, + creator: comment.creator.as_json(only: [ :id, :name ]) + } + end, + pagination: { + next_page: page.next_param + } + }.to_json + end +end diff --git a/app/models/ai/tool/list_users.rb b/app/models/ai/tool/list_users.rb index 0c4fc3e85..bd644929e 100644 --- a/app/models/ai/tool/list_users.rb +++ b/app/models/ai/tool/list_users.rb @@ -1,4 +1,4 @@ -class Ai::Tool::ListUsers < RubyLLM::Tool +class Ai::Tool::ListUsers < Ai::Tool description <<-MD Lists all users accessible by the current user. The response is paginated so you may need to iterate through multiple pages to get the full list. @@ -23,18 +23,27 @@ class Ai::Tool::ListUsers < RubyLLM::Tool type: :string, desc: "Which page to return. Leave balnk to get the first page", required: false + param :ids, + type: :string, + desc: "If provided, will return only the users with the given IDs (comma-separated)", + required: false + + def execute(**params) + scope = User.all + + scope = scope.where(id: params[:ids].split(",").map(&:to_i)) if params[:ids].present? - def execute(page: nil) page = GearedPagination::Recordset.new( - User.all, + scope, ordered_by: { name: :asc, id: :desc } - ).page(page) + ).page(params[:page]) { collections: page.records.map do |user| { id: user.id, - name: user.name + name: user.name, + url: user_path(user) } end, pagination: { diff --git a/app/models/command/ask.rb b/app/models/command/ask.rb index 8712c5bb4..02f2bec3c 100644 --- a/app/models/command/ask.rb +++ b/app/models/command/ask.rb @@ -6,7 +6,9 @@ class Command::Ask < Command end def execute - Current.user.resume_or_start_conversation(question) + conversation = Conversation.create_or_find_by(user: Current.user) + conversation.ask(question) if question.present? + Command::Result::ShowModal.new(turbo_frame: "conversation", url: conversation_path) end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 1bdc4ca13..bd6731863 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -1,13 +1,13 @@ class Conversation < ApplicationRecord - broadcasts_refreshes + include Broadcastable - belongs_to :user + belongs_to :user, class_name: "User" has_many :messages, dependent: :destroy enum :state, %w[ ready thinking ].index_by(&:itself), default: :ready - def price - messages.pluck(:price_microcents).compact.sum.to_d / 100_000 + def cost + messages.where.not(cost_microcents: nil).sum(:cost_microcents).to_d / 100_000 end def clear @@ -15,25 +15,37 @@ class Conversation < ApplicationRecord touch end - def ask(question) + def ask(question, **attributes) raise ArgumentError, "Question cannot be blank" if question.blank? + message = nil with_lock do - return false if thinking? + return if thinking? thinking! - messages.create!(role: :user, content: question) + message = messages.create!(**attributes, role: :user, content: question) end + + message.broadcast_create + broadcast_state_change + + message end def respond(answer, **attributes) raise ArgumentError, "Answer cannot be blank" if answer.blank? + message = nil with_lock do - return false unless thinking? + return unless thinking? - messages.create!(**attributes, role: :assistant, content: answer) + message = messages.create!(**attributes, role: :assistant, content: answer) ready! end + + message.broadcast_create + broadcast_state_change + + message end end diff --git a/app/models/conversation/broadcastable.rb b/app/models/conversation/broadcastable.rb new file mode 100644 index 000000000..b9cd0b147 --- /dev/null +++ b/app/models/conversation/broadcastable.rb @@ -0,0 +1,9 @@ +module Conversation::Broadcastable + extend ActiveSupport::Concern + + def broadcast_state_change + broadcast_replace_to user, :conversation, + target: [ self, :thinking_indicator ], + partial: "conversations/show/thinking_indicator" + end +end diff --git a/app/models/conversation/message.rb b/app/models/conversation/message.rb index 1d5db9748..26a662e30 100644 --- a/app/models/conversation/message.rb +++ b/app/models/conversation/message.rb @@ -1,40 +1,24 @@ class Conversation::Message < ApplicationRecord + include Pagination, Broadcastable, ClientIdentifiable, Promptable, Respondable + + ALL_EMOJI_REGEX = /\A(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+\z/u + has_rich_text :content belongs_to :conversation, inverse_of: :messages + has_one :owner, through: :conversation, source: :user enum :role, %w[ user assistant ].index_by(&:itself) - after_create_commit :generate_response_later, if: :user? + validates :client_message_id, presence: true - def generate_response_later - Conversation::ResponseGeneratorJob.perform_later(self) + scope :ordered, -> { order(created_at: :asc, id: :asc) } + + def all_emoji? + content.to_plain_text.match?(ALL_EMOJI_REGEX) end - def generate_response - response = Conversation::ResponseGenerator.new(self).generate - - message_attributes = { - model_id: response.model_id, - input_tokens: response.input_tokens, - output_tokens: response.output_tokens, - input_cost_microcents: response.input_cost_microcents, - output_cost_microcents: response.output_cost_microcents, - cost_microcents: response.cost_microcents - } - - conversation.respond(response.answer, **message_attributes) - end - - def to_llm - RubyLLM::Message.new( - role: role.to_sym, - content: content.to_plain_text, - tool_calls: nil, - tool_call_id: nil, - input_tokens: input_tokens, - output_tokens: output_tokens, - model_id: model_id - ) + def to_partial_path + "conversations/messages" end end diff --git a/app/models/conversation/message/broadcastable.rb b/app/models/conversation/message/broadcastable.rb new file mode 100644 index 000000000..91bd6b683 --- /dev/null +++ b/app/models/conversation/message/broadcastable.rb @@ -0,0 +1,9 @@ +module Conversation::Message::Broadcastable + extend ActiveSupport::Concern + + def broadcast_create + broadcast_append_to owner, :conversation, + target: [ conversation, :transcript ], + partial: "conversations/messages/message" + end +end diff --git a/app/models/conversation/message/client_identifiable.rb b/app/models/conversation/message/client_identifiable.rb new file mode 100644 index 000000000..a465f2509 --- /dev/null +++ b/app/models/conversation/message/client_identifiable.rb @@ -0,0 +1,11 @@ +module Conversation::Message::ClientIdentifiable + extend ActiveSupport::Concern + + included do + before_validation :generate_client_message_id, if: -> { client_message_id.blank? } + end + + def generate_client_message_id + self.client_message_id = SecureRandom.base36 + end +end diff --git a/app/models/conversation/message/pagination.rb b/app/models/conversation/message/pagination.rb new file mode 100644 index 000000000..2d31d8b2d --- /dev/null +++ b/app/models/conversation/message/pagination.rb @@ -0,0 +1,11 @@ +module Conversation::Message::Pagination + extend ActiveSupport::Concern + + PAGE_SIZE = 10 + + included do + scope :last_page, -> { ordered.last(PAGE_SIZE) } + scope :before, ->(message) { where(created_at: ...message.created_at, id: ...message.id) } + scope :page_before, ->(message) { before(message).last_page } + end +end diff --git a/app/models/conversation/message/promptable.rb b/app/models/conversation/message/promptable.rb new file mode 100644 index 000000000..ec1fee8b9 --- /dev/null +++ b/app/models/conversation/message/promptable.rb @@ -0,0 +1,19 @@ +module Conversation::Message::Promptable + extend ActiveSupport::Concern + + def to_llm + RubyLLM::Message.new( + role: role.to_sym, + content: to_prompt, + tool_calls: nil, + tool_call_id: nil, + input_tokens: input_tokens, + output_tokens: output_tokens, + model_id: model_id + ) + end + + def to_prompt + content.body.fragment.replace("a") { |link| "[#{link.text}](#{link["href"]})" }.to_plain_text + end +end diff --git a/app/models/conversation/message/respondable.rb b/app/models/conversation/message/respondable.rb new file mode 100644 index 000000000..523bb063c --- /dev/null +++ b/app/models/conversation/message/respondable.rb @@ -0,0 +1,26 @@ +module Conversation::Message::Respondable + extend ActiveSupport::Concern + + included do + after_create_commit :generate_response_later, if: :user? + end + + def generate_response_later + Conversation::Message::ResponseGeneratorJob.perform_later(self) + end + + def generate_response + response = Conversation::Message::ResponseGenerator.new(self).generate + + message_attributes = { + model_id: response.model_id, + input_tokens: response.input_tokens, + output_tokens: response.output_tokens, + input_cost_microcents: response.input_cost_microcents, + output_cost_microcents: response.output_cost_microcents, + cost_microcents: response.cost_microcents + } + + conversation.respond(response.answer, **message_attributes) + end +end diff --git a/app/models/conversation/response_generator.rb b/app/models/conversation/message/response_generator.rb similarity index 54% rename from app/models/conversation/response_generator.rb rename to app/models/conversation/message/response_generator.rb index 651ccdb6d..cca26cdea 100644 --- a/app/models/conversation/response_generator.rb +++ b/app/models/conversation/message/response_generator.rb @@ -1,17 +1,15 @@ -class Conversation::ResponseGenerator - SYSTEM_PROMPT = <<~PROMPT - You are **Fizzy**, a helpful assistant for the Fizzy app by 37signals. - Fizz helps users track projects, manage tasks, and collaborate with their teams. +class Conversation::Message::ResponseGenerator + include Ai::Prompts - ### 🗂 App Structure - - An account can have multiple **collections** - - Each collection contains **cards** - - Cards go through various **stages** and have a **creator** and one or more **assignees** + PROMPT = <<~PROMPT + You are **Fizzy**, a helpful assistant for the Fizzy app by 37signals. + Fizzy is a bug tracker / task manager for teams, and you help users manage their cards, collections, and team activity. ### 🧠 Your Role - You help users with anything related to their Fizz data — tasks, projects, trends, and team activity. + You help users with anything related to Fizzy — their cards, collections, trends, and team activity. - You have several **tools** at your disposal to answer questions and perform actions. Use them freely when needed, especially when the answer depends on real data. + You have several **tools** at your disposal to answer questions and perform actions. + Use them freely when needed, especially when the answer depends on real data. ### ✅ Guidelines - Be **concise**, **accurate**, and **friendly** @@ -20,42 +18,57 @@ class Conversation::ResponseGenerator - Do **not** include phrases like “If you want more…” or “Let me know if…” — just answer the question as asked - Stick strictly to the user's intent — no speculation, hedging, or filler - If you're unsure what they mean, ask a clarifying question — but only if you truly cannot infer it from context - - Always assume questions are about **their own Fizz data** — cards, collections, or team activity - - If a question isn’t related to Fizz, respond politely with “I don’t know” or “I’m not sure” and explain that you can only answer questions related to Fizzy + - Always assume questions are about **their own Fizzy data** — cards, collections, or team activity + - If a question isn’t related to Fizzy, respond politely with “I don’t know” or “I’m not sure” and explain that you can only answer questions related to Fizzy - Don’t explain concepts or go off-topic — answer only what was asked - Respond in **Markdown** + - Include links to relevant cards, collections, or users when appropriate + - When you quote or talk about a card always link to it + - When you quote or talk about a comment or status change always link to its card When in doubt, examine their cards, collections, or team activity to figure out the answer. You're here to help — not to anticipate. PROMPT - attr_reader :message + attr_reader :message, :prompt, :llm_model delegate :conversation, to: :message - def initialize(message) + def initialize(message, prompt: PROMPT, llm_model: nil) @message = message + @prompt = prompt + @llm_model = llm_model end def generate + reset_token_counters + response = llm.ask(message.content.to_plain_text) answer = markdown_to_html(response.content) Response.new( answer: answer, - input_tokens: response.input_tokens, - output_tokens: response.output_tokens, + input_tokens: input_tokens, + output_tokens: output_tokens, model_id: response.model_id ) end private + attr_reader :input_tokens, :output_tokens + + def reset_token_counters + @input_tokens = 0 + @output_tokens = 0 + end + def llm - RubyLLM.chat.tap do |chat| + RubyLLM.chat(model: llm_model).tap do |chat| chat.with_tool(Ai::Tool::ListCards.new) chat.with_tool(Ai::Tool::ListCollections.new) chat.with_tool(Ai::Tool::ListComments.new) + chat.with_tool(Ai::Tool::ListStatusChanges.new) chat.with_tool(Ai::Tool::ListUsers.new) chat.reset_messages! @@ -64,7 +77,9 @@ class Conversation::ResponseGenerator chat.add_message(message.to_llm) end - chat.with_instructions(instructions) + chat.with_instructions join_prompts(prompt, domain_model_prompt, user_data_injection_prompt) + + track_token_usage_of_intermediate_messages(chat) end end @@ -72,12 +87,11 @@ class Conversation::ResponseGenerator conversation.messages.order(id: :asc).where(id: ...message.id).limit(50).with_rich_text_content end - def instructions - [ SYSTEM_PROMPT, user_context ].compact.join("\n\n").strip - end - - def user_context - "You are talking to “#{conversation.user.name}”, their Fizzy User ID is #{conversation.user.id}" + def track_token_usage_of_intermediate_messages(chat) + chat.on_end_message do |response| + @input_tokens = response.input_tokens + @output_tokens = response.output_tokens + end end def markdown_to_html(markdown) diff --git a/app/models/conversation/response_generator/response.rb b/app/models/conversation/message/response_generator/response.rb similarity index 95% rename from app/models/conversation/response_generator/response.rb rename to app/models/conversation/message/response_generator/response.rb index bd9b4cf16..f404835a3 100644 --- a/app/models/conversation/response_generator/response.rb +++ b/app/models/conversation/message/response_generator/response.rb @@ -1,4 +1,4 @@ -class Conversation::ResponseGenerator::Response +class Conversation::Message::ResponseGenerator::Response MICROCENTS_PER_DOLLAR = 100_000 attr_reader :answer, :input_tokens, :output_tokens, :model_id, :tool_calls, :tool_call_id diff --git a/app/models/user.rb b/app/models/user.rb index bd8cf990d..ee00bb158 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ class User < ApplicationRecord include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher, - SignalUser, Staff, Transferable, Conversational + SignalUser, Staff, Transferable include Timelined # Depends on Accessor has_one_attached :avatar @@ -20,6 +20,8 @@ class User < ApplicationRecord has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all has_many :period_activity_summaries, dependent: :destroy + has_one :conversation, dependent: :destroy + normalizes :email_address, with: ->(value) { value.strip.downcase } def deactivate diff --git a/app/models/user/conversational.rb b/app/models/user/conversational.rb deleted file mode 100644 index 503351652..000000000 --- a/app/models/user/conversational.rb +++ /dev/null @@ -1,13 +0,0 @@ -module User::Conversational - extend ActiveSupport::Concern - - included do - has_one :conversation, dependent: :destroy - end - - def resume_or_start_conversation(question = nil) - (conversation || create_conversation).tap do |conversation| - conversation.ask(question) if question.present? - end - end -end diff --git a/app/views/commands/_terminal.html.erb b/app/views/commands/_terminal.html.erb index 444fab807..42d0ae390 100644 --- a/app/views/commands/_terminal.html.erb +++ b/app/views/commands/_terminal.html.erb @@ -1,5 +1,3 @@ -<%= turbo_stream_from Current.user.resume_or_start_conversation %> - <%= tag.div \ id: "command-terminal", class: "terminal full-width flex flex-column justify-end", @@ -24,7 +22,7 @@ dialog_target: "dialog", dialog_modal_value: "true", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" } do %> - <%= turbo_frame_tag nil, refresh: :morph, data: { terminal_target: "modalTurboFrame" } %> + <%= turbo_frame_tag nil, refresh: :morph, data: { terminal_target: "modalTurboFrame" }, target: "_top" %> <% end %> <%= render "commands/form" %> diff --git a/app/views/conversations/_message.html.erb b/app/views/conversations/_message.html.erb deleted file mode 100644 index 5cd354b42..000000000 --- a/app/views/conversations/_message.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
diff --git a/app/views/conversations/messages/_message.html.erb b/app/views/conversations/messages/_message.html.erb new file mode 100644 index 000000000..f5676101a --- /dev/null +++ b/app/views/conversations/messages/_message.html.erb @@ -0,0 +1,13 @@ +<% cache message do %> + <%= tag.div( + id: "message_#{message.client_message_id}", + class: token_list( + "conversation__message", + "conversation__message--#{message.role}", + message.all_emoji? && "conversation__message--emoji", + local_assigns[:extra_classes] + ), + ) do %> + <%= message.content %> + <% end %> +<% end %> diff --git a/app/views/conversations/messages/index.html.erb b/app/views/conversations/messages/index.html.erb new file mode 100644 index 000000000..90f6aaee6 --- /dev/null +++ b/app/views/conversations/messages/index.html.erb @@ -0,0 +1,36 @@ +<%= turbo_frame_tag dom_id(@conversation, :messages_container) do %> + <%= tag.div\ + id: dom_id(@conversation, :messages), + class: "conversation__messages", + data: { + controller: "conversation--messages", + conversation__messages_failed_message_class: "conversation__message--failed", + conversation__messages_emoji_message_class: "conversation__message--emoji", + action: "turbo:before-stream-render@document->conversation--messages#beforeStreamRender" + } do %> + <%= tag.div \ + id: dom_id(@conversation, :transcript), + class: "conversation__messages__trascript", + data: { + conversation__messages_target: "messages", + } do %> + <%= conversation_previous_page_link(@messages.first, class: "conversation__messages__previous-page") %> + <%= render collection: @messages, partial: "conversations/messages/message", cached: true %> + <% end %> + + + <%= render( + partial: "conversations/messages/message", + locals: { + extra_classes: "$extraClasses$", + message: Conversation::Message.new( + conversation: @conversation, + role: :user, + client_message_id: "$clientMessageId$", + content: "$content$" + ) + } + ) %> + + <% end %> +<% end %> diff --git a/app/views/conversations/messages/index.turbo_stream.erb b/app/views/conversations/messages/index.turbo_stream.erb new file mode 100644 index 000000000..7e6f60acb --- /dev/null +++ b/app/views/conversations/messages/index.turbo_stream.erb @@ -0,0 +1,8 @@ +<% if @messages.any? %> + <%= turbo_stream.prepend dom_id(@conversation, :transcript) do %> + <%= conversation_previous_page_link(@messages.first, class: "conversation__messages__previous-page") %> + <%= render collection: @messages, partial: "conversations/messages/message", cached: true %> + <% end %> +<% else %> + <%= turbo_stream.remove dom_id(@conversation, :load_more) %> +<% end %> diff --git a/app/views/conversations/show.html.erb b/app/views/conversations/show.html.erb index f9dd6b39b..842e779e5 100644 --- a/app/views/conversations/show.html.erb +++ b/app/views/conversations/show.html.erb @@ -1,19 +1,9 @@ -<%= turbo_stream_from @conversation %> - <%= turbo_frame_tag "conversation", target: "_top" do %> -