Make sending messages feel snappy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+38
-24
@@ -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)
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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 %>
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user