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 %>