diff --git a/app/assets/stylesheets/conversation.css b/app/assets/stylesheets/conversation.css
index 95641849c..f5b0bbe8a 100644
--- a/app/assets/stylesheets/conversation.css
+++ b/app/assets/stylesheets/conversation.css
@@ -20,6 +20,7 @@
.conversation__message {
display: flex;
+ flex-direction: column;
.action-text-content {
border-radius: 0.5rem;
@@ -51,7 +52,7 @@
}
.conversation__message--assistant {
- justify-content: flex-start;
+ align-items: flex-start;
.action-text-content {
margin-inline-end: 15%;
@@ -67,8 +68,15 @@
color: var(--color-negative);
}
+ .conversation__message--failed[data-error]::after {
+ color: var(--color-negative);
+ content: attr(data-error);
+ display: block;
+ padding: var(--inline-space);
+ }
+
.conversation__message--user {
- justify-content: flex-end;
+ align-items: flex-end;
.action-text-content {
background-color: var(--color-selected);
diff --git a/app/assets/stylesheets/fizzy-menu.css b/app/assets/stylesheets/fizzy-menu.css
index cb7361a72..df3007c12 100644
--- a/app/assets/stylesheets/fizzy-menu.css
+++ b/app/assets/stylesheets/fizzy-menu.css
@@ -1,23 +1,10 @@
@layer components {
- .fizzy-dialog:is(.panel) {
- --panel-border-color: var(--color-selected-dark);
- --panel-border-radius: 1.4em;
- --panel-size: 45ch;
-
- align-items: stretch;
- box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
- 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
- 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
- 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
- flex-direction: column;
- gap: 2px;
- }
-
- .input:is(.fizzy-menu) {
+ .input:is(.fizzy-menu-trigger) {
--input-border-color: var(--color-selected-dark);
--input-padding: 0.2em 2em 0.2em 0.9em;
background-color: var(--color-canvas);
+ inline-size: auto;
margin-block-start: 0.1em;
::-webkit-search-cancel-button {
@@ -38,7 +25,21 @@
}
}
- .fizzy-dialog__close {
+ .fizzy-menu:is(.panel) {
+ --panel-border-color: var(--color-selected-dark);
+ --panel-border-radius: 1.4em;
+ --panel-size: 45ch;
+
+ align-items: stretch;
+ box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
+ 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
+ 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
+ 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .fizzy-menu__close {
@media (any-hover: hover) {
display: none;
}
@@ -52,6 +53,7 @@
flex-wrap: wrap;
gap: var(--gap);
inline-size: 100%;
+ list-style: none;
justify-content: center;
margin: var(--block-space) auto calc(var(--block-space-half) / 2);
max-inline-size: 100%;
diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css
index c758ed5e2..118d3d77e 100644
--- a/app/assets/stylesheets/rich-text-content.css
+++ b/app/assets/stylesheets/rich-text-content.css
@@ -21,7 +21,7 @@
:where(h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
- margin-block: var(--block-margin);
+ margin-block: 0 var(--block-margin);
&:not(lexxy-editor &) {
overflow-wrap: break-word;
@@ -171,13 +171,22 @@
margin-inline: 0;
max-inline-size: 100%;
- &:is(figure) img,
- &:is(figure) video {
- block-size: auto;
- display: block;
+ &:is(figure) {
+ inline-size: fit-content;
margin-inline: auto;
- max-inline-size: 100%;
- user-select: none;
+ display: block;
+
+ img, video {
+ block-size: auto;
+ display: block;
+ margin-inline: auto;
+ max-inline-size: 100%;
+ user-select: none;
+ }
+
+ > a {
+ display: block;
+ }
}
:where(progress) {
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 44e6a3afb..1691a36eb 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -79,6 +79,7 @@
.half-width { inline-size: 50%; }
.max-width { max-inline-size: 100%; }
.min-content { inline-size: min-content; }
+ .fit-content { inline-size: fit-content; }
.max-inline-size { max-inline-size: 100%; }
/* Overflow */
diff --git a/app/controllers/admin/prompt_sandboxes_controller.rb b/app/controllers/admin/prompt_sandboxes_controller.rb
index e4176265a..32c37ae13 100644
--- a/app/controllers/admin/prompt_sandboxes_controller.rb
+++ b/app/controllers/admin/prompt_sandboxes_controller.rb
@@ -23,6 +23,7 @@ class Admin::PromptSandboxesController < AdminController
private
def build_activity_summary
summarizer = Event::Summarizer.new(@day_timeline.events, prompt: @prompt, llm_model: @llm_model)
- Event::ActivitySummary.new(content: summarizer.summarize)
+ content, cost_in_microcents = summarizer.summarize
+ Event::ActivitySummary.new(content: content, cost_in_microcents: cost_in_microcents)
end
end
diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb
index 0a6ec8b31..b7585ac72 100644
--- a/app/controllers/conversations/messages_controller.rb
+++ b/app/controllers/conversations/messages_controller.rb
@@ -7,6 +7,10 @@ class Conversations::MessagesController < ApplicationController
def create
@conversation.ask(question, **message_params)
+ rescue Ai::Quota::UsageExceedsQuotaError
+ render json: { error: "You've depleted your quota" }, status: :too_many_requests
+ rescue Conversation::InvalidStateError
+ render json: { error: "Fizzy is still working on an answer to your last question" }, status: :conflict
end
private
diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb
index 578b98ad3..abb1e7947 100644
--- a/app/helpers/filters_helper.rb
+++ b/app/helpers/filters_helper.rb
@@ -16,7 +16,7 @@ module FiltersHelper
end
def filter_place_menu_item(path, label, icon)
- tag.li class: "popup__group", data: { filter_target: "item", navigable_list_target: "item" } do
+ tag.li class: "popup__group", id: "filter-place-#{label.parameterize}", data: { filter_target: "item", navigable_list_target: "item" } do
concat icon_tag(icon)
concat(link_to(path, class: "popup__item btn") do
concat tag.span(label, class: "overflow-ellipsis")
@@ -35,4 +35,12 @@ module FiltersHelper
navigable_list_actionable_items_value: true
}, &block
end
+
+ def filter_hotkey_link(title, path, key, icon)
+ link_to path, class: "popup__group btn borderless", id: "filter-hotkey-#{key}", role: "listitem", data: { filter_target: "item", navigable_list_target: "item", controller: "hotkey", action: "keydown.#{key}@document->hotkey#click keydown.shift+#{key}@document->hotkey#click" } do
+ concat icon_tag(icon)
+ concat tag.span(title.html_safe)
+ concat tag.kbd(key)
+ end
+ end
end
diff --git a/app/javascript/controllers/conversation/composer_controller.js b/app/javascript/controllers/conversation/composer_controller.js
index 368716846..0f7e83223 100644
--- a/app/javascript/controllers/conversation/composer_controller.js
+++ b/app/javascript/controllers/conversation/composer_controller.js
@@ -27,7 +27,10 @@ export default class extends Controller {
if (event.detail.success) {
this.#reset()
} else {
- this.conversationMessagesOutlet.failPendingMessage(this.clientMessageIdInputTarget.value)
+ this.#failPendingMessage(
+ this.clientMessageIdInputTarget.value,
+ event.detail.fetchResponse
+ )
}
}
@@ -60,4 +63,18 @@ export default class extends Controller {
this.textInputTarget.value = ""
this.clientMessageIdInputTarget.value = ""
}
+
+ async #failPendingMessage(clientMessageId, response) {
+ let errorMessage = null
+
+ if (response?.contentType?.includes('application/json')) {
+ const error = JSON.parse(await response.responseText)
+ errorMessage = error.error
+ }
+
+ this.conversationMessagesOutlet.failPendingMessage(
+ this.clientMessageIdInputTarget.value,
+ errorMessage
+ )
+ }
}
diff --git a/app/javascript/controllers/conversation/messages_controller.js b/app/javascript/controllers/conversation/messages_controller.js
index 4d484ee2d..32e3e58e9 100644
--- a/app/javascript/controllers/conversation/messages_controller.js
+++ b/app/javascript/controllers/conversation/messages_controller.js
@@ -12,11 +12,15 @@ export default class extends Controller {
this.scrollToBottom()
}
- failPendingMessage(clientMessageId) {
+ failPendingMessage(clientMessageId, errorMessage) {
const message = this.element.querySelector(`#message_${clientMessageId}`)
if (message) {
message.classList.add(this.failedMessageClass)
+
+ if (errorMessage) {
+ message.dataset.error = errorMessage
+ }
}
}
diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js
index 533c3bb89..a5aa1a94f 100644
--- a/app/javascript/controllers/navigable_list_controller.js
+++ b/app/javascript/controllers/navigable_list_controller.js
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
- static targets = [ "item" ]
+ static targets = [ "item", "input" ]
static values = {
reverseOrder: { type: Boolean, default: false },
selectionAttribute: { type: String, default: "aria-selected" },
@@ -73,6 +73,7 @@ export default class extends Controller {
async #setCurrentFrom(element) {
const selectedItem = this.#visibleItems.find(item => item.contains(element))
+ const id = selectedItem?.getAttribute("id")
if (selectedItem) {
this.#clearSelection()
@@ -80,6 +81,9 @@ export default class extends Controller {
this.currentItem = selectedItem
await nextFrame()
if (this.focusOnSelectionValue) { this.currentItem.focus() }
+ if (this.hasInputTarget && id) {
+ this.inputTarget.setAttribute("aria-activedescendant", id)
+ }
}
}
diff --git a/app/jobs/conversation/message/response_generator_job.rb b/app/jobs/conversation/message/response_generator_job.rb
index 40ecfa408..0ec4de601 100644
--- a/app/jobs/conversation/message/response_generator_job.rb
+++ b/app/jobs/conversation/message/response_generator_job.rb
@@ -1,5 +1,16 @@
class Conversation::Message::ResponseGeneratorJob < ApplicationJob
- retry_on RubyLLM::RateLimitError, wait: 2.second, attempts: 3
+ discard_on StandardError do |job, error|
+ Rails.error.report(error, severity: :error)
+
+ if conversation = job.arguments.first.try(:conversation)
+ conversation.respond("Something went wrong. Please try again in a moment.")
+ end
+ end
+
+ retry_on RubyLLM::RateLimitError, RubyLLM::ServiceUnavailableError, wait: 2.seconds, attempts: 3 do |job, error|
+ message = job.arguments.first
+ message.conversation.respond("Fizzy is very busy at the moment. Please try again in a minute.")
+ end
def perform(message)
message.generate_response
diff --git a/app/models/ai.rb b/app/models/ai.rb
new file mode 100644
index 000000000..4b4c17743
--- /dev/null
+++ b/app/models/ai.rb
@@ -0,0 +1,5 @@
+module Ai
+ def self.table_name_prefix
+ "ai_"
+ end
+end
diff --git a/app/models/ai/list_cards_tool.rb b/app/models/ai/list_cards_tool.rb
index 531f67d2d..ec105ba73 100644
--- a/app/models/ai/list_cards_tool.rb
+++ b/app/models/ai/list_cards_tool.rb
@@ -70,6 +70,7 @@ class Ai::ListCardsTool < Ai::Tool
def execute(**params)
cards = Card
.where(collection: user.collections)
+ .published
.with_rich_text_description
.includes(:stage, :creator, :assignees, :goldness, :collection)
diff --git a/app/models/ai/quota.rb b/app/models/ai/quota.rb
new file mode 100644
index 000000000..955a855af
--- /dev/null
+++ b/app/models/ai/quota.rb
@@ -0,0 +1,50 @@
+class Ai::Quota < ApplicationRecord
+ class UsageExceedsQuotaError < StandardError; end
+
+ belongs_to :user
+
+ before_create -> { reset }
+
+ validates :limit, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :used, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ def spend(cost)
+ cost = Money.wrap(cost)
+
+ transaction do
+ reset_if_due
+ increment!(:used, cost.in_microcents)
+ end
+ end
+
+ def ensure_not_depleted
+ reset_if_due
+
+ if depleted?
+ raise UsageExceedsQuotaError
+ end
+ end
+
+ private
+ def reset_if_due
+ reset if due_for_reset?
+ end
+
+ def reset
+ attributes = { used: 0, reset_at: 7.days.from_now }
+
+ if persisted?
+ update(**attributes)
+ else
+ assign_attributes(**attributes)
+ end
+ end
+
+ def due_for_reset?
+ reset_at.before?(Time.current)
+ end
+
+ def depleted?
+ used >= limit
+ end
+end
diff --git a/app/models/ai/quota/money.rb b/app/models/ai/quota/money.rb
new file mode 100644
index 000000000..f229c8def
--- /dev/null
+++ b/app/models/ai/quota/money.rb
@@ -0,0 +1,42 @@
+class Ai::Quota::Money < Data.define(:value)
+ CENTS_PER_DOLLAR = 100
+ MICROCENTS_PER_CENT = 1_000_000
+ MICROCENTS_PER_DOLLAR = CENTS_PER_DOLLAR * MICROCENTS_PER_CENT
+ NUMBER_REGEX = /\d+(\.\d+)?/
+
+ class << self
+ def wrap(value)
+ microcents = case value
+ when nil
+ raise ArgumentError, "#{self} can't wrap nil"
+ when self
+ value.value
+ when Integer
+ value
+ when String
+ convert_dollars_to_microcents(BigDecimal(value[NUMBER_REGEX]))
+ else
+ convert_dollars_to_microcents(value)
+ end
+
+ new(microcents)
+ end
+
+ private
+ def convert_dollars_to_microcents(dollars)
+ (dollars.to_d * MICROCENTS_PER_DOLLAR).round.to_i
+ end
+ end
+
+ def to_i
+ in_microcents
+ end
+
+ def in_microcents
+ value
+ end
+
+ def in_dollars
+ in_microcents.to_d / MICROCENTS_PER_DOLLAR
+ end
+end
diff --git a/app/models/card/promptable.rb b/app/models/card/promptable.rb
index dc069d131..b9a00597e 100644
--- a/app/models/card/promptable.rb
+++ b/app/models/card/promptable.rb
@@ -1,8 +1,6 @@
module Card::Promptable
extend ActiveSupport::Concern
- MAX_COMMENTS = 10
-
included do
include Rails.application.routes.url_helpers
end
@@ -31,7 +29,6 @@ module Card::Promptable
* Number of comments: #{comments.count}
* Path: #{collection_card_path(collection, self, script_name: Account.sole.slug)}
- #{comments.last(MAX_COMMENTS).collect(&:to_prompt).join("\n")}
END OF CARD #{id}
PROMPT
end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 184394fb1..bf3a4a0ab 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -8,16 +8,9 @@ class Conversation < ApplicationRecord
enum :state, %w[ ready thinking ].index_by(&:itself), default: :ready
- def cost
- messages.where.not(cost_microcents: nil).sum(:cost_microcents).to_d / 100_000
- end
-
- def clear
- messages.delete_all
- touch
- end
-
def ask(question, **attributes)
+ user.ensure_ai_quota_not_depleted
+
create_message_with_state_change(**attributes, role: :user, content: question) do
raise(InvalidStateError, "Can't ask questions while thinking") if thinking?
thinking!
@@ -25,10 +18,14 @@ class Conversation < ApplicationRecord
end
def respond(answer, **attributes)
- create_message_with_state_change(**attributes, role: :assistant, content: answer) do
+ message = create_message_with_state_change(**attributes, role: :assistant, content: answer) do
raise(InvalidStateError, "Can't respond when not thinking") unless thinking?
ready!
end
+
+ user.spend_ai_quota(message.cost) if message.cost
+
+ message
end
private
diff --git a/app/models/conversation/message.rb b/app/models/conversation/message.rb
index dc817c500..ab4e32cfb 100644
--- a/app/models/conversation/message.rb
+++ b/app/models/conversation/message.rb
@@ -13,6 +13,10 @@ class Conversation::Message < ApplicationRecord
scope :ordered, -> { order(created_at: :asc, id: :asc) }
+ def cost
+ cost_in_microcents && Ai::Quota::Money.new(cost_in_microcents)
+ end
+
def all_emoji?
content.to_plain_text.all_emoji?
end
diff --git a/app/models/conversation/message/respondable.rb b/app/models/conversation/message/respondable.rb
index 523bb063c..d3d8f03a2 100644
--- a/app/models/conversation/message/respondable.rb
+++ b/app/models/conversation/message/respondable.rb
@@ -16,9 +16,9 @@ module Conversation::Message::Respondable
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
+ input_cost_in_microcents: response.input_cost_in_microcents,
+ output_cost_in_microcents: response.output_cost_in_microcents,
+ cost_in_microcents: response.cost_in_microcents
}
conversation.respond(response.answer, **message_attributes)
diff --git a/app/models/conversation/message/response_generator.rb b/app/models/conversation/message/response_generator.rb
index 032d2a0d8..365f9090d 100644
--- a/app/models/conversation/message/response_generator.rb
+++ b/app/models/conversation/message/response_generator.rb
@@ -12,13 +12,13 @@ class Conversation::Message::ResponseGenerator
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
+ ### Your Role
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.
- ### ✅ Guidelines
+ ### 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
@@ -31,6 +31,16 @@ class Conversation::Message::ResponseGenerator
- Don’t explain concepts or go off-topic — answer only what was asked
- Respond in **Markdown**
- Always include links to cards, collections, comments, or users
+ - Always respond with a nicely formatted markdown link; NEVER respond with URLs or URL paths
+ - You are allowed to tell the user about themselves, the current time, their account, cards, collections, and comments
+
+ ### IMPORTANT: URL Handling
+ - **NEVER modify URLs in any way** - use them exactly as provided
+ - Always respond with markdown links, never bare URLs or paths
+ - If a URL starts with `/`, keep it as a relative path - do NOT add any domain
+ - Example: `{ "url": "/cards/123" }` becomes `[Card #123](/cards/123)
+ - `[/foo/bar]` isn't a valid Markdown link
+ - If a link doesn't have a title (e.g. `[](https://example.com)`) then use "Link" as the title (e.g. `[Link](https://example.com)`)
You're here to help — not to anticipate.
PROMPT
@@ -80,7 +90,7 @@ class Conversation::Message::ResponseGenerator
chat.add_message(message.to_llm)
end
- chat.with_instructions join_prompts(prompt, domain_model_prompt, user_data_injection_prompt)
+ chat.with_instructions join_prompts(prompt, domain_model_prompt, user_data_injection_prompt, user_info_prompt)
track_token_usage_of_intermediate_messages(chat)
end
@@ -90,6 +100,12 @@ class Conversation::Message::ResponseGenerator
conversation.messages.order(id: :asc).where(id: ...message.id).limit(50).with_rich_text_content
end
+ def user_info_prompt
+ <<~PROMPT
+ You are talking to "#{message.owner.name}" who's User ID is #{message.owner.id}
+ PROMPT
+ end
+
def track_token_usage_of_intermediate_messages(chat)
chat.on_end_message do |response|
@input_tokens = response.input_tokens
diff --git a/app/models/conversation/message/response_generator/response.rb b/app/models/conversation/message/response_generator/response.rb
index f404835a3..a62bf5c74 100644
--- a/app/models/conversation/message/response_generator/response.rb
+++ b/app/models/conversation/message/response_generator/response.rb
@@ -1,6 +1,4 @@
class Conversation::Message::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:)
@@ -10,15 +8,11 @@ class Conversation::Message::ResponseGenerator::Response
@model_id = model_id
end
- def cost
- cost_microcents.to_d / MICROCENTS_PER_DOLLAR
+ def cost_in_microcents
+ input_cost_in_microcents + output_cost_in_microcents
end
- def cost_microcents
- input_cost_microcents + output_cost_microcents
- end
-
- def input_cost_microcents
+ def input_cost_in_microcents
return unless token_price = input_token_price_microcents
(input_tokens * token_price).to_i
@@ -27,10 +21,10 @@ class Conversation::Message::ResponseGenerator::Response
def input_token_price_microcents
return unless model_info
- price_per_million_tokens_to_microcents(model_info.input_price_per_million)
+ price_per_million_tokens_in_microcents(model_info.input_price_per_million)
end
- def output_cost_microcents
+ def output_cost_in_microcents
return unless token_price = output_token_price_microcents
(output_tokens * token_price).to_i
@@ -39,7 +33,7 @@ class Conversation::Message::ResponseGenerator::Response
def output_token_price_microcents
return unless model_info
- price_per_million_tokens_to_microcents(model_info.output_price_per_million)
+ price_per_million_tokens_in_microcents(model_info.output_price_per_million)
end
def model_info
@@ -47,8 +41,8 @@ class Conversation::Message::ResponseGenerator::Response
end
private
- def price_per_million_tokens_to_microcents(price)
+ def price_per_million_tokens_in_microcents(price)
single_token_price = price.to_d / 1_000_000
- single_token_price * MICROCENTS_PER_DOLLAR
+ Ai::Quota::Money.wrap(single_token_price).in_microcents
end
end
diff --git a/app/models/event/activity_summary.rb b/app/models/event/activity_summary.rb
index 4e773c6a3..b275bc455 100644
--- a/app/models/event/activity_summary.rb
+++ b/app/models/event/activity_summary.rb
@@ -8,10 +8,11 @@ class Event::ActivitySummary < ApplicationRecord
key = key_for(events)
# Outside to avoid holding the transaction during the LLM request
- summary = Event::Summarizer.new(events).summarize
+ summary, cost_in_microcents = Event::Summarizer.new(events).summarize
create_or_find_by!(key: key) do |record|
record.content = summary
+ record.cost_in_microcents = cost_in_microcents
end
end
diff --git a/app/models/event/summarizer.rb b/app/models/event/summarizer.rb
index e18bbdbf0..cf10b57c6 100644
--- a/app/models/event/summarizer.rb
+++ b/app/models/event/summarizer.rb
@@ -29,7 +29,7 @@ class Event::Summarizer
def summarize
response = chat.ask join_prompts("Summarize the following content:", summarizable_content)
- response.content
+ [ response.content, calculate_cost_in_microcents(response) ]
end
def summarizable_content
@@ -43,4 +43,21 @@ class Event::Summarizer
chat = RubyLLM.chat(model: llm_model)
chat.with_instructions(join_prompts(prompt, domain_model_prompt, user_data_injection_prompt))
end
+
+ def calculate_cost_in_microcents(response)
+ model_info = RubyLLM.models.find(response.model_id)
+
+ input_cost = calculate_token_cost(response.input_tokens, model_info.input_price_per_million)
+ output_cost = calculate_token_cost(response.output_tokens, model_info.output_price_per_million)
+
+ input_cost + output_cost
+ end
+
+ def calculate_token_cost(token_count, price_per_million)
+ return 0 unless price_per_million
+
+ single_token_price = price_per_million.to_d / 1_000_000
+ token_cost_dollars = token_count * single_token_price
+ Ai::Quota::Money.wrap(token_cost_dollars).in_microcents
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 21007d711..676ce459a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,6 @@
class User < ApplicationRecord
include Accessor, Attachable, Assignee, Mentionable, Named, Notifiable, Role, Searcher,
- SignalUser, Staff, Transferable, Conversational
+ SignalUser, Staff, Transferable, Conversational, AiQuota
include Timelined # Depends on Accessor
has_one_attached :avatar
diff --git a/app/models/user/ai_quota.rb b/app/models/user/ai_quota.rb
new file mode 100644
index 000000000..b34879fb9
--- /dev/null
+++ b/app/models/user/ai_quota.rb
@@ -0,0 +1,22 @@
+module User::AiQuota
+ extend ActiveSupport::Concern
+
+ DEFAULT_QUOTA = Ai::Quota::Money.wrap("$100").in_microcents
+
+ included do
+ has_one :ai_quota, class_name: "Ai::Quota"
+ end
+
+ def spend_ai_quota(cost)
+ fetch_or_create_ai_quota.spend(cost)
+ end
+
+ def ensure_ai_quota_not_depleted
+ fetch_or_create_ai_quota.ensure_not_depleted
+ end
+
+ private
+ def fetch_or_create_ai_quota
+ ai_quota || create_ai_quota!(limit: DEFAULT_QUOTA)
+ end
+end
diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb
index 5775947fe..e1b2a01bb 100644
--- a/app/views/cards/edit.html.erb
+++ b/app/views/cards/edit.html.erb
@@ -14,7 +14,7 @@
<%= general_prompts(@card.collection) %>
<% end %>
- <%= form.button "Save changes", type: :submit, class: "btn btn--reversed", style: "--btn-background: #{@card.color}" %>
+ <%= form.button "Save changes", type: :submit, class: "btn btn--reversed fit-content", style: "--btn-background: #{@card.color}" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
<% end %>
<% end %>
diff --git a/app/views/cards/index/_collections_filter.html.erb b/app/views/cards/index/_collections_filter.html.erb
index 7761ce1ad..3b3b6580f 100644
--- a/app/views/cards/index/_collections_filter.html.erb
+++ b/app/views/cards/index/_collections_filter.html.erb
@@ -5,14 +5,14 @@
<% end %>
<% if Current.user.collections.one? %>
- <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "popup__group", data: { filter_target: "item", navigable_list_target: "item" } do %>
+ <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "popup__group", id: "filter-collection-init", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= tag.div class: "popup__item btn" do %>
<%= Current.user.collections.first.name %>
GO TO ›
<% end %>
<% end %>
<% elsif Current.user.collections.many? %>
- <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "popup__group", data: { filter_target: "item", navigable_list_target: "item" } do %>
+ <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "popup__group", id: "filter-collections-all", data: { filter_target: "item", navigable_list_target: "item" } do %>