Merge main into bundle-emails branch

This commit is contained in:
Jorge Manrubia
2025-08-28 12:42:43 +02:00
21 changed files with 149 additions and 140 deletions
+37 -31
View File
@@ -389,43 +389,15 @@
display: none;
}
.card__body {
display: block;
margin: 0;
}
.card__footer {
margin-block: -0.2em 2em;
}
.card__header {
margin-block-start: calc(var(--card-padding-block) * -1.1);
margin-inline: calc(-1 * var(--card-padding-inline));
max-inline-size: unset;
}
.workflow-stage--current {
transition: translate 150ms ease-out;
[open] & {
translate: -2em;
}
}
.card__link {
z-index: 1;
}
.card__meta-text--updated {
border: 0;
font-size: var(--text-x-small);
opacity: 0.66;
padding: 0;
text-transform: none;
.local-time-value {
font-weight: normal;
}
.card__body {
display: block;
margin: 0;
}
.workflow-stage--current {
@@ -438,6 +410,40 @@
flex: 0 1 auto;
inline-size: fit-content;
margin: 0 0 0 auto;
transition: translate 150ms ease-out;
[open] & {
translate: -2em;
}
}
.card__link {
z-index: 1;
}
.card__footer {
margin-block: -0.2em 2em;
}
.card__meta {
grid-template-areas: "text-updated";
grid-template-columns: 1fr;
}
.card__meta-text {
line-height: 1.5;
}
.card__meta-text--updated {
border: 0;
font-size: var(--text-x-small);
opacity: 0.66;
padding: 0;
text-transform: none;
.local-time-value {
font-weight: normal;
}
}
.card__bubble {
+13
View File
@@ -43,4 +43,17 @@ module FiltersHelper
concat tag.kbd(key)
end
end
def sorted_by_label(sort_value)
case sort_value
when "newest"
"Newest to oldest"
when "oldest"
"Oldest to newest"
when "latest"
"Recently updated"
else
sort_value.humanize
end
end
end
+43
View File
@@ -0,0 +1,43 @@
class Ai::UsageCost
attr_reader :model_id, :input_tokens, :output_tokens
class << self
def from_llm_response(response)
new \
model_id: response.model_id,
input_tokens: response.input_tokens,
output_tokens: response.output_tokens
end
end
def initialize(model_id:, input_tokens:, output_tokens:)
@model_id = model_id
@input_tokens = input_tokens
@output_tokens = output_tokens
end
def in_microcents
input_cost_in_microcents + output_cost_in_microcents
end
def input_cost_in_microcents
calculate_token_cost(input_tokens, model_info.input_price_per_million)
end
def output_cost_in_microcents
calculate_token_cost(output_tokens, model_info.output_price_per_million)
end
private
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
def model_info
@model_info ||= RubyLLM.models.find(model_id)
end
end
@@ -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_in_microcents: response.input_cost_in_microcents,
output_cost_in_microcents: response.output_cost_in_microcents,
cost_in_microcents: response.cost_in_microcents
input_cost_in_microcents: response.cost.input_cost_in_microcents,
output_cost_in_microcents: response.cost.output_cost_in_microcents,
cost_in_microcents: response.cost.in_microcents
}
conversation.respond(response.answer, **message_attributes)
@@ -8,41 +8,7 @@ class Conversation::Message::ResponseGenerator::Response
@model_id = model_id
end
def cost_in_microcents
input_cost_in_microcents + output_cost_in_microcents
def cost
@cost ||= Ai::UsageCost.from_llm_response(self)
end
def input_cost_in_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_in_microcents(model_info.input_price_per_million)
end
def output_cost_in_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_in_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_in_microcents(price)
single_token_price = price.to_d / 1_000_000
Ai::Quota::Money.wrap(single_token_price).in_microcents
end
end
+3 -3
View File
@@ -8,11 +8,11 @@ class Event::ActivitySummary < ApplicationRecord
key = key_for(events)
# Outside to avoid holding the transaction during the LLM request
summary, cost_in_microcents = Event::Summarizer.new(events).summarize
summarizer = Event::Summarizer.new(events)
create_or_find_by!(key: key) do |record|
record.content = summary
record.cost_in_microcents = cost_in_microcents
record.content = summarizer.summarized_content
record.cost_in_microcents = summarizer.cost.in_microcents
end
end
+10 -20
View File
@@ -27,9 +27,12 @@ class Event::Summarizer
@llm_model = llm_model
end
def summarize
response = chat.ask join_prompts("Summarize the following content:", summarizable_content)
[ response.content, calculate_cost_in_microcents(response) ]
def summarized_content
llm_response.content
end
def cost
Ai::UsageCost.from_llm_response(llm_response)
end
def summarizable_content
@@ -39,25 +42,12 @@ class Event::Summarizer
private
attr_reader :prompt, :llm_model
def llm_response
@llm_response ||= chat.ask join_prompts("Summarize the following content:", summarizable_content)
end
def chat
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
+2 -2
View File
@@ -1,6 +1,6 @@
class User < ApplicationRecord
include Accessor, Attachable, Assignee, Configurable, Mentionable, Named, Notifiable, Role,
Searcher, SignalUser, Staff, Transferable, Conversational, AiQuota
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named,
Notifiable, Role, Searcher, SignalUser, Staff, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
+1
View File
@@ -56,6 +56,7 @@ class User::Filtering
end
def show_tags?
return unless Tag.any?
expanded? || filter.tags.any?
end
@@ -1,5 +1,5 @@
<div class="settings__panel panel shadow center">
<h2 class="divider txt-large">Do or Die</h2>
<h2 class="divider txt-large">Sink or Swim</h2>
<p class="margin-none-block-start">Choose default settings for this account. You can override them in individual collections.</p>
<%= render "entropy/auto_close", model: account.default_entropy_configuration, url: account_entropy_configuration_path %>
</div>
@@ -5,7 +5,7 @@
<%= form.hidden_field :engagement, value: "on_deck" %>
<%= form.label :engagement, value: "on_deck", class: "btn", aria: { label: "Move to On Deck"} do %>
<%= form.radio_button :engagement, "on_deck", checked: @card.on_deck?, class: "for-screen-reader", data: { action: "change->form#submit" } %>
<span>Maybe?</span>
<span>On Deck</span>
<% end %>
<% end %>
@@ -20,7 +20,7 @@
<%= form.hidden_field :engagement, value: "doing" %>
<%= form.label :engagement, value: "doing", class: "btn", aria: { label: "Move to Doing"} do %>
<%= form.radio_button :engagement, "doing", checked: @card.doing?, class: "for-screen-reader", data: { action: "change->form#submit" } %>
<span style="min-inline-size: 8ch">Yes!</span>
<span style="min-inline-size: 8ch">Working On</span>
<% end %>
<% end %>
</div>
@@ -1,9 +1,9 @@
<div class="cards__heading" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="cards__filter btn input input--select borderless overflow-ellipsis" data-action="click->dialog#open:stop">
<% if filter.stages.any? %>
Yes!: <%= filter.stages.map(&:name).to_sentence %>
Working On: <%= filter.stages.map(&:name).to_sentence %>
<% else %>
Yes!
Working On
<% end %>
</button>
@@ -3,7 +3,7 @@
<%= render "cards/index/workflow_filter", workflow: workflow, filter: filter %>
<% else %>
<h2 class="cards__heading">
Yes!
Working On
</h2>
<% end %>
@@ -1,5 +1,5 @@
<section id="on-deck-cards" class="cards cards--on-deck" data-drag-and-drop-target="container" data-drop-target="on_deck">
<h2 class="cards__heading">Maybe?</h2>
<h2 class="cards__heading">On Deck</h2>
<% if page.used? %>
<%= render partial: "cards/display/preview", collection: page.records, as: :card, locals: { draggable: true }, cached: true %>
@@ -1,4 +1,4 @@
<%= turbo_frame_tag @collection, :entropy_configuration do %>
<h2 class="divider txt-large">Do or Die</h2>
<h2 class="divider txt-large">Sink or Swim</h2>
<%= render "entropy/auto_close", model: collection, url: collection_entropy_configuration_path(collection) %>
<% end %>
+1 -1
View File
@@ -16,7 +16,7 @@
<div class="separator margin-block-half" style="--border-color: var(--color-ink-light); min-block-size: 3lh;"></div>
<div class="flex flex-column full-width" style="flex-basis: 48%">
<strong>Cards in Maybe?/Yes!</strong>
<strong>Cards in On Deck/Working On</strong>
<p class="txt-x-small margin-none-block-start">Back to The Stream after…</p>
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
@@ -4,7 +4,7 @@
<div class="quick-filter position-relative <%= "default-value" if filter.sorted_by.latest? %>" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="btn input input--select flex-inline txt-x-small" data-action="click->dialog#open:stop">
<span class="overflow-ellipsis">
<%= filter.sorted_by.humanize %>
<%= sorted_by_label(filter.sorted_by) %>
</span>
</button>
@@ -26,7 +26,7 @@
<% end %>
<%= form.button type: "submit", class: "popup__item btn" do %>
<span class="overflow-ellipsis flex-item-grow"><%= sort.humanize %></span>
<span class="overflow-ellipsis flex-item-grow"><%= sorted_by_label(sort) %></span>
<% if filter.sorted_by == sort %>
<%= icon_tag "check", class: "checked flex-item-justify-end" %>
<% end %>
@@ -1,6 +1,6 @@
<section id="doing-cards" class="cards cards--doing" style="view-transition-name: cards-container;">
<h2 class="cards__heading">
Yes!
Working On
</h2>
<% if page.used? %>
@@ -1,6 +1,6 @@
<section id="considering-cards" class="cards cards--on-deck">
<h2 class="cards__heading">
Maybe?
On Deck
</h2>
<% if page.used? %>
+22
View File
@@ -0,0 +1,22 @@
require "test_helper"
class Ai::ResponseCostTest < ActiveSupport::TestCase
test "price calculations" do
response_cost = Ai::UsageCost.new(
model_id: "gpt-4",
input_tokens: 198,
output_tokens: 2
)
# We've got 198 input tokens, so that's
# 198 * 3000 = 594000
assert_equal 594000, response_cost.input_cost_in_microcents
# We've got 2 output tokens, so that's
# 2 * 6000 = 12000
assert_equal 12000, response_cost.output_cost_in_microcents
# So the total is 594000 + 12000 micro-cents
assert_equal 606000, response_cost.in_microcents
end
end
@@ -1,32 +0,0 @@
require "test_helper"
class Conversation::Message::ResponseGenerator::ResponseTest < ActiveSupport::TestCase
test "price calculations" do
response = Conversation::Message::ResponseGenerator::Response.new(
answer: "Hi!",
input_tokens: 198,
output_tokens: 2,
model_id: "gpt-4"
)
# The price of an input token is 30 USD per million tokens
# and 60 USD per million output tokens
# That's 0.00003 cents per input token and 0.00006 cents
# per output token
# Which is 3000 micro-cents per input token and 6000 micro-cents
# per output token
assert_equal "3000.0".to_d, response.input_token_price_microcents
assert_equal "6000.0".to_d, response.output_token_price_microcents
# We've got 198 input tokens, so that's
# 198 * 3000 = 594000
assert_equal 594000, response.input_cost_in_microcents
# We've got 2 output tokens, so that's
# 2 * 6000 = 12
assert_equal 12000, response.output_cost_in_microcents
# So the total is 594000 + 12000 micro-cents
assert_equal 606000, response.cost_in_microcents
end
end