Add the ask command

This commit is contained in:
Stanko K.R.
2025-07-31 13:25:49 +02:00
parent 445ae632cc
commit 13eeb8574d
33 changed files with 969 additions and 3 deletions
+89
View File
@@ -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;
}
}
}
}
}
+11
View File
@@ -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
/* ------------------------------------------------------------------------ */
+6
View File
@@ -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
+117
View File
@@ -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
+47
View File
@@ -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
+61
View File
@@ -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
+45
View File
@@ -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
+12
View File
@@ -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
+2
View File
@@ -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
+12
View File
@@ -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
+39
View File
@@ -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
+40
View File
@@ -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 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
- Dont 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
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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>
+19
View File
@@ -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 %>
+10
View File
@@ -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
View File
@@ -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"
+157
View File
@@ -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
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
two:
user: two
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class Conversation::MessageTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class ConversationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end