diff --git a/app/controllers/discourse_ai/admin/ai_usage_controller.rb b/app/controllers/discourse_ai/admin/ai_usage_controller.rb index 606db6ca..ea57cd4a 100644 --- a/app/controllers/discourse_ai/admin/ai_usage_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_usage_controller.rb @@ -6,6 +6,9 @@ module DiscourseAi requires_plugin "discourse-ai" def show + end + + def report render json: AiUsageSerializer.new(create_report, root: false) end diff --git a/assets/javascripts/discourse/components/ai-usage.gjs b/assets/javascripts/discourse/components/ai-usage.gjs index 668a41b1..54b54afa 100644 --- a/assets/javascripts/discourse/components/ai-usage.gjs +++ b/assets/javascripts/discourse/components/ai-usage.gjs @@ -1,15 +1,22 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { fn, hash } from "@ember/helper"; -import { on } from "@ember/modifier"; import { action } from "@ember/object"; import { LinkTo } from "@ember/routing"; import { service } from "@ember/service"; -import { eq } from "truth-helpers"; +import { eq, gt, lt } from "truth-helpers"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import DButton from "discourse/components/d-button"; import DateTimeInputRange from "discourse/components/date-time-input-range"; import avatar from "discourse/helpers/avatar"; +import concatClass from "discourse/helpers/concat-class"; import { ajax } from "discourse/lib/ajax"; +import { number } from "discourse/lib/formatter"; import i18n from "discourse-common/helpers/i18n"; +import { bind } from "discourse-common/utils/decorators"; +import AdminConfigAreaCard from "admin/components/admin-config-area-card"; +import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list"; +import AdminPageSubheader from "admin/components/admin-page-subheader"; import Chart from "admin/components/chart"; import ComboBox from "select-kit/components/combo-box"; @@ -22,18 +29,30 @@ export default class AiUsage extends Component { @tracked selectedModel; @tracked selectedPeriod = "month"; @tracked isCustomDateActive = false; + @tracked loadingData = true; + + constructor() { + super(...arguments); + this.fetchData(); + } @action async fetchData() { - const response = await ajax("/admin/plugins/discourse-ai/ai-usage.json", { - data: { - start_date: moment(this.startDate).format("YYYY-MM-DD"), - end_date: moment(this.endDate).format("YYYY-MM-DD"), - feature: this.selectedFeature, - model: this.selectedModel, - }, - }); + const response = await ajax( + "/admin/plugins/discourse-ai/ai-usage-report.json", + { + data: { + start_date: moment(this.startDate).format("YYYY-MM-DD"), + end_date: moment(this.endDate).format("YYYY-MM-DD"), + feature: this.selectedFeature, + model: this.selectedModel, + }, + } + ); this.data = response; + this.loadingData = false; + this._cachedFeatures = null; + this._cachedModels = null; } @action @@ -53,6 +72,11 @@ export default class AiUsage extends Component { this.onFilterChange(); } + @bind + takeUsers(start, end) { + return this.data.users.slice(start, end); + } + normalizeTimeSeriesData(data) { if (!data?.length) { return []; @@ -79,16 +103,16 @@ export default class AiUsage extends Component { ); for ( - let m = moment(startDate); - m.isSameOrBefore(endDate); - m.add(1, interval) + let currentMoment = moment(startDate); + currentMoment.isSameOrBefore(endDate); + currentMoment.add(1, interval) ) { - const dateKey = m.format(format); + const dateKey = currentMoment.format(format); const existingData = dataMap.get(dateKey); normalized.push( existingData || { - period: m.format(), + period: currentMoment.format(), total_tokens: 0, total_cached_tokens: 0, total_request_tokens: 0, @@ -131,19 +155,19 @@ export default class AiUsage extends Component { }), datasets: [ { - label: "Response Tokens", + label: i18n("discourse_ai.usage.response_tokens"), data: normalizedData.map((row) => row.total_response_tokens), backgroundColor: colors.response, }, { - label: "Net Request Tokens", + label: i18n("discourse_ai.usage.net_request_tokens"), data: normalizedData.map( (row) => row.total_request_tokens - row.total_cached_tokens ), backgroundColor: colors.request, }, { - label: "Cached Request Tokens", + label: i18n("discourse_ai.usage.cached_request_tokens"), data: normalizedData.map((row) => row.total_cached_tokens), backgroundColor: colors.cached, }, @@ -190,9 +214,9 @@ export default class AiUsage extends Component { get periodOptions() { return [ - { id: "day", name: "Last 24 Hours" }, - { id: "week", name: "Last Week" }, - { id: "month", name: "Last Month" }, + { id: "day", name: i18n("discourse_ai.usage.periods.last_day") }, + { id: "week", name: i18n("discourse_ai.usage.periods.last_week") }, + { id: "month", name: i18n("discourse_ai.usage.periods.last_month") }, ]; } @@ -253,33 +277,31 @@ export default class AiUsage extends Component { } diff --git a/assets/stylesheets/modules/llms/common/usage.scss b/assets/stylesheets/modules/llms/common/usage.scss index 04564d38..e9c7a2f4 100644 --- a/assets/stylesheets/modules/llms/common/usage.scss +++ b/assets/stylesheets/modules/llms/common/usage.scss @@ -60,9 +60,6 @@ &__summary { margin: 2em 0; - padding: 1.5em; - background: var(--primary-very-low); - border-radius: 0.5em; } &__summary-title { @@ -81,7 +78,7 @@ display: flex; flex-direction: column; padding: 1em; - background: var(--secondary); + background: var(--primary-very-low); border-radius: 0.25em; .label { @@ -120,22 +117,38 @@ margin-top: 2em; @media (max-width: 768px) { - grid-template-columns: 1fr; + grid-template-columns: none; + display: flex; + flex-direction: column; } } - &__features, - &__users, - &__models { - background: var(--primary-very-low); - padding: 1em; - border-radius: 0.5em; - } + &__users { + grid-column: span 2; - &__features-title, - &__users-title, - &__models-title { - margin-bottom: 1em; + .admin-config-area-card__content { + display: flex; + + .ai-usage__users-table { + &:first-child { + margin-right: 2em; + + &.-double-width { + margin-right: 0; + } + } + + &.-double-width { + .ai-usage__users-username { + width: auto; + } + } + + .ai-usage__users-username { + width: 50px; + } + } + } } &__features-table, diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0e403243..baa85203 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -146,7 +146,18 @@ en: total_requests: "Total requests" request_tokens: "Request tokens" response_tokens: "Response tokens" + net_request_tokens: "Net request tokens" cached_tokens: "Cached tokens" + cached_request_tokens: "Cached request tokens" + no_users: "No user usage data found" + no_models: "No model usage data found" + no_features: "No feature usage data found" + subheader_description: "Tokens are the basic units that LLMs use to understand and generate text, usage data may affect costs." + periods: + last_day: "Last 24 hours" + last_week: "Last week" + last_month: "Last month" + custom: "Custom..." ai_persona: tool_strategies: diff --git a/config/routes.rb b/config/routes.rb index 0fea7529..ae82f77c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,6 +79,7 @@ Discourse::Application.routes.draw do to: "discourse_ai/admin/rag_document_fragments#indexing_status_check" get "/ai-usage", to: "discourse_ai/admin/ai_usage#show" + get "/ai-usage-report", to: "discourse_ai/admin/ai_usage#report" resources :ai_llms, only: %i[index create show update destroy], diff --git a/lib/completions/report.rb b/lib/completions/report.rb index acb33f51..6982b9a7 100644 --- a/lib/completions/report.rb +++ b/lib/completions/report.rb @@ -14,33 +14,33 @@ module DiscourseAi end def total_tokens - stats.total_tokens + stats.total_tokens || 0 end def total_cached_tokens - stats.total_cached_tokens + stats.total_cached_tokens || 0 end def total_request_tokens - stats.total_request_tokens + stats.total_request_tokens || 0 end def total_response_tokens - stats.total_response_tokens + stats.total_response_tokens || 0 end def total_requests - stats.total_requests + stats.total_requests || 0 end def stats @stats ||= base_query.select( "COUNT(*) as total_requests", - "SUM(request_tokens + response_tokens) as total_tokens", + "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(request_tokens) as total_request_tokens", - "SUM(response_tokens) as total_response_tokens", + "SUM(COALESCE(request_tokens,0)) as total_request_tokens", + "SUM(COALESCE(response_tokens,0)) as total_response_tokens", )[ 0 ] @@ -66,10 +66,10 @@ module DiscourseAi .order("DATE_TRUNC('#{period}', created_at)") .select( "DATE_TRUNC('#{period}', created_at) as period", - "SUM(request_tokens + response_tokens) as total_tokens", + "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(request_tokens) as total_request_tokens", - "SUM(response_tokens) as total_response_tokens", + "SUM(COALESCE(request_tokens,0)) as total_request_tokens", + "SUM(COALESCE(response_tokens,0)) as total_response_tokens", ) end @@ -83,10 +83,10 @@ module DiscourseAi "users.username", "users.uploaded_avatar_id", "COUNT(*) as usage_count", - "SUM(request_tokens + response_tokens) as total_tokens", + "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(request_tokens) as total_request_tokens", - "SUM(response_tokens) as total_response_tokens", + "SUM(COALESCE(request_tokens,0)) as total_request_tokens", + "SUM(COALESCE(response_tokens,0)) as total_response_tokens", ) end @@ -97,10 +97,10 @@ module DiscourseAi .select( "case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name", "COUNT(*) as usage_count", - "SUM(request_tokens + response_tokens) as total_tokens", + "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(request_tokens) as total_request_tokens", - "SUM(response_tokens) as total_response_tokens", + "SUM(COALESCE(request_tokens,0)) as total_request_tokens", + "SUM(COALESCE(response_tokens,0)) as total_response_tokens", ) end @@ -111,10 +111,10 @@ module DiscourseAi .select( "language_model as llm", "COUNT(*) as usage_count", - "SUM(request_tokens + response_tokens) as total_tokens", + "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", - "SUM(request_tokens) as total_request_tokens", - "SUM(response_tokens) as total_response_tokens", + "SUM(COALESCE(request_tokens,0)) as total_request_tokens", + "SUM(COALESCE(response_tokens,0)) as total_response_tokens", ) end diff --git a/spec/requests/admin/ai_usage_controller_spec.rb b/spec/requests/admin/ai_usage_controller_spec.rb index dacb17c3..eae9285b 100644 --- a/spec/requests/admin/ai_usage_controller_spec.rb +++ b/spec/requests/admin/ai_usage_controller_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe DiscourseAi::Admin::AiUsageController do fab!(:admin) fab!(:user) - let(:usage_path) { "/admin/plugins/discourse-ai/ai-usage.json" } + let(:usage_report_path) { "/admin/plugins/discourse-ai/ai-usage-report.json" } before { SiteSetting.discourse_ai_enabled = true } @@ -36,7 +36,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "returns correct data structure" do - get usage_path + get usage_report_path expect(response.status).to eq(200) @@ -48,14 +48,18 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "respects date filters" do - get usage_path, params: { start_date: 3.days.ago.to_date, end_date: 1.day.ago.to_date } + get usage_report_path, + params: { + start_date: 3.days.ago.to_date, + end_date: 1.day.ago.to_date, + } json = response.parsed_body expect(json["summary"]["total_tokens"]).to eq(450) # sum of all tokens end it "filters by feature" do - get usage_path, params: { feature: "summarize" } + get usage_report_path, params: { feature: "summarize" } json = response.parsed_body @@ -66,7 +70,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "filters by model" do - get usage_path, params: { model: "gpt-3.5" } + get usage_report_path, params: { model: "gpt-3.5" } json = response.parsed_body models = json["models"] @@ -76,10 +80,10 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "handles different period groupings" do - get usage_path, params: { period: "hour" } + get usage_report_path, params: { period: "hour" } expect(response.status).to eq(200) - get usage_path, params: { period: "month" } + get usage_report_path, params: { period: "month" } expect(response.status).to eq(200) end end @@ -102,7 +106,11 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "returns hourly data when period is day" do - get usage_path, params: { start_date: 1.day.ago.to_date, end_date: Time.current.to_date } + get usage_report_path, + params: { + start_date: 1.day.ago.to_date, + end_date: Time.current.to_date, + } expect(response.status).to eq(200) json = response.parsed_body @@ -121,7 +129,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do before { sign_in(user) } it "blocks access" do - get usage_path + get usage_report_path expect(response.status).to eq(404) end end @@ -133,7 +141,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do end it "returns error" do - get usage_path + get usage_report_path expect(response.status).to eq(404) end end