Make sending messages feel snappy

This commit is contained in:
Stanko K.R.
2025-08-04 07:24:58 +02:00
parent 13eeb8574d
commit e49c7cb1cb
37 changed files with 700 additions and 157 deletions
+43 -25
View File
@@ -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;
}
}
}
@@ -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
@@ -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
+15
View File
@@ -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
@@ -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 = ""
}
}
@@ -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
}
}
@@ -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
@@ -1,5 +0,0 @@
class Conversation::ResponseGeneratorJob < ApplicationJob
def perform(message)
message.generate_response
end
end
+8
View File
@@ -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
+13 -7
View File
@@ -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: {
+7 -6
View File
@@ -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: {
+35 -4
View File
@@ -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|
+84
View File
@@ -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
+14 -5
View File
@@ -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: {
+3 -1
View File
@@ -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
+21 -9
View File
@@ -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
+9
View File
@@ -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
+12 -28
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 isnt related to Fizz, respond politely with I dont know or Im 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 isnt related to Fizzy, respond politely with I dont know or Im not sure and explain that you can only answer questions related to Fizzy
- Dont 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)
@@ -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
+3 -1
View File
@@ -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
-13
View File
@@ -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
+1 -3
View File
@@ -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" %>
@@ -1,5 +0,0 @@
<div id="<%= dom_id(message) %>" class="conversation__message conversation__message--<%= message.role %>">
<div>
<%= message.content %>
</div>
</div>
@@ -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 %>
@@ -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 %>
<template data-conversation--messages-target="messageTemplate">
<%= render(
partial: "conversations/messages/message",
locals: {
extra_classes: "$extraClasses$",
message: Conversation::Message.new(
conversation: @conversation,
role: :user,
client_message_id: "$clientMessageId$",
content: "$content$"
)
}
) %>
</template>
<% end %>
<% end %>
@@ -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 %>
+5 -15
View File
@@ -1,19 +1,9 @@
<%= turbo_stream_from @conversation %>
<%= turbo_frame_tag "conversation", target: "_top" do %>
<div class="conversation" id="<%= dom_id(@conversation) %>">
<div class="conversation__messages">
<%= render collection: @conversation.messages, partial: "conversations/message" %>
</div>
<%= form_with(model: Conversation::Message.new, local: true, html: { class: "conversation__composer" }) do |form| %>
<div class="conversation__composer__input">
<%= form.text_area :content, placeholder: "Type your message here...", rows: 3, class: "form-control" %>
</div>
<div class="conversation__composer__submit">
<%= form.submit "Send", class: "btn" %>
</div>
<% end %>
<%= turbo_stream_from Current.user, :conversation %>
<div class="conversation">
<%= turbo_frame_tag dom_id(@conversation, :messages_container), src: conversation_messages_path, target: "_top" %>
<%= render partial: "conversations/show/thinking_indicator", locals: { conversation: @conversation } %>
<%= render partial: "conversations/show/composer", locals: { conversation: @conversation } %>
</div>
<% end %>
@@ -0,0 +1,34 @@
<%= form_with(
model: Conversation::Message.new,
local: true,
html: {
id: dom_id(conversation, :composer),
class: "conversation__composer",
data: {
controller: "conversation--composer",
conversation__composer_conversation__messages_outlet: "##{dom_id(conversation, :messages)}",
action: "turbo:submit-end->conversation--composer#submitEnd"
}
}) do |form| %>
<%= form.hidden_field(
:client_message_id,
data: { conversation__composer_target: "clientMessageIdInput" }) %>
<div class="conversation__composer__input">
<%= form.text_area(
:content,
placeholder: "Type your message here...",
rows: 3,
data: { conversation__composer_target: "textInput" }) %>
</div>
<div class="conversation__composer__submit">
<%= form.submit(
"Send",
class: "btn",
data: {
conversation__composer_target: "submitButton",
action: "click->conversation--composer#submit"
}) %>
</div>
<% end %>
@@ -0,0 +1,6 @@
<%= tag.div(
id: dom_id(conversation, :thinking_indicator),
class: "conversation__thinking-indicator conversation__thinking-indicator--#{conversation.state}"
) do %>
<span class="spinner"></span> Thinking...
<% end %>
+1 -1
View File
@@ -117,7 +117,7 @@ Rails.application.routes.draw do
resource :conversation, only: %i[ show create ] do
scope module: :conversations do
resource :messages, only: %i[ create ]
resources :messages, only: %i[ index create ]
end
end
@@ -2,7 +2,8 @@ class CreateConversationMessages < ActiveRecord::Migration[8.1]
def change
create_table :conversation_messages do |t|
t.belongs_to :conversation, null: false, foreign_key: true
t.string :role
t.string :role, null: false
t.string :client_message_id, null: false
t.string :model_id
t.bigint :input_tokens
t.bigint :output_tokens