Add the ask command
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
@layer components {
|
||||
.conversation {
|
||||
--color-border: #fff;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
|
||||
.conversation__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
height: 100%;
|
||||
margin: 1rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation__composer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
min-height: 6rem;
|
||||
padding: 1rem;
|
||||
|
||||
.conversation__composer__submit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.conversation__composer__input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.conversation__composer__input textarea {
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation__message {
|
||||
}
|
||||
|
||||
.conversation__message--user {
|
||||
.action-text-content {
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
border-top: 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 {
|
||||
.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;
|
||||
}
|
||||
|
||||
details {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
summary {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.terminal__modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto 15rem;
|
||||
margin-bottom: 3.55rem;
|
||||
height: calc(100vh - 15rem);
|
||||
}
|
||||
|
||||
/* Lexical prompt insertions
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ class CommandsController < ApplicationController
|
||||
respond_with_composite_response(result)
|
||||
when Command::Result::Redirection
|
||||
redirect_to result.url
|
||||
when Command::Result::ShowModal
|
||||
respond_with_turbo_frame_modal(result.turbo_frame, result.url)
|
||||
else
|
||||
redirect_back_or_to root_path
|
||||
end
|
||||
@@ -68,6 +70,10 @@ class CommandsController < ApplicationController
|
||||
render json: { message: chat_response.content }, status: :accepted
|
||||
end
|
||||
|
||||
def respond_with_turbo_frame_modal(turbo_frame, url)
|
||||
render json: { turbo_frame: turbo_frame, url: url }, status: :accepted
|
||||
end
|
||||
|
||||
def respond_with_error(command)
|
||||
error_message = command.error_messages.map { |msg| "- #{msg}\n" }.join
|
||||
render json: { error: error_message, context_url: command.context.url }, status: :unprocessable_entity
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Conversations::MessagesController < ApplicationController
|
||||
def create
|
||||
Current.user.resume_or_start_conversation(message_params[:content])
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
def message_params
|
||||
params.require(:conversation_message).permit(:content)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class ConversationsController < ApplicationController
|
||||
def create
|
||||
Current.user.resume_or_start_conversation(question_params[:conversation][:question])
|
||||
redirect_to conversation_path, status: :see_other
|
||||
end
|
||||
|
||||
def show
|
||||
@conversation = Current.user.conversation
|
||||
end
|
||||
|
||||
private
|
||||
def conversation_params
|
||||
params.require(:conversation).permit(:question)
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,7 @@ class Prompts::CommandsController < ApplicationController
|
||||
[ "/user", "Open user profile", "/user " ],
|
||||
[ "/tag", "Tag selected cards", "/tag #" ],
|
||||
[ "/stage", "Move cards to a Workflow Stage", "/stage " ],
|
||||
[ "/ask", "Ask a question about cards", "/ask " ],
|
||||
[ "/help", "Show help menu", "/help" ]
|
||||
]
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import { marked } from "marked"
|
||||
import { nextFrame } from "helpers/timing_helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "input", "form", "output", "confirmation", "recentCommands" ]
|
||||
static targets = [ "input", "form", "output", "confirmation", "recentCommands", "modalTurboFrame" ]
|
||||
static classes = [ "error", "confirmation", "help", "output", "busy" ]
|
||||
static values = { originalInput: String, waitingForConfirmation: Boolean }
|
||||
static outlets = [ "dialog" ]
|
||||
|
||||
connect() {
|
||||
if (this.waitingForConfirmationValue) { this.focus() }
|
||||
@@ -141,7 +142,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#handleJsonResponse(responseJson) {
|
||||
const { confirmation, message, redirect_to } = responseJson
|
||||
const { confirmation, message, redirect_to, turbo_frame, url } = responseJson
|
||||
|
||||
if (message) {
|
||||
this.#showOutput(message)
|
||||
@@ -154,6 +155,10 @@ export default class extends Controller {
|
||||
if (redirect_to) {
|
||||
Turbo.visit(redirect_to)
|
||||
}
|
||||
|
||||
if (turbo_frame && url) {
|
||||
this.#showTurboFrameModal(turbo_frame, url)
|
||||
}
|
||||
}
|
||||
|
||||
async #requestConfirmation(confirmationPrompt) {
|
||||
@@ -204,4 +209,11 @@ export default class extends Controller {
|
||||
#hideOutput(html) {
|
||||
this.element.classList.remove(this.outputClass)
|
||||
}
|
||||
|
||||
#showTurboFrameModal(name, url) {
|
||||
this.inputTarget.blur()
|
||||
this.modalTurboFrameTarget.id = name
|
||||
this.modalTurboFrameTarget.src = url
|
||||
this.dialogOutlet.open()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Conversation::ResponseGeneratorJob < ApplicationJob
|
||||
def perform(message)
|
||||
message.generate_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
class Ai::Tool::ListCards < RubyLLM::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.
|
||||
A next page exists if the `pagination.next_page` field is present in the response.
|
||||
Responses are JSON objects that look like this:
|
||||
```
|
||||
{
|
||||
"cards": [
|
||||
{ "id": 3 },
|
||||
{ "id": 4 }
|
||||
],
|
||||
"pagination": {
|
||||
"next_page": "e3c2gh75e4..."
|
||||
}
|
||||
}
|
||||
```
|
||||
Each collection object has the following fields:
|
||||
- id [Integer, not null]
|
||||
- title [String, not null] - The title of the card
|
||||
- status [String, not null] - Enum of "creating", "draft" and "published"
|
||||
- last_active_at [DateTime, not null] - The last time the card was updated
|
||||
- collection_id [Integer, not null] - The ID of the collection this card belongs to
|
||||
- stage [Object, not null] - The stage this card is in, with fields:
|
||||
- id [Integer, not null]
|
||||
- name [String, not null]
|
||||
- creator [Object, not null] - The user who created the card, with fields:
|
||||
- id [Integer, not null]
|
||||
- name [String, not null]
|
||||
- assignees [Array of Objects, not null] - The users assigned to the card, each with fields:
|
||||
- id [Integer, not null]
|
||||
- name [String, not null]
|
||||
MD
|
||||
|
||||
param :query,
|
||||
type: :string,
|
||||
desc: "If provided, will perform a semantinc search by embeddings and return only matching cards",
|
||||
required: false
|
||||
param :page,
|
||||
type: :string,
|
||||
desc: "Which page to return. Leave balnk to get the first page",
|
||||
required: false
|
||||
param :collection_id,
|
||||
type: :integer,
|
||||
desc: "If provided, will return only cards for the specified collection",
|
||||
required: false
|
||||
param :golden,
|
||||
type: :boolean,
|
||||
desc: "If provided, will return only golden cards",
|
||||
required: false
|
||||
param :created_at_gte,
|
||||
type: :string,
|
||||
desc: "If provided, will return only card created on or after after the given ISO timestamp",
|
||||
required: false
|
||||
param :created_at_lte,
|
||||
type: :string,
|
||||
desc: "If provided, will return only card created on or before the given ISO timestamp",
|
||||
required: false
|
||||
param :last_active_at_gte,
|
||||
type: :string,
|
||||
desc: "If provided, will return only card that were last active on or after after the given ISO timestamp",
|
||||
required: false
|
||||
param :last_active_at_lte,
|
||||
type: :string,
|
||||
desc: "If provided, will return only card that were last active on or before the given ISO timestamp",
|
||||
required: false
|
||||
|
||||
def execute(**params)
|
||||
cards = Card.all.includes(:stage, :creator, :assignees, :goldness)
|
||||
|
||||
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?
|
||||
|
||||
if params[:last_active_at_gte].present?
|
||||
timestamp = Time.iso8601(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])
|
||||
cards = cards.where(last_active_at: ..timestamp)
|
||||
end
|
||||
|
||||
if params[:created_at_gte].present?
|
||||
timestamp = Time.iso8601(params[:created_at_gte])
|
||||
cards = cards.where(created_at: timestamp..)
|
||||
end
|
||||
|
||||
if params[:created_at_lte].present?
|
||||
timestamp = Time.iso8601(params[:created_at_lte])
|
||||
cards = cards.where(created_at: ..timestamp)
|
||||
end
|
||||
|
||||
page = GearedPagination::Recordset.new(cards, ordered_by: { id: :desc }).page(page)
|
||||
|
||||
{
|
||||
cards: page.records.map do |card|
|
||||
{
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
status: card.status,
|
||||
last_active_at: card.last_active_at,
|
||||
collection_id: card.collection_id,
|
||||
golden: card.golden?,
|
||||
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)
|
||||
}
|
||||
end,
|
||||
pagination: {
|
||||
next_page: page.next_param
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Ai::Tool::ListCollections < RubyLLM::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.
|
||||
Responses are JSON objects that look like this:
|
||||
```
|
||||
{
|
||||
"collections": [
|
||||
{ "id": 3, "name": "Foo" },
|
||||
{ "id": 4, "name": "Bar" }
|
||||
],
|
||||
"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
|
||||
|
||||
def execute(page: nil)
|
||||
puts "= TOOL CALL: ListCollections"
|
||||
|
||||
page = GearedPagination::Recordset.new(
|
||||
Collection.all,
|
||||
ordered_by: { name: :asc, id: :desc }
|
||||
).page(page)
|
||||
|
||||
{
|
||||
collections: page.records.map do |collection|
|
||||
{
|
||||
id: collection.id,
|
||||
name: collection.name
|
||||
}
|
||||
end,
|
||||
pagination: {
|
||||
next_page: page.next_param
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class Ai::Tool::ListComments < RubyLLM::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.
|
||||
Responses are JSON objects that look like this:
|
||||
```
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"id": 3,
|
||||
"card_id": 5,
|
||||
"body": "This is a comment",
|
||||
"created_at": "2023-10-01T12:00:00Z",
|
||||
"creator": { "id": 1, "name": "John Doe" },
|
||||
"reactions": [
|
||||
{ "content": "👍", "reacter": { "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
|
||||
|
||||
def execute(page: nil)
|
||||
page = GearedPagination::Recordset.new(
|
||||
Comment.all.preload(:card, :creator, reactions: [ :reacter ]),
|
||||
ordered_by: { created_at: :asc, id: :desc }
|
||||
).page(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 ]),
|
||||
reactions: comment.reactions.map do |reaction|
|
||||
{
|
||||
content: reaction.content,
|
||||
reacter: reaction.reacter.as_json(only: [ :id, :name ])
|
||||
}
|
||||
end
|
||||
}
|
||||
end,
|
||||
pagination: {
|
||||
next_page: page.next_param
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Ai::Tool::ListUsers < RubyLLM::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.
|
||||
Responses are JSON objects that look like this:
|
||||
```
|
||||
{
|
||||
"collections": [
|
||||
{ "id": 3, "name": "John Doe" },
|
||||
{ "id": 4, "name": "Johanna 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
|
||||
|
||||
def execute(page: nil)
|
||||
page = GearedPagination::Recordset.new(
|
||||
User.all,
|
||||
ordered_by: { name: :asc, id: :desc }
|
||||
).page(page)
|
||||
|
||||
{
|
||||
collections: page.records.map do |user|
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name
|
||||
}
|
||||
end,
|
||||
pagination: {
|
||||
next_page: page.next_param
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class Command::Ask < Command
|
||||
store_accessor :data, :question, :card_ids
|
||||
|
||||
def title
|
||||
"Ask '#{question}'"
|
||||
end
|
||||
|
||||
def execute
|
||||
Current.user.resume_or_start_conversation(question)
|
||||
Command::Result::ShowModal.new(turbo_frame: "conversation", url: conversation_path)
|
||||
end
|
||||
end
|
||||
@@ -67,6 +67,8 @@ class Command::Parser
|
||||
Command::VisitUrl.new(url: command_arguments.first)
|
||||
when "/tag"
|
||||
Command::Tag.new(tag_title: tag_title_from(combined_arguments), card_ids: cards.ids)
|
||||
when "/ask"
|
||||
Command::Ask.new(question: combined_arguments, card_ids: cards.ids)
|
||||
when /^gid:\/\//
|
||||
parse_gid command_name
|
||||
else
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class Command::Result::ShowModal
|
||||
attr_reader :url, :turbo_frame
|
||||
|
||||
def initialize(url:, turbo_frame:)
|
||||
@url = url
|
||||
@turbo_frame = turbo_frame
|
||||
end
|
||||
|
||||
def as_json
|
||||
{ turbo_frame: turbo_frame, url: url }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Conversation < ApplicationRecord
|
||||
broadcasts_refreshes
|
||||
|
||||
belongs_to :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
|
||||
end
|
||||
|
||||
def clear
|
||||
messages.delete_all
|
||||
touch
|
||||
end
|
||||
|
||||
def ask(question)
|
||||
raise ArgumentError, "Question cannot be blank" if question.blank?
|
||||
|
||||
with_lock do
|
||||
return false if thinking?
|
||||
|
||||
thinking!
|
||||
messages.create!(role: :user, content: question)
|
||||
end
|
||||
end
|
||||
|
||||
def respond(answer, **attributes)
|
||||
raise ArgumentError, "Answer cannot be blank" if answer.blank?
|
||||
|
||||
with_lock do
|
||||
return false unless thinking?
|
||||
|
||||
messages.create!(**attributes, role: :assistant, content: answer)
|
||||
ready!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Conversation::Message < ApplicationRecord
|
||||
has_rich_text :content
|
||||
|
||||
belongs_to :conversation, inverse_of: :messages
|
||||
|
||||
enum :role, %w[ user assistant ].index_by(&:itself)
|
||||
|
||||
after_create_commit :generate_response_later, if: :user?
|
||||
|
||||
def generate_response_later
|
||||
Conversation::ResponseGeneratorJob.perform_later(self)
|
||||
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
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
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.
|
||||
|
||||
### 🗂 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**
|
||||
|
||||
### 🧠 Your Role
|
||||
You help users with anything related to their Fizz data — tasks, projects, 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.
|
||||
|
||||
### ✅ Guidelines
|
||||
- Be **concise**, **accurate**, and **friendly**
|
||||
- Speak naturally — no corporate tone or robotic phrasing
|
||||
- **Never suggest follow-up questions, extra details, or further actions** unless the user explicitly asks
|
||||
- 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
|
||||
- Don’t explain concepts or go off-topic — answer only what was asked
|
||||
- Respond in **Markdown**
|
||||
|
||||
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
|
||||
|
||||
delegate :conversation, to: :message
|
||||
|
||||
def initialize(message)
|
||||
@message = message
|
||||
end
|
||||
|
||||
def generate
|
||||
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,
|
||||
model_id: response.model_id
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def llm
|
||||
RubyLLM.chat.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::ListUsers.new)
|
||||
|
||||
chat.reset_messages!
|
||||
|
||||
previous_messages.each do |message|
|
||||
chat.add_message(message.to_llm)
|
||||
end
|
||||
|
||||
chat.with_instructions(instructions)
|
||||
end
|
||||
end
|
||||
|
||||
def previous_messages
|
||||
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}"
|
||||
end
|
||||
|
||||
def markdown_to_html(markdown)
|
||||
renderer = Redcarpet::Render::HTML.new
|
||||
markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true)
|
||||
markdowner.render(markdown).html_safe
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Conversation::ResponseGenerator::Response
|
||||
MICROCENTS_PER_DOLLAR = 100_000
|
||||
|
||||
attr_reader :answer, :input_tokens, :output_tokens, :model_id, :tool_calls, :tool_call_id
|
||||
|
||||
def initialize(answer:, input_tokens:, output_tokens:, model_id:)
|
||||
@answer = answer
|
||||
@input_tokens = input_tokens
|
||||
@output_tokens = output_tokens
|
||||
@model_id = model_id
|
||||
end
|
||||
|
||||
def cost
|
||||
cost_microcents.to_d / MICROCENTS_PER_DOLLAR
|
||||
end
|
||||
|
||||
def cost_microcents
|
||||
input_cost_microcents + output_cost_microcents
|
||||
end
|
||||
|
||||
def input_cost_microcents
|
||||
return unless token_price = input_token_price_microcents
|
||||
|
||||
(input_tokens * token_price).to_i
|
||||
end
|
||||
|
||||
def input_token_price_microcents
|
||||
return unless model_info
|
||||
|
||||
price_per_million_tokens_to_microcents(model_info.input_price_per_million)
|
||||
end
|
||||
|
||||
def output_cost_microcents
|
||||
return unless token_price = output_token_price_microcents
|
||||
|
||||
(output_tokens * token_price).to_i
|
||||
end
|
||||
|
||||
def output_token_price_microcents
|
||||
return unless model_info
|
||||
|
||||
price_per_million_tokens_to_microcents(model_info.output_price_per_million)
|
||||
end
|
||||
|
||||
def model_info
|
||||
@model_info ||= RubyLLM.models.find(model_id)
|
||||
end
|
||||
|
||||
private
|
||||
def price_per_million_tokens_to_microcents(price)
|
||||
single_token_price = price.to_d / 1_000_000
|
||||
single_token_price * MICROCENTS_PER_DOLLAR
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher,
|
||||
SignalUser, Staff, Transferable
|
||||
SignalUser, Staff, Transferable, Conversational
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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,8 +1,11 @@
|
||||
<%= turbo_stream_from Current.user.resume_or_start_conversation %>
|
||||
|
||||
<%= tag.div \
|
||||
id: "command-terminal",
|
||||
class: "terminal full-width flex flex-column justify-end",
|
||||
data: {
|
||||
controller: "toggle-class terminal navigable-list",
|
||||
terminal_dialog_outlet: "#terminal-modal",
|
||||
terminal_error_class: "terminal--error",
|
||||
terminal_confirmation_class: "terminal--confirmation",
|
||||
terminal_help_class: "terminal--showing-help",
|
||||
@@ -13,6 +16,16 @@
|
||||
action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideMenus" } do %>
|
||||
<%= turbo_frame_tag :recent_commands, src: commands_path, data: { terminal_target: "recentCommands" } %>
|
||||
<div class="terminal__output" data-terminal-target="output"></div>
|
||||
<%= tag.dialog \
|
||||
id: "terminal-modal",
|
||||
class: "terminal__modal",
|
||||
data: {
|
||||
controller: "dialog",
|
||||
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" } %>
|
||||
<% end %>
|
||||
|
||||
<%= render "commands/form" %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<div id="<%= dom_id(message) %>" class="conversation__message conversation__message--<%= message.role %>">
|
||||
<div>
|
||||
<%= message.content %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<%= 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -115,6 +115,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resource :conversation, only: %i[ show create ] do
|
||||
scope module: :conversations do
|
||||
resource :messages, only: %i[ create ]
|
||||
end
|
||||
end
|
||||
|
||||
namespace :my do
|
||||
resources :pins
|
||||
end
|
||||
@@ -175,6 +181,10 @@ Rails.application.routes.draw do
|
||||
polymorphic_path(event.target, options)
|
||||
end
|
||||
|
||||
# resolve "Conversation::Message" do |message, options|
|
||||
# polymorphic_path([ :conversations, :messages ], options)
|
||||
# end
|
||||
#
|
||||
get "up", to: "rails/health#show", as: :rails_health_check
|
||||
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||
get "service-worker" => "pwa#service_worker"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class CreateConversations < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :conversations do |t|
|
||||
t.belongs_to :user, null: false, foreign_key: true, index: { unique: true }
|
||||
t.string :state, :string
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
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 :model_id
|
||||
t.bigint :input_tokens
|
||||
t.bigint :output_tokens
|
||||
t.bigint :input_cost_microcents
|
||||
t.bigint :output_cost_microcents
|
||||
t.bigint :cost_microcents
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
Generated
+26
@@ -208,6 +208,30 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_12_195130) do
|
||||
t.index ["card_id"], name: "index_comments_on_card_id"
|
||||
end
|
||||
|
||||
create_table "conversation_messages", force: :cascade do |t|
|
||||
t.string "client_message_id", null: false
|
||||
t.integer "conversation_id", null: false
|
||||
t.bigint "cost_microcents"
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "input_cost_microcents"
|
||||
t.bigint "input_tokens"
|
||||
t.string "model_id"
|
||||
t.bigint "output_cost_microcents"
|
||||
t.bigint "output_tokens"
|
||||
t.string "role", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["conversation_id"], name: "index_conversation_messages_on_conversation_id"
|
||||
end
|
||||
|
||||
create_table "conversations", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "state"
|
||||
t.string "string"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["user_id"], name: "index_conversations_on_user_id", unique: true
|
||||
end
|
||||
|
||||
create_table "creators_filters", id: false, force: :cascade do |t|
|
||||
t.integer "creator_id", null: false
|
||||
t.integer "filter_id", null: false
|
||||
@@ -438,6 +462,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_12_195130) do
|
||||
add_foreign_key "commands", "commands", column: "parent_id"
|
||||
add_foreign_key "commands", "users"
|
||||
add_foreign_key "comments", "cards"
|
||||
add_foreign_key "conversation_messages", "conversations"
|
||||
add_foreign_key "conversations", "users"
|
||||
add_foreign_key "events", "collections"
|
||||
add_foreign_key "mentions", "users", column: "mentionee_id"
|
||||
add_foreign_key "mentions", "users", column: "mentioner_id"
|
||||
|
||||
@@ -634,6 +634,125 @@ columns:
|
||||
- *24
|
||||
- *6
|
||||
- *9
|
||||
conversation_messages:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: client_message_id
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: conversation_id
|
||||
cast_type: *3
|
||||
sql_type_metadata: *4
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: cost_microcents
|
||||
cast_type: *11
|
||||
sql_type_metadata: *12
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *5
|
||||
- *6
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: input_cost_microcents
|
||||
cast_type: *11
|
||||
sql_type_metadata: *12
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: input_tokens
|
||||
cast_type: *11
|
||||
sql_type_metadata: *12
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: model_id
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: output_cost_microcents
|
||||
cast_type: *11
|
||||
sql_type_metadata: *12
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: output_tokens
|
||||
cast_type: *11
|
||||
sql_type_metadata: *12
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: role
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *9
|
||||
conversations:
|
||||
- *5
|
||||
- *6
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: state
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: string
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *9
|
||||
- *25
|
||||
creators_filters:
|
||||
- *24
|
||||
- *19
|
||||
@@ -1201,6 +1320,8 @@ primary_keys:
|
||||
collections_filters:
|
||||
commands: id
|
||||
comments: id
|
||||
conversation_messages: id
|
||||
conversations: id
|
||||
creators_filters:
|
||||
entropy_configurations: id
|
||||
event_activity_summaries: id
|
||||
@@ -1248,6 +1369,8 @@ data_sources:
|
||||
collections_filters: true
|
||||
commands: true
|
||||
comments: true
|
||||
conversation_messages: true
|
||||
conversations: true
|
||||
creators_filters: true
|
||||
entropy_configurations: true
|
||||
event_activity_summaries: true
|
||||
@@ -1948,6 +2071,40 @@ indexes:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
conversation_messages:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: conversation_messages
|
||||
name: index_conversation_messages_on_conversation_id
|
||||
unique: false
|
||||
columns:
|
||||
- conversation_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
conversations:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: conversations
|
||||
name: index_conversations_on_user_id
|
||||
unique: true
|
||||
columns:
|
||||
- user_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
creators_filters:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: creators_filters
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
conversation: one
|
||||
role: MyString
|
||||
|
||||
two:
|
||||
conversation: two
|
||||
role: MyString
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
|
||||
two:
|
||||
user: two
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class Conversation::MessageTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class ConversationTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
Reference in New Issue
Block a user